Pick a YouTube playlist from a list of the user's own
The new-list form fetches the user's playlists and offers them in a
dropdown instead of asking for a pasted URL. A Google error shows in
place of the dropdown rather than failing the form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
diff --git a/lib/rank_king/google.rb b/lib/rank_king/google.rb
index 8c13bb2..cc813d5 100644
--- a/lib/rank_king/google.rb
+++ b/lib/rank_king/google.rb
@@ -15,6 +15,7 @@ module RankKing
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"
PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"
SCOPE = "https://www.googleapis.com/auth/youtube.readonly"
@@ -55,30 +56,44 @@ module RankKing
body.fetch("access_token") { raise Error, "Google sent no access token" }
end
+ # The user's own playlists, as [playlist id, title]. YouTube leaves
+ # out Watch Later and Liked videos.
+ #: (String access_token) -> Array[[String, String]]
+ def self.playlists(access_token)
+ pages(access_token, PLAYLISTS_URL, { part: "snippet", mine: "true" }).map do |item|
+ [item.fetch("id"), item.dig("snippet", "title").to_s] #: [String, String]
+ end
+ 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]]
def self.playlist_videos(access_token, playlist_id)
- videos = [] #: Array[[String, String]]
- page = nil #: String?
- loop do
- query = { part: "snippet", playlistId: playlist_id, maxResults: "50" } #: Hash[Symbol, String]
- query[:pageToken] = page if page
- body = transport.call(:get, PLAYLIST_ITEMS_URL, query, access_token)
+ items = pages(access_token, PLAYLIST_ITEMS_URL, { part: "snippet", playlistId: playlist_id })
+ items.filter_map do |item|
+ snippet = item.fetch("snippet")
+ next unless snippet["videoOwnerChannelTitle"]
- items = body["items"] || [] #: Array[Hash[String, untyped]]
- items.each do |item|
- snippet = item.fetch("snippet")
- next unless snippet["videoOwnerChannelTitle"]
+ [snippet.dig("resourceId", "videoId"), snippet.fetch("title")] #: [String, String]
+ end
+ end
- videos << [snippet.dig("resourceId", "videoId"), snippet.fetch("title")]
- 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)
+ items = [] #: Array[Hash[String, untyped]]
+ page = nil #: String?
+ loop do
+ params = { **query, maxResults: "50" } #: Hash[Symbol, String]
+ params[:pageToken] = page if page
+ body = transport.call(:get, url, params, access_token)
+ items.concat(body["items"] || [])
page = body["nextPageToken"]
break unless page
end
- videos
+ items
end
#: () -> Hash[Symbol, String]
diff --git a/lib/rank_king/sources/youtube_playlist.rb b/lib/rank_king/sources/youtube_playlist.rb
index decf4d1..1e166ea 100644
--- a/lib/rank_king/sources/youtube_playlist.rb
+++ b/lib/rank_king/sources/youtube_playlist.rb
@@ -21,22 +21,22 @@ module RankKing
#: (List list) -> YouTubePlaylist
def self.for(list) = new(list)
+ # A choice of the user's playlists, fetched as the form is drawn. A
+ # Google error says so in place of the choice rather than failing
+ # the whole form.
#: (User user) -> _Component
def self.fields(user)
- Views::Sources::YouTubePlaylistFields.new(connected: !user.google_account.nil?, configured: Google.configured?)
+ account = user.google_account
+ playlists = account && Google.playlists(Google.access_token(account.refresh_token))
+ Views::Sources::YouTubePlaylistFields.new(playlists:, configured: Google.configured?)
+ rescue Google::Error => e
+ Views::Sources::YouTubePlaylistFields.new(playlists: nil, configured: true, error: e.message)
end
- # A playlist's id, from its URL or as pasted on its own.
- #: (String text) -> String?
- def self.playlist_id(text)
- text = text.strip
- text[/[?&]list=([\w-]+)/, 1] || text[/\A[\w-]+\z/]
- end
-
- # Follows the playlist from the new-list form and fetches it.
+ # Follows the playlist chosen on the new-list form and fetches it.
#: (List list, Hash[String, untyped] params) -> bool
def self.create(list, params)
- playlist_id = playlist_id(params["playlist"].to_s)
+ playlist_id = params["playlist"].to_s[/\A[\w-]+\z/]
return false unless playlist_id && list.user.google_account
DB[TABLE].insert(list_id: list.id, playlist_id:)
diff --git a/lib/rank_king/views/sources/youtube_playlist.rb b/lib/rank_king/views/sources/youtube_playlist.rb
index e8a0783..63fe2a8 100644
--- a/lib/rank_king/views/sources/youtube_playlist.rb
+++ b/lib/rank_king/views/sources/youtube_playlist.rb
@@ -5,21 +5,28 @@ require "phlex"
module RankKing
module Views
module Sources
- # The YouTube playlist source's fields on the new-list form: the
- # playlist to follow, once the user has connected Google.
+ # The YouTube playlist source's fields on the new-list form: a choice
+ # of the user's playlists, once they have connected Google. playlists
+ # is nil until then.
class YouTubePlaylistFields < Phlex::HTML
- def initialize(connected:, configured:)
- @connected = connected
+ def initialize(playlists:, configured:, error: nil)
+ @playlists = playlists
@configured = configured
+ @error = error
end
def view_template
- if @connected
+ if @error
+ p { "Google would not list your playlists: #{@error}" }
+ elsif @playlists&.empty?
+ p { "Your YouTube account has no playlists yet." }
+ elsif @playlists
label(class: "field") do
plain "Playlist"
- input(type: "text", name: "playlist", required: true,
- placeholder: "https://www.youtube.com/playlist?list=…")
- small { "Its URL or ID. Private playlists work too." }
+ select(name: "playlist", required: true) do
+ @playlists.each { |id, title| option(value: id) { title } }
+ end
+ small { "Private playlists are listed too. YouTube does not share Watch Later." }
end
elsif @configured
p do
diff --git a/sig/rank_king/views.rbs b/sig/rank_king/views.rbs
index 168667a..79b75e1 100644
--- a/sig/rank_king/views.rbs
+++ b/sig/rank_king/views.rbs
@@ -30,7 +30,7 @@ module RankKing
end
class YouTubePlaylistFields
- def initialize: (connected: bool, configured: bool) -> void
+ def initialize: (playlists: Array[[String, String]]?, configured: bool, ?error: String?) -> void
def call: () -> String
end
end
diff --git a/test/test_youtube.rb b/test/test_youtube.rb
index 3e0252d..04ccacb 100644
--- a/test/test_youtube.rb
+++ b/test/test_youtube.rb
@@ -20,11 +20,12 @@ class TestYouTube < Minitest::Test
# at a time, so fetching it has to follow nextPageToken.
class FakeGoogle
attr_reader :calls
- attr_accessor :videos, :error
+ attr_accessor :videos, :playlists, :error
def initialize
@calls = []
@videos = []
+ @playlists = [["PLabc123", "Music"], ["PLdef456", "Talks"]]
end
def call(method, url, params, access_token)
@@ -38,6 +39,8 @@ class TestYouTube < Minitest::Test
else
{ "access_token" => "access-#{params[:refresh_token]}" }
end
+ when RankKing::Google::PLAYLISTS_URL
+ { "items" => playlists.map { |id, title| { "id" => id, "snippet" => { "title" => title } } } }
when RankKing::Google::PLAYLIST_ITEMS_URL
page = params[:pageToken].to_i
body = { "items" => videos[page * 2, 2].to_a.map { |id, title| item(id, title) } }
@@ -146,17 +149,40 @@ class TestYouTube < Minitest::Test
refute_includes last_response.body, %(name="playlist")
end
- def test_new_list_takes_a_playlist_once_connected
+ def test_new_list_offers_the_users_playlists
connect_google
get "/lists/new"
- assert_includes last_response.body, %(name="playlist")
+ assert_includes last_response.body, %(<select name="playlist" required>)
+ assert_includes last_response.body, %(<option value="PLabc123">Music</option>)
+ assert_includes last_response.body, %(<option value="PLdef456">Talks</option>)
+
+ _, url, params, access_token = @google.calls.last
+ assert_equal RankKing::Google::PLAYLISTS_URL, url
+ assert_equal "true", params[:mine]
+ assert_equal "access-refresh-the-code", access_token
+ end
+
+ def test_new_list_says_when_there_are_no_playlists
+ connect_google
+ @google.playlists = []
+ get "/lists/new"
+ assert_includes last_response.body, "no playlists yet"
+ end
+
+ def test_new_list_still_works_when_google_fails
+ connect_google
+ @google.error = "Token has been expired or revoked."
+ get "/lists/new"
+ assert last_response.ok?
+ assert_includes last_response.body, "Token has been expired or revoked."
+ assert_includes last_response.body, %(name="items")
end
def test_create_a_list_from_a_playlist
connect_google
@google.videos = [["v1", "First"], ["v2", nil], ["v3", "Third"], ["v4", "Fourth"], ["v1", "First again"]]
- create_list(playlist: "https://www.youtube.com/playlist?list=#{PLAYLIST}&si=x")
+ create_list(playlist: PLAYLIST)
assert last_response.redirect?
list = RankKing::List.first
@@ -241,14 +267,6 @@ class TestYouTube < Minitest::Test
assert_equal 0, RankKing::DB[:youtube_playlist_items].count
end
- def test_playlist_id
- playlist_id = RankKing::Sources::YouTubePlaylist.method(:playlist_id)
- assert_equal PLAYLIST, playlist_id.(" #{PLAYLIST} ")
- assert_equal PLAYLIST, playlist_id.("https://www.youtube.com/playlist?list=#{PLAYLIST}")
- assert_equal PLAYLIST, playlist_id.("https://www.youtube.com/watch?v=abc&list=#{PLAYLIST}&index=2")
- assert_nil playlist_id.("https://www.youtube.com/watch?v=abc")
- end
-
private
def user = RankKing::User.first(login: LOGIN)