Cache each video's title, channel and thumbnail
youtube_items keeps what YouTube said about each item's video at the
last sync, for showing names and thumbnails later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
diff --git a/db/migrate/005_add_youtube.rb b/db/migrate/005_add_youtube.rb
index 818795a..cca97fe 100644
--- a/db/migrate/005_add_youtube.rb
+++ b/db/migrate/005_add_youtube.rb
@@ -20,10 +20,14 @@ Sequel.migration do
end
# The video behind each item of a YouTubePlaylist list, which is how
- # a sync recognizes the item again.
+ # a sync recognizes the item again, and what YouTube said about it at
+ # the last sync.
create_table(:youtube_items) do
foreign_key :item_id, :items, primary_key: true, on_delete: :cascade
String :video_id, null: false
+ String :title, null: false
+ String :channel_title
+ String :thumbnail_url
end
end
end
diff --git a/lib/rank_king/google.rb b/lib/rank_king/google.rb
index ff0a4a3..e3dcb4b 100644
--- a/lib/rank_king/google.rb
+++ b/lib/rank_king/google.rb
@@ -13,6 +13,11 @@ module RankKing
# Google refused a request, or answered with something unexpected.
class Error < StandardError; end
+ # A video in a playlist, as YouTube describes it.
+ # Signed in sig/rank_king/google.rbs: a Data class.
+ # @rbs skip
+ Video = Data.define(:id, :title, :channel_title, :thumbnail_url)
+
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_URL = "https://oauth2.googleapis.com/token"
PLAYLISTS_URL = "https://www.googleapis.com/youtube/v3/playlists"
@@ -65,20 +70,32 @@ module RankKing
}
end
- # The videos in a playlist, in playlist order, as [video id, title].
- # Deleted videos, and private ones the user cannot see, carry no
- # owner and are left out.
- #: (String access_token, String playlist_id) -> Array[[String, String]]
+ # The videos in a playlist, in playlist order. Deleted videos, and
+ # private ones the user cannot see, carry no owner and are left out.
+ #: (String access_token, String playlist_id) -> Array[Video]
def self.playlist_videos(access_token, playlist_id)
items = pages(access_token, PLAYLIST_ITEMS_URL, { part: "snippet", playlistId: playlist_id })
items.filter_map { |item|
snippet = item.fetch("snippet")
- next unless snippet["videoOwnerChannelTitle"]
-
- [snippet.dig("resourceId", "videoId"), snippet.fetch("title")] #: [String, String]
+ channel_title = snippet["videoOwnerChannelTitle"]
+ next unless channel_title
+
+ Video.new(
+ id: snippet.dig("resourceId", "videoId"),
+ title: snippet.fetch("title"),
+ channel_title:,
+ thumbnail_url: thumbnail_url(snippet["thumbnails"] || {}),
+ )
}
end
+ # The medium thumbnail (320×180), or the nearest size there is.
+ #: (Hash[String, untyped] thumbnails) -> String?
+ def self.thumbnail_url(thumbnails)
+ size = %w[medium high default standard maxres].find { |name| thumbnails.key?(name) }
+ size && thumbnails.dig(size, "url")
+ end
+
# Every item of a YouTube list endpoint, following its pages.
#: (String access_token, String url, Hash[Symbol, String] query) -> Array[Hash[String, untyped]]
def self.pages(access_token, url, query)
diff --git a/lib/rank_king/sources/youtube_playlist.rb b/lib/rank_king/sources/youtube_playlist.rb
index 66277a7..3d63408 100644
--- a/lib/rank_king/sources/youtube_playlist.rb
+++ b/lib/rank_king/sources/youtube_playlist.rb
@@ -67,8 +67,11 @@ module RankKing
raise Google::Error, "Connect Google in settings to sync this list" unless account
access_token = Google.access_token(account.refresh_token)
- videos = Google.playlist_videos(access_token, playlist_id).uniq { |video_id, _| video_id }
- entries = videos.map { |video_id, title| Entry.new(key: video_id, name: title) }
+ # Kept for saved, which caches each video's details by its item.
+ @videos = Google.playlist_videos(access_token, playlist_id).uniq(&:id).to_h { |video|
+ [video.id, video] #: [String, Google::Video]
+ }
+ entries = @videos.each_value.map { |video| Entry.new(key: video.id, name: video.title) }
Sync.call(@list, entries, source: self)
end
@@ -85,7 +88,11 @@ module RankKing
#: (Item item, Entry entry) -> void
def saved(item, entry)
- DB[ITEMS].replace(item_id: item.id, video_id: entry.key)
+ video = @videos&.fetch(entry.key)
+ DB[ITEMS].replace(
+ item_id: item.id, video_id: entry.key, title: entry.name,
+ channel_title: video&.channel_title, thumbnail_url: video&.thumbnail_url,
+ )
end
end
end
diff --git a/sig/rank_king/google.rbs b/sig/rank_king/google.rbs
index b41a984..911da83 100644
--- a/sig/rank_king/google.rbs
+++ b/sig/rank_king/google.rbs
@@ -7,3 +7,17 @@ module RankKing
def self.transport=: (^(Symbol, String, Hash[Symbol, String], String?) -> Hash[String, untyped]) -> void
end
end
+
+# Video is a Data class, whose members the inline parser cannot read.
+module RankKing
+ module Google
+ class Video
+ attr_reader id: String
+ attr_reader title: String
+ attr_reader channel_title: String
+ attr_reader thumbnail_url: String?
+
+ def self.new: (id: String, title: String, channel_title: String, thumbnail_url: String?) -> instance
+ end
+ end
+end
diff --git a/test/test_youtube.rb b/test/test_youtube.rb
index 82cae5a..6186190 100644
--- a/test/test_youtube.rb
+++ b/test/test_youtube.rb
@@ -53,8 +53,14 @@ class TestYouTube < Minitest::Test
# A nil title stands for a deleted video, which has no owner.
def item(id, title)
- snippet = { "title" => title || "Deleted video", "resourceId" => { "videoId" => id } }
- snippet["videoOwnerChannelTitle"] = "A channel" if title
+ snippet = { "title" => title || "Deleted video", "resourceId" => { "videoId" => id }, "thumbnails" => {} }
+ if title
+ snippet["videoOwnerChannelTitle"] = "A channel"
+ snippet["thumbnails"] = {
+ "default" => { "url" => "https://i.ytimg.com/vi/#{id}/default.jpg" },
+ "medium" => { "url" => "https://i.ytimg.com/vi/#{id}/mqdefault.jpg" },
+ }
+ end
{ "snippet" => snippet }
end
end
@@ -191,6 +197,10 @@ class TestYouTube < Minitest::Test
assert_equal %w[First Third Fourth], names(list)
assert_equal PLAYLIST, RankKing::DB[:youtube_sources].where(list_id: list.id).get(:playlist_id)
assert_equal %w[v1 v3 v4], RankKing::DB[:youtube_items].order(:item_id).map(:video_id)
+ first = RankKing::DB[:youtube_items].first(video_id: "v1")
+ assert_equal "First", first[:title]
+ assert_equal "A channel", first[:channel_title]
+ assert_equal "https://i.ytimg.com/vi/v1/mqdefault.jpg", first[:thumbnail_url]
tokens = @google.calls.select { |_, url, _, _| url == RankKing::Google::TOKEN_URL }
assert_equal "refresh-the-code", tokens.last[2][:refresh_token]
@@ -225,6 +235,7 @@ class TestYouTube < Minitest::Test
assert_equal ["First, renamed", "Third"], names(list)
refute_nil second.reload.archived_at
assert_equal "First, renamed", first.reload.name
+ assert_equal "First, renamed", RankKing::DB[:youtube_items].first(item_id: first.id)[:title]
@google.videos = [["v2", "Second"]]
sync(list)