youtube: read through InnerTube with a pasted cookie
Advanced Protection accounts cannot grant OAuth to an unverified client, and the Data API never returns Watch Later. The cookie is pasted for each fetch so rank-king never holds a full-session credential.

Assisted-by: Claude Opus 5.5 via Claude Code
change nmxqpwxssqrylotsllqvuxwmqqvkwqnu
commit ac28c84f3b27c3895222d7907e48405410e83524
author Alpha Chen <alpha@kejadlen.dev>
date
parent mwmkpxvk
diff --git a/AGENTS.md b/AGENTS.md
index 1358e0a..d631527 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,8 +24,8 @@ db/migrate/        — Sequel migrations
 lib/rank_king/     — app code
   config.rb        — Config data class, reads from ENV
   db.rb            — Sequel connection + migration runner
-  models.rb        — Sequel models (User, GoogleAccount, List, Item, Axis, Result)
-  google.rb        — Google client: OAuth and the YouTube Data API
+  models.rb        — Sequel models (User, List, Item, Axis, Result)
+  innertube.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
@@ -41,7 +41,7 @@ Steepfile          — type checking config
 public/            — static assets
   vendor/gloss/    — vendored Gloss CSS and JS (refresh with rake gloss:vendor)
   css/app.css      — Rank King's layout over Gloss
-  js/app.js        — axis tags, confirmations, busy buttons, new-list source fields
+  js/app.js        — axis tags, confirmations, busy buttons, new-list source fields, YouTube playlist picker
 .github/workflows/ — CI, PR previews, preview reaper (GitHub Actions)
 Dockerfile         — production image
 fly.toml           — Fly config for the main demo and PR previews
@@ -50,9 +50,9 @@ fly.toml           — Fly config for the main demo and PR previews
 
 ## Domain model
 
-- **User** has many Lists; identified by the login on the `Remote-User` header. Their settings page (`/settings`) connects a Google account (**GoogleAccount**, which keeps only the refresh token)
+- **User** has many Lists; identified by the login on the `Remote-User` header
 - **List** belongs to a User and has many Items and Axes, and one source (`source_kind` names a class in `Sources`). Each source renders its own sync dialog on the list page and applies what it posts: `Text`'s edits items by id, and a kind that fetches calls `Sync.call`. A fetching kind keeps its config in its own table keyed by `list_id` and its per-item details in a table keyed by `item_id`
