commit 69e9f1b18e0d484986efcd6bf92890dae6cfec2f
parent df945a23403c034c7e1f2a9afada915a414d77ad
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 11:27:44 -0400
editor: the X account session shows the login source in use, checks whether an X login is visible in it, and lets the operator choose it
- XSessionSection: a "Login source" select (Automatic / Browser login /
Connected profile, accessible name "x cookie source"), the source in use
("x cookie source in use"), a Check button ("check x login") whose result is
one line ("x login status"); the Connect text says the window is the
operator's own browser, that Google's sign-in may still refuse an embedded
browser, and that X's password login is the reliable path. Every earlier
accessible name is unchanged.
- xSessionActions: checkXLoginAction, setXCookieSourceAction (through
saveSettings); the session actions return the source in use too; Connect
logs which browser opened.
- e2e x-session.spec: the default source with nothing set; the browser source
over a fixture Firefox store, Check's line, the select persisting across a
reload, and back to automatic.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 305 insertions(+), 39 deletions(-)
diff --git a/editor/app/settings/components/XSessionSection.tsx b/editor/app/settings/components/XSessionSection.tsx
@@ -1,44 +1,108 @@
"use client";
-// "Connect X account" — the operator-facing half of the X session broker.
+// The X account session — where the X fetchers' login comes from, whether a
+// login is visible there, and the operator-facing half of the session broker.
//
-// Why this exists: gallery-dl is the primary X fetcher and its worst flaw is
-// that X cookies expire within days. A persistent, logged-in browser profile
-// removes that problem without touching the fetch path at all — the fetcher
-// just reads a jar this keeps fresh.
+// Two sources (release 16 slice XL, common/social/xCookieSource.ts):
+// - the operator's everyday browser (`cookiesFromBrowser`): gallery-dl reads
+// its X login on every fetch, so nothing expires while the operator stays
+// logged in to x.com there — no window at all;
+// - a profile connected here once: a persistent, logged-in browser profile
+// the fetcher re-exports fresh cookies from, because X cookies otherwise
+// expire within days.
-import { useState, useTransition } from "react";
+import { useEffect, useState, useTransition } from "react";
import {
+ checkXLoginAction,
clearXSessionAction,
connectXAccountAction,
refreshXCookiesAction,
+ setXCookieSourceAction,
+ type XSessionActionResult,
} from "../xSessionActions";
import type { XSessionStatus } from "yt-dlp-transcript-common/social/xSessionBroker";
+import type { XLoginStatus } from "yt-dlp-transcript-common/social/xBrowserLogin";
+import {
+ resolveXCookieSource,
+ xCookieSourceLabel,
+ type XCookieSourceView,
+} from "yt-dlp-transcript-common/social/xCookieSource";
-export function XSessionSection({ initial }: { initial: XSessionStatus }) {
+export function XSessionSection({
+ initial,
+ initialSource,
+}: {
+ initial: XSessionStatus;
+ initialSource: XCookieSourceView;
+}) {
const [status, setStatus] = useState<XSessionStatus>(initial);
+ const [source, setSource] = useState<XCookieSourceView>(initialSource);
+ const [login, setLogin] = useState<XLoginStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
+ // Two transitions: the session actions (Connect waits for the window to
+ // close) and the source controls (Check, the source select).
const [pending, startTransition] = useTransition();
+ const [checking, startChecking] = useTransition();
+
+ // A save of cookiesFromBrowser above (or another tab's choice) re-renders the
+ // page with a new resolved source; take it.
+ useEffect(() => {
+ setSource(initialSource);
+ // Keyed on the values, not the object: every server render makes a new one.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [initialSource.source, initialSource.chosen, initialSource.browserSpec]);
- const run = (
- fn: () => Promise<
- { ok: true; status: XSessionStatus } | { ok: false; error: string }
- >,
- okNote: string,
- ) =>
+ const run = (fn: () => Promise<XSessionActionResult>, okNote: (r: { browser?: string }) => string) =>
startTransition(async () => {
setError(null);
setNote(null);
const res = await fn();
if (res.ok) {
setStatus(res.status);
- setNote(okNote);
+ setSource(res.source);
+ setLogin(null);
+ setNote(okNote(res));
+ } else {
+ setError(res.error);
+ }
+ });
+
+ const check = () =>
+ startChecking(async () => {
+ setError(null);
+ setNote(null);
+ const res = await checkXLoginAction();
+ if (res.ok) {
+ setLogin(res.login);
+ setSource(res.login);
} else {
setError(res.error);
}
});
+ const choose = (choice: string) =>
+ startChecking(async () => {
+ setError(null);
+ setNote(null);
+ const res = await setXCookieSourceAction(choice);
+ if (res.ok) {
+ setSource(res.source);
+ setLogin(null);
+ setNote("Login source saved.");
+ } else {
+ setError(res.error);
+ }
+ });
+
+ // What "Automatic" resolves to right now, for its option's text.
+ const automatic = xCookieSourceLabel(
+ resolveXCookieSource({
+ browserSpec: source.browserSpec,
+ profileConnected: status.looksAuthenticated,
+ }),
+ );
+
return (
<section
data-x-session=""
@@ -62,12 +126,68 @@ export function XSessionSection({ initial }: { initial: XSessionStatus }) {
</div>
<p className="text-xs text-muted-foreground">
- X post archiving needs a logged-in session, and X cookies expire within
- days. Connecting an account once stores a browser profile on this host;
- the fetcher then re-exports fresh cookies from it automatically, so the
- session stops being the thing that breaks.
+ X post archiving needs a logged-in session, from one of two sources.{" "}
+ <strong>Browser login</strong>: your everyday browser, named by{" "}
+ <code>cookiesFromBrowser</code> above — gallery-dl reads its X login on
+ every fetch, so nothing expires while you stay logged in to x.com there,
+ and no window is needed. <strong>Connected profile</strong>: a browser
+ profile connected here once; the fetcher re-exports fresh cookies from
+ it, because X cookies otherwise expire within days.
+ </p>
+
+ <div className="flex flex-wrap items-center gap-2">
+ <label htmlFor="x-cookie-source" className="text-sm">
+ Login source
+ </label>
+ <select
+ id="x-cookie-source"
+ aria-label="x cookie source"
+ value={source.chosen ? source.source : "auto"}
+ onChange={(e) => choose(e.target.value)}
+ disabled={pending || checking}
+ className="rounded-md border border-border bg-background px-2 py-1 text-sm disabled:opacity-50"
+ >
+ <option value="auto">Automatic — now: {automatic}</option>
+ <option value="browser">
+ Browser login{source.browserSpec ? ` (${source.browserSpec})` : ""}
+ </option>
+ <option value="profile">Connected profile</option>
+ </select>
+ <button
+ type="button"
+ onClick={check}
+ disabled={pending || checking}
+ aria-label="check x login"
+ className="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
+ >
+ {checking ? "Checking…" : "Check"}
+ </button>
+ </div>
+
+ <p aria-label="x cookie source in use" className="text-xs text-muted-foreground">
+ In use: {source.label}
+ {source.chosen ? "" : " (automatic)"}
</p>
+ {login && (
+ <p
+ aria-label="x login status"
+ className={
+ "text-xs " +
+ (login.authTokenVisible === true
+ ? "text-success"
+ : login.authTokenVisible === false
+ ? "text-destructive"
+ : "text-muted-foreground")
+ }
+ >
+ {login.summary}{" "}
+ <span className="text-muted-foreground">
+ (checked {new Date(login.checkedAt).toLocaleTimeString()})
+ </span>
+ </p>
+ )}
+
{status.cookiesUpdatedAt && (
<p className="text-xs text-muted-foreground">
Cookies last exported:{" "}
@@ -79,7 +199,9 @@ export function XSessionSection({ initial }: { initial: XSessionStatus }) {
<button
type="button"
onClick={() =>
- run(connectXAccountAction, "Connected. Cookies exported.")
+ run(connectXAccountAction, (r) =>
+ `Connected${r.browser ? ` with ${r.browser}` : ""}. Cookies exported.`,
+ )
}
disabled={pending}
aria-label="connect x account"
@@ -89,7 +211,7 @@ export function XSessionSection({ initial }: { initial: XSessionStatus }) {
</button>
<button
type="button"
- onClick={() => run(refreshXCookiesAction, "Cookies refreshed.")}
+ onClick={() => run(refreshXCookiesAction, () => "Cookies refreshed.")}
disabled={pending || !status.hasProfile}
aria-label="refresh x cookies"
className="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
@@ -98,7 +220,7 @@ export function XSessionSection({ initial }: { initial: XSessionStatus }) {
</button>
<button
type="button"
- onClick={() => run(clearXSessionAction, "Session cleared.")}
+ onClick={() => run(clearXSessionAction, () => "Session cleared.")}
disabled={pending || !status.hasProfile}
aria-label="clear x session"
className="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
@@ -109,9 +231,13 @@ export function XSessionSection({ initial }: { initial: XSessionStatus }) {
<p className="text-xs text-muted-foreground">
Connecting opens a browser window <strong>on the machine running the
- editor</strong>; complete the login (2FA and captcha included), then
- close the window. The login is never automated — that is what gets
- accounts flagged.
+ editor</strong>: your own Chromium or Chrome when one is installed (else
+ Playwright's bundled Chromium), without the automation signals.
+ Complete the login (2FA and captcha included), then close the window.
+ Google's sign-in may still refuse an embedded browser (“this
+ browser or app may not be secure”) — <strong>X's own
+ password login is the reliable path</strong>. The login is never
+ automated — that is what gets accounts flagged.
</p>
{note && (
diff --git a/editor/app/settings/page.tsx b/editor/app/settings/page.tsx
@@ -3,6 +3,8 @@ import Link from "next/link";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { readXSessionStatus } from "yt-dlp-transcript-common/social/xSessionBroker";
+import { resolveXCookieSourceFor } from "yt-dlp-transcript-common/social/xBrowserLogin";
+import { xCookieSourceView } from "yt-dlp-transcript-common/social/xCookieSource";
import { SettingsForm } from "./components/SettingsForm";
import { XSessionSection } from "./components/XSessionSection";
@@ -29,8 +31,13 @@ export default async function SettingsPage() {
["galleryDlBin", paths.galleryDlBin],
];
- // The X session broker's current state (see xSessionActions.ts).
+ // The X session broker's current state and the X login source in use (see
+ // xSessionActions.ts). The source is resolved here, not read: with no stored
+ // choice it depends on cookiesFromBrowser and the profile.
const xSession = await readXSessionStatus(paths);
+ const xSource = xCookieSourceView(
+ await resolveXCookieSourceFor(paths, settings),
+ );
return (
<div className="flex flex-col gap-8">
@@ -86,7 +93,7 @@ export default async function SettingsPage() {
</section>
<section className="flex flex-col gap-3 border-t border-border pt-6">
- <XSessionSection initial={xSession} />
+ <XSessionSection initial={xSession} initialSource={xSource} />
</section>
<section className="flex flex-col gap-3 border-t border-border pt-6">
diff --git a/editor/app/settings/xSessionActions.ts b/editor/app/settings/xSessionActions.ts
@@ -1,16 +1,21 @@
"use server";
-// Server actions for the X session broker (common/social/xSessionBroker.ts).
+// Server actions for the X session broker (common/social/xSessionBroker.ts)
+// and the X login source (common/social/xCookieSource.ts, release 16 slice XL).
//
// "Connect X account" opens a HEADED browser on the editor host so the operator
// can log in by hand — password, 2FA and captcha included. That is the whole
// point: automating an X login is what gets accounts flagged, and a human doing
-// it once produces a profile that stays valid.
+// it once produces a profile that stays valid. The window is the operator's own
+// Chromium or Chrome when one is installed, without the automation signals
+// (common/social/xBrowser.ts).
//
// Because it opens a window on the SERVER's display, this is deliberately an
// explicit operator action and never something a fetch triggers on its own.
+import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import {
clearXSession,
connectXAccount,
@@ -18,28 +23,49 @@ import {
refreshXCookies,
type XSessionStatus,
} from "yt-dlp-transcript-common/social/xSessionBroker";
+import {
+ readXLoginStatus,
+ resolveXCookieSourceFor,
+ type XLoginStatus,
+} from "yt-dlp-transcript-common/social/xBrowserLogin";
+import {
+ isXCookieSource,
+ xCookieSourceView,
+ type XCookieSourceView,
+} from "yt-dlp-transcript-common/social/xCookieSource";
+import { saveSettings } from "./saveSettings";
+// Every session action returns the source in use beside the profile's status:
+// connecting or forgetting a profile can move the read-time default.
export type XSessionActionResult =
- | { ok: true; status: XSessionStatus }
+ | { ok: true; status: XSessionStatus; source: XCookieSourceView; browser?: string }
| { ok: false; error: string };
+async function sourceNow(): Promise<XCookieSourceView> {
+ return xCookieSourceView(await resolveXCookieSourceFor(getPaths(), getSettings()));
+}
+
export async function xSessionStatusAction(): Promise<XSessionStatus> {
return readXSessionStatus(getPaths());
}
export async function connectXAccountAction(): Promise<XSessionActionResult> {
try {
- const status = await connectXAccount(getPaths());
+ const { browser, ...status } = await connectXAccount(getPaths(), {
+ // Which browser opened, and any fallback, in the editor's log.
+ onLog: (line) => console.log(`[x-session] ${line}`),
+ });
if (!status.looksAuthenticated) {
return {
ok: false,
error:
- "The browser closed without a logged-in X session (no auth_token " +
- "cookie was exported). Try again and complete the login before " +
- "closing the window.",
+ `The browser (${browser}) closed without a logged-in X session (no ` +
+ "auth_token cookie was exported). Try again and complete the login " +
+ "before closing the window — X's own password login is the reliable " +
+ "path where Google's sign-in refuses.",
};
}
- return { ok: true, status };
+ return { ok: true, status, source: await sourceNow(), browser };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
@@ -47,7 +73,10 @@ export async function connectXAccountAction(): Promise<XSessionActionResult> {
export async function refreshXCookiesAction(): Promise<XSessionActionResult> {
try {
- return { ok: true, status: await refreshXCookies(getPaths()) };
+ const status = await refreshXCookies(getPaths(), {
+ onLog: (line) => console.log(`[x-session] ${line}`),
+ });
+ return { ok: true, status, source: await sourceNow() };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
@@ -56,8 +85,51 @@ export async function refreshXCookiesAction(): Promise<XSessionActionResult> {
export async function clearXSessionAction(): Promise<XSessionActionResult> {
try {
await clearXSession(getPaths());
- return { ok: true, status: await readXSessionStatus(getPaths()) };
+ return {
+ ok: true,
+ status: await readXSessionStatus(getPaths()),
+ source: await sourceNow(),
+ };
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+}
+
+// THE CHECK: the source in use, whether an X login (an auth_token for x.com)
+// is visible in it, and when it was last seen. Reads the browser's cookie store
+// read-only (a private copy) or the profile's exported jar.
+export type XLoginCheckResult =
+ | { ok: true; login: XLoginStatus }
+ | { ok: false; error: string };
+
+export async function checkXLoginAction(): Promise<XLoginCheckResult> {
+ try {
+ return { ok: true, login: await readXLoginStatus(getPaths(), getSettings()) };
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+}
+
+// THE CHOICE: "browser", "profile", or "auto" — no stored choice, the default
+// resolved at read time. Writes `social.x.cookieSource` through the one
+// settings writer and nothing else.
+export type XSourceChoiceResult =
+ | { ok: true; source: XCookieSourceView }
+ | { ok: false; error: string };
+
+export async function setXCookieSourceAction(
+ choice: string,
+): Promise<XSourceChoiceResult> {
+ if (choice !== "auto" && !isXCookieSource(choice)) {
+ return { ok: false, error: `Unknown X login source "${choice}".` };
+ }
+ try {
+ await saveSettings({
+ social: { x: choice === "auto" ? {} : { cookieSource: choice } },
+ });
} catch (e) {
return { ok: false, error: (e as Error).message };
}
+ revalidatePath("/settings");
+ return { ok: true, source: await sourceNow() };
}
diff --git a/editor/e2e/x-session.spec.ts b/editor/e2e/x-session.spec.ts
@@ -1,9 +1,15 @@
-// The X session broker UI (common/social/xSessionBroker.ts). The headed login
+// The X session broker UI (common/social/xSessionBroker.ts) and the X login
+// source (common/social/xCookieSource.ts, release 16 slice XL). The headed login
// is never driven here — it would open a real browser and hit x.com, which the
-// suite must never do.
+// suite must never do. The browser source reads a FIXTURE Firefox store written
+// under the test corpus, never a real browser profile.
import { test, expect } from "@playwright/test";
-import { resetData } from "./helpers";
+import { readJson, resetData, resolvePath, writeSettings } from "./helpers";
+// By relative path: a runtime import of the package specifier does not resolve
+// under playwright's loader (digest.spec.ts says why).
+import { writeFirefoxCookieStore } from "../../common/social/__fixtures__/firefoxCookieStore";
+
test("settings page renders the X session section", async ({ page }) => {
await resetData("empty");
await page.goto("/settings");
@@ -13,4 +19,59 @@ test("settings page renders the X session section", async ({ page }) => {
// never click it (it would open a real browser window and hit x.com).
await expect(page.getByLabel("connect x account")).toBeVisible();
await expect(page.getByLabel("refresh x cookies")).toBeDisabled();
+ // No cookiesFromBrowser in the fixture settings and no profile: the
+ // read-time default is the profile.
+ await expect(page.getByLabel("x cookie source", { exact: true })).toHaveValue("auto");
+ await expect(page.getByLabel("x cookie source in use")).toHaveText(
+ "In use: Connected profile (automatic)",
+ );
+});
+
+test("the login source select persists, and Check shows a status line", async ({ page }) => {
+ await resetData("empty");
+ const profileDir = resolvePath("test-transcripts/firefox-fixture/xl.default-release");
+ await writeFirefoxCookieStore(profileDir, [
+ {
+ host: ".x.com",
+ name: "auth_token",
+ value: "fixture-not-a-real-token",
+ lastAccessed: Date.UTC(2026, 9, 1, 8, 30, 0) * 1000,
+ },
+ { host: ".example.com", name: "session", value: "not-x" },
+ ]);
+ const spec = `firefox:${profileDir}`;
+ await writeSettings({ cookiesFromBrowser: spec });
+
+ await page.goto("/settings");
+ const section = page.locator("[data-x-session]");
+ const select = page.getByLabel("x cookie source", { exact: true });
+ const inUse = page.getByLabel("x cookie source in use");
+
+ // cookiesFromBrowser set, no profile connected: the browser, by default.
+ await expect(select).toHaveValue("auto");
+ await expect(inUse).toHaveText(`In use: Browser login (${spec}) (automatic)`);
+
+ await page.getByLabel("check x login").click();
+ await expect(page.getByLabel("x login status")).toContainText(
+ "An X login is visible in firefox (its auth_token last used 2026-10-01 08:30:00 UTC).",
+ );
+
+ // Choose the profile: stored, shown, and still chosen after a reload.
+ await select.selectOption("profile");
+ await expect(section.getByRole("status")).toHaveText("Login source saved.");
+ await expect(inUse).toHaveText("In use: Connected profile");
+ expect((await readJson<{ social?: unknown }>("test-settings.json")).social).toEqual({
+ x: { cookieSource: "profile" },
+ });
+
+ await page.reload();
+ await expect(select).toHaveValue("profile");
+ await expect(inUse).toHaveText("In use: Connected profile");
+ await page.getByLabel("check x login").click();
+ await expect(page.getByLabel("x login status")).toContainText("No profile is connected.");
+
+ // Back to automatic: nothing stored, the default decides again.
+ await select.selectOption("auto");
+ await expect(inUse).toHaveText(`In use: Browser login (${spec}) (automatic)`);
+ expect((await readJson<{ social?: unknown }>("test-settings.json")).social).toEqual({ x: {} });
});