【rails】Youtube動画の保存について

# はじめに
現在私はポートフォリオを作成しており、Youtubeapiを導入して、データの取得はできるようになりました。なので、次にAPIのリクエスト制限を回避したいと思い、それらの動画データをデータベースに保存しようと考えました。また、レスポンスも早くなりそうだなと感じ、ユーザーの満足度も少し向上するなと考えております。

# 現状
Viewで動画検索をすると`クエリパラメータ`を作成し、Youtube apiがそれらをもとに動画データを取得して、動画を表示することは以下のコードできています。
```php:views/index.html.erb
<h1>YouTube動画検索</h1>

<%= form_with url: videos_path, method: :get, local: true do |f| %>
  <%= f.text_field :query, placeholder: '検索キーワードを入力' %>
  <%= f.submit '検索' %>
<% end %>

<% if @videos.present? %>
  <h2>検索結果</h2>
  <div>
    <% @videos.each do |video| %>
      <div>
        <div>
          <iframe width="100%" height="200" src="https://www.youtube.com/embed/<%= video[:channel_id] %>" frameborder="0" allowfullscreen></iframe>
          <div>
            <h5><%= video[:channel_title] %></h5>
            <p><%= video[:title] %></p>
            <% if current_user %>
              <%= render 'shared/btn', video: video %>
            <% end %>
          </div>
        </div>
      </div>
    <% end %>
  </div>
<% else %>
  <p>動画が見つかりませんでした。</p>
<% end %>

```
```php:videos_controller.rb
def index
  youtube = Rails.application.config.youtube_service
  
  if params[:query].present?
    search_query = params[:query]
    @videos = search_youtube_videos(search_query, youtube)
  end
end

private
  
def search_youtube_videos(query, youtube)
  response = youtube.list_searches(
    'snippet',
    q: query,
    max_results: 1
  )

  response.items.map do |item|
    {
      title: item.snippet.title,
      channel_id: item.id.video_id,
      channel_title: item.snippet.channel_title
    }
  end
end
```

現状では、コントローラでAPIデータを取得し、データの処理をしていますが、これは以下の理由によりあまり良くないそうです。
+ コントローラが肥大化しやすい
+ コードの再利用性が低い

そのため、動画のデータを保存する役割と現在のAPIデータを取得・処理する役割をサービスクラスを活用した設計にしたいと思います。

:::note info
サービスクラス
アプリケーションの具体的な処理やロジックを実装するための専用クラスで、今回だとYoutube apiの処理を行うために必要です。
:::

# 流れ
今回行いたい処理は大きく２つです。
１つ目は、自分の筋トレスタイルを選択して、カテゴリーの画像を押すとそれらのクエリパラメータを取得することができ、それらに当てはまる動画をデータベースから表示する処理です。「筋トレのスタイル」に応じた「筋トレ部位」の動画を保存しておく必要があります。例えば「筋トレのスタイル」がジムの場合、「筋トレの部位」は合計で７つ用意しているので、それぞれ２つずつの動画を保存するとジムだけでも14の動画データを保存する必要があります。
![スクリーンショット 2025-01-29 9.11.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952789/f770de5e-ffcf-0e81-33d3-7372b22142de.png)

２つ目は、動画のフリー検索から動画を保存する処理です。これについても、筋トレ以外の動画が表示されないようにしたいので、`Youtube api`から取得した動画の説明欄から、まず用意している７つのカテゴリーがあれば動画を保存し、カテゴリー名がなければ動画が保存できず、表示されないようにする必要があります。
![スクリーンショット 2025-01-29 9.19.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952789/4792603c-ca71-3371-c2fa-7db9651d737f.png)

# 実装
１つ目の流れを実装していきます。まずはサービスクラスの用意をします。`app/services/youtube_video_service.rb`を作成してください。ここではAPIから取得したデータを処理して適切なカテゴリーとスタイルに紐付けする仕組みを実装します。まずは完成のコードです。
```php:youtube_video_service.rb
class YoutubeVideoService
  def initialize(youtube = Rails.application.config.youtube_service)
    @youtube = youtube
  end

  def fetch_and_save_videos_for_category_and_style(category_name, style_name)
    category = Category.find_by(name: category_name)
    training_style = TrainingStyle.find_by(name: style_name)
    return unless category && training_style

    videos = fetch_videos_from_youtube("#{style_name} #{category_name}")
    save_videos_to_database(category, training_style, videos)
  end

  private

  def fetch_videos_from_youtube(query)
    response = @youtube.list_searches(
      'snippet',
      q: "筋トレ #{query}",
      max_results: 2
    )

    response.items.map do |item|
      {
        title: item.snippet.title,
        video_id: item.id.video_id,
        channel_title: item.snippet.channel_title
      }
    end
  end

  def save_videos_to_database(category, training_style, videos)
    videos.each do |video_information|
      category.videos.find_or_create_by(channel_id: video_information[:video_id]) do |video|
        video.title = video_information[:title]
        video.channel_title = video_information[:channel_title_]
        video.training_style = training_style
      end
    end
  end
end
```
#### １ クラス定義と初期化メソッド
ここから少しずつ解説をしていきます。
```php
class YoutubeVideoService
  def initialize(youtube = Rails.application.config.youtube_service)
    @youtube = youtube
  end
```
上記の記述はクラス定義と初期化メソッドを行なっています。まずYoutubeVideoServiceというサービスクラスをつけ、これらが動画の取得と保存を行います。`initialize`メソッドを活用することで、特別な設定をすることなくAPIクライアントが使用できるようになっています。それらを`@youtube`に格納することで、再利用しやすくしています。