-- Sources: `Text` (typed in, edited by id) and `YouTube` (follows one of the owner's playlists through their Google account; `youtube_sources` by `list_id`, `youtube_items` by `item_id`)
+- Sources: `Text` (typed in, edited by id) and `YouTube` (follows one of the owner's playlists, Watch Later included, with a youtube.com cookie pasted for each fetch and never kept; `youtube_sources` by `list_id`, `youtube_items` by `item_id`)
 - **Item** belongs to a List; removed items, and items missing from the latest sync, are archived (`archived_at`) and hidden everywhere
 - **Axis** belongs to a List, has many Results
 - **Result** belongs to an Axis, a winner (Item), and a loser (Item)
@@ -85,18 +85,30 @@ 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)
-- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET_COMMAND` (required) — a
-  Google OAuth web client with the YouTube Data API enabled, for
-  connecting Google. The secret is whatever the command prints, run once
-  at boot (e.g. `op read op://…` or `cat ~/.config/rank-king/secret`).
-  Its redirect URI is `<app URL>/settings/google/callback`. A consent
-  screen left in Testing mode expires refresh tokens after 7 days. The
-  Fly demos set placeholders in `fly.toml`, since anyone can open them.
-
-Tests set `DATABASE_URL=:memory:` and a dummy Google client in
-`test/test_helper.rb`. The app hands its `google` client to every
-source; the YouTube tests subclass the app to return a `Google`
-subclass whose `request` answers without the network.
+
+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.
+
+## YouTube
+
+YouTube is read through InnerTube (`youtubei/v1/browse`), the private
+API youtube.com's own pages call, not OAuth and the Data API: an
+Advanced Protection account cannot grant OAuth to an unverified client,
+and the Data API never returns Watch Later. Requests carry the user's
+pasted Cookie header and a `SAPISIDHASH` Authorization signed from it.
+The cookie is pasted on the new-list form, where app.js posts it to
+`/youtube/playlists` for the picker, and again in each sync dialog;
+rank-king never stores it.
+
+- `FEplaylist_aggregation` lists playlists (Watch Later and Liked
+  videos included); `VL<playlist id>` browses one.
+- A signed-out cookie still gets `200`, with `logged_in` `0` and an
+  error alert, so `InnerTube#check` raises rather than let a sync
+  archive the whole list.
+- `InnerTube::CLIENT`'s `clientVersion` may need bumping if YouTube
+  starts refusing it.
 
 ## Conventions
 
diff --git a/Steepfile b/Steepfile
index c928741..ef1fdbf 100644
--- a/Steepfile
+++ b/Steepfile
@@ -10,5 +10,5 @@ target :lib do
   ignore "lib/rank_king/views", inline: true
   signature "sig"
 
-  library "securerandom", "open3", "net-http", "json", "uri"
+  library "securerandom", "digest", "net-http", "json", "uri"
 end
diff --git a/db/migrate/005_add_youtube.rb b/db/migrate/005_add_youtube.rb
index b6bf69f..f102690 100644
--- a/db/migrate/005_add_youtube.rb
+++ b/db/migrate/005_add_youtube.rb
@@ -2,17 +2,6 @@
 
 Sequel.migration do
   change do
-    # A user's connection to Google, for reading their YouTube playlists.
-    # Only the refresh token is kept: each sync trades it for an access
-    # token.
-    create_table(:google_accounts) do
-      primary_key :id
-      foreign_key :user_id, :users, null: false, unique: true, on_delete: :cascade
-      String :refresh_token, null: false
-      DateTime :created_at, null: false
-      DateTime :updated_at, null: false
-    end
-
     # The YouTube source's config: which playlist a list follows.
     create_table(:youtube_sources) do
       foreign_key :list_id, :lists, primary_key: true, on_delete: :cascade
diff --git a/fly.toml b/fly.toml
index 4cb066b..f8e8383 100644
--- a/fly.toml
+++ b/fly.toml
@@ -16,14 +16,6 @@ primary_region = 'sjc'
 
 [build]
 
-# The app requires a Google client. The demos get a placeholder rather
-# than the real one: anyone who opens them is the one demo user, so a
-# Google account connected there would be everyone's. Connect Google on
-# a demo fails at Google's consent screen.
-[env]
-  GOOGLE_CLIENT_ID = 'demo'
-  GOOGLE_CLIENT_SECRET_COMMAND = 'echo demo'
-
 [processes]
   app = 'rackup -o 0.0.0.0 demo.ru'
 
diff --git a/lib/rank_king/config.rb b/lib/rank_king/config.rb
index 3a4204f..efbcda8 100644
--- a/lib/rank_king/config.rb
+++ b/lib/rank_king/config.rb
@@ -1,6 +1,5 @@
 # frozen_string_literal: true
 
-require "open3"
 require "securerandom"
 
 module RankKing
@@ -8,8 +7,6 @@ module RankKing
   Config = Data.define(
     :database_url,
     :session_secret,
-    :google_client_id,
-    :google_client_secret,
   )
 
   class Config
@@ -23,23 +20,8 @@ module RankKing
       new(
         database_url: env.fetch("DATABASE_URL", ":memory:"),
         session_secret: env.fetch("SESSION_SECRET") { SecureRandom.hex(64) },
-        # A Google OAuth client, for connecting YouTube. The secret comes
-        # from a command, so it can live in a file or a password manager
-        # rather than the environment.
-        google_client_id: env.fetch("GOOGLE_CLIENT_ID"),
-        google_client_secret: secret_from(env.fetch("GOOGLE_CLIENT_SECRET_COMMAND")),
       )
     end
-
-    # Runs a shell command, such as `op read op://…` or `cat path`, and
-    # returns what it prints.
-    #: (String command) -> String
-    def self.secret_from(command)
-      output, status = Open3.capture2(command)
-      raise "#{command} exited with #{status.exitstatus}" unless status.success?
-
-      output.strip
-    end
   end
 
   CONFIG = Config.from_env
diff --git a/lib/rank_king/google.rb b/lib/rank_king/google.rb
deleted file mode 100644
index 2df93f8..0000000
--- a/lib/rank_king/google.rb
+++ /dev/null
@@ -1,141 +0,0 @@
-# frozen_string_literal: true
-
-require "json"
-require "net/http"
-require "uri"
-
-module RankKing
-  # The Google APIs rank-king calls: OAuth, to connect a user's account,
-  # and the YouTube Data API, to read their playlists. Every call goes
-  # through #request, which a subclass overrides to answer without the
-  # network, as the tests' does.
-  class Google
-    # 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"
-    PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"
-    SCOPE = "https://www.googleapis.com/auth/youtube.readonly"
-
-    #: (client_id: String, client_secret: String) -> void
-    def initialize(client_id:, client_secret:)
-      @client_id = client_id
-      @client_secret = client_secret
-    end
-
-    # Where to send the user to grant read access to their YouTube
-    # account. prompt=consent makes Google send a refresh token every time.
-    #: (redirect_uri: String, state: String) -> String
-    def authorize_url(redirect_uri:, state:)
-      query = URI.encode_www_form(
-        client_id: @client_id, redirect_uri:, state:, scope: SCOPE,
-        response_type: "code", access_type: "offline", prompt: "consent",
-      )
-      "#{AUTH_URL}?#{query}"
-    end
-
-    # Trades the code Google sent back for a refresh token.
-    #: (String code, redirect_uri: String) -> String
-    def refresh_token(code, redirect_uri:)
-      body = request(:post, TOKEN_URL, { **client, code:, redirect_uri:, grant_type: "authorization_code" }, nil)
-      body.fetch("refresh_token") { raise Error, "Google sent no refresh token" }
-    end
-
-    #: (String refresh_token) -> String
-    def access_token(refresh_token)
-      body = request(:post, TOKEN_URL, { **client, refresh_token:, grant_type: "refresh_token" }, nil)
-      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 playlists(access_token)
-      pages(access_token, PLAYLISTS_URL, { part: "snippet", mine: "true" }).map { |item|
-        [item.fetch("id"), item.dig("snippet", "title").to_s] #: [String, String]
-      }
-    end
-
-    # 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 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")
-        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
-
-    private
-
-    # The medium thumbnail (320×180), or the nearest size there is.
-    #: (Hash[String, untyped] thumbnails) -> String?
-    def 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 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 = request(:get, url, params, access_token)
-
-        items.concat(body["items"] || [])
-        page = body["nextPageToken"]
-        break unless page
-      end
-      items
-    end
-
-    #: () -> Hash[Symbol, String]
-    def client = { client_id: @client_id, client_secret: @client_secret }
-
-    # Makes one request and returns its JSON body: a POST of a form, or
-    # a GET with a query, authorized by the access token when there is
-    # one.
-    #: (Symbol method, String url, Hash[Symbol, String] params, String? access_token) -> Hash[String, untyped]
-    def request(method, url, params, access_token)
-      uri = URI(url)
-      request = if method == :post
-        Net::HTTP::Post.new(uri).tap do |post|
-          post.set_form_data(params)
-        end
-      else
-        uri.query = URI.encode_www_form(params)
-        Net::HTTP::Get.new(uri)
-      end
-      request["Authorization"] = "Bearer #{access_token}" if access_token
-
-      response = Net::HTTP.start(uri.hostname.to_s, uri.port, use_ssl: true) { |http| http.request(request) }
-      body = JSON.parse(response.body.to_s)
-      return body if response.is_a?(Net::HTTPSuccess)
-
-      error = body["error"]
-      message = error.is_a?(Hash) ? error["message"] : body["error_description"] || error
-      raise Error, message || "HTTP #{response.code}"
-    rescue JSON::ParserError
-      raise Error, "Google answered HTTP #{response&.code} with no JSON"
-    end
-  end
-end
diff --git a/lib/rank_king/innertube.rb b/lib/rank_king/innertube.rb
new file mode 100644
index 0000000..8c0d712
--- /dev/null
+++ b/lib/rank_king/innertube.rb
@@ -0,0 +1,175 @@
+# frozen_string_literal: true
+
+require "digest"
+require "json"
+require "net/http"
+require "uri"
+
+module RankKing
+  # InnerTube, the API youtube.com's own pages call, signed in with the
+  # user's browser cookie. It stands in for OAuth and the YouTube Data
+  # API, which an Advanced Protection account cannot grant to an
+  # unverified client and which never shares Watch Later. Every call goes
+  # through #request, which a subclass overrides to answer without the
+  # network, as the tests' does.
+  class InnerTube
+    # YouTube 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/innertube.rbs: a Data class.
+    # @rbs skip
+    Video = Data.define(:id, :title, :channel_title, :thumbnail_url)
+
+    ORIGIN = "https://www.youtube.com"
+    BROWSE_URL = "#{ORIGIN}/youtubei/v1/browse?prettyPrint=false"
+    # The web client InnerTube is told it is serving. YouTube may refuse
+    # a version once it is old enough.
+    CLIENT = { clientName: "WEB", clientVersion: "2.20261020.00.00", hl: "en", gl: "US" }.freeze
+    # Each cookie a request is signed with, and the scheme its signature
+    # goes under in the Authorization header.
+    API_COOKIES = {
+      "SAPISID" => "SAPISIDHASH",
+      "__Secure-1PAPISID" => "SAPISID1PHASH",
+      "__Secure-3PAPISID" => "SAPISID3PHASH",
+    }.freeze #: Hash[String, String]
+
+    # A Cookie header as pasted, without the "Cookie:" it may have been
+    # copied with, or nil when it has no cookie to sign requests with.
+    #: (String text) -> String?
+    def self.cookie(text)
+      cookie = text.strip.delete_prefix("Cookie:").strip.gsub(/\s*\n\s*/, " ")
+      cookie if API_COOKIES.keys.any? { |name| jar(cookie)[name] }
+    end
+
+    #: (String cookie) -> Hash[String, String]
+    def self.jar(cookie)
+      jar = {} #: Hash[String, String]
+      cookie.split(/;\s*/).each do |pair|
+        name, value = pair.split("=", 2)
+        jar[name.to_s] = value if value
+      end
+      jar
+    end
+
+    # Google's signature for a request made with a cookie: for each API
+    # cookie, a SHA-1 of the time, its value, and the origin.
+    #: (String cookie, ?at: Integer) -> String
+    def self.authorization(cookie, at: Time.now.to_i)
+      jar = jar(cookie)
+      API_COOKIES.filter_map { |name, scheme|
+        value = jar[name]
+        "#{scheme} #{at}_#{Digest::SHA1.hexdigest("#{at} #{value} #{ORIGIN}")}" if value
+      }.join(" ")
+    end
+
+    # The user's playlists, as [playlist id, title], in YouTube's order:
+    # Watch Later and Liked videos among their own and the ones they saved.
+    #: (String cookie) -> Array[[String, String]]
+    def playlists(cookie)
+      lockups = pages(cookie, { browseId: "FEplaylist_aggregation" }).flat_map { |page| find_all(page, "lockupViewModel") }
+      lockups.filter_map { |lockup|
+        next unless lockup["contentType"] == "LOCKUP_CONTENT_TYPE_PLAYLIST"
+
+        title = lockup.dig("metadata", "lockupMetadataViewModel", "title", "content").to_s
+        [lockup.fetch("contentId"), title] #: [String, String]
+      }
+    end
+
+    # The videos in a playlist, in playlist order. Deleted videos, and
+    # private ones the user cannot see, are not playable and are left out.
+    # Some videos come with no channel.
+    #: (String cookie, String playlist_id) -> Array[Video]
+    def playlist_videos(cookie, playlist_id)
+      renderers = pages(cookie, { browseId: "VL#{playlist_id}" }).flat_map { |page| find_all(page, "playlistVideoRenderer") }
+      renderers.filter_map { |renderer|
+        next unless renderer["isPlayable"]
+
+        id = renderer.fetch("videoId")
+        Video.new(
+          id:,
+          title: text(renderer["title"]).to_s,
+          channel_title: text(renderer["shortBylineText"]),
+          thumbnail_url: "https://i.ytimg.com/vi/#{id}/mqdefault.jpg",
+        )
+      }
+    end
+
+    private
+
+    # Every page of a browse, following its continuations.
+    #: (String cookie, Hash[Symbol, String] body) -> Array[Hash[String, untyped]]
+    def pages(cookie, body)
+      pages = [] #: Array[Hash[String, untyped]]
+      loop do
+        page = request(cookie, body)
+        check(page) if pages.empty?
+        pages << page
+
+        token = find_all(page, "continuationItemRenderer")
+          .flat_map { |item| find_all(item, "continuationCommand") }
+          .first&.dig("token")
+        break unless token
+
+        body = { continuation: token }
+      end
+      pages
+    end
+
+    # A cookie YouTube has signed out still gets answers, as anyone would,
+    # so a sync must not take them for an empty playlist.
+    #: (Hash[String, untyped] page) -> void
+    def check(page)
+      params = page.dig("responseContext", "serviceTrackingParams").to_a.flat_map { |service| service["params"].to_a }
+      unless params.any? { |param| param["key"] == "logged_in" && param["value"] == "1" }
+        raise Error, "YouTube signed the cookie out. Paste a fresh one in settings."
+      end
+
+      alert = find_all(page, "alertRenderer").find { |a| a["type"] == "ERROR" }
+      raise Error, text(alert["text"]) || "YouTube sent an error" if alert
+    end
+
+    # The words of an InnerTube text, which comes either whole or in runs.
+    #: (untyped node) -> String?
+    def text(node)
+      return unless node.is_a?(Hash)
+
+      node["simpleText"] || node["runs"]&.map { |run| run["text"] }&.join
+    end
+
+    # Every value under key, however deep: InnerTube nests the same
+    # renderers differently from page to page.
+    #: (untyped node, String key, ?Array[Hash[String, untyped]] found) -> Array[Hash[String, untyped]]
+    def find_all(node, key, found = [])
+      case node
+      when Hash
+        node.each { |k, v| k == key ? found << v : find_all(v, key, found) }
+      when Array
+        node.each { |v| find_all(v, key, found) }
+      end
+      found
+    end
+
+    # Makes one browse request and returns its JSON body.
+    #: (String cookie, Hash[Symbol, String] body) -> Hash[String, untyped]
+    def request(cookie, body)
+      uri = URI(BROWSE_URL)
+      post = Net::HTTP::Post.new(uri)
+      post["Cookie"] = cookie
+      post["Authorization"] = self.class.authorization(cookie)
+      post["Origin"] = ORIGIN
+      post["X-Origin"] = ORIGIN
+      post["X-Goog-AuthUser"] = "0"
+      post["Content-Type"] = "application/json"
+      post.body = JSON.generate({ context: { client: CLIENT }, **body })
+
+      response = Net::HTTP.start(uri.hostname.to_s, uri.port, use_ssl: true) { |http| http.request(post) }
+      json = JSON.parse(response.body.to_s)
+      return json if response.is_a?(Net::HTTPSuccess)
+
+      raise Error, json.dig("error", "message") || "HTTP #{response.code}"
+    rescue JSON::ParserError
+      raise Error, "YouTube answered HTTP #{response&.code} with no JSON"
+    end
+  end
+end
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
index 38a89e8..8e3f8ae 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -10,12 +10,6 @@ module RankKing
   # @rbs skip
   class User < Sequel::Model(DB[:users])
     one_to_many :lists, class: "RankKing::List"
-    one_to_one :google_account, class: "RankKing::GoogleAccount"
-  end
-
-  # @rbs skip
-  class GoogleAccount < Sequel::Model(DB[:google_accounts])
-    many_to_one :user, class: "RankKing::User"
   end
 
   # @rbs skip
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index 2262dd3..a32ad40 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -8,19 +8,19 @@ 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, google: Google) -> Text
-      def self.for(list, google:) = new(list)
+      #: (List list, innertube: InnerTube) -> Text
+      def self.for(list, innertube:) = new(list)
 
       #: () -> String
       def self.title = "Typed list"
 
