sources: hand the app's config to each source
Assisted-by: Claude Opus 5.5 via Claude Code
change lskopnsopuxztvrusmuolloxrumyuwnz
commit 216359ce59793431e252ef2a77f56c5fdbde2f71
author Alpha Chen <alpha@kejadlen.dev>
date
parent nmxqpwxs
diff --git a/AGENTS.md b/AGENTS.md
index d631527..00becad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,7 +25,7 @@ lib/rank_king/     — app code
   config.rb        — Config data class, reads from ENV
   db.rb            — Sequel connection + migration runner
   models.rb        — Sequel models (User, List, Item, Axis, Result)
-  innertube.rb     — YouTube client: InnerTube, signed with a pasted browser cookie
+  inner_tube.rb    — YouTube client: InnerTube, signed with a pasted browser cookie
   sources.rb       — Sources.fetch: a list's source_kind names a class in Sources
   sources/         — one class per source kind
   sync.rb          — reconciles a list's items with a fresh snapshot from a fetching source
@@ -86,10 +86,12 @@ fly.toml           — Fly config for the main demo and PR previews
 - `DATABASE_URL` — SQLite path (default: `:memory:`)
 - `SESSION_SECRET` — session signing key (auto-generated if unset)
 
-Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`. The app
-hands its `innertube` client to every source; the YouTube tests subclass
-the app to return an `InnerTube` subclass whose `request` answers
-without the network.
+Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`. Clients that
+talk to the outside world live on `Config`, which the app hands to every
+source; a source types the argument as an interface naming only what it
+reads (`Sources::_InnerTubeConfig`). The YouTube tests subclass the app
+to override `config` with an `InnerTube` subclass whose `request`
+answers without the network.
 
 ## YouTube
 
diff --git a/lib/rank_king/config.rb b/lib/rank_king/config.rb
index efbcda8..54bbf8f 100644
--- a/lib/rank_king/config.rb
+++ b/lib/rank_king/config.rb
@@ -2,11 +2,14 @@
 
 require "securerandom"
 
+require_relative "inner_tube"
+
 module RankKing
   # @rbs skip
   Config = Data.define(
     :database_url,
     :session_secret,
+    :inner_tube,
   )
 
   class Config
@@ -20,6 +23,7 @@ module RankKing
       new(
         database_url: env.fetch("DATABASE_URL", ":memory:"),
         session_secret: env.fetch("SESSION_SECRET") { SecureRandom.hex(64) },
+        inner_tube: InnerTube.new,
       )
     end
   end
diff --git a/lib/rank_king/innertube.rb b/lib/rank_king/inner_tube.rb
similarity index 99%
rename from lib/rank_king/innertube.rb
rename to lib/rank_king/inner_tube.rb
index 8c0d712..507ed3e 100644
--- a/lib/rank_king/innertube.rb
+++ b/lib/rank_king/inner_tube.rb
@@ -17,7 +17,7 @@ module RankKing
     class Error < StandardError; end
 
     # A video in a playlist, as YouTube describes it.
-    # Signed in sig/rank_king/innertube.rbs: a Data class.
+    # Signed in sig/rank_king/inner_tube.rbs: a Data class.
     # @rbs skip
     Video = Data.define(:id, :title, :channel_title, :thumbnail_url)
 
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index a32ad40..e6c48fa 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -8,8 +8,8 @@ module RankKing
     # over the list's items, which matches them by id, so there is nothing
     # to fetch and no table of its own.
     class Text
-      #: (List list, innertube: InnerTube) -> Text
-      def self.for(list, innertube:) = new(list)
+      #: (List list, Config config) -> Text
+      def self.for(list, config) = new(list)
 
       #: () -> String
       def self.title = "Typed list"
@@ -19,8 +19,8 @@ module RankKing
 
       # Adds the items typed into the new-list form, of which there must
       # be at least two.
-      #: (List list, Hash[String, untyped] params, innertube: InnerTube) -> bool
-      def self.create(list, params, innertube:)
+      #: (List list, Hash[String, untyped] params, Config config) -> bool
+      def self.create(list, params, config)
         names = params["items"].to_s.lines.map(&:strip).reject(&:empty?)
         return false if names.size < 2
 
diff --git a/lib/rank_king/sources/youtube.rb b/lib/rank_king/sources/youtube.rb
index 5acdf27..a64a79a 100644
--- a/lib/rank_king/sources/youtube.rb
+++ b/lib/rank_king/sources/youtube.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-require_relative "../innertube"
+require_relative "../inner_tube"
 require_relative "../sync"
 require_relative "../views/sources/youtube"
 
@@ -22,17 +22,17 @@ module RankKing
       #: () -> String
       def self.title = "YouTube playlist"
 
