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
change
commit 37eab927ca56b2e28565fd59df0ca67987e416ed
author Claude <noreply@anthropic.com>
date
parent d245449e
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)