-      #: (User user, google: Google) -> _Component
-      def self.fields(user, google:) = Views::Sources::TextFields.new
+      #: (csrf: Method) -> _Component
+      def self.fields(csrf:) = Views::Sources::TextFields.new
 
       # Adds the items typed into the new-list form, of which there must
       # be at least two.
-      #: (List list, Hash[String, untyped] params, google: Google) -> bool
-      def self.create(list, params, google:)
+      #: (List list, Hash[String, untyped] params, innertube: InnerTube) -> bool
+      def self.create(list, params, innertube:)
         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 bbc3b31..5acdf27 100644
--- a/lib/rank_king/sources/youtube.rb
+++ b/lib/rank_king/sources/youtube.rb
@@ -1,53 +1,64 @@
 # frozen_string_literal: true
 
-require_relative "../google"
+require_relative "../innertube"
 require_relative "../sync"
 require_relative "../views/sources/youtube"
 
 module RankKing
   module Sources
     # A list that follows one of the owner's YouTube playlists, read
-    # through their connected Google account, so private ones work too.
-    # Each sync fetches the whole playlist again.
+    # through InnerTube, so private ones and Watch Later work too. Nothing
+    # signs rank-king in for good: each fetch takes a youtube.com cookie
+    # pasted in for it, and the whole playlist is fetched again.
     class YouTube
       # Which playlist each list follows, keyed by list_id.
       TABLE = :youtube_sources
       # Which video each item is, keyed by item_id.
       ITEMS = :youtube_items