-      #: (List list, innertube: InnerTube) -> YouTube
-      def self.for(list, innertube:) = new(list, innertube:)
+      #: (List list, _InnerTubeConfig config) -> YouTube
+      def self.for(list, config) = new(list, config)
 
       #: (csrf: Method) -> _Component
       def self.fields(csrf:) = Views::Sources::YouTubeFields.new(csrf:)
 
       # The choice of playlists a pasted cookie opens. Whatever stops it
       # is said in the choice's place.
-      #: (String text, innertube: InnerTube) -> _Component
-      def self.playlists(text, innertube:)
-        Views::Sources::YouTubePlaylists.new(playlists: innertube.playlists(cookie(text)))
+      #: (untyped text, _InnerTubeConfig config) -> _Component
+      def self.playlists(text, config)
+        Views::Sources::YouTubePlaylists.new(playlists: config.inner_tube.playlists(cookie(text)))
       rescue InnerTube::Error => e
         Views::Sources::YouTubePlaylists.new(playlists: nil, error: e.message)
       end
@@ -44,21 +44,21 @@ module RankKing
 
       # Follows the playlist chosen on the new-list form and fetches it
       # with the cookie pasted there.
-      #: (List list, Hash[String, untyped] params, innertube: InnerTube) -> bool
-      def self.create(list, params, innertube:)
+      #: (List list, Hash[String, untyped] params, _InnerTubeConfig config) -> bool
+      def self.create(list, params, config)
         playlist_id = params["playlist"].to_s[/\A[\w-]+\z/]
         cookie = InnerTube.cookie(params["cookie"].to_s)
         return false unless playlist_id && cookie
 
         DB[TABLE].insert(list_id: list.id, playlist_id:)
-        new(list, innertube:).fetch(cookie)
+        new(list, config).fetch(cookie)
         true
       end
 
-      #: (List list, innertube: InnerTube) -> void
-      def initialize(list, innertube:)
+      #: (List list, _InnerTubeConfig config) -> void
+      def initialize(list, config)
         @list = list
-        @innertube = innertube
+        @inner_tube = config.inner_tube
       end
 
       #: () -> String
@@ -76,7 +76,7 @@ module RankKing
       #: (String cookie) -> Sync::Summary
       def fetch(cookie)
         # Kept for saved, which caches each video's details by its item.
