Class: YouTube::AutoLinkService

Inherits:
Object
  • Object
show all
Defined in:
app/services/youtube/auto_link_service.rb

Overview

Discovers YouTube channel videos and matches them to local Video records
that don't have a youtube_id yet, using title similarity and duration.

Usage:
service = YouTube::AutoLinkService.new
matches = service.discover_matches

=> [{ youtube_id:, youtube_title:, youtube_duration:, youtube_thumbnail:,

local_video:, confidence:, title_similarity: }, ...]

service.link!(video_id, youtube_id)

Constant Summary collapse

TITLE_SIMILARITY_THRESHOLD =
0.6
DURATION_TOLERANCE_SECONDS =
5

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client: nil) ⇒ AutoLinkService

Returns a new instance of AutoLinkService.



21
22
23
24
# File 'app/services/youtube/auto_link_service.rb', line 21

def initialize(client: nil)
  @client = client || YouTube::ApiClient.new
  @logger = Rails.logger
end

Instance Attribute Details

#clientObject (readonly)

Returns the value of attribute client.



19
20
21
# File 'app/services/youtube/auto_link_service.rb', line 19

def client
  @client
end

#loggerObject (readonly)

Returns the value of attribute logger.



19
20
21
# File 'app/services/youtube/auto_link_service.rb', line 19

def logger
  @logger
end

Instance Method Details

#discover_matches(local_scope: nil) ⇒ Array<Hash>

Fetch all channel videos from YouTube and match against unlinked local videos.

Parameters:

  • local_scope (ActiveRecord::Relation) (defaults to: nil)

    restrict which local videos may match (default: all videos)

Returns:

  • (Array<Hash>)

    sorted by confidence (high first)



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'app/services/youtube/auto_link_service.rb', line 30

def discover_matches(local_scope: nil)
  channel = @client.my_channel
  return [] unless channel

  youtube_videos = fetch_all_channel_videos(channel.id)
  return [] if youtube_videos.empty?

  existing_youtube_ids = Video.where.not(youtube_id: [nil, '']).pluck(:youtube_id).to_set
  unlinked_yt_videos = youtube_videos.reject { |v| existing_youtube_ids.include?(v[:youtube_id]) }
  return [] if unlinked_yt_videos.empty?

  scope = local_scope || Video.all
  local_candidates = scope
                     .where('youtube_id IS NULL OR youtube_id = ?', '')
                     .pluck(:id, :title, :duration_in_seconds)
                     .map { |id, title, dur| { id: id, title: title.to_s, duration: dur } }
  return [] if local_candidates.empty?

  match_videos(unlinked_yt_videos, local_candidates)
end

#link!(video_id, youtube_id) ⇒ Object

Link a local video to a YouTube video ID and trigger metadata sync.

Parameters:

  • video_id (Integer)
  • youtube_id (String)


79
80
81
82
83
84
# File 'app/services/youtube/auto_link_service.rb', line 79

def link!(video_id, youtube_id)
  video = Video.find(video_id)
  video.update!(youtube_id: youtube_id)
  YouTubeSyncWorker.perform_async(video_id)
  video
end

#safe_link!(video_id, youtube_id) ⇒ Object

Like #link! but raises if the local row is already linked or the YouTube ID is taken elsewhere.

Raises:

  • (ArgumentError)


87
88
89
90
91
92
93
94
95
96
# File 'app/services/youtube/auto_link_service.rb', line 87

def safe_link!(video_id, youtube_id)
  video = Video.find(video_id)
  raise ArgumentError, 'Video already has a YouTube ID' if video.youtube_id.present?

  if Video.where.not(id: video.id).exists?(youtube_id: youtube_id)
    raise ArgumentError, 'That YouTube ID is already linked to another video'
  end

  link!(video_id, youtube_id)
end

#search_for_video(video) ⇒ Array<Hash>

Search YouTube channel for videos matching a specific local video's title.

Parameters:

  • video (Video)

    the local video to find on YouTube

Returns:

  • (Array<Hash>)

    YouTube video candidates



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'app/services/youtube/auto_link_service.rb', line 54

def search_for_video(video)
  return [] if video.title.blank?

  channel = @client.my_channel
  return [] unless channel

  results = @client.list_channel_videos(channel_id: channel.id, max_results: 20)
  return [] if results.items.blank?

  yt_ids = results.items.map { |item| item.id.video_id }
  yt_details = fetch_video_details(yt_ids)

  existing_youtube_ids = Video.where.not(youtube_id: [nil, '']).pluck(:youtube_id).to_set

  yt_details
    .reject { |v| existing_youtube_ids.include?(v[:youtube_id]) }
    .map { |yt| yt.merge(title_similarity: title_similarity(video.title, yt[:youtube_title])) }
    .select { |yt| yt[:title_similarity] > 0.3 }
    .sort_by { |yt| -yt[:title_similarity] }
    .first(10)
end