diff --git a/audio-player/src/player_engine.rs b/audio-player/src/player_engine.rs index 00951f4..3a9da48 100644 --- a/audio-player/src/player_engine.rs +++ b/audio-player/src/player_engine.rs @@ -18,6 +18,18 @@ use url::Url; /// How long we wait for the initial prefetch of a network stream. const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(30); + +/// What playback logs for a source: local paths verbatim, network URLs +/// reduced to scheme and host — stream URLs embed access tokens +/// (googlevideo `sig`, tidal tokens) and must never reach the log. +fn display_source(source_str: &str) -> String { + match Url::parse(source_str) { + Ok(url) if matches!(url.scheme(), "http" | "https") => { + format!("{}://{}/…", url.scheme(), url.host_str().unwrap_or("?")) + } + _ => source_str.to_string(), + } +} /// Interval between elapsed-position updates while playing. const TICK_INTERVAL: Duration = Duration::from_millis(250); @@ -176,7 +188,7 @@ impl PlayerEngine { }); } - #[instrument(skip(self))] + #[instrument(skip_all, fields(source = %display_source(source_str)))] pub fn play(&mut self, source_str: &str) -> Result { self.reset(); @@ -203,7 +215,10 @@ impl PlayerEngine { self.generation += 1; let duration = match Url::parse(source_str) { Ok(url) if matches!(url.scheme(), "http" | "https") => { - trace!(%url, "opening network stream"); + trace!( + host = url.host_str().unwrap_or("?"), + "opening network stream" + ); // Windowed fetching: some CDNs (googlevideo) reject plain // and open-ended requests with 403 and only serve bounded // ranges (see audio-player/src/windowed_http.rs). @@ -401,3 +416,22 @@ fn send_reply(tx: Sender, value: T) { warn!("player engine reply receiver dropped"); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn logged_sources_never_carry_url_tokens() { + // Stream URLs embed access tokens; only scheme and host may be + // logged. Local paths pass through verbatim. + assert_eq!( + display_source("https://rr1.googlevideo.com/videoplayback?sig=SECRET&x=1"), + "https://rr1.googlevideo.com/…" + ); + assert_eq!( + display_source("/home/user/music/song.m4a"), + "/home/user/music/song.m4a" + ); + } +} diff --git a/cbd-tui/src/rpc.rs b/cbd-tui/src/rpc.rs index a4dff1a..b5481fa 100644 --- a/cbd-tui/src/rpc.rs +++ b/cbd-tui/src/rpc.rs @@ -37,6 +37,22 @@ pub struct RpcClient { pub update_stream: Streaming, } +/// Whether a library listing may be served from the session cache. +/// +/// The server-side folder providers mutate behind the client's back — +/// captures finish (`W`), queues get saved (`w`), bookmarks appear, +/// files change on disk — so a cached listing turns freshly captured +/// content invisible until a restart. Their listings are cheap local +/// directory walks on the server; always refetch them. Remote provider +/// nodes (tidal, youtube) keep the cache that makes back-navigation +/// instant. +fn is_cacheable(path: &str) -> bool { + const MUTABLE_ROOTS: [&str; 4] = ["/captures", "/queues", "/bookmarks", "/fs"]; + !MUTABLE_ROOTS.iter().any(|root| { + path == *root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/')) + }) +} + impl RpcClient { pub async fn connect(addr: &'static str) -> Result> { let endpoint = Endpoint::from_static(addr).connect_lazy(); @@ -79,7 +95,7 @@ impl RpcClient { &mut self, path: &str, ) -> Result, Box> { - if self.library_node_cache.contains_key(path) { + if is_cacheable(path) && self.library_node_cache.contains_key(path) { return Ok(self.library_node_cache.get(path)); } let get_library_node_request = Request::new(GetLibraryNodeRequest { @@ -90,6 +106,8 @@ impl RpcClient { .get_library_node(get_library_node_request) .await?; if let Some(library_node) = response.into_inner().node { + // Non-cacheable nodes are stored too (the return borrows from + // the map) — they are just always refetched above. self.library_node_cache .insert(path.to_string(), library_node); return Ok(self.library_node_cache.get(path)); @@ -298,3 +316,31 @@ impl RpcClient { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mutable_provider_listings_are_never_cached() { + // Freshly captured/saved content must show up on the next visit + // (a cached /captures hid new captures until a TUI restart). + for path in [ + "/captures", + "/captures/faves", + "/queues", + "/queues/road trip", + "/bookmarks/b", + "/fs/music", + ] { + assert!(!is_cacheable(path), "{path}"); + } + // Remote providers keep instant back-navigation… + for path in ["/", "/tidal", "/tidal/artists/1", "/youtube/search/x"] { + assert!(is_cacheable(path), "{path}"); + } + // …and prefix look-alikes are not swept up. + assert!(is_cacheable("/fsdy")); + assert!(is_cacheable("/queuestore")); + } +} diff --git a/plan/summary.md b/plan/summary.md index 506dff3..8e03187 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -1,5 +1,22 @@ # Implementation summaries +## capture visibility + log redaction (2026-07-21, follow-up) + +"Problems with capturing" turned out to be a display bug: the capture +had succeeded on disk, but the TUI's `RpcClient` caches every library +listing for the whole session, so a `/captures` (or `/queues`, +`/bookmarks`, `/fs`) listing visited once never showed later captures +or saved queues until a restart. Listings under those mutable roots +are now always refetched — they are cheap local directory walks on the +server — while remote provider nodes (tidal, youtube) keep the cache +that makes back-navigation instant (`is_cacheable`, unit-tested). + +Found alongside in the same log: the player engine's `play` span +recorded the full stream URL — googlevideo `sig` tokens included — into +`cbd.log`. Sources are now logged as `scheme://host/…` only (local +paths verbatim; `display_source`, unit-tested). 181 workspace tests +green. + ## youtube stream fetching (2026-07-21, follow-up) The rustypipe swap fixed the decode problem but real playback then hit