-        @videos = @innertube.playlist_videos(cookie, playlist_id).uniq(&:id).to_h { |video|
+        @videos = @inner_tube.playlist_videos(cookie, playlist_id).uniq(&:id).to_h { |video|
           [video.id, video] #: [String, InnerTube::Video]
         }
         entries = @videos.each_value.map { |video| Entry.new(key: video.id, name: video.title) }
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index a51aa7a..813d343 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -4,7 +4,7 @@ require "roda"
 
 require_relative "models"
 require_relative "proxy_auth"
-require_relative "innertube"
+require_relative "inner_tube"
 require_relative "views/home"
 require_relative "views/list/new"
 require_relative "views/list/show"
@@ -40,7 +40,7 @@ module RankKing
       # A fragment for the new-list form: the playlists a pasted cookie
       # opens. It is a POST so the cookie stays out of URLs and logs.
       r.post "youtube", "playlists" do
-        Sources::YouTube.playlists(r.params["cookie"], innertube:).call
+        Sources::YouTube.playlists(r.params["cookie"], config).call
       end
 
       r.on "lists" do
@@ -58,7 +58,7 @@ module RankKing
             # down and nothing is made.
             list = DB.transaction {
               l = List.create(name: name, user_id: user.id, source_kind: source_kind)
-              raise Sequel::Rollback unless Sources.fetch(source_kind).create(l, r.params, innertube:)
+              raise Sequel::Rollback unless Sources.fetch(source_kind).create(l, r.params, config)
 
               axis_names.each do |n|
                 Axis.create(list_id: l.id, name: n)
@@ -166,13 +166,12 @@ module RankKing
 
     private
 
-    # The InnerTube client every route and source goes through. Tests
-    # subclass the app to hand back a fake one.
-    #: () -> InnerTube
-    def innertube = InnerTube.new
+    # What the app runs with. Tests subclass the app to swap in fakes.
+    #: () -> Config
+    def config = CONFIG
 
     #: (List list) -> Sources::_Source
-    def source(list) = Sources.fetch(list.source_kind).for(list, innertube:)
+    def source(list) = Sources.fetch(list.source_kind).for(list, config)
 
     # Names from a repeated field (`axes[]`), one per tag.
     #: (untyped) -> Array[String]
diff --git a/sig/rank_king/config.rbs b/sig/rank_king/config.rbs
index f359a38..c1447e5 100644
--- a/sig/rank_king/config.rbs
+++ b/sig/rank_king/config.rbs
@@ -5,7 +5,8 @@ module RankKing
   class Config
     attr_reader database_url: String
     attr_reader session_secret: String
+    attr_reader inner_tube: InnerTube
 
-    def self.new: (database_url: String, session_secret: String) -> instance
+    def self.new: (database_url: String, session_secret: String, inner_tube: InnerTube) -> instance
   end
 end
diff --git a/sig/rank_king/innertube.rbs b/sig/rank_king/inner_tube.rbs
similarity index 100%
rename from sig/rank_king/innertube.rbs
rename to sig/rank_king/inner_tube.rbs
diff --git a/sig/rank_king/sources.rbs b/sig/rank_king/sources.rbs
index fb54a16..93150bb 100644
--- a/sig/rank_king/sources.rbs
+++ b/sig/rank_king/sources.rbs
@@ -35,9 +35,9 @@ module RankKing
 
     # A source kind: a class in Sources, named by a list's source_kind.
     interface _SourceClass
-      # The source for a list, configured from the kind's own table. Every
-      # kind is handed the app's InnerTube client, which only YouTube uses.
-      def for: (List list, innertube: InnerTube) -> _Source
+      # The source for a list, configured from the kind's own table and
+      # the app's config.
+      def for: (List list, Config config) -> _Source
 
       # What the kind is called where a list's source is chosen or shown.
       def title: () -> String
@@ -48,7 +48,12 @@ module RankKing
 
       # Sets a new list up from what those fields posted: false, and the
       # list is not made, when they are not enough to go on.
-      def create: (List list, Hash[String, untyped] params, innertube: InnerTube) -> bool
+      def create: (List list, Hash[String, untyped] params, Config config) -> bool
+    end
+
+    # All a YouTube source reads off the app's config.
+    interface _InnerTubeConfig
+      def inner_tube: () -> InnerTube
     end
 
     # A view component, as a source's form is.
diff --git a/test/test_youtube.rb b/test/test_youtube.rb
index 54971d8..bc8eff8 100644
--- a/test/test_youtube.rb
+++ b/test/test_youtube.rb
@@ -10,11 +10,11 @@ require_relative "../lib/rank_king/web"
 class TestYouTube < Minitest::Test
   include Rack::Test::Methods
 
-  # The app, with the fake InnerTube in place of the real one.
+  # The app, with the fake InnerTube in its config.
   class App < RankKing::Web
-    singleton_class.attr_accessor :innertube
+    singleton_class.attr_accessor :inner_tube
 
-    private def innertube = self.class.innertube
+    private def config = RankKing::CONFIG.with(inner_tube: self.class.inner_tube)
   end
 
   def app = App.app
@@ -102,8 +102,8 @@ class TestYouTube < Minitest::Test
     RankKing::DB[:lists].delete
     RankKing::DB[:users].delete
 
-    @innertube = FakeInnerTube.new
-    App.innertube = @innertube
+    @inner_tube = FakeInnerTube.new
+    App.inner_tube = @inner_tube
   end
 
   def test_new_list_asks_for_a_cookie_before_the_playlists
@@ -112,7 +112,7 @@ class TestYouTube < Minitest::Test
     assert_includes last_response.body, %(<textarea name="cookie")
     assert_includes last_response.body, %(data-playlists="/youtube/playlists")
     refute_includes last_response.body, %(name="playlist")
-    assert_empty @innertube.calls
+    assert_empty @inner_tube.calls
   end
 
   def test_a_pasted_cookie_lists_the_playlists
@@ -122,29 +122,29 @@ class TestYouTube < Minitest::Test
     assert_includes last_response.body, %(<option value="WL">Watch later</option>)
     assert_includes last_response.body, %(<option value="PLabc123">Music</option>)
     refute_includes last_response.body, "<html"
-    assert_equal [COOKIE, { browseId: "FEplaylist_aggregation" }], @innertube.calls.last
+    assert_equal [COOKIE, { browseId: "FEplaylist_aggregation" }], @inner_tube.calls.last
   end
 
   def test_listing_playlists_needs_the_form_token
     post "/youtube/playlists", { cookie: COOKIE }
     assert_equal 403, last_response.status
-    assert_empty @innertube.calls
+    assert_empty @inner_tube.calls
   end
 
   def test_a_cookie_with_nothing_to_sign_with_lists_nothing
     playlists("SID=sid; HSID=hsid")
     assert_includes last_response.body, "no SAPISID"
-    assert_empty @innertube.calls
+    assert_empty @inner_tube.calls
   end
 
   def test_says_when_there_are_no_playlists
-    @innertube.user_playlists = []
+    @inner_tube.user_playlists = []
     playlists(COOKIE)
     assert_includes last_response.body, "no playlists yet"
   end
 
   def test_says_when_youtube_signed_the_cookie_out
-    @innertube.signed_out = true
+    @inner_tube.signed_out = true
     playlists(COOKIE)
     assert last_response.ok?
     assert_includes last_response.body, "YouTube signed the cookie out."
@@ -159,7 +159,7 @@ class TestYouTube < Minitest::Test
   end
 
   def test_create_a_list_from_a_playlist
-    @innertube.videos = [["v1", "First"], ["v2", nil], ["v3", "Third"], ["v4", "Fourth"], ["v1", "First again"]]
+    @inner_tube.videos = [["v1", "First"], ["v2", nil], ["v3", "Third"], ["v4", "Fourth"], ["v1", "First again"]]
 
     create_list(playlist: PLAYLIST)
     assert last_response.redirect?
@@ -175,23 +175,23 @@ class TestYouTube < Minitest::Test
     assert_equal "A channel", first[:channel_title]
     assert_equal "https://i.ytimg.com/vi/v1/mqdefault.jpg", first[:thumbnail_url]
 
-    assert_equal [{ browseId: "VLPLabc123" }, { continuation: "1" }, { continuation: "2" }], @innertube.calls.map(&:last)
-    assert_equal [COOKIE], @innertube.calls.map(&:first).uniq
+    assert_equal [{ browseId: "VLPLabc123" }, { continuation: "1" }, { continuation: "2" }], @inner_tube.calls.map(&:last)
+    assert_equal [COOKIE], @inner_tube.calls.map(&:first).uniq
   end
 
   def test_create_a_list_from_watch_later
-    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
+    @inner_tube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: "WL")
 
     assert_equal %w[First Second], names(RankKing::List.first)
-    assert_equal({ browseId: "VLWL" }, @innertube.calls.last.last)
+    assert_equal({ browseId: "VLWL" }, @inner_tube.calls.last.last)
   end
 
   def test_create_needs_a_cookie
     create_list(playlist: PLAYLIST, cookie: "SID=sid")
     assert_equal 422, last_response.status
     assert_equal 0, RankKing::List.count
-    assert_empty @innertube.calls
+    assert_empty @inner_tube.calls
   end
 
   def test_create_needs_a_playlist
@@ -201,28 +201,28 @@ class TestYouTube < Minitest::Test
   end
 
   def test_sync_adds_archives_and_restores_by_video
-    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
+    @inner_tube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
     list = RankKing::List.first
     first, second = list.items_dataset.order(:id).all
 
-    @innertube.videos = [["v1", "First, renamed"], ["v3", "Third"]]
+    @inner_tube.videos = [["v1", "First, renamed"], ["v3", "Third"]]
     sync(list, cookie: "SAPISID=fresh")
     assert last_response.redirect?
     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]