#### ２ メインメソッド
```php
def fetch_and_save_videos_for_category_and_style(category_name, style_name)
  category = Category.find_by(name: category_name)
  training_style = TrainingStyle.find_by(name: style_name)
  return unless category && training_style

  videos = fetch_videos_from_youtube("#{style_name} #{category_name}")
  save_videos_to_database(category, training_style, videos)
end
```
上記の部分がメインメソッドになります。`fetch_videos_from_youtube`メソッドと`save_videos_to_database`メソッドは後で詳しく説明をします。
このメソッドでは、特定のカテゴリーとスタイルに応じたYoutube動画を取得し、それらをデータベースに保存するメソッドです。
```php
category = Category.find_by(name: category_name)
training_style = TrainingStyle.find_by(name: style_name)
```
まず、`Category.find_by`によりカテゴリーテーブルから指定されたレコードの取得を行います。どのレコードを取得するかは`(name: category_name)`や`(name: style_name)`の部分で判断しています。(name: category_name)を例にして以下に説明をまとめます。
| コード | 役割 |
|:-:|:-|
| name | categoriesテーブルのカラム名 |
| category_name | メソッドに渡された引数 |

メインメソッドを活用する際に、例えば、`Category.all.each do |category|`で`category.name`とすることで、メソッドの引数にnameカラムの要素を１つずつ取得することができ、`name`と一致するそれぞれのnameカラムのレコードを取得していくことができる。

#### ３ Youtube apiからデータの取得
```php
def fetch_videos_from_youtube(query)
  response = @youtube.list_searches(
    'snippet',
    q: "筋トレ #{query}",
    max_results: 2
  )

  response.items.map do |item|
    {
      title: item.snippet.title,
      video_id: item.id.video_id,
      channel_title: item.snippet.channel_title
    }
  end
end
```
こちらは`@youtube.list_searches`で動画のデータを検索してくれるメソッドです。`YouTube Data API`に対して検索クエリを送信し、その結果を`response`変数に格納しています。以下はAPIリクエストのプロパティ一覧です。
| プロパティ | 役割 |
|:-:|:-|
| part | どの種類のデータを取得するか指定する。`snippet`、`id`などがある。`snippet`はタイトル・チャンネル情報・説明文などのデータが取得できる |
| q | 検索クエリであり、キーワードが入る |
| maxResults | 取得する動画の最大件数で、指定できる値は`１〜50` |
| type | 検索するデータの種類を指定する。partとの違いは、そもそも動画を取得したいのか、チャンネルの情報を取得したいのかなど、検索範囲を指定している。partはデータの種類なので、typeで指定された範囲でデータを取得する |
| order | 検索結果の並び順を指定する。`date`（新しい順）、`rating`(評価の高い順)、`viewCount`（再生回数の多い順）、`relevance`（関連性が高い順）などがある |
| videoDuration | 動画の長さでフィルタリングをする。`short`（４分未満）、`medium`（４分以上20分未満）、`long`（20分以上）などがある |

それぞれ格納したデータを`map`を活用して、タイトルと動画IDとチャンネル名を含むハッシュの形で成形しています。

#### ４ データベースへの保存メソッド
```php
def save_videos_to_database(category, training_style, videos)
  videos.each do |video_information|
    category.videos.find_or_create_by(channel_id: video_information[:video_id]) do |video|
      video.title = video_information[:title]
      video.channel_title = video_information[:channel_title]
      video.training_style = training_style
    end
  end
end
```
動画情報を１件ずつ取り出すために、`each`文を活用します。
```php
category.videos.find_or_create_by(channel_id: video_information[:video_id]) do |video|
```
上記の部分の`category.videos`はcategoryに関連付けられたvideoテーブルを対象に検索または新規作成を行います。つまり、categoryが「胸筋」の場合、胸筋の`category_id`が１なので、それらのデータを検索し、新規作成されたら`category_id`に１がデータ保存される仕組みです。
`find_or_create_by`はデータがあれば何もせず、なければデータを保存する役割です。それらを調べるのに、videosテーブルのchannel_idカラムをもとに、今回のデータが重複していないか調べ、重複していなかったらデータが保存される仕組みです。また、この`channel_id`は`category_id`と同様に新規作成されたらデータが保存される仕組みです。

