From a605b8bb8c094b011c14f1c4360d7450e77f443c Mon Sep 17 00:00:00 2001 From: wakamex Date: Sat, 19 Sep 2026 19:55:24 -0400 Subject: [PATCH 1/4] fix(playback): manage Node and retry YouTube sign-in failures --- .github/workflows/ci.yml | 2 + README.md | 20 +-- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 3 + src-tauri/src/downloader.rs | 273 ++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 207 ++++++++---------------- src-tauri/src/node.rs | 206 ++++++++++++++++++++++++ src-tauri/src/ytdlp.rs | 13 ++ src-tauri/tauri.linux.conf.json | 2 + src-tauri/tauri.macos.conf.json | 2 +- src/lib/ytdlp.ts | 8 +- 11 files changed, 581 insertions(+), 157 deletions(-) create mode 100644 src-tauri/src/downloader.rs create mode 100644 src-tauri/src/node.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ac7dd9..841c6ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,3 +60,5 @@ jobs: sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libdbus-1-dev patchelf - name: cargo test run: cargo test --lib --manifest-path src-tauri/Cargo.toml + - name: Managed Node install smoke test + run: cargo test --locked --lib --manifest-path src-tauri/Cargo.toml node::tests::installs_official_node_and_reuses_it -- --ignored --exact diff --git a/README.md b/README.md index 895ed82..8e2a857 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,10 @@ Download the latest installer from the [Releases](../../releases) page and run i - **Windows 10/11:** run the `.exe` NSIS installer. Pick the `x64` build, or the `arm64` one on a Windows on ARM device. -- **macOS 10.15+ (beta):** open the universal `.dmg`; the same build supports - Apple Silicon and Intel. Login cookies are encrypted with a Keychain-backed - key. -- **Linux (beta):** install the `.deb` or `.rpm`, or run the `.AppImage`. - Playback requires WebKitGTK 4.1, GTK 3 and the GStreamer base, good and - libav plugins. Signing in also requires a desktop Secret Service provider - such as GNOME Keyring or a compatible KWallet setup. -- On first launch the app downloads its own copy of yt-dlp (~12 MB) into its - data folder and keeps it updated automatically. -- Signing in is optional: browse and playback work anonymously; sign in to get - your library, likes, and playlists. +- macOS 13.5+ (beta): open the universal `.dmg`; the same build supports Apple Silicon and Intel. The managed Node playback runtime requires macOS 13.5 or later. Login cookies are encrypted with a Keychain-backed key. +- Linux (beta): install the `.deb` or `.rpm`, or run the `.AppImage`. Playback requires WebKitGTK 4.1, GTK 3, the GStreamer base, good and libav plugins, and `libatomic` for the managed Node runtime. The `.deb` and `.rpm` install those dependencies; AppImage users need to install them through their distribution. Signing in also requires a desktop Secret Service provider such as GNOME Keyring or a compatible KWallet setup. +- On first launch the app downloads its own copies of yt-dlp and Node into its data folder. It verifies the Node download against the official release checksum. yt-dlp updates automatically; the Node version is maintained with app releases. +- Browse without signing in, or sign in to get your library, likes, and playlists. Playback starts anonymously; if YouTube requires sign-in, the app retries using the selected account's saved session. > **Linux and macOS builds are in beta.** They are compiled and unit-tested in > CI, but get far less real-world testing than the Windows build. If something @@ -89,10 +82,7 @@ false-positive on. The binary is downloaded directly from yt-dlp's official GitHub releases. **Will Google ban my account for using this?** -Browsing/search/library requests look identical to the official web app, and -audio streaming is fully anonymous (never tied to your account). There are no -known cases of accounts being banned for third-party players — but no -guarantees; see the disclaimer above. +Playback starts anonymously, but a retry after YouTube requests sign-in uses your selected account. Those requests are tied to that account. There is no guarantee against account restrictions; see the disclaimer above. The retry uses a temporary cookie file that is removed afterward, while the saved account cookies remain encrypted. **Playback suddenly stopped working.** YouTube periodically changes its streaming internals. yt-dlp usually ships a diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 03fdcf8..d439302 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7201,6 +7201,7 @@ dependencies = [ "resvg", "serde", "serde_json", + "sha2", "souvlaki", "tauri", "tauri-build", @@ -7216,6 +7217,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", + "tempfile", "time", "tokio", "tower", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cfcfb95..37e132f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -68,6 +68,9 @@ discord-rich-presence = "0.2" # MD5 for Last.fm request signing (api_sig = md5 of sorted params + secret). # Tiny, single-purpose crate. See src/lastfm.rs. md5 = "0.7" +# Verify the managed Node download and give yt-dlp disposable cookie files. +sha2 = "0.10" +tempfile = "3" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = [ diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs new file mode 100644 index 0000000..ea84b08 --- /dev/null +++ b/src-tauri/src/downloader.rs @@ -0,0 +1,273 @@ +//! yt-dlp playback commands. Node runs the player challenges; session cookies +//! are supplied only for the caller's authenticated retry. + +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::process::Command; + +const FORMAT: &str = "bestaudio[ext=webm][protocol^=http]/bestaudio[protocol^=http]/bestaudio"; +const READ_TIMEOUT: Duration = Duration::from_secs(60); + +pub fn command(yt_dlp: &Path, node: &Path, cookies: Option<&Path>) -> Command { + let mut cmd = Command::new(yt_dlp); + // App-owned configuration: explicitly select Node, including paths with + // spaces, instead of relying on yt-dlp's Deno-only default or the PATH. + cmd.args(["--ignore-config", "--no-js-runtimes", "--js-runtimes"]); + let mut runtime = std::ffi::OsString::from("node:"); + runtime.push(node); + cmd.arg(runtime); + cmd.args(["--no-playlist", "--no-warnings", "--socket-timeout", "15"]); + if let Some(path) = cookies { + cmd.arg("--cookies").arg(path); + } + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + cmd.kill_on_drop(true); + cmd +} + +pub fn needs_login(error: &str) -> bool { + error.contains("Sign in to confirm") +} + +/// yt-dlp rewrites its cookie jar on exit. Always pass a disposable copy, +/// keeping the encrypted account jar under the session keeper's control. +pub fn session_file(jar: &str) -> Result { + use std::io::Write; + let mut file = tempfile::Builder::new() + .prefix("ytubic-cookies-") + .tempfile() + .map_err(|e| format!("create playback session: {e}"))?; + file.write_all(jar.as_bytes()) + .and_then(|_| file.flush()) + .map_err(|e| format!("write playback session: {e}"))?; + Ok(file) +} + +async fn read_stderr(mut stderr: tokio::process::ChildStderr) -> String { + // Drain the pipe even after the limit so a verbose failure cannot + // deadlock the child. The final error is at the end of yt-dlp's output. + const LIMIT: usize = 16 * 1024; + let mut tail = Vec::new(); + let mut buffer = [0; 4096]; + while let Ok(n) = stderr.read(&mut buffer).await { + if n == 0 { + break; + } + tail.extend_from_slice(&buffer[..n]); + if tail.len() > LIMIT { + tail.drain(..tail.len() - LIMIT); + } + } + String::from_utf8_lossy(&tail).trim().to_string() +} + +pub async fn download( + yt_dlp: &Path, + node: &Path, + video_id: &str, + destination: &Path, + cookies: Option<&Path>, +) -> Result<(), String> { + let mut cmd = command(yt_dlp, node, cookies); + cmd.args([ + "-f", + FORMAT, + "--no-part", + "-q", + "--retries", + "5", + "--extractor-retries", + "3", + "-o", + "-", + ]); + cmd.arg(format!("https://www.youtube.com/watch?v={video_id}")); + // Truncate on every attempt, including the authenticated retry. + let mut file = tokio::fs::File::create(destination) + .await + .map_err(|e| format!("create audio file: {e}"))?; + let mut child = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("spawn yt-dlp: {e}"))?; + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut errors = tokio::spawn(read_stderr(child.stderr.take().expect("piped stderr"))); + let copy = async { + let mut buffer = [0; 64 * 1024]; + loop { + let n = tokio::time::timeout(READ_TIMEOUT, stdout.read(&mut buffer)) + .await + .map_err(|_| "audio download timed out".to_string())? + .map_err(|e| format!("read audio: {e}"))?; + if n == 0 { + break; + } + file.write_all(&buffer[..n]) + .await + .map_err(|e| format!("write audio: {e}"))?; + } + file.flush().await.map_err(|e| format!("flush audio: {e}")) + } + .await; + if copy.is_err() { + let _ = child.kill().await; + } + let status = tokio::time::timeout(READ_TIMEOUT, child.wait()).await; + if status.is_err() { + let _ = child.kill().await; + } + let stderr = match tokio::time::timeout(Duration::from_secs(5), &mut errors).await { + Ok(result) => result.map_err(|e| format!("read yt-dlp error: {e}"))?, + Err(_) => { + errors.abort(); + return Err("yt-dlp error output timed out".to_string()); + } + }; + copy?; + let status = status + .map_err(|_| "yt-dlp exit timed out".to_string())? + .map_err(|e| format!("wait for yt-dlp: {e}"))?; + if !status.success() { + return Err(format!("yt-dlp {status}: {stderr}")); + } + Ok(()) +} + +pub async fn resolve( + yt_dlp: &Path, + node: &Path, + video_id: &str, + cookies: Option<&Path>, +) -> Result { + let output = tokio::time::timeout( + READ_TIMEOUT, + command(yt_dlp, node, cookies) + .args(["-j", "-f", FORMAT]) + .arg(format!("https://www.youtube.com/watch?v={video_id}")) + .output(), + ) + .await + .map_err(|_| "audio lookup timed out".to_string())? + .map_err(|e| format!("spawn yt-dlp: {e}"))?; + if !output.status.success() { + return Err(format!( + "yt-dlp {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + String::from_utf8(output.stdout).map_err(|e| format!("audio metadata is not UTF-8: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_copy_is_private_and_removed_after_use() { + let jar = "# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tSID\ttest\n"; + let file = session_file(jar).unwrap(); + let path = file.path().to_path_buf(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), jar); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + // Simulate yt-dlp saving rotations into its own copy. + std::fs::write(&path, "updated jar").unwrap(); + drop(file); + assert!(!path.exists()); + } + + #[test] + fn login_retry_is_limited_to_youtube_sign_in_errors() { + assert!(needs_login("ERROR: Sign in to confirm you’re not a bot.")); + assert!(needs_login("ERROR: Sign in to confirm you're not a bot.")); + assert!(!needs_login("HTTP Error 403: Forbidden")); + assert!(!needs_login("Video unavailable")); + assert!(!needs_login("node executable not found")); + } + + #[test] + fn runtime_and_cookie_paths_with_spaces_are_single_arguments() { + let cmd = command( + Path::new("yt-dlp"), + Path::new("path with spaces/node"), + Some(Path::new("session with spaces.txt")), + ); + let args: Vec<_> = cmd + .as_std() + .get_args() + .map(|s| s.to_string_lossy().into_owned()) + .collect(); + assert!(args + .windows(2) + .any(|a| a == ["--js-runtimes", "node:path with spaces/node"])); + assert!(args + .windows(2) + .any(|a| a == ["--cookies", "session with spaces.txt"])); + assert!(args.contains(&"--no-js-runtimes".to_string())); + assert!(!command(Path::new("yt-dlp"), Path::new("node"), None) + .as_std() + .get_args() + .any(|a| a == "--cookies")); + } + + #[test] + #[ignore = "requires YTUBIC_TEST_YTDLP, YTUBIC_TEST_NODE, YTUBIC_TEST_VIDEO_ID and YouTube access; optional YTUBIC_TEST_COOKIES"] + fn real_ytdlp_playback() { + let yt_dlp = + std::path::PathBuf::from(std::env::var_os("YTUBIC_TEST_YTDLP").expect("yt-dlp path")); + let node = + std::path::PathBuf::from(std::env::var_os("YTUBIC_TEST_NODE").expect("Node path")); + let video_id = std::env::var("YTUBIC_TEST_VIDEO_ID").expect("video id"); + let root = tempfile::tempdir().unwrap(); + let audio = root.path().join("audio.webm"); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + crate::node::ensure(&node).await.unwrap(); + let result = download(&yt_dlp, &node, &video_id, &audio, None).await; + match result { + Err(error) if needs_login(&error) => { + println!("YouTube requested sign-in; retrying with the saved session"); + let jar = std::fs::read_to_string( + std::env::var_os("YTUBIC_TEST_COOKIES") + .expect("cookie jar for login retry"), + ) + .unwrap(); + let cookies = session_file(&jar).unwrap(); + let path = cookies.path().to_path_buf(); + download(&yt_dlp, &node, &video_id, &audio, Some(&path)) + .await + .unwrap(); + let metadata = resolve(&yt_dlp, &node, &video_id, Some(&path)) + .await + .unwrap(); + let metadata: serde_json::Value = serde_json::from_str(&metadata).unwrap(); + assert_eq!(metadata["id"], video_id); + drop(cookies); + assert!(!path.exists()); + } + other => other.unwrap(), + } + let bytes = std::fs::read(audio).unwrap(); + assert!(bytes.len() > 32 * 1024); + assert_eq!(&bytes[..4], &[0x1a, 0x45, 0xdf, 0xa3]); + use sha2::Digest; + println!( + "Downloaded {} bytes; SHA-256 {:x}", + bytes.len(), + sha2::Sha256::digest(&bytes) + ); + }); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9866725..9dafc1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,13 +1,11 @@ use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; -use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::process::Command as TokioCommand; use tokio::sync::{Mutex, Notify}; use axum::{ @@ -26,8 +24,10 @@ use tower_http::services::ServeFile; mod appid; mod discord; +mod downloader; mod lastfm; mod media; +mod node; mod power; // Taskbar thumbnail toolbar (prev / play-pause / next under the taskbar // preview). Windows-only shell surface; see src/thumbbar.rs. @@ -3142,8 +3142,8 @@ async fn set_cache_meta( Ok(()) } -/// Make the managed yt-dlp binary available (download on first run, -/// throttled self-update after). Invoked by the frontend on mount so +/// Make the managed yt-dlp and Node binaries available (download on first +/// run, throttled yt-dlp self-update after). Invoked by the frontend on mount so /// the `ytdlp-state` event listener is guaranteed to exist before any /// state event fires; also serves as the retry path after a failed /// download. Idempotent — see `ytdlp::ensure`. @@ -3154,41 +3154,35 @@ async fn ensure_ytdlp(app: tauri::AppHandle) { /// Run yt-dlp to resolve a videoId into metadata JSON. #[tauri::command] -fn resolve_stream_ytdlp(app: tauri::AppHandle, video_id: String) -> Result { +async fn resolve_stream_ytdlp(app: tauri::AppHandle, video_id: String) -> Result { if !sanitize_video_id(&video_id) { return Err(format!("invalid videoId: {video_id}")); } - let url = format!("https://www.youtube.com/watch?v={video_id}"); - let mut command = std::process::Command::new(ytdlp::program(&ytdlp::managed_path(&app))); - command.args([ - "-j", - // Progressive only: the resolved URL is handed straight to an - //