feat: sign in to YouTube with browser cookies

Answers the bot challenge from inside the app. Settings gains a browser
picker listing only the browsers actually installed; choosing one passes
--cookies-from-browser to every yt-dlp call, so YouTube sees an
authenticated session. Verified against a live block: refused without
cookies, resolved with them.

Arc is Chromium underneath but is not one of yt-dlp's known names, so it
is addressed by its profile directory instead.

yt-dlp's errors are translated into something actionable. The stock bot
message points at command-line flags a user cannot type; it now names
the setting that fixes it, and Safari's protected cookie store gets its
own message about Full Disk Access rather than a bare 'Operation not
permitted'.

A Check connection button reports whether YouTube is reachable, testing
against the newest video in the feed — the hardcoded id it used at first
had been taken down, so it reported a dead video rather than the
connection.
This commit is contained in:
vincent
2026-08-29 14:21:35 +02:00
parent 7aae080e46
commit 22c7a3a5ec
5 changed files with 257 additions and 15 deletions
+78 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import {
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
checkPrereqs, importTakeoutCsv, listBrowsers, pickLibraryFolder, pickTakeoutFile,
previewTakeoutImport, setCookieSource, testYoutube,
} from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import {
@@ -9,7 +10,8 @@ import {
} from "../types";
import TakeoutGuide from "./TakeoutGuide";
import {
BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented, SUBPANEL,
BTN, BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented,
SUBPANEL,
} from "./ui";
interface Props {
@@ -23,6 +25,8 @@ interface Props {
onStreamQuality: (q: Quality) => void;
subLang: string;
onSubLang: (l: string) => void;
browser: string;
onBrowser: (b: string) => void;
onError: (message: string) => void;
}
@@ -47,11 +51,40 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
export default function Settings({
onClose, onImported, appearance, onAppearance, quality, onQuality,
streamQuality, onStreamQuality, subLang, onSubLang, onError,
streamQuality, onStreamQuality, subLang, onSubLang, browser, onBrowser, onError,
}: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
const [busy, setBusy] = useState(false);
const [browsers, setBrowsers] = useState<Array<[string, string]>>([]);
const [check, setCheck] = useState<{ ok: boolean; message: string } | null>(null);
const [checking, setChecking] = useState(false);
useEffect(() => {
listBrowsers().then(setBrowsers).catch(() => setBrowsers([]));
}, []);
const chooseBrowser = async (id: string) => {
onBrowser(id);
setCheck(null);
try {
await setCookieSource(id);
} catch (e) {
onError(String(e));
}
};
const runCheck = async () => {
setChecking(true);
setCheck(null);
try {
setCheck({ ok: true, message: await testYoutube() });
} catch (e) {
setCheck({ ok: false, message: String(e) });
} finally {
setChecking(false);
}
};
const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null));
useEffect(() => { load(); }, []);
@@ -208,6 +241,48 @@ export default function Settings({
</p>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Sign in to YouTube</SectionHeading>
<p className={`mt-1.5 ${HELP}`}>
YouTube sometimes asks a machine to prove it is not a bot, and then
nothing will stream or download. Pointing the app at a browser you are
already signed into clears that. The cookies are read on this Mac and
sent only to YouTube.
</p>
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
<span className={LABEL}>Use cookies</span>
<select
value={browser}
onChange={(e) => void chooseBrowser(e.target.value)}
className={SELECT}
>
<option value="">Not signed in</option>
{browsers.map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
<div className="mt-2 flex items-center gap-2">
<button onClick={runCheck} disabled={checking} className={`${BTN} cursor-pointer`}>
{checking ? "Checking…" : "Check connection"}
</button>
{check && (
<span
className={`text-[11px] leading-snug ${
check.ok
? "text-slate-500 dark:text-slate-400"
: "text-red-600 dark:text-red-400"
}`}
>
{check.message}
</span>
)}
</div>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Appearance</SectionHeading>
<div className="mt-2 flex items-center justify-between gap-3">