#### ５ Rakeタスク実行
これまでに作成した、サービスクラスを以下のように入力して実行してください。
```php:lib/tasks/fetch_video.rake
namespace :fetch_video do
  desc 'カテゴリーごとのYoutube動画データを取得して保存'
  task fetch_video: :environment do
    styles = %w[ジム 自重 ダンベル]
    Category.all.each do |category|
      styles.each do |style|
        YoutubeVideoService.new.fetch_and_save_videos_for_category_and_style(category.name, style)
        puts "動画データを保存しました: #{category.name}の#{style}"
      end
    end
  end
end
```
これを記述できたら、あとはrakeタスクを実行してください。
```php:ターミナル
docker-compose exec web bundle exec rake fetch_video:fetch_video
```
以上で、１つ目にやりたい「筋トレのスタイル」に応じた「筋トレ部位」の動画を保存する実装です。

* * *

次に、２つ目の実装の方法です。先ほどのサービスクラスに追記していきます。まずは完成版（先ほどのコードも有り）を以下に記載します。
```php:youtube_video_service.rb
class YoutubeVideoService
  def initialize(youtube = Rails.application.config.youtube_service)
    @youtube = youtube
  end

  def fetch_and_save_videos_for_category_and_style(category_name, style_name)
    category = Category.find_by(name: category_name)
    training_style = TrainingStyle.find_by(name: style_name)
    return unless category && training_style

    videos = fetch_videos_from_youtube("#{style_name} #{category_name}")
    save_videos_to_database(category, training_style, videos)
  end

  def fetch_and_save_videos_from_youtube(query)
    videos = fetch_videos_from_youtube(query)
    classify_and_save_videos(videos)
  end

  private

  def fetch_videos_from_youtube(query)
    response = @youtube.list_searches(
      'snippet',
      q: "筋トレ #{query}",
      max_results: 2
    )

    response.items.map do |item|
      {
        title: item.snippet.title,
        video_id: item.id.video_id,
        channel_title: item.snippet.channel_title,
        description: item.snippet.description
      }
    end
  end

  def classify_and_save_videos(videos)
    categorized_videos = []

    videos.each do |video_information|
      category = determine_category(video_information[:title], video_information[:description])
      training_style = determine_training_style(video_information[:title], video_information[:description])

      next unless category # カテゴリが特定できなければスキップ

      video = category.videos.find_or_create_by(channel_id: video_information[:video_id]) do |video|
        video.title = video_information[:title]
        video.channel_title = video_information[:channel_title]
        video.training_style = training_style
      end
      categorized_videos << video
    end

    categorized_videos
  end

  def determine_category(title, description)
    category_keywords = %w[胸筋 背筋 肩 腹筋 脚 上腕三頭筋 上腕二頭筋]

    matched_keyword = category_keywords.find { |keyword| title.include?(keyword) || description.include?(keyword) }
    matched_keyword ? Category.find_by(name: matched_keyword) : nil
  end

  def determine_training_style(title, description)
    if title.include?("ジム") || description.include?("ジム")
      TrainingStyle.find_by(name: "ジム")
    elsif title.include?("ダンベル") || description.include?("ダンベル")
      TrainingStyle.find_by(name: "ダンベル")
    else
      TrainingStyle.find_by(name: "自重") # デフォルトは自重
    end
  end

  def save_videos_to_database(category, training_style, videos)
    videos.each do |video_information|
      category.videos.find_or_create_by(channel_id: video_information[:video_id]) do |video|
        video.title = video_information[:title]
        video.channel_title = video_information[:channel_title_]
        video.training_style = training_style
      end
    end
  end
end
```
以下に詳細の説明を行っていきます。

#### １ フリー検索のリクエストを受け取る
ビューでフリー検索をして、それらの値をクエリパラメータに送り、コントローラで受け取る記述が以下になります。
```php:videos/index.html.erb
<%= form_with url: videos_path, method: :get, local: true do |f| %>
  <%= f.text_field :query, placeholder: '検索キーワードを入力' %>
  <%= f.submit '検索'%>
<% end %>
```
```php:videos_controller.rb
if params[:query].present?
  search_query = params[:query]
  @videos = YoutubeVideoService.new.fetch_and_save_videos_from_youtube(search_query)
else
  @videos = []
end
```
これらができたら、実際にサービスクラスの`fetch_and_save_videos_from_youtube`メソッドの詳細を説明していきます。

