//! The extraction seam between the provider and YouTube //! (see `architecture/youtube-rustypipe.md` D1). //! //! Everything the provider needs from YouTube goes through the //! [`Extract`] trait: the real implementation wraps a pure-Rust //! [`rustypipe`] Innertube client (no subprocess, no Python), tests use //! a fake. All errors are typed; messages may name videos, playlists, //! and public API endpoints — never cookie values or auth headers. use std::fmt::Debug; use std::path::{Path, PathBuf}; use std::time::Duration; use async_trait::async_trait; use rustypipe::client::RustyPipe; use rustypipe::model::VideoItem; use tracing::{debug, warn}; /// Errors from the extractor. #[derive(Debug, thiserror::Error)] pub enum ExtractError { /// The client could not be built (storage directory, TLS backend). #[error("cannot build the youtube client: {0}")] Client(String), /// A YouTube request failed (network, extraction, API change). #[error("youtube request failed: {0}")] Fetch(String), /// The video exists but offers no audio streams. #[error("video has no audio streams")] NoAudio, } /// One video as the provider sees it. #[derive(Clone, Debug)] pub struct VideoEntry { /// YouTube video id (the track path's last segment). pub id: String, pub title: String, /// Channel name; the wire track's artist. pub artist: Option, /// Duration in seconds; `None` for livestreams. pub duration: Option, } /// One playlist of the logged-in user. #[derive(Clone, Debug)] pub struct PlaylistEntry { /// YouTube playlist id (the node path's last segment). pub id: String, pub title: String, } /// What the provider needs from YouTube. Implementations must be /// side-effect free towards the provider: every call is independent, /// there is no session state beyond the login performed at init. #[async_trait] pub trait Extract: Send + Sync + Debug { /// The top `limit` video results for a search query, result order. async fn search_videos( &self, query: &str, limit: usize, ) -> Result, ExtractError>; /// Metadata of one video. async fn video(&self, id: &str) -> Result; /// The playable audio stream URL of one video — `audio/mp4` (AAC) /// preferred, since the local player cannot decode Opus /// (architecture/youtube-rustypipe.md D2). async fn audio_stream_url(&self, id: &str) -> Result; /// The logged-in user's saved playlists (first page). async fn saved_playlists(&self) -> Result, ExtractError>; /// One playlist's name and up to `limit` videos (bounded /// pagination). async fn playlist_videos( &self, id: &str, limit: usize, ) -> Result<(String, Vec), ExtractError>; } /// The real extractor: one [`RustyPipe`] Innertube client. pub struct RustyPipeExtractor { rp: RustyPipe, } impl Debug for RustyPipeExtractor { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RustyPipeExtractor").finish_non_exhaustive() } } /// Maps a rustypipe failure to [`ExtractError::Fetch`]. rustypipe's /// errors are typed (HTTP status, extraction, auth) and never carry /// cookie values. fn fetch_err(err: rustypipe::error::Error) -> ExtractError { ExtractError::Fetch(err.to_string()) } fn video_entry(item: VideoItem) -> VideoEntry { VideoEntry { id: item.id, title: item.name, artist: item.channel.map(|channel| channel.name), duration: item.duration, } } /// Picks the audio stream to play from `(mime, average_bitrate)` pairs: /// the highest-bitrate `audio/mp4` (AAC — the local rodio/symphonia /// player decodes it), falling back to the highest-bitrate stream of /// any type when no mp4 exists. Returns the index into the input. fn pick_audio_stream(streams: &[(String, u32)]) -> Option { let best = |mp4_only: bool| { streams .iter() .enumerate() .filter(|(_, (mime, _))| !mp4_only || mime.starts_with("audio/mp4")) .max_by_key(|(_, (_, bitrate))| *bitrate) .map(|(index, _)| index) }; best(true).or_else(|| best(false)) } impl RustyPipeExtractor { /// Builds the client. `storage_dir` holds rustypipe's cache file /// (client state and the rotated auth cookie — as secret as the /// cookies file itself); `timeout` bounds every YouTube request. /// `botguard_bin` optionally names a `rustypipe-botguard` binary for /// PO-token attestation (rustypipe also auto-detects it on PATH). pub fn new( storage_dir: PathBuf, timeout: Duration, botguard_bin: Option<&Path>, ) -> Result { std::fs::create_dir_all(&storage_dir) .map_err(|err| ExtractError::Client(format!("storage directory: {err}")))?; let mut builder = RustyPipe::builder() .storage_dir(storage_dir) .timeout(timeout); if let Some(bin) = botguard_bin { builder = builder.botguard_bin(bin.as_os_str().to_owned()); } let rp = builder .build() .map_err(|err| ExtractError::Client(err.to_string()))?; Ok(Self { rp }) } /// Logs in from a Netscape `cookies.txt` export, cache first /// (architecture/youtube-rustypipe.md D3): a still-valid rotated /// cookie in the rustypipe cache wins over re-reading the /// stale-prone file. Returns whether the client is authenticated. /// Never fails — anything short of a working login degrades to /// logged-out with a warning naming only the file *path*. pub async fn login(&self, cookies_file: Option<&Path>) -> bool { let Some(path) = cookies_file else { return false; }; // The cached cookie is refreshed by rustypipe on use and // outlives the original export (YouTube rotates cookies). if self.rp.user_auth_check_cookie().await.is_ok() { debug!("youtube login restored from the rustypipe cache"); return true; } let contents = match std::fs::read_to_string(path) { Ok(contents) => contents, Err(err) => { warn!( cookies = %path.display(), "cookies file not readable, running logged out: {err}" ); return false; } }; match self.rp.user_auth_set_cookie_txt(&contents).await { Ok(()) => { debug!(cookies = %path.display(), "youtube login succeeded"); true } Err(err) => { warn!( cookies = %path.display(), "youtube login failed, running logged out: {err}" ); false } } } } #[async_trait] impl Extract for RustyPipeExtractor { async fn search_videos( &self, query: &str, limit: usize, ) -> Result, ExtractError> { let result = self .rp .query() .search::(query) .await .map_err(fetch_err)?; Ok(result .items .items .into_iter() .take(limit) .map(video_entry) .collect()) } async fn video(&self, id: &str) -> Result { // `player` (not `video_details`) because it carries the // duration — and it is the same call the stream fetch uses. let player = self.rp.query().player(id).await.map_err(fetch_err)?; let details = player.details; Ok(VideoEntry { id: details.id, title: details.name.unwrap_or_default(), artist: details.channel_name, duration: (details.duration > 0).then_some(details.duration), }) } async fn audio_stream_url(&self, id: &str) -> Result { let player = self.rp.query().player(id).await.map_err(fetch_err)?; let candidates: Vec<(String, u32)> = player .audio_streams .iter() .map(|stream| (stream.mime.clone(), stream.average_bitrate)) .collect(); let Some(index) = pick_audio_stream(&candidates) else { return Err(ExtractError::NoAudio); }; let stream = &player.audio_streams[index]; if !stream.mime.starts_with("audio/mp4") { // Playable URL, but the local player has no Opus decoder — // hand it out anyway (a future player may cope) and say so. warn!(video = id, mime = %stream.mime, "no mp4 audio stream; local decoding may fail"); } Ok(stream.url.clone()) } async fn saved_playlists(&self) -> Result, ExtractError> { let playlists = self.rp.query().saved_playlists().await.map_err(fetch_err)?; Ok(playlists .items .into_iter() .map(|item| PlaylistEntry { id: item.id, title: item.name, }) .collect()) } async fn playlist_videos( &self, id: &str, limit: usize, ) -> Result<(String, Vec), ExtractError> { let mut playlist = self.rp.query().playlist(id).await.map_err(fetch_err)?; playlist .videos .extend_limit(self.rp.query(), limit) .await .map_err(fetch_err)?; let videos = playlist .videos .items .into_iter() .take(limit) .map(video_entry) .collect(); Ok((playlist.name, videos)) } } #[cfg(test)] mod tests { use super::*; #[test] fn stream_pick_prefers_mp4_then_bitrate() { let streams = |list: &[(&str, u32)]| -> Vec<(String, u32)> { list.iter().map(|(m, b)| (m.to_string(), *b)).collect() }; // The best mp4 wins even against a higher-bitrate opus. let mixed = streams(&[ ("audio/webm; codecs=\"opus\"", 128_000), ("audio/mp4; codecs=\"mp4a.40.2\"", 96_000), ("audio/mp4; codecs=\"mp4a.40.5\"", 48_000), ]); assert_eq!(pick_audio_stream(&mixed), Some(1)); // Without mp4, the highest bitrate of anything is used. let opus_only = streams(&[ ("audio/webm; codecs=\"opus\"", 64_000), ("audio/webm; codecs=\"opus\"", 128_000), ]); assert_eq!(pick_audio_stream(&opus_only), Some(1)); assert_eq!(pick_audio_stream(&[]), None); } #[tokio::test] async fn login_without_a_readable_file_degrades_quietly() { // Network-free: no cached cookie (fresh storage dir) and a // missing file short-circuit before any request. let dir = tempfile::TempDir::new().expect("tempdir"); let extractor = RustyPipeExtractor::new(dir.path().join("rustypipe"), Duration::from_secs(5), None) .expect("client builds"); assert!(!extractor.login(None).await); assert!(!extractor.login(Some(&dir.path().join("gone.txt"))).await); } }