Connect Google and add a YouTube playlist source
A settings page connects the user's Google account through OAuth and
keeps its refresh token. A YouTubePlaylist list follows one of their
playlists, private ones included: the new-list form takes its URL, and
the sync dialog fetches it again, showing a spinner while it works.
Each source kind now renders its own new-list fields and sets the list
up from them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
change
commit d245449e7d1b9303eb69c6dbefa37b4f1ca05fa6
author Claude <noreply@anthropic.com>
date
parent 8c7c46d7
diff --git a/AGENTS.md b/AGENTS.md
index 22245d6..0be41c2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,7 +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, List, Item, Axis, Result)
+  models.rb        — Sequel models (User, GoogleAccount, List, Item, Axis, Result)
+  google.rb        — Google OAuth and the YouTube Data API, over a swappable transport
   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
@@ -39,7 +40,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 and delete confirmation
+  js/app.js        — axis tags, confirmations, busy buttons, new-list source fields
 .github/workflows/ — CI, PR previews, preview reaper (GitHub Actions)
 Dockerfile         — production image
 fly.toml           — Fly config for the main demo and PR previews
@@ -48,8 +49,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
+- **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)
 - **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 `YouTubePlaylist` (follows one of the owner's playlists through their Google account; `youtube_playlist_sources` by `list_id`, `youtube_playlist_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)
@@ -82,8 +84,15 @@ 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` — a Google OAuth web client
+  with the YouTube Data API enabled, for connecting Google. Its redirect
+  URI is `<app URL>/settings/google/callback`. Without them YouTube lists
+  are not offered. A consent screen left in Testing mode expires refresh
+  tokens after 7 days.
 
-Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`.
+Tests set `DATABASE_URL=:memory:` and a dummy Google client in
+`test/test_helper.rb`, and swap `RankKing::Google.transport` so no
+request leaves them.
 
 ## Conventions
 
@@ -102,7 +111,9 @@ Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`.
   rather than a new button or screen.
 - A list's source is fixed when it is made. `Sources::KINDS` lists the
   kinds the new-list form offers, and each kind's `title` names it there
-  and in settings. Its sync dialog opens from the list's ⋯ menu.
+  and in settings. Its sync dialog opens from the list's ⋯ menu. Each
+  kind also renders its own fields on the new-list form (`fields`) and
+  sets the new list up from them (`create`).
 - Every request needs a `Remote-User` header or it gets a 401. That is safe only because a proxy (Tailscale identity) writes the header. `StubLogin` fills it in for `demo.ru` (dev server, Fly apps) and nowhere else: never in `config.ru`, and never in front of real data.
 - Lists are private to their owner (404 for anyone else)
 - Config is a frozen `Data.define` value object
diff --git a/Steepfile b/Steepfile
index ae41aec..4ef2b3c 100644
--- a/Steepfile
+++ b/Steepfile
@@ -10,5 +10,5 @@ target :lib do
   ignore "lib/rank_king/views", inline: true
   signature "sig"
 
-  library "securerandom"
+  library "securerandom", "net-http", "json", "uri"
 end
diff --git a/db/migrate/005_add_youtube.rb b/db/migrate/005_add_youtube.rb
new file mode 100644
index 0000000..6ab0207
--- /dev/null
+++ b/db/migrate/005_add_youtube.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+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 YouTubePlaylist source's config: which playlist a list follows.
+    create_table(:youtube_playlist_sources) do
+      foreign_key :list_id, :lists, primary_key: true, on_delete: :cascade
+      String :playlist_id, null: false
+    end
+
+    # The video behind each item of a YouTubePlaylist list, which is how
+    # a sync recognizes the item again.
+    create_table(:youtube_playlist_items) do
+      foreign_key :item_id, :items, primary_key: true, on_delete: :cascade
+      String :video_id, null: false
+    end
+  end
+end
diff --git a/lib/rank_king/config.rb b/lib/rank_king/config.rb
index efbcda8..89124f1 100644
--- a/lib/rank_king/config.rb
+++ b/lib/rank_king/config.rb
@@ -7,6 +7,8 @@ module RankKing
   Config = Data.define(
     :database_url,
     :session_secret,
+    :google_client_id,
+    :google_client_secret,
   )
 
   class Config
@@ -20,6 +22,10 @@ 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. Without one the
+        # settings page says so and YouTube lists cannot be made.
+        google_client_id: env["GOOGLE_CLIENT_ID"],
+        google_client_secret: env["GOOGLE_CLIENT_SECRET"],
       )
     end
   end
diff --git a/lib/rank_king/google.rb b/lib/rank_king/google.rb
new file mode 100644
index 0000000..8c13bb2
--- /dev/null
+++ b/lib/rank_king/google.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require "json"
+require "net/http"
+require "uri"
+
+require_relative "config"
+
+module RankKing
+  # The Google APIs rank-king calls: OAuth, to connect a user's account,
+  # and the YouTube Data API, to read their playlists.
+  module Google
+    # Google refused a request, or answered with something unexpected.
+    class Error < StandardError; end
+
+    AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
+    TOKEN_URL = "https://oauth2.googleapis.com/token"
+    PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"
+    SCOPE = "https://www.googleapis.com/auth/youtube.readonly"
+
+    # 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. Tests swap it for canned answers.
+    singleton_class.attr_writer :transport
+
+    #: () -> ^(Symbol, String, Hash[Symbol, String], String?) -> Hash[String, untyped]
+    def self.transport
+      @transport ||= ->(method, url, params, access_token) { request(method, url, params, access_token) }
+    end
+
+    #: () -> bool
+    def self.configured? = !!(CONFIG.google_client_id && CONFIG.google_client_secret)
+
+    # 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 self.authorize_url(redirect_uri:, state:)
+      query = URI.encode_www_form(
+        client_id: CONFIG.google_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 self.refresh_token(code, redirect_uri:)
+      body = transport.call(: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 self.access_token(refresh_token)
+      body = transport.call(:post, TOKEN_URL, { **client, refresh_token:, grant_type: "refresh_token" }, nil)
+      body.fetch("access_token") { raise Error, "Google sent no access token" }
+    end
+
+    # The videos in a playlist, in playlist order, as [video id, title].
+    # Deleted videos, and private ones the user cannot see, carry no
+    # owner and are left out.
+    #: (String access_token, String playlist_id) -> Array[[String, String]]
+    def self.playlist_videos(access_token, playlist_id)
+      videos = [] #: Array[[String, String]]
+      page = nil #: String?
+      loop do
+        query = { part: "snippet", playlistId: playlist_id, maxResults: "50" } #: Hash[Symbol, String]
+        query[:pageToken] = page if page
+        body = transport.call(:get, PLAYLIST_ITEMS_URL, query, access_token)
+
+        items = body["items"] || [] #: Array[Hash[String, untyped]]
+        items.each do |item|
+          snippet = item.fetch("snippet")
+          next unless snippet["videoOwnerChannelTitle"]
+
+          videos << [snippet.dig("resourceId", "videoId"), snippet.fetch("title")]
+        end
+
+        page = body["nextPageToken"]
+        break unless page
+      end
+      videos
+    end
+
+    #: () -> Hash[Symbol, String]
+    def self.client
+      { client_id: CONFIG.google_client_id.to_s, client_secret: CONFIG.google_client_secret.to_s }
+    end
+
+    #: (Symbol method, String url, Hash[Symbol, String] params, String? access_token) -> Hash[String, untyped]
+    def self.request(method, url, params, access_token)
+      uri = URI(url)
+      request = if method == :post
+        Net::HTTP::Post.new(uri).tap { |post| post.set_form_data(params) }
+      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/models.rb b/lib/rank_king/models.rb
index 0e2dca8..3865b10 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -10,6 +10,12 @@ 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.rb b/lib/rank_king/sources.rb
index fcf89e1..3be114e 100644
--- a/lib/rank_king/sources.rb
+++ b/lib/rank_king/sources.rb
@@ -12,12 +12,13 @@ module RankKing
 end
 
 require_relative "sources/text"
+require_relative "sources/youtube_playlist"
 
 module RankKing
   module Sources
     # Every kind a list can be made from, in the order the new-list form
     # offers them. A list keeps its kind for good.
-    KINDS = %w[Text].freeze #: Array[String]
+    KINDS = %w[Text YouTubePlaylist].freeze #: Array[String]
 
     # A list's source_kind names a class in this module.
     #: (String kind) -> _SourceClass
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index e4a5257..7445d07 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -14,6 +14,20 @@ module RankKing
       #: () -> String
       def self.title = "Typed list"
 
+      #: (User user) -> _Component
+      def self.fields(user) = 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) -> bool
+      def self.create(list, params)
+        names = params["items"].to_s.lines.map(&:strip).reject(&:empty?)
+        return false if names.size < 2
+
+        names.each { |name| Item.create(list_id: list.id, name:) }
+        true
+      end
+
       #: (List list) -> void
       def initialize(list)
         @list = list
diff --git a/lib/rank_king/sources/youtube_playlist.rb b/lib/rank_king/sources/youtube_playlist.rb
new file mode 100644
index 0000000..decf4d1
--- /dev/null
+++ b/lib/rank_king/sources/youtube_playlist.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require_relative "../google"
+require_relative "../sync"
+require_relative "../views/sources/youtube_playlist"
+
+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.
+    class YouTubePlaylist
+      # Which playlist each list follows, keyed by list_id.
+      TABLE = :youtube_playlist_sources
+      # Which video each item is, keyed by item_id.
+      ITEMS = :youtube_playlist_items
+
+      #: () -> String
+      def self.title = "YouTube playlist"
+
+      #: (List list) -> YouTubePlaylist
+      def self.for(list) = new(list)
+
+      #: (User user) -> _Component
+      def self.fields(user)
+        Views::Sources::YouTubePlaylistFields.new(connected: !user.google_account.nil?, configured: Google.configured?)
+      end
+
+      # A playlist's id, from its URL or as pasted on its own.
+      #: (String text) -> String?
+      def self.playlist_id(text)
+        text = text.strip
+        text[/[?&]list=([\w-]+)/, 1] || text[/\A[\w-]+\z/]
+      end
+
+      # Follows the playlist from the new-list form and fetches it.
+      #: (List list, Hash[String, untyped] params) -> bool
+      def self.create(list, params)
+        playlist_id = playlist_id(params["playlist"].to_s)
+        return false unless playlist_id && list.user.google_account
+
+        DB[TABLE].insert(list_id: list.id, playlist_id:)
+        new(list).fetch
+        true
+      end
+
+      #: (List list) -> void
+      def initialize(list)
+        @list = list
+      end
+
+      #: () -> String
+      def label = "Sync from YouTube"
+
+      #: (csrf: Method) -> _Component
+      def form(csrf:) = Views::Sources::YouTubePlaylist.new(list: @list, csrf:)
+
+      # The dialog posts nothing to apply: syncing is fetching.
+      #: (Hash[String, untyped] params) -> void
+      def sync(params)
+        fetch
+      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)
+        videos = Google.playlist_videos(access_token, playlist_id).uniq { |video_id, _| video_id }
+        entries = videos.map { |video_id, title| Entry.new(key: video_id, name: title) }
+        Sync.call(@list, entries, source: self)
+      end
+
+      #: () -> String
+      def playlist_id = DB[TABLE].where(list_id: @list.id).get(:playlist_id).to_s
+
+      #: () -> Hash[String, Item]
+      def items_by_key
+        video_ids = DB[ITEMS].where(item_id: Item.where(list_id: @list.id).select(:id)).to_hash(:item_id, :video_id)
+        Item.where(id: video_ids.keys).all.to_h do |item|
+          [video_ids.fetch(item.id).to_s, item] #: [String, Item]
+        end
+      end
+
+      #: (Item item, Entry entry) -> void
+      def saved(item, entry)
+        DB[ITEMS].replace(item_id: item.id, video_id: entry.key)
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/views/home.rb b/lib/rank_king/views/home.rb
index 1a9d8eb..9302a29 100644
--- a/lib/rank_king/views/home.rb
+++ b/lib/rank_king/views/home.rb
@@ -14,6 +14,8 @@ module RankKing
       def view_template
         render Layout.new do
           render PageHeader.new(title: "Rank King") do
+            a(href: "/settings", class: "icon-button", aria_label: "Settings",
+              style: "--gl-icon: var(--gl-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/list/new.rb b/lib/rank_king/views/list/new.rb
index 8ee0ee5..6bb5ba3 100644
--- a/lib/rank_king/views/list/new.rb
+++ b/lib/rank_king/views/list/new.rb
@@ -29,18 +29,16 @@ module RankKing
               label(class: "field") do
                 plain "Source"
                 select(name: "source") do
-                  @sources.each { |value, name| option(value: value) { name } }
+                  @sources.each { |kind, title, _fields| option(value: kind) { title } }
                 end
                 small { "A list keeps its source." }
               end
 
-              # The chosen source's own fields. Each source gets one of these.
-              fieldset(data_source: "Text") do
-                label(class: "field") do
-                  plain "Items"
-                  textarea(name: "items", rows: 6, required: true)
-                  small { "One per line, at least two." }
-                end
+              # Each source's own fields. Only the chosen source's are
+              # enabled, so only they are checked and posted; app.js
+              # switches them when the dropdown changes.
+              @sources.each_with_index do |(kind, _title, fields), index|
+                fieldset(data_source: kind, disabled: index > 0, hidden: index > 0) { render fields }
               end
 
               render AxisTags.new
diff --git a/lib/rank_king/views/settings.rb b/lib/rank_king/views/settings.rb
new file mode 100644
index 0000000..f0835f6
--- /dev/null
+++ b/lib/rank_king/views/settings.rb
@@ -0,0 +1,48 @@
+# 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:, google_configured:, csrf:)
+        @user = user
+        @google_configured = google_configured
+        @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
+            elsif @google_configured
+              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" }
+            else
+              p { "This server has no Google client set up. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to 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/text.rb b/lib/rank_king/views/sources/text.rb
index a19df91..3bb6b96 100644
--- a/lib/rank_king/views/sources/text.rb
+++ b/lib/rank_king/views/sources/text.rb
@@ -5,6 +5,17 @@ require "phlex"
 module RankKing
   module Views
     module Sources
+      # The Text source's fields on the new-list form.
+      class TextFields < Phlex::HTML
+        def view_template
+          label(class: "field") do
+            plain "Items"
+            textarea(name: "items", rows: 6, required: true)
+            small { "One per line, at least two." }
+          end
+        end
+      end
+
       # The Text source's sync dialog: every item's name, editable in place,
       # and a box for new ones. It fills a <dialog> as Gloss lays one out:
       # header, body, footer, so the form is the body and the footer's
diff --git a/lib/rank_king/views/sources/youtube_playlist.rb b/lib/rank_king/views/sources/youtube_playlist.rb
new file mode 100644
index 0000000..e8a0783
--- /dev/null
+++ b/lib/rank_king/views/sources/youtube_playlist.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+module RankKing
+  module Views
+    module Sources
+      # The YouTube playlist source's fields on the new-list form: the
+      # playlist to follow, once the user has connected Google.
+      class YouTubePlaylistFields < Phlex::HTML
+        def initialize(connected:, configured:)
+          @connected = connected
+          @configured = configured
+        end
+
+        def view_template
+          if @connected
+            label(class: "field") do
+              plain "Playlist"
+              input(type: "text", name: "playlist", required: true,
+                    placeholder: "https://www.youtube.com/playlist?list=…")
+              small { "Its URL or ID. Private playlists work too." }
+            end
+          elsif @configured
+            p do
+              plain "Connect Google in "
+              a(href: "/settings") { "your settings" }
+              plain " to make a list from a YouTube playlist."
+            end
+          else
+            p { "This server has no Google client set up, so YouTube lists cannot be made yet." }
+          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.
+      class YouTubePlaylist < Phlex::HTML
+        def initialize(list:, csrf:)
+          @list = list
+          @csrf = csrf
+        end
+
+        def view_template
+          action = "/lists/#{@list.id}/sync"
+
+          header { "Sync from YouTube" }
+          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." }
+            if (synced_at = @list.synced_at)
+              small { "Last synced #{synced_at.strftime("%b %-d, %Y at %-I:%M %p")}." }
+            end
+          end
+          footer do
+            button(type: "button", commandfor: "sync-dialog", command: "close") { "Cancel" }
+            button(type: "submit", form: "sync-form", data_variant: "primary") { "Sync" }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 82cdc63..909b507 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -1,10 +1,13 @@
 # frozen_string_literal: true
 
 require "roda"
+require "securerandom"
 
 require_relative "models"
 require_relative "proxy_auth"
+require_relative "google"
 require_relative "views/home"
+require_relative "views/settings"
 require_relative "views/list/new"
 require_relative "views/list/show"
 require_relative "views/list/compare"
@@ -16,10 +19,15 @@ 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] do |e|
-      response.status = 401
+    plugin :error_handler, classes: [ProxyAuth::MissingLogin, Google::Error] do |e|
       response["Content-Type"] = "text/plain"
-      "Unauthorized: #{e.message}.\n"
+      if e.is_a?(Google::Error)
+        response.status = 502
+        "Google: #{e.message}\n"
+      else
+        response.status = 401
+        "Unauthorized: #{e.message}.\n"
+      end
     end
 
     route do |r|
@@ -31,6 +39,46 @@ module RankKing
         Views::Home.new(lists: lists).call
       end
 
+      r.on "settings" do
+        r.get true do
+          Views::Settings.new(user:, google_configured: Google.configured?, 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
+            r.halt 404 unless Google.configured?
+
+            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
+      end
+
       r.on "lists" do
         r.is do
           r.post do
@@ -40,28 +88,27 @@ module RankKing
             source_kind = r.params["source"] || "Text"
             r.halt 422 unless Sources::KINDS.include?(source_kind)
 
-            items_text = r.params["items"].to_s.strip
-            r.halt 422 if items_text.empty?
-
-            item_names = items_text.lines.map(&:strip).reject(&:empty?)
-            r.halt 422 if item_names.size < 2
-
             axis_names = names_param(r.params["axes"])
 
+            # The source fills the list from its own fields, or turns it
+            # down and nothing is made.
             list = DB.transaction do
               l = List.create(name: name, user_id: user.id, source_kind: source_kind)
-              item_names.each { |n| Item.create(list_id: l.id, name: n) }
+              raise Sequel::Rollback unless Sources.fetch(source_kind).create(l, r.params)
+
               axis_names.each { |n| Axis.create(list_id: l.id, name: n) }
               l
             end
+            r.halt 422 unless list
 
             r.redirect "/lists/#{list.id}"
           end
         end
 
         r.get "new" do
-          kinds = Sources::KINDS.to_h do |kind|
-            [kind, Sources.fetch(kind).title] #: [String, String]
+          kinds = Sources::KINDS.map do |kind|
+            source = Sources.fetch(kind)
+            [kind, source.title, source.fields(user)] #: [String, String, Sources::_Component]
           end
           Views::ListViews::New.new(sources: kinds, csrf: method(:csrf_token)).call
         end
diff --git a/public/css/app.css b/public/css/app.css
index 3899300..ed2fbfa 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -40,6 +40,9 @@ 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;
@@ -153,3 +156,27 @@ main > header [popovertarget] { anchor-name: --rk-list-actions; }
 }
 
 [data-matchup] + .btn { align-self: center; }
+
+/* -------------------- */
+/* Busy */
+/* -------------------- */
+
+/* A button whose form is still working (see data-busy in app.js) spins
+   where an icon would go. Gloss has no spinner, and a busy button never
+   carries an icon, so its ::before is free. */
+button[aria-busy="true"]::before {
+  content: "";
+  display: inline-block;
+  width: 1em;
+  height: 1em;
+  margin-inline-end: var(--gl-space-2xs);
+  vertical-align: -0.125em;
+  border: 2px solid currentColor;
+  border-inline-end-color: transparent;
+  border-radius: 50%;
+  animation: rk-spin 0.8s linear infinite;
+}
+
+@keyframes rk-spin {
+  to { rotate: 1turn; }
+}
diff --git a/public/js/app.js b/public/js/app.js
index f2ea4f5..66edbce 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -40,4 +40,34 @@
     var message = event.target.dataset.confirm;
     if (message && !window.confirm(message)) event.preventDefault();
   });
+
+  // A slow form shows it is working: <form data-busy="Syncing…"> relabels
+  // the button that sent it and marks it busy until the next page loads.
+  document.addEventListener("submit", function (event) {
+    var label = event.target.dataset.busy;
+    var button = event.submitter;
+    if (!label || !button || event.defaultPrevented) return;
+
+    button.textContent = label;
+    button.setAttribute("aria-busy", "true");
+    setTimeout(function () { button.disabled = true; });
+  });
+
+  // The new-list form shows only the chosen source's fields. Disabling
+  // the others keeps their required fields from blocking the submit.
+  function showSource(select) {
+    select.form.querySelectorAll("fieldset[data-source]").forEach(function (fieldset) {
+      var chosen = fieldset.dataset.source === select.value;
+      fieldset.disabled = !chosen;
+      fieldset.hidden = !chosen;
+    });
+  }
+
+  document.addEventListener("change", function (event) {
+    if (event.target.matches("select[name=source]")) showSource(event.target);
+  });
+
+  document.addEventListener("DOMContentLoaded", function () {
+    document.querySelectorAll("select[name=source]").forEach(showSource);
+  });
 })();
diff --git a/sig/gems/rack.rbs b/sig/gems/rack.rbs
new file mode 100644
index 0000000..e3ed899
--- /dev/null
+++ b/sig/gems/rack.rbs
@@ -0,0 +1,7 @@
+# Rack ships no signatures. web.rb calls only this of it directly.
+module Rack
+  module Utils
+    # Compares two strings in time that does not depend on where they differ.
+    def self.secure_compare: (String a, String b) -> bool
+  end
+end
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 742fbb9..2f35e90 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -22,9 +22,15 @@ class Roda
 
   def response: () -> RodaResponse
 
+  # The sessions plugin's: the signed cookie session.
+  def session: () -> Hash[String, untyped]
+
   class RodaRequest
     def env: () -> Hash[String, untyped]
 
+    # Scheme, host and port, as the client reached the app.
+    def base_url: () -> String
+
     def is: (*untyped matchers) { (*untyped) -> untyped } -> void
     def on: (*untyped matchers) { (*untyped) -> untyped } -> void
     def root: () { () -> untyped } -> void
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index 5fff59b..a6ff83e 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -17,7 +17,11 @@ module Sequel
   class Database
     def []: (Symbol table) -> Dataset[row]
 
-    def transaction: [T] () { () -> T } -> T
+    # Raising Rollback inside the block undoes it and returns nil.
+    def transaction: [T] () { () -> T } -> T?
+  end
+
+  class Rollback < StandardError
   end
 
   # Generic over what a row comes back as: a hash from a plain dataset,
@@ -29,6 +33,7 @@ module Sequel
 
     # Rows keyed by one column's value.
     def to_hash: (Symbol key) -> Hash[untyped, T]
+               | (Symbol key, Symbol value) -> Hash[untyped, untyped]
 
     def order: (*untyped columns) -> Dataset[T]
 
@@ -36,6 +41,16 @@ module Sequel
 
     def first: () -> T?
 
+    # One column of the first row.
+    def get: (Symbol column) -> untyped
+
+    def insert: (**untyped values) -> Integer
+
+    # Inserts, replacing the row whose key conflicts.
+    def replace: (**untyped values) -> Integer
+
+    def join: (Symbol table, untyped conditions) -> Dataset[T]
+
     def select: (*Symbol columns) -> Dataset[row]
 
     def each: () { (T) -> void } -> void
@@ -50,6 +65,8 @@ module Sequel
 
     def self.many_to_one: (Symbol name, **untyped options) -> void
 
+    def self.one_to_one: (Symbol name, **untyped options) -> void
+
     def self.create: (**untyped values) -> instance
 
     def self.all: () -> Array[instance]
diff --git a/sig/rank_king/config.rbs b/sig/rank_king/config.rbs
index f359a38..1625670 100644
--- a/sig/rank_king/config.rbs
+++ b/sig/rank_king/config.rbs
@@ -5,7 +5,9 @@ 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) -> instance
+    def self.new: (database_url: String, session_secret: String, google_client_id: String?, google_client_secret: String?) -> instance
   end
 end
diff --git a/sig/rank_king/google.rbs b/sig/rank_king/google.rbs
new file mode 100644
index 0000000..b41a984
--- /dev/null
+++ b/sig/rank_king/google.rbs
@@ -0,0 +1,9 @@
+# The transport's writer is defined on the singleton class, which the
+# inline parser cannot read; tests are what call it.
+module RankKing
+  module Google
+    self.@transport: (^(Symbol, String, Hash[Symbol, String], String?) -> Hash[String, untyped])?
+
+    def self.transport=: (^(Symbol, String, Hash[Symbol, String], String?) -> Hash[String, untyped]) -> void
+  end
+end
diff --git a/sig/rank_king/models.rbs b/sig/rank_king/models.rbs
index 76e948a..074ea63 100644
--- a/sig/rank_king/models.rbs
+++ b/sig/rank_king/models.rbs
@@ -6,6 +6,12 @@ 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 bc7f3af..ae193ec 100644
--- a/sig/rank_king/sources.rbs
+++ b/sig/rank_king/sources.rbs
@@ -40,6 +40,13 @@ module RankKing
 
       # 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) -> _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) -> 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 ce245a1..168667a 100644
--- a/sig/rank_king/views.rbs
+++ b/sig/rank_king/views.rbs
@@ -8,16 +8,36 @@ module RankKing
       def call: () -> String
     end
 
+    class Settings
+      def initialize: (user: User, google_configured: bool, csrf: Method) -> void
+      def call: () -> String
+    end
+
     module Sources
       class Text
         def initialize: (list: List, csrf: Method) -> void
         def call: () -> String
       end
+
+      class TextFields
+        def initialize: () -> void
+        def call: () -> String
+      end
+
+      class YouTubePlaylist
+        def initialize: (list: List, csrf: Method) -> void
+        def call: () -> String
+      end
+
+      class YouTubePlaylistFields
+        def initialize: (connected: bool, configured: bool) -> void
+        def call: () -> String
+      end
     end
 
     module ListViews
       class New
-        def initialize: (sources: Hash[String, String], csrf: Method) -> void
+        def initialize: (sources: Array[[String, String, ::RankKing::Sources::_Component]], csrf: Method) -> void
         def call: () -> String
       end
 
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 29efc4c..9458bfb 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,3 +1,7 @@
 # frozen_string_literal: true
 
 ENV["DATABASE_URL"] = ":memory:"
+# A Google client, so the YouTube routes are on; no request leaves the
+# tests, which swap RankKing::Google.transport for canned answers.
+ENV["GOOGLE_CLIENT_ID"] = "client-id"
+ENV["GOOGLE_CLIENT_SECRET"] = "client-secret"
diff --git a/test/test_youtube.rb b/test/test_youtube.rb
new file mode 100644
index 0000000..3e0252d
--- /dev/null
+++ b/test/test_youtube.rb
@@ -0,0 +1,289 @@
+# frozen_string_literal: true
+
+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
+
+  def app = RankKing::Web.app
+
+  LOGIN = "alpha@example.com"
+  PLAYLIST = "PLabc123"
+
+  # Google, as far as rank-king talks to it. The playlist pages two videos
+  # at a time, so fetching it has to follow nextPageToken.
+  class FakeGoogle
+    attr_reader :calls
+    attr_accessor :videos, :error
+
+    def initialize
+      @calls = []
+      @videos = []
+    end
+
+    def call(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::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
+    end
+
+    private
+
+    # A nil title stands for a deleted video, which has no owner.
+    def item(id, title)
+      snippet = { "title" => title || "Deleted video", "resourceId" => { "videoId" => id } }
+      snippet["videoOwnerChannelTitle"] = "A channel" if title
+      { "snippet" => snippet }
+    end
+  end
+
+  def setup
+    header "Remote-User", LOGIN
+    RankKing::DB[:results].delete
+    RankKing::DB[:axes].delete
+    RankKing::DB[:items].delete
+    RankKing::DB[:lists].delete
+    RankKing::DB[:users].delete
+
+    @google = FakeGoogle.new
+    RankKing::Google.transport = @google.method(:call)
+  end
+
+  def teardown
+    RankKing::Google.transport = nil
+  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_settings_says_when_google_is_not_set_up
+    unconfigured do
+      get "/settings"
+      refute_includes last_response.body, "/settings/google/connect"
+      assert_includes last_response.body, "GOOGLE_CLIENT_ID"
+
+      get "/settings/google/connect"
+      assert_equal 404, last_response.status
+    end
+  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
+    get "/lists/new"
+    assert_includes last_response.body, %(<option value="YouTubePlaylist">YouTube playlist</option>)
+    assert_includes last_response.body, %(<a href="/settings">your settings</a>)
+    refute_includes last_response.body, %(name="playlist")
+  end
+
+  def test_new_list_takes_a_playlist_once_connected
+    connect_google
+    get "/lists/new"
+    assert_includes last_response.body, %(name="playlist")
+  end
+
+  def test_create_a_list_from_a_playlist
+    connect_google
+    @google.videos = [["v1", "First"], ["v2", nil], ["v3", "Third"], ["v4", "Fourth"], ["v1", "First again"]]
+
+    create_list(playlist: "https://www.youtube.com/playlist?list=#{PLAYLIST}&si=x")
+    assert last_response.redirect?
+
+    list = RankKing::List.first
+    assert_equal "YouTubePlaylist", list.source_kind
+    refute_nil list.synced_at
+    assert_equal %w[First Third Fourth], names(list)
+    assert_equal PLAYLIST, RankKing::DB[:youtube_playlist_sources].where(list_id: list.id).get(:playlist_id)
+    assert_equal %w[v1 v3 v4], RankKing::DB[:youtube_playlist_items].order(:item_id).map(:video_id)
+
+    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
+  end
+
+  def test_create_needs_google
+    create_list(playlist: PLAYLIST)
+    assert_equal 422, last_response.status
+    assert_equal 0, RankKing::List.count
+  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"]]
+    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)
+    assert last_response.redirect?
+    assert_equal ["First, renamed", "Third"], names(list)
+    refute_nil second.reload.archived_at
+    assert_equal "First, renamed", first.reload.name
+
+    @google.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"]]
+    create_list(playlist: PLAYLIST)
+
+    get "/lists/#{RankKing::List.first.id}"
+    assert_includes last_response.body, "Sync from YouTube"
+    assert_includes last_response.body, %(data-busy="Syncing…")
+  end
+
+  def test_google_errors_are_reported
+    connect_google
+    @google.videos = [["v1", "First"], ["v2", "Second"]]
+    create_list(playlist: PLAYLIST)
+    list = RankKing::List.first
+
+    @google.error = "Token has been expired or revoked."
+    sync(list)
+    assert_equal 502, last_response.status
+    assert_includes last_response.body, "Token has been expired or revoked."
+    assert_equal %w[First Second], names(list)
+  end
+
+  def test_deleting_a_youtube_list
+    connect_google
+    @google.videos = [["v1", "First"], ["v2", "Second"]]
+    create_list(playlist: PLAYLIST)
+    list = RankKing::List.first
+
+    csrf_post "/lists/#{list.id}/delete", {}, from: "/lists/#{list.id}/settings"
+    assert last_response.redirect?
+    assert_equal 0, RankKing::DB[:youtube_playlist_sources].count
+    assert_equal 0, RankKing::DB[:youtube_playlist_items].count
+  end
+
+  def test_playlist_id
+    playlist_id = RankKing::Sources::YouTubePlaylist.method(:playlist_id)
+    assert_equal PLAYLIST, playlist_id.(" #{PLAYLIST} ")
+    assert_equal PLAYLIST, playlist_id.("https://www.youtube.com/playlist?list=#{PLAYLIST}")
+    assert_equal PLAYLIST, playlist_id.("https://www.youtube.com/watch?v=abc&list=#{PLAYLIST}&index=2")
+    assert_nil playlist_id.("https://www.youtube.com/watch?v=abc")
+  end
+
+  private
+
+  def user = RankKing::User.first(login: LOGIN)
+
+  # As if the server had no Google client.
+  def unconfigured
+    google = RankKing::Google.singleton_class
+    google.alias_method :__configured?, :configured?
+    google.define_method(:configured?) { false }
+    yield
+  ensure
+    google.alias_method :configured?, :__configured?
+    google.remove_method :__configured?
+  end
+
+  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
+  end
+
+  def names(list) = list.items_dataset.order(:id).map(:name)
+
+  def create_list(playlist:)
+    csrf_post "/lists", { name: "Videos", source: "YouTubePlaylist", playlist: }, from: "/lists/new"
+  end
+
+  def sync(list)
+    csrf_post "/lists/#{list.id}/sync", {}, from: "/lists/#{list.id}"
+  end
+
+  def csrf_post(path, params, from:)
+    get from
+    token = last_response.body[/action="#{Regexp.escape(path)}".*?name="_csrf" value="([^"]+)"/m, 1]
+    post path, params.merge("_csrf" => token)
+  end
+end