sources: hand the app's config to each source
Assisted-by: Claude Opus 5.5 via Claude Code
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