From ea0022e28c7c19bc0da01dfa07d6ccffa693a84d Mon Sep 17 00:00:00 2001 From: Paul Otto Date: Thu, 12 Nov 2026 11:10:29 +0100 Subject: [PATCH 1/7] fix(ytmusic): read a sign-in YouTube stopped taking from the browser again A browser that goes on using its sign-in gets new cookies for it, and the copy cloofy took stops working after a day or two (#26). The sign-in now keeps which browser it came from and whose it is. When YouTube no longer takes the cookies, cloofy reads them from that browser again, once per run, and keeps them only if they sign in the same account. Requests for the library wait for that check, so they go out with the new cookies. Co-Authored-By: Claude Fable 5.2 --- crates/cloofy-ytmusic/src/account.rs | 43 ++++++++ crates/cloofy-ytmusic/src/auth.rs | 97 +++++++++++++++++-- crates/cloofy-ytmusic/src/innertube.rs | 42 ++++++-- crates/cloofy-ytmusic/src/lib.rs | 68 ++++++++++++- crates/cloofy-ytmusic/src/library.rs | 8 +- .../src/content/docs/docs/youtube-music.md | 2 +- 6 files changed, 236 insertions(+), 24 deletions(-) diff --git a/crates/cloofy-ytmusic/src/account.rs b/crates/cloofy-ytmusic/src/account.rs index 95f06ac..e15dddf 100644 --- a/crates/cloofy-ytmusic/src/account.rs +++ b/crates/cloofy-ytmusic/src/account.rs @@ -17,6 +17,8 @@ const MAX_ACCOUNTS: u32 = 10; /// The comments in the sign-in's `cookies.txt` that say which account it acts as. const INDEX_NOTE: &str = "# cloofy-authuser: "; const PAGE_NOTE: &str = "# cloofy-pageid: "; +const BROWSER_NOTE: &str = "# cloofy-browser: "; +const USER_NOTE: &str = "# cloofy-user: "; /// The account requests act as. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -52,6 +54,36 @@ impl Identity { } } +/// Where a sign-in came from, kept with it so that cloofy can read it from there again when +/// YouTube stops taking the cookies: a browser that goes on using its sign-in gets new cookies +/// for it, and the ones cloofy copied stop working after a day or two. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct Origin { + /// The browser as yt-dlp's `--cookies-from-browser` takes it; `None` for a pasted header. + pub browser: Option, + /// YouTube's ID of the account signed in as. Cookies read again are only kept when they + /// are this account's: the browser may have signed in to another one since. + pub user: Option, +} + +impl Origin { + /// Reads it from the sign-in's `cookies.txt`; empty where the file says nothing, as files + /// from before cloofy 0.5.3 do. + pub(crate) fn parse(text: &str) -> Self { + let note = |prefix: &str| { + let lines = text.lines().map(|line| line.trim_end_matches('\r')); + lines.filter_map(|line| line.strip_prefix(prefix)).map(str::trim).rfind(|value| !value.is_empty()) + }; + Self { browser: note(BROWSER_NOTE).map(str::to_owned), user: note(USER_NOTE).map(str::to_owned) } + } + + /// The lines that keep it in a `cookies.txt`, as comments other readers skip. + pub(crate) fn notes(&self) -> String { + let note = |prefix: &str, value: &Option| value.as_ref().map(|value| format!("{prefix}{value}\n")); + [note(BROWSER_NOTE, &self.browser), note(USER_NOTE, &self.user)].into_iter().flatten().collect() + } +} + /// An account the cookies can act as. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Account { @@ -166,6 +198,17 @@ fn parse(page: &Value, index: u32) -> Vec { mod tests { use super::*; + #[test] + fn where_a_sign_in_came_from_survives_its_file() { + let origin = Origin { browser: Some("brave+gnomekeyring".into()), user: Some("UCabc".into()) }; + let text = format!("# Netscape HTTP Cookie File\n{}{}", Identity::default().notes(), origin.notes()); + assert_eq!(Origin::parse(&text), origin); + assert_eq!(Identity::parse(&text), Identity::default(), "next to the account's own notes"); + // A file from before 0.5.3, and a pasted header's. + assert_eq!(Origin::parse("# cloofy-authuser: 1\n"), Origin::default()); + assert_eq!(Origin::default().notes(), ""); + } + fn fixture() -> Value { serde_json::from_str(include_str!("../tests/fixtures/accounts_list.json")).unwrap() } diff --git a/crates/cloofy-ytmusic/src/auth.rs b/crates/cloofy-ytmusic/src/auth.rs index 949b13d..a8f6abe 100644 --- a/crates/cloofy-ytmusic/src/auth.rs +++ b/crates/cloofy-ytmusic/src/auth.rs @@ -17,9 +17,10 @@ use tokio::process::Command; use crate::{ YtMusic, - account::{self, Account}, + account::{self, Account, Origin}, cookies::Jar, - innertube, library, + innertube::{self, InnerTube}, + library, }; /// A browser yt-dlp can read cookies from. @@ -192,8 +193,11 @@ pub struct SignIn { } impl SignIn { - fn new(jar: Jar) -> Result { - Ok(Self { client: YtMusic::unsaved(jar)? }) + /// `browser` is where the cookies were read, as yt-dlp names it; `None` for pasted ones. + fn new(jar: Jar, browser: Option) -> Result { + let client = YtMusic::unsaved(jar)?; + client.api().set_origin(Origin { browser, user: None }); + Ok(Self { client }) } /// The accounts it can act as: each Google account signed in to the browser, in Google's @@ -207,9 +211,13 @@ impl SignIn { self.client.api().set_identity(account.identity.clone()); } - /// Asks YouTube Music whose sign-in it is; fails when it turns the cookies down. + /// Asks YouTube Music whose sign-in it is; fails when it turns the cookies down. The + /// account is kept with the sign-in, for when cloofy reads the browser's cookies again. pub async fn check(&self) -> Result { - library::me(self.client.api()).await + let api = self.client.api(); + let user = library::me(api).await?; + api.set_origin(Origin { user: Some(user.id.clone()), ..api.origin().unwrap_or_default() }); + Ok(user) } /// Keeps it in place of the sign-in before, with the account chosen and the cookies @@ -241,7 +249,7 @@ pub async fn from_browser(paths: &Paths, browser: &mut Browser) -> Result