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:
+20
-2
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo,
|
||||
fetchDurations, onRefreshProgress, refreshFeeds,
|
||||
fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
|
||||
} from "./api";
|
||||
import Player from "./components/Player";
|
||||
import Settings from "./components/Settings";
|
||||
@@ -55,6 +55,13 @@ export default function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// Index into the current feed, so the player can step through it.
|
||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
||||
const [browser, setBrowser] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("flighttube.browser") ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
const [subLang, setSubLang] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("flighttube.subLang");
|
||||
@@ -84,6 +91,14 @@ export default function App() {
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
|
||||
// The backend holds no state across launches, so the stored choice has to be
|
||||
// handed back to it before the first yt-dlp call.
|
||||
useEffect(() => {
|
||||
setCookieSource(browser).catch(() => {
|
||||
/* falls back to no cookies */
|
||||
});
|
||||
}, [browser]);
|
||||
|
||||
const { mode, setMode } = useAppearance();
|
||||
const windowFullscreen = useWindowFullscreen();
|
||||
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
|
||||
@@ -104,13 +119,14 @@ export default function App() {
|
||||
localStorage.setItem("flighttube.quality", quality);
|
||||
localStorage.setItem("flighttube.streamQuality", streamQuality);
|
||||
localStorage.setItem("flighttube.subLang", subLang);
|
||||
localStorage.setItem("flighttube.browser", browser);
|
||||
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
|
||||
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
|
||||
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}, [view, quality, streamQuality, subLang, downloadedOnly, hideShorts, sidebarHidden]);
|
||||
}, [view, quality, streamQuality, subLang, browser, downloadedOnly, hideShorts, sidebarHidden]);
|
||||
|
||||
// Offline, the only videos that can be played are the ones already on disk,
|
||||
// so the feed collapses to those regardless of the toggle.
|
||||
@@ -459,6 +475,8 @@ export default function App() {
|
||||
onStreamQuality={setStreamQuality}
|
||||
subLang={subLang}
|
||||
onSubLang={setSubLang}
|
||||
browser={browser}
|
||||
onBrowser={setBrowser}
|
||||
onError={setFailure}
|
||||
onImported={(n) => {
|
||||
reload();
|
||||
|
||||
+10
@@ -32,6 +32,16 @@ export const deleteAllDownloads = () => invoke<number>("delete_all_downloads");
|
||||
/** Fills in missing video lengths, a batch at a time. Returns how many. */
|
||||
export const fetchDurations = () => invoke<number>("fetch_durations");
|
||||
|
||||
/** Browsers installed here that yt-dlp can read cookies from: [id, label]. */
|
||||
export const listBrowsers = () => invoke<Array<[string, string]>>("list_browsers");
|
||||
|
||||
/** Empty string signs out. */
|
||||
export const setCookieSource = (browser: string) =>
|
||||
invoke<void>("set_cookie_source", { browser });
|
||||
|
||||
/** Resolves a known video to check whether YouTube is currently reachable. */
|
||||
export const testYoutube = () => invoke<string>("test_youtube");
|
||||
|
||||
/** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */
|
||||
export const listSubtitles = (videoId: string) =>
|
||||
invoke<Array<[string, string]>>("list_subtitles", { videoId });
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user