From 22c7a3a5ecd0313fdbc37c39b0a695b195f8e87b Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 29 Aug 2026 14:21:35 +0200 Subject: [PATCH] feat: sign in to YouTube with browser cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/commands.rs | 156 +++++++++++++++++++++++++++++++++--- src-tauri/src/lib.rs | 3 + src/App.tsx | 22 ++++- src/api.ts | 10 +++ src/components/Settings.tsx | 81 ++++++++++++++++++- 5 files changed, 257 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1fd921b..86dcfaa 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -46,17 +46,138 @@ pub struct AppState { /// argv prefix that runs yt-dlp: either a system binary, or the bundled /// Python interpreter followed by the zipapp. pub yt_dlp: Vec, + /// Value for yt-dlp's --cookies-from-browser, when signed in. + pub cookies_from: Arc>>, } impl AppState { - /// A ready-to-configure yt-dlp process. - fn yt_dlp(&self) -> tokio::process::Command { + /// A ready-to-configure yt-dlp process, carrying cookies when configured. + async fn yt_dlp(&self) -> tokio::process::Command { let mut cmd = tokio::process::Command::new(&self.yt_dlp[0]); cmd.args(&self.yt_dlp[1..]); + if let Some(from) = self.cookies_from.lock().await.clone() { + cmd.arg("--cookies-from-browser").arg(from); + } cmd } } +/// Browsers yt-dlp can read cookies from, as (id, label, --cookies-from-browser +/// value). Arc is Chromium underneath but is not one of yt-dlp's known names, +/// so it is addressed by its profile directory. +fn browser_options() -> Vec<(String, String, String)> { + let home = std::env::var("HOME").unwrap_or_default(); + vec![ + ("safari", "Safari", "/Applications/Safari.app", "safari".to_string()), + ("arc", "Arc", "/Applications/Arc.app", + format!("chrome:{home}/Library/Application Support/Arc/User Data")), + ("chrome", "Google Chrome", "/Applications/Google Chrome.app", "chrome".to_string()), + ("firefox", "Firefox", "/Applications/Firefox.app", "firefox".to_string()), + ("brave", "Brave", "/Applications/Brave Browser.app", "brave".to_string()), + ("edge", "Microsoft Edge", "/Applications/Microsoft Edge.app", "edge".to_string()), + ("vivaldi", "Vivaldi", "/Applications/Vivaldi.app", "vivaldi".to_string()), + ] + .into_iter() + .filter(|(_, _, app, _)| std::path::Path::new(app).exists()) + .map(|(id, label, _, value)| (id.to_string(), label.to_string(), value)) + .collect() +} + +/// The browsers actually installed, for the Settings picker. +#[tauri::command] +pub async fn list_browsers() -> Result, String> { + Ok(browser_options() + .into_iter() + .map(|(id, label, _)| (id, label)) + .collect()) +} + +/// Chooses which browser's cookies yt-dlp should use. An empty id signs out. +#[tauri::command] +pub async fn set_cookie_source( + browser: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let value = browser_options() + .into_iter() + .find(|(id, _, _)| *id == browser) + .map(|(_, _, value)| value); + *state.cookies_from.lock().await = value; + Ok(()) +} + +/// Turns yt-dlp's stderr into something worth showing. +/// +/// The bot challenge is the one users hit most, and its stock message points at +/// command-line flags they have no way to type, so it is replaced with the +/// setting that actually fixes it. +fn explain_yt_dlp_error(stderr: &str, signed_in: bool) -> String { + if stderr.contains("Sign in to confirm") || stderr.contains("not a bot") { + return if signed_in { + "YouTube is still refusing this machine even with browser cookies. The sign-in may have expired — reopen YouTube in that browser, or wait a while before trying again." + .into() + } else { + "YouTube is asking this machine to prove it is not a bot. Open Settings and pick a browser under Sign in to YouTube; the app will use that browser's session." + .into() + }; + } + if stderr.contains("Operation not permitted") && stderr.contains("Safari") { + return "macOS blocked access to Safari's cookies. Give FlightTube Full Disk Access in System Settings → Privacy & Security, or pick a different browser." + .into(); + } + if stderr.contains("could not find") && stderr.contains("cookies database") { + return "That browser has no cookie store on this Mac. Pick another under Settings → Sign in to YouTube." + .into(); + } + stderr + .lines() + .rev() + .find(|l| l.contains("ERROR")) + .unwrap_or("yt-dlp failed") + .to_string() +} + +/// Tries a real extraction so Settings can report whether YouTube is reachable. +#[tauri::command] +pub async fn test_youtube(state: State<'_, AppState>) -> Result { + let signed_in = state.cookies_from.lock().await.is_some(); + // Test against the newest video in the feed. A hardcoded id is no good — + // the one this used at first had been taken down, so the check reported a + // dead video rather than the connection. + let target = state + .db + .lock() + .await + .list_feed(&FeedFilter { limit: Some(1), ..Default::default() })? + .first() + .map(|v| v.id.clone()) + .ok_or("Import your subscriptions first — there is nothing to test with.")?; + + let mut cmd = state.yt_dlp().await; + cmd.args([ + "--no-playlist", + "--simulate", + "--print", + "%(id)s", + &format!("https://www.youtube.com/watch?v={target}"), + ]); + let out = cmd + .output() + .await + .map_err(|e| format!("Could not run yt-dlp: {e}"))?; + if out.status.success() { + return Ok(if signed_in { + "YouTube is reachable, using your browser sign-in.".into() + } else { + "YouTube is reachable.".into() + }); + } + Err(explain_yt_dlp_error( + &String::from_utf8_lossy(&out.stderr), + signed_in, + )) +} + /// Comfortably inside the ~6h lifetime of YouTube's signed URLs. const STREAM_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3 * 3600); @@ -354,6 +475,18 @@ pub async fn resolve_stream( return Ok(Stream { url: Some(u), playlist: None }); } + // Distinguish "YouTube is refusing us" from "this video has no stream". + let signed_in = state.cookies_from.lock().await.is_some(); + let mut probe = state.yt_dlp().await; + probe.args(["--no-playlist", "--simulate", "--print", "%(id)s", &url]); + if let Ok(out) = probe.output().await { + if !out.status.success() { + return Err(explain_yt_dlp_error( + &String::from_utf8_lossy(&out.stderr), + signed_in, + )); + } + } Err("Could not find a playable stream for this video.".into()) } @@ -535,7 +668,7 @@ fn resolution_height(stream_inf: &str) -> Option { /// Runs yt-dlp and returns every non-empty stdout line. async fn yt_dlp_lines(state: &State<'_, AppState>, args: &[&str]) -> Vec { - let mut cmd = state.yt_dlp(); + let mut cmd = state.yt_dlp().await; cmd.args(["--no-warnings", "--no-playlist", "--simulate"]); cmd.args(args); let Ok(out) = cmd.output().await else { return Vec::new() }; @@ -551,7 +684,7 @@ async fn yt_dlp_lines(state: &State<'_, AppState>, args: &[&str]) -> Vec /// Runs yt-dlp and returns its first non-empty stdout line, or None. async fn yt_dlp_print(state: &State<'_, AppState>, args: &[&str]) -> Option { - let mut cmd = state.yt_dlp(); + let mut cmd = state.yt_dlp().await; cmd.args(["--no-warnings", "--no-playlist", "--simulate"]); cmd.args(args); let out = cmd.output().await.ok()?; @@ -811,6 +944,7 @@ pub async fn download_video( let mut child = state .yt_dlp() + .await .args(&args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -932,12 +1066,13 @@ pub async fn download_video( ); Ok(()) } else { - let msg = stderr_lines - .iter() - .rev() - .find(|l| l.contains("ERROR")) - .cloned() - .unwrap_or_else(|| format!("yt-dlp exited with {status}")); + let signed_in = state.cookies_from.lock().await.is_some(); + let joined = stderr_lines.join("\n"); + let msg = if joined.trim().is_empty() { + format!("yt-dlp exited with {status}") + } else { + explain_yt_dlp_error(&joined, signed_in) + }; let db = state.db.lock().await; db.set_download_state(&video_id, DownloadState::Failed, Some(&msg))?; drop(db); @@ -1202,6 +1337,7 @@ pub fn build_state(app: &AppHandle) -> Result { prereqs: Arc::new(Mutex::new(None)), streams: Arc::new(Mutex::new(HashMap::new())), yt_dlp: resolve_yt_dlp(app), + cookies_from: Arc::new(Mutex::new(None)), }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3eadae5..2bebc14 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -161,6 +161,9 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { commands::delete_all_downloads, commands::list_subtitles, commands::fetch_durations, + commands::list_browsers, + commands::set_cookie_source, + commands::test_youtube, commands::get_connectivity, commands::set_library_path, commands::open_external, diff --git a/src/App.tsx b/src/App.tsx index c40fcea..a28a74d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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(null); const [failure, setFailure] = useState(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(); diff --git a/src/api.ts b/src/api.ts index 73cbbf0..bc26f9d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -32,6 +32,16 @@ export const deleteAllDownloads = () => invoke("delete_all_downloads"); /** Fills in missing video lengths, a batch at a time. Returns how many. */ export const fetchDurations = () => invoke("fetch_durations"); +/** Browsers installed here that yt-dlp can read cookies from: [id, label]. */ +export const listBrowsers = () => invoke>("list_browsers"); + +/** Empty string signs out. */ +export const setCookieSource = (browser: string) => + invoke("set_cookie_source", { browser }); + +/** Resolves a known video to check whether YouTube is currently reachable. */ +export const testYoutube = () => invoke("test_youtube"); + /** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */ export const listSubtitles = (videoId: string) => invoke>("list_subtitles", { videoId }); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 33cb632..604a396 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -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(null); const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null); const [busy, setBusy] = useState(false); + const [browsers, setBrowsers] = useState>([]); + 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({

+
+ Sign in to YouTube +

+ 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. +

+ + +
+ + {check && ( + + {check.message} + + )} +
+
+
Appearance