+      # Where the new-list form sends a pasted cookie for its choice of
+      # playlists.
+      PLAYLISTS_PATH = "/youtube/playlists"
 
       #: () -> String
       def self.title = "YouTube playlist"
 
-      #: (List list, google: Google) -> YouTube
-      def self.for(list, google:) = new(list, google:)
-
-      # 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, google: Google) -> _Component
-      def self.fields(user, google:)
-        account = user.google_account
-        playlists = account && google.playlists(google.access_token(account.refresh_token))
-        Views::Sources::YouTubeFields.new(playlists:)
-      rescue Google::Error => e
-        Views::Sources::YouTubeFields.new(playlists: nil, error: e.message)
+      #: (List list, innertube: InnerTube) -> YouTube
+      def self.for(list, innertube:) = new(list, innertube:)
+
+      #: (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)))
+      rescue InnerTube::Error => e
+        Views::Sources::YouTubePlaylists.new(playlists: nil, error: e.message)
       end
 
-      # Follows the playlist chosen on the new-list form and fetches it.
-      #: (List list, Hash[String, untyped] params, google: Google) -> bool
-      def self.create(list, params, google:)
+      #: (untyped text) -> String
+      def self.cookie(text)
+        InnerTube.cookie(text.to_s) or raise InnerTube::Error, "That cookie has no SAPISID to sign requests with."
+      end
+
+      # 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:)
         playlist_id = params["playlist"].to_s[/\A[\w-]+\z/]
-        return false unless playlist_id && list.user.google_account
+        cookie = InnerTube.cookie(params["cookie"].to_s)
+        return false unless playlist_id && cookie
 
         DB[TABLE].insert(list_id: list.id, playlist_id:)
-        new(list, google:).fetch
+        new(list, innertube:).fetch(cookie)
         true
       end
 
-      #: (List list, google: Google) -> void
-      def initialize(list, google:)
+      #: (List list, innertube: InnerTube) -> void
+      def initialize(list, innertube:)
         @list = list
-        @google = google
+        @innertube = innertube
       end
 
       #: () -> String
@@ -56,21 +67,17 @@ module RankKing
       #: (csrf: Method) -> _Component
       def form(csrf:) = Views::Sources::YouTube.new(list: @list, csrf:)
 
-      # The dialog posts nothing to apply: syncing is fetching.
+      # The dialog posts the cookie to fetch with.
       #: (Hash[String, untyped] params) -> void
       def sync(params)
-        fetch
+        fetch(self.class.cookie(params["cookie"]))
       end
 
-      #: () -> Sync::Summary
-      def fetch
-        account = @list.user.google_account
-        raise Google::Error, "Connect Google in settings to sync this list" unless account
-
-        access_token = @google.access_token(account.refresh_token)
+      #: (String cookie) -> Sync::Summary
+      def fetch(cookie)
         # 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]
+        @videos = @innertube.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) }
         Sync.call(@list, entries, source: self)
diff --git a/lib/rank_king/views/home.rb b/lib/rank_king/views/home.rb
index 2232771..1a9d8eb 100644
--- a/lib/rank_king/views/home.rb
+++ b/lib/rank_king/views/home.rb
@@ -14,8 +14,6 @@ module RankKing
       def view_template
         render Layout.new do
           render PageHeader.new(title: "Rank King") do
-            a(href: "/settings", class: "icon-button", data_size: "lg", aria_label: "Settings",
-              style: "--gl-icon: var(--rk-icon-settings)")
             a(href: "/lists/new", class: "btn", data_variant: "primary", data_size: "sm",
               style: "--gl-icon: var(--gl-icon-plus)") { "New list" }
           end
diff --git a/lib/rank_king/views/settings.rb b/lib/rank_king/views/settings.rb
deleted file mode 100644
index a5c63e8..0000000
--- a/lib/rank_king/views/settings.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-require "phlex"
-
-require_relative "layout"
-
-module RankKing
-  module Views
-    # The user's own settings, as against a list's. A future setting is a
-    # new section here.
-    class Settings < Phlex::HTML
-      def initialize(user:, csrf:)
-        @user = user
-        @csrf = csrf
-      end
-
-      def view_template
-        render Layout.new(title: "Settings — Rank King") do
-          render PageHeader.new(title: "Settings", back: "/", back_label: "Lists")
-
-          section do
-            h2(class: "type-label") { "Google" }
-            if @user.google_account
-              render_connected
-            else
-              p { "Connect Google to make lists from your YouTube playlists, private ones included." }
-              a(href: "/settings/google/connect", class: "btn", data_variant: "primary") { "Connect Google" }
-            end
-          end
-        end
-      end
-
-      private
-
-      def render_connected
-        action = "/settings/google/disconnect"
-        p { "Connected. Lists can sync from your YouTube playlists." }
-        form(method: "post", action:, data_confirm: "Disconnect Google? YouTube lists will stop syncing.") do
-          input(type: "hidden", name: "_csrf", value: @csrf.call(action))
-          button(type: "submit", data_variant: "danger", data_size: "sm") { "Disconnect" }
-        end
-      end
-    end
-  end
-end
diff --git a/lib/rank_king/views/sources/youtube.rb b/lib/rank_king/views/sources/youtube.rb
index 675ced5..f08612d 100644
--- a/lib/rank_king/views/sources/youtube.rb
+++ b/lib/rank_king/views/sources/youtube.rb
@@ -5,10 +5,38 @@ require "phlex"
 module RankKing
   module Views
     module Sources
-      # 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.
+      # Where a youtube.com cookie is pasted, on the new-list form and in
+      # the sync dialog. rank-king keeps it only for the one fetch.
+      class YouTubeCookie < Phlex::HTML
+        def view_template
+          label(class: "field") do
+            plain "YouTube cookie"
+            textarea(name: "cookie", rows: 3, required: true, autocomplete: "off", spellcheck: "false")
+            small { "The Cookie request header from a signed-in youtube.com tab, copied from your browser's developer tools. It is used for this fetch and not kept." }
+          end
+        end
+      end
+
+      # The YouTube playlist source's fields on the new-list form: a cookie,
+      # then a choice of the playlists it opens, which app.js fetches into
+      # [data-playlists] once the cookie is pasted.
       class YouTubeFields < Phlex::HTML