-    assert_equal "SAPISID=fresh", @innertube.calls.last.first
+    assert_equal "SAPISID=fresh", @inner_tube.calls.last.first
 
-    @innertube.videos = [["v2", "Second"]]
+    @inner_tube.videos = [["v2", "Second"]]
     sync(list)
     assert_equal %w[Second], names(list)
     assert_nil second.reload.archived_at
   end
 
   def test_sync_dialog_asks_for_a_cookie_and_shows_progress
-    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
+    @inner_tube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
 
     get "/lists/#{RankKing::List.first.id}"
@@ -232,23 +232,23 @@ class TestYouTube < Minitest::Test
   end
 
   def test_sync_needs_a_cookie
-    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
+    @inner_tube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
     list = RankKing::List.first
-    @innertube.calls.clear
+    @inner_tube.calls.clear
 
     sync(list, cookie: "")
     assert_equal 502, last_response.status
     assert_includes last_response.body, "no SAPISID"
-    assert_empty @innertube.calls
+    assert_empty @inner_tube.calls
   end
 
   def test_a_signed_out_cookie_archives_nothing
-    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
+    @inner_tube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
     list = RankKing::List.first
 
-    @innertube.signed_out = true
+    @inner_tube.signed_out = true
     sync(list)
     assert_equal 502, last_response.status
     assert_includes last_response.body, "YouTube: YouTube signed the cookie out."
@@ -256,11 +256,11 @@ class TestYouTube < Minitest::Test
   end
 
   def test_youtube_errors_are_reported
-    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
+    @inner_tube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
     list = RankKing::List.first
 
-    @innertube.error = "Request contains an invalid argument."
+    @inner_tube.error = "Request contains an invalid argument."
     sync(list)
     assert_equal 502, last_response.status
     assert_includes last_response.body, "Request contains an invalid argument."
@@ -268,7 +268,7 @@ class TestYouTube < Minitest::Test
   end
 
   def test_deleting_a_youtube_list
-    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
+    @inner_tube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
     list = RankKing::List.first