#### ２ サービスクラスの追記
```php:youtube_video_service.rb
def fetch_and_save_videos_from_youtube(query)
  videos = fetch_videos_from_youtube(query)
  classify_and_save_videos(videos)
end

private

def fetch_videos_from_youtube(query) #もともと作成しているメソッド
  response = @youtube.list_searches(
    'snippet',
    q: "筋トレ #{query}",
    max_results: 2
  )

  response.items.map do |item|
    {
      title: item.snippet.title,
      video_id: item.id.video_id,
      channel_title: item.snippet.channel_title,
      description: item.snippet.description  #この部分を追記
    }
  end
end

def classify_and_save_videos(videos)
  categorized_videos = []

  videos.each do |video_information|
    category = determine_category(video_information[:title], video_information[:description])
    training_style = determine_training_style(video_information[:title], video_information[:description])

    next unless category # カテゴリが特定できなければスキップ

    video = category.videos.find_or_create_by(channel_id: video_information[:video_id]) do |video|
      video.title = video_information[:title]
      video.channel_title = video_information[:channel_title]
      video.training_style = training_style
    end
    categorized_videos << video
  end

  categorized_videos
end

def determine_category(title, description)
  category_keywords = %w[胸筋 背筋 肩 腹筋 脚 上腕三頭筋 上腕二頭筋]

  matched_keyword = category_keywords.find { |keyword| title.include?(keyword) || description.include?(keyword) }
  matched_keyword ? Category.find_by(name: matched_keyword) : nil
end

def determine_training_style(title, description)
  if title.include?("ジム") || description.include?("ジム")
    TrainingStyle.find_by(name: "ジム")
  elsif title.include?("ダンベル") || description.include?("ダンベル")
    TrainingStyle.find_by(name: "ダンベル")
  else
    TrainingStyle.find_by(name: "自重") # デフォルトは自重
  end
end
```

まずは`classify_and_save_videos`メソッドについて説明していきます。
```php
def classify_and_save_videos(videos)
  categorized_videos = []

  videos.each do |video_information|
    category = determine_category(video_information[:title], video_information[:description])
    training_style = determine_training_style(video_information[:title], video_information[:description])

    next unless category # カテゴリが特定できなければスキップ

    video = category.videos.find_or_create_by(channel_id: video_information[:video_id]) do |video|
      video.title = video_information[:title]
      video.channel_title = video_information[:channel_title]
      video.training_style = training_style
    end
    categorized_videos << video
  end
```
まずこのメソッドの役割は取得した動画のデータをカテゴリーとスタイルし、データベースに保存するものです。さらにその後に、保存したデータをすぐに表示等の処理で使用するため、動画のリストを一度`categorized_videos`にためて、その後のコントローラやビューで使用できるようにするのに、`categorized_videos`が必要です。
その他の処理については、１つ目の実践と重なる部分が多いため省略します。

次にカテゴリーやスタイルの分類の処理です。
```php
def determine_category(title, description)
  category_keywords = %w[胸筋 背筋 肩 腹筋 脚 上腕三頭筋 上腕二頭筋]

  matched_keyword = category_keywords.find { |keyword| title.include?(keyword) || description.include?(keyword) }
  matched_keyword ? Category.find_by(name: matched_keyword) : nil
end

def determine_training_style(title, description)
  if title.include?("ジム") || description.include?("ジム")
    TrainingStyle.find_by(name: "ジム")
  elsif title.include?("ダンベル") || description.include?("ダンベル")
    TrainingStyle.find_by(name: "ダンベル")
  else
    TrainingStyle.find_by(name: "自重") # デフォルトは自重
  end
end
```
動画の`title`、`description`に`category_keywords`の配列の文字があれば、それに応じた`category_id`が`find_by`によって取得ができます。スタイルも同じような流れですが、スタイルの場合はカテゴリーが取得できたら、スタイルに当てはまるものがなくてもデフォルトで自重になるようしています。
ここまでの流れで作成したメソッドを`fetch_and_save_videos_from_youtube`メソッドでまとめている。

# おわりに
ここまでの流れをChatGPTにメンター代わりになってもらい、少しずつ理解しながら進めてきました。少しずつですが、理解が進んでいますが、やはり細かい部分の勉強がもっと必要だなと感じました。このアプリが完成したらチェリー本で勉強していきたいと思います。最後まで閲覧いただきまことにありがとうございましｔ