+        def initialize(csrf:)
+          @csrf = csrf
+        end
+
+        def view_template
+          path = RankKing::Sources::YouTube::PLAYLISTS_PATH
+          render YouTubeCookie.new
+          div(data_playlists: path, data_csrf: @csrf.call(path)) do
+            render YouTubePlaylists.new(playlists: nil)
+          end
+        end
+      end
+
+      # The choice of playlists, or why there is none yet. playlists is nil
+      # until a cookie is pasted.
+      class YouTubePlaylists < Phlex::HTML
         def initialize(playlists:, error: nil)
           @playlists = playlists
           @error = error
@@ -16,10 +44,12 @@ module RankKing
 
         def view_template
           if @error
-            p { "Google would not list your playlists: #{@error}" }
-          elsif @playlists&.empty?
+            p { "YouTube would not list your playlists: #{@error}" }
+          elsif @playlists.nil?
+            p { "Paste a cookie to choose one of your playlists." }
+          elsif @playlists.empty?
             p { "Your YouTube account has no playlists yet." }
-          elsif @playlists
+          else
             label(class: "field") do
               plain "Playlist"
               select(name: "playlist", required: true) do
@@ -27,20 +57,14 @@ module RankKing
                   option(value: id) { title }
                 end
               end
-              small { "Private playlists are listed too. YouTube does not share Watch Later." }
-            end
-          else
-            p do
-              plain "Connect Google in "
-              a(href: "/settings") { "your settings" }
-              plain " to make a list from a YouTube playlist."
+              small { "Watch Later and private playlists are listed too." }
             end
           end
         end
       end
 
-      # The YouTube playlist source's sync dialog: there is nothing to
-      # fill in, so it says what syncing does and shows it working.
+      # The YouTube playlist source's sync dialog: a fresh cookie to fetch
+      # with, and what syncing does, shown working.
       class YouTube < Phlex::HTML
         def initialize(list:, csrf:)
           @list = list
@@ -54,6 +78,7 @@ module RankKing
           form(id: "sync-form", method: "post", action:, data_busy: "Syncing…") do
             input(type: "hidden", name: "_csrf", value: @csrf.call(action))
             p { "Fetches the playlist again. New videos are added, and videos that left it are hidden." }
+            render YouTubeCookie.new
             if (synced_at = @list.synced_at)
               small { "Last synced #{synced_at.strftime("%b %-d, %Y at %-I:%M %p")}." }
             end
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 1d687e9..a51aa7a 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -1,13 +1,11 @@
 # frozen_string_literal: true
 
 require "roda"
-require "securerandom"
 
 require_relative "models"
 require_relative "proxy_auth"
-require_relative "google"
+require_relative "innertube"
 require_relative "views/home"
-require_relative "views/settings"
 require_relative "views/list/new"
 require_relative "views/list/show"
 require_relative "views/list/compare"
@@ -19,11 +17,11 @@ module RankKing
     plugin :static, %w[ /css /js /vendor ]
     plugin :sessions, secret: CONFIG.session_secret
     plugin :route_csrf, csrf_failure: :empty_403, check_request_methods: %w[POST]
-    plugin :error_handler, classes: [ProxyAuth::MissingLogin, Google::Error] do |e|
+    plugin :error_handler, classes: [ProxyAuth::MissingLogin, InnerTube::Error] do |e|
       response["Content-Type"] = "text/plain"
-      if e.is_a?(Google::Error)
+      if e.is_a?(InnerTube::Error)
         response.status = 502
-        "Google: #{e.message}\n"
+        "YouTube: #{e.message}\n"
       else
         response.status = 401
         "Unauthorized: #{e.message}.\n"
@@ -39,42 +37,10 @@ module RankKing
         Views::Home.new(lists: lists).call
       end
 
-      r.on "settings" do
-        r.get true do
-          Views::Settings.new(user:, csrf: method(:csrf_token)).call
-        end
-
-        # Connecting Google is the OAuth dance: off to Google with a state
-        # only this session knows, and back with a code for a refresh token.
-        r.on "google" do
-          redirect_uri = "#{r.base_url}/settings/google/callback"
-
-          r.get "connect" do
-            state = SecureRandom.hex(16)
-            session["google_state"] = state
-            r.redirect google.authorize_url(redirect_uri:, state:)
-          end
-
-          r.get "callback" do
-            state = session.delete("google_state").to_s
-            r.halt 403 if state.empty? || !Rack::Utils.secure_compare(state, r.params["state"].to_s)
-            # The user said no on Google's side.
-            r.redirect "/settings" if r.params["error"]
-
-            refresh_token = google.refresh_token(r.params["code"].to_s, redirect_uri:)
-            if (account = user.google_account)
-              account.update(refresh_token:)
-            else
-              GoogleAccount.create(user_id: user.id, refresh_token:)
-            end
-            r.redirect "/settings"
-          end
-
-          r.post "disconnect" do
-            user.google_account&.destroy
-            r.redirect "/settings"
-          end
-        end
+      # 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
       end
 
       r.on "lists" do
@@ -92,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, google:)
+              raise Sequel::Rollback unless Sources.fetch(source_kind).create(l, r.params, innertube:)
 
               axis_names.each do |n|
                 Axis.create(list_id: l.id, name: n)
@@ -108,7 +74,7 @@ module RankKing
         r.get "new" do
           kinds = Sources::KINDS.map { |kind|
             source = Sources.fetch(kind)
-            [kind, source.title, source.fields(user, google:)] #: [String, String, Sources::_Component]
+            [kind, source.title, source.fields(csrf: method(:csrf_token))] #: [String, String, Sources::_Component]
           }
           Views::ListViews::New.new(sources: kinds, csrf: method(:csrf_token)).call
         end
@@ -200,13 +166,13 @@ module RankKing
 
     private
 
-    # The Google client every route and source goes through. Tests
+    # The InnerTube client every route and source goes through. Tests
     # subclass the app to hand back a fake one.
-    #: () -> Google
-    def google = Google.new(client_id: CONFIG.google_client_id, client_secret: CONFIG.google_client_secret)
+    #: () -> InnerTube
+    def innertube = InnerTube.new
 
     #: (List list) -> Sources::_Source
-    def source(list) = Sources.fetch(list.source_kind).for(list, google:)
+    def source(list) = Sources.fetch(list.source_kind).for(list, innertube:)
 
     # Names from a repeated field (`axes[]`), one per tag.
     #: (untyped) -> Array[String]
diff --git a/public/css/app.css b/public/css/app.css
index 0df38f8..d8fe6b2 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -6,10 +6,6 @@
   /* Lucide "ellipsis", drawn the way Gloss draws its own icon tokens.
      Gloss has no "more" icon yet; this belongs upstream when it does. */
   --rk-icon-more: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='1'/%3E%3Ccircle cx='19' cy='12' r='1'/%3E%3Ccircle cx='5' cy='12' r='1'/%3E%3C/svg%3E");
-
-  /* Lucide "settings", drawn the same way. Gloss' own settings icon is a
-     ring of loose spokes that reads as a sun at this size, not a gear. */
-  --rk-icon-settings: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
 }
 
 /* -------------------- */
@@ -81,9 +77,6 @@ section {
   gap: var(--gl-space-2xs);
 }
 
