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