-/* A section's own action keeps its button's width. */
-section > :is(a.btn, form) { align-self: flex-start; }
-
 /* Actions at the foot of a form: one on the right, or two pushed apart. */
 form footer {
   display: flex;
diff --git a/public/js/app.js b/public/js/app.js
index 66edbce..ba312fd 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -67,6 +67,31 @@
     if (event.target.matches("select[name=source]")) showSource(event.target);
   });
 
+  // A pasted YouTube cookie fetches its playlists into the form's
+  // [data-playlists], which the server draws as a fragment.
+  var playlistsTimer;
+  document.addEventListener("input", function (event) {
+    var cookie = event.target;
+    var fieldset = cookie.name === "cookie" && cookie.closest("fieldset");
+    var target = fieldset && fieldset.querySelector("[data-playlists]");
+    if (!target) return;
+
+    clearTimeout(playlistsTimer);
+    playlistsTimer = setTimeout(function () {
+      if (!cookie.value.trim()) return;
+
+      var body = new FormData();
+      body.append("cookie", cookie.value);
+      body.append("_csrf", target.dataset.csrf);
+      target.setAttribute("aria-busy", "true");
+      fetch(target.dataset.playlists, { method: "POST", body: body })
+        .then(function (response) { return response.text(); })
+        .then(function (html) { target.innerHTML = html; })
+        .catch(function () { target.textContent = "Could not reach rank-king to list your playlists."; })
+        .finally(function () { target.removeAttribute("aria-busy"); });
+    }, 300);
+  });
+
   document.addEventListener("DOMContentLoaded", function () {
     document.querySelectorAll("select[name=source]").forEach(showSource);
   });
diff --git a/sig/rank_king/config.rbs b/sig/rank_king/config.rbs
index 672805c..f359a38 100644
--- a/sig/rank_king/config.rbs
+++ b/sig/rank_king/config.rbs
@@ -5,9 +5,7 @@ module RankKing
   class Config
     attr_reader database_url: String
     attr_reader session_secret: String
-    attr_reader google_client_id: String
-    attr_reader google_client_secret: String
 
-    def self.new: (database_url: String, session_secret: String, google_client_id: String, google_client_secret: String) -> instance
+    def self.new: (database_url: String, session_secret: String) -> instance
   end
 end
diff --git a/sig/rank_king/google.rbs b/sig/rank_king/innertube.rbs
similarity index 63%
rename from sig/rank_king/google.rbs
rename to sig/rank_king/innertube.rbs
index 4dd1d82..99336d6 100644
--- a/sig/rank_king/google.rbs
+++ b/sig/rank_king/innertube.rbs
@@ -1,13 +1,13 @@
 # Video is a Data class, whose members the inline parser cannot read.
 module RankKing
-  class Google
+  class InnerTube
     class Video
       attr_reader id: String
       attr_reader title: String
-      attr_reader channel_title: String
-      attr_reader thumbnail_url: 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
+      def self.new: (id: String, title: String, channel_title: String?, thumbnail_url: String) -> instance
     end
   end
 end
diff --git a/sig/rank_king/models.rbs b/sig/rank_king/models.rbs
index cf0aafc..caac5e6 100644
--- a/sig/rank_king/models.rbs
+++ b/sig/rank_king/models.rbs
@@ -6,12 +6,6 @@ module RankKing
     def login: () -> String
     def lists: () -> Array[List]
     def lists_dataset: () -> Sequel::Dataset[List]
-    def google_account: () -> GoogleAccount?
-  end
-
-  class GoogleAccount < Sequel::Model
-    def user_id: () -> Integer
-    def refresh_token: () -> String
   end
 
   class List < Sequel::Model
diff --git a/sig/rank_king/sources.rbs b/sig/rank_king/sources.rbs
index 4374991..fb54a16 100644
--- a/sig/rank_king/sources.rbs
+++ b/sig/rank_king/sources.rbs
@@ -36,18 +36,19 @@ 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 Google client, which only YouTube uses.
-      def for: (List list, google: Google) -> _Source
+      # kind is handed the app's InnerTube client, which only YouTube uses.
+      def for: (List list, innertube: InnerTube) -> _Source
 
       # What the kind is called where a list's source is chosen or shown.
       def title: () -> String
 
-      # The kind's own fields on the new-list form.
-      def fields: (User user, google: Google) -> _Component
+      # The kind's own fields on the new-list form, which may post to
+      # routes of their own as they are filled in.
+      def fields: (csrf: Method) -> _Component
 
       # 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, google: Google) -> bool
+      def create: (List list, Hash[String, untyped] params, innertube: InnerTube) -> bool
     end
 
     # A view component, as a source's form is.
diff --git a/sig/rank_king/views.rbs b/sig/rank_king/views.rbs
index ece8ee0..d7612a7 100644
--- a/sig/rank_king/views.rbs
+++ b/sig/rank_king/views.rbs
@@ -8,11 +8,6 @@ module RankKing
       def call: () -> String
     end
 
-    class Settings
-      def initialize: (user: User, csrf: Method) -> void
-      def call: () -> String
-    end
-
     module Sources
       class Text
         def initialize: (list: List, csrf: Method) -> void
@@ -30,6 +25,11 @@ module RankKing
       end
 
       class YouTubeFields
+        def initialize: (csrf: Method) -> void
+        def call: () -> String
+      end
+
+      class YouTubePlaylists
         def initialize: (playlists: Array[[String, String]]?, ?error: String?) -> void
         def call: () -> String
       end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index baf903d..29efc4c 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,7 +1,3 @@
 # frozen_string_literal: true
 
 ENV["DATABASE_URL"] = ":memory:"
-# The Google client the app requires. No request reaches Google: the
-# YouTube tests' app subclass hands out a fake one.
-ENV["GOOGLE_CLIENT_ID"] = "client-id"
-ENV["GOOGLE_CLIENT_SECRET_COMMAND"] = "echo client-secret"
diff --git a/test/test_youtube.rb b/test/test_youtube.rb
index 0252a5e..54971d8 100644
--- a/test/test_youtube.rb
+++ b/test/test_youtube.rb
@@ -4,73 +4,93 @@ require_relative "test_helper"
 
 require "minitest/autorun"
 require "rack/test"
-require "uri"
 
 require_relative "../lib/rank_king/web"
 
 class TestYouTube < Minitest::Test
   include Rack::Test::Methods
 
-  # The app, with the fake Google in place of the real one.
+  # The app, with the fake InnerTube in place of the real one.
   class App < RankKing::Web
-    singleton_class.attr_accessor :google
+    singleton_class.attr_accessor :innertube
 
-    private def google = self.class.google
+    private def innertube = self.class.innertube
   end
 
   def app = App.app
 
   LOGIN = "alpha@example.com"
   PLAYLIST = "PLabc123"
+  COOKIE = "SID=sid; SAPISID=sapisid; __Secure-3PAPISID=3papisid"
 
-  # Google, as far as rank-king talks to it: the real client with its
-  # requests answered here. The playlist pages two videos at a time, so
-  # fetching it has to follow nextPageToken.
-  class FakeGoogle < RankKing::Google
+  # InnerTube, as far as rank-king talks to it: the real client with its
+  # requests answered here, in the shapes youtube.com gets. A playlist
+  # pages two videos at a time, so fetching it has to follow its
+  # continuations.
+  class FakeInnerTube < RankKing::InnerTube
     attr_reader :calls
-    attr_accessor :videos, :user_playlists, :error
+    attr_accessor :videos, :user_playlists, :error, :signed_out
 
     def initialize
-      super(client_id: "client-id", client_secret: "client-secret")
+      super
       @calls = []
       @videos = []
-      @user_playlists = [["PLabc123", "Music"], ["PLdef456", "Talks"]]
+      @user_playlists = [["WL", "Watch later"], ["PLabc123", "Music"]]
     end
 
     private
 
-    def request(method, url, params, access_token)
-      @calls << [method, url, params, access_token]
-      raise RankKing::Google::Error, error if error
-
-      case url
-      when RankKing::Google::TOKEN_URL
-        if params[:grant_type] == "authorization_code"
-          { "refresh_token" => "refresh-#{params[:code]}" }
-        else
-          { "access_token" => "access-#{params[:refresh_token]}" }
-        end
-      when RankKing::Google::PLAYLISTS_URL
-        { "items" => user_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) } }
-        body["nextPageToken"] = (page + 1).to_s if videos.size > (page + 1) * 2
-        body
-      end
+    def request(cookie, body)
+      @calls << [cookie, body]
+      raise RankKing::InnerTube::Error, error if error
+      return signed_out_page if signed_out
+      return playlists_page if body[:browseId] == "FEplaylist_aggregation"
+
+      page = body[:continuation].to_i
+      items = videos[page * 2, 2].to_a.map { |id, title| video(id, title) }
+      items << continuation(page + 1, first: page.zero?) if videos.size > (page + 1) * 2
+      return signed_in("onResponseReceivedActions" => [{ "appendContinuationItemsAction" => { "continuationItems" => items } }]) if page.positive?
+
+      list = { "playlistVideoListRenderer" => { "contents" => items } }
+      signed_in("contents" => { "twoColumnBrowseResultsRenderer" => { "tabs" => [
+        { "tabRenderer" => { "content" => { "sectionListRenderer" => { "contents" => [
+          { "itemSectionRenderer" => { "contents" => [list] } },
+        ] } } } },
+      ] } })
     end
 
-    # A nil title stands for a deleted video, which has no owner.
-    def item(id, 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 }
+    def signed_in(body) = { "responseContext" => tracking("1"), **body }
+
+    def tracking(logged_in) = { "serviceTrackingParams" => [{ "params" => [{ "key" => "logged_in", "value" => logged_in }] }] }
+
+    def signed_out_page
+      { "responseContext" => tracking("0"),
+        "alerts" => [{ "alertRenderer" => { "type" => "ERROR", "text" => { "runs" => [{ "text" => "The playlist does not exist." }] } } }] }
+    end
+
+    def playlists_page
+      lockups = user_playlists.map { |id, title|
+        { "lockupViewModel" => { "contentId" => id, "contentType" => "LOCKUP_CONTENT_TYPE_PLAYLIST",
+                                 "metadata" => { "lockupMetadataViewModel" => { "title" => { "content" => title } } } } }
+      }
+      signed_in("contents" => { "richGridRenderer" => { "contents" => lockups.map { { "richItemRenderer" => { "content" => _1 } } } } })
+    end
+
+    # A nil title stands for a deleted video, which is not playable.
+    def video(id, title)
+      { "playlistVideoRenderer" => {
+        "videoId" => id, "isPlayable" => !title.nil?,
+        "title" => { "runs" => [{ "text" => title || "[Deleted video]" }] },
+        "shortBylineText" => { "runs" => [{ "text" => "A channel" }] },
+      } }
+    end
+
+    # The first page wraps its continuation in a command executor; later
+    # ones carry it bare.
+    def continuation(token, first:)
+      command = { "continuationCommand" => { "token" => token.to_s } }
+      endpoint = first ? { "commandExecutorCommand" => { "commands" => [{}, command] } } : command
+      { "continuationItemRenderer" => { "continuationEndpoint" => endpoint } }
     end
   end
 
@@ -82,105 +102,64 @@ class TestYouTube < Minitest::Test
     RankKing::DB[:lists].delete
     RankKing::DB[:users].delete
 
-    @google = FakeGoogle.new
-    App.google = @google
+    @innertube = FakeInnerTube.new
+    App.innertube = @innertube
   end
 
-  def test_settings_offers_to_connect_google
-    get "/settings"
-    assert last_response.ok?
-    assert_includes last_response.body, %(href="/settings/google/connect")
-  end
-
-  def test_connecting_google_keeps_a_refresh_token
-    get "/settings/google/connect"
-    assert last_response.redirect?
-    location = URI(last_response.location)
-    query = URI.decode_www_form(location.query).to_h
-    assert_equal "accounts.google.com", location.host
-    assert_equal "client-id", query["client_id"]
-    assert_equal "http://example.org/settings/google/callback", query["redirect_uri"]
-    assert_equal RankKing::Google::SCOPE, query["scope"]
-    assert_equal "offline", query["access_type"]
-
-    get "/settings/google/callback", { state: query["state"], code: "the-code" }
-    assert_equal "/settings", URI(last_response.location).path
-    assert_equal "refresh-the-code", user.google_account.refresh_token
-
-    get "/settings"
-    assert_includes last_response.body, %(action="/settings/google/disconnect")
-  end
-
-  def test_callback_needs_the_state_it_was_sent_with
-    get "/settings/google/connect"
-    get "/settings/google/callback", { state: "forged", code: "the-code" }
-
-    assert_equal 403, last_response.status
-    assert_nil user.google_account
-  end
-
-  def test_callback_without_a_connect_is_refused
-    get "/settings/google/callback", { state: "", code: "the-code" }
-    assert_equal 403, last_response.status
-  end
-
-  def test_declining_on_google_connects_nothing
-    get "/settings/google/connect"
-    state = URI.decode_www_form(URI(last_response.location).query).to_h["state"]
-    get "/settings/google/callback", { state:, error: "access_denied" }
-
-    assert_equal "/settings", URI(last_response.location).path
-    assert_nil user.google_account
-    assert_empty @google.calls
-  end
-
-  def test_disconnecting_google
-    connect_google
-    csrf_post "/settings/google/disconnect", {}, from: "/settings"
-
-    assert last_response.redirect?
-    assert_nil user.google_account
-  end
-
-  def test_new_list_asks_to_connect_google_first
+  def test_new_list_asks_for_a_cookie_before_the_playlists
     get "/lists/new"
     assert_includes last_response.body, %(<option value="YouTube">YouTube playlist</option>)
-    assert_includes last_response.body, %(<a href="/settings">your settings</a>)
+    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
   end
 
-  def test_new_list_offers_the_users_playlists
-    connect_google
-    get "/lists/new"
+  def test_a_pasted_cookie_lists_the_playlists
+    playlists("Cookie: #{COOKIE}\n")
+    assert last_response.ok?
     assert_includes last_response.body, %(<select name="playlist" required>)
+    assert_includes last_response.body, %(<option value="WL">Watch later</option>)
     assert_includes last_response.body, %(<option value="PLabc123">Music</option>)
-    assert_includes last_response.body, %(<option value="PLdef456">Talks</option>)
+    refute_includes last_response.body, "<html"
+    assert_equal [COOKIE, { browseId: "FEplaylist_aggregation" }], @innertube.calls.last
+  end
 
-    _, 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
+  def test_listing_playlists_needs_the_form_token
+    post "/youtube/playlists", { cookie: COOKIE }
+    assert_equal 403, last_response.status
+    assert_empty @innertube.calls
   end
 
-  def test_new_list_says_when_there_are_no_playlists
-    connect_google
-    @google.user_playlists = []
-    get "/lists/new"
+  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
+  end
+
+  def test_says_when_there_are_no_playlists
+    @innertube.user_playlists = []
+    playlists(COOKIE)
     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"
+  def test_says_when_youtube_signed_the_cookie_out
+    @innertube.signed_out = true
+    playlists(COOKIE)
     assert last_response.ok?
-    assert_includes last_response.body, "Token has been expired or revoked."
-    assert_includes last_response.body, %(name="items")
+    assert_includes last_response.body, "YouTube signed the cookie out."
+  end
+
+  def test_authorization_signs_each_api_cookie
+    at = 1_700_000_000
+    sign = ->(value) { Digest::SHA1.hexdigest("#{at} #{value} https://www.youtube.com") }
+
+    assert_equal "SAPISIDHASH #{at}_#{sign.("sapisid")} SAPISID3PHASH #{at}_#{sign.("3papisid")}",
+      RankKing::InnerTube.authorization(COOKIE, at:)
   end
 
   def test_create_a_list_from_a_playlist
-    connect_google
-    @google.videos = [["v1", "First"], ["v2", nil], ["v3", "Third"], ["v4", "Fourth"], ["v1", "First again"]]
+    @innertube.videos = [["v1", "First"], ["v2", nil], ["v3", "Third"], ["v4", "Fourth"], ["v1", "First again"]]
 
     create_list(playlist: PLAYLIST)
     assert last_response.redirect?
@@ -196,73 +175,100 @@ class TestYouTube < Minitest::Test
     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]
-    pages = @google.calls.select { |_, url, _, _| url == RankKing::Google::PLAYLIST_ITEMS_URL }
-    assert_equal 3, pages.size
-    assert_equal ["access-refresh-the-code"], pages.map(&:last).uniq
+    assert_equal [{ browseId: "VLPLabc123" }, { continuation: "1" }, { continuation: "2" }], @innertube.calls.map(&:last)
+    assert_equal [COOKIE], @innertube.calls.map(&:first).uniq
   end
 
-  def test_create_needs_google
-    create_list(playlist: PLAYLIST)
+  def test_create_a_list_from_watch_later
+    @innertube.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)
+  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
   end
 
   def test_create_needs_a_playlist
-    connect_google
     create_list(playlist: "not a playlist")
     assert_equal 422, last_response.status
     assert_equal 0, RankKing::List.count
   end
 
   def test_sync_adds_archives_and_restores_by_video
-    connect_google
-    @google.videos = [["v1", "First"], ["v2", "Second"]]
+    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
     list = RankKing::List.first
     first, second = list.items_dataset.order(:id).all
 
-    @google.videos = [["v1", "First, renamed"], ["v3", "Third"]]
-    sync(list)
+    @innertube.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
 
-    @google.videos = [["v2", "Second"]]
+    @innertube.videos = [["v2", "Second"]]
     sync(list)
     assert_equal %w[Second], names(list)
     assert_nil second.reload.archived_at
   end
 
-  def test_sync_dialog_shows_progress
-    connect_google
-    @google.videos = [["v1", "First"], ["v2", "Second"]]
+  def test_sync_dialog_asks_for_a_cookie_and_shows_progress
+    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
 
     get "/lists/#{RankKing::List.first.id}"
     assert_includes last_response.body, "Sync from YouTube"
+    assert_includes last_response.body, %(<textarea name="cookie")
     assert_includes last_response.body, %(data-busy="Syncing…")
   end
 
-  def test_google_errors_are_reported
-    connect_google
-    @google.videos = [["v1", "First"], ["v2", "Second"]]
+  def test_sync_needs_a_cookie
+    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
     list = RankKing::List.first
+    @innertube.calls.clear
+
+    sync(list, cookie: "")
+    assert_equal 502, last_response.status
+    assert_includes last_response.body, "no SAPISID"
+    assert_empty @innertube.calls
+  end
 
-    @google.error = "Token has been expired or revoked."
+  def test_a_signed_out_cookie_archives_nothing
+    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
+    create_list(playlist: PLAYLIST)
+    list = RankKing::List.first
+
+    @innertube.signed_out = true
     sync(list)
     assert_equal 502, last_response.status
-    assert_includes last_response.body, "Token has been expired or revoked."
+    assert_includes last_response.body, "YouTube: YouTube signed the cookie out."
+    assert_equal %w[First Second], names(list)
+  end
+
+  def test_youtube_errors_are_reported
+    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
+    create_list(playlist: PLAYLIST)
+    list = RankKing::List.first
+
+    @innertube.error = "Request contains an invalid argument."
+    sync(list)
+    assert_equal 502, last_response.status
+    assert_includes last_response.body, "Request contains an invalid argument."
     assert_equal %w[First Second], names(list)
   end
 
   def test_deleting_a_youtube_list
-    connect_google
-    @google.videos = [["v1", "First"], ["v2", "Second"]]
+    @innertube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
     list = RankKing::List.first
 
@@ -274,23 +280,22 @@ class TestYouTube < Minitest::Test
 
   private
 
-  def user = RankKing::User.first(login: LOGIN)
+  def names(list) = list.items_dataset.order(:id).map(:name)
 
-  def connect_google
-    get "/settings/google/connect"
-    state = URI.decode_www_form(URI(last_response.location).query).to_h["state"]
-    get "/settings/google/callback", { state:, code: "the-code" }
-    @google.calls.clear
+  # Posts the cookie as app.js does, with the token the new-list form
+  # carries for it.
+  def playlists(cookie)
+    get "/lists/new"
+    token = last_response.body[/data-playlists="\/youtube\/playlists" data-csrf="([^"]+)"/, 1]
+    post "/youtube/playlists", { cookie:, "_csrf" => token }
   end
 
-  def names(list) = list.items_dataset.order(:id).map(:name)
-
-  def create_list(playlist:)
-    csrf_post "/lists", { name: "Videos", source: "YouTube", playlist: }, from: "/lists/new"
+  def create_list(playlist:, cookie: COOKIE)
+    csrf_post "/lists", { name: "Videos", source: "YouTube", playlist:, cookie: }, from: "/lists/new"
   end
 
-  def sync(list)
-    csrf_post "/lists/#{list.id}/sync", {}, from: "/lists/#{list.id}"
+  def sync(list, cookie: COOKIE)
+    csrf_post "/lists/#{list.id}/sync", { cookie: }, from: "/lists/#{list.id}"
   end
 
   def csrf_post(path, params, from:)