Refetch mutable library listings and redact stream URLs from logs

The TUI cached every library listing for the whole session, so a
finished capture (or a saved queue) never appeared under /captures
until a restart — captures looked broken while they had succeeded on
disk. Listings under /captures, /queues, /bookmarks, and /fs are now
always refetched (cheap local walks on the server); remote provider
nodes keep the instant back-navigation cache.

The player engine also logged full stream URLs (including googlevideo
sig tokens) through its play span; sources are now logged as
scheme://host only, local paths verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-21 18:36:27 +02:00
parent 973bb7bbc6
commit c84dec9ca2
3 changed files with 100 additions and 3 deletions

View File

@ -18,6 +18,18 @@ use url::Url;
/// How long we wait for the initial prefetch of a network stream. /// How long we wait for the initial prefetch of a network stream.
const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(30); 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. /// Interval between elapsed-position updates while playing.
const TICK_INTERVAL: Duration = Duration::from_millis(250); 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<MediaInfo> { pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
self.reset(); self.reset();
@ -203,7 +215,10 @@ impl PlayerEngine {
self.generation += 1; self.generation += 1;
let duration = match Url::parse(source_str) { let duration = match Url::parse(source_str) {
Ok(url) if matches!(url.scheme(), "http" | "https") => { 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 // Windowed fetching: some CDNs (googlevideo) reject plain
// and open-ended requests with 403 and only serve bounded // and open-ended requests with 403 and only serve bounded
// ranges (see audio-player/src/windowed_http.rs). // ranges (see audio-player/src/windowed_http.rs).
@ -401,3 +416,22 @@ fn send_reply<T>(tx: Sender<T>, value: T) {
warn!("player engine reply receiver dropped"); 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"
);
}
}

View File

@ -37,6 +37,22 @@ pub struct RpcClient {
pub update_stream: Streaming<GetUpdateStreamResponse>, pub update_stream: Streaming<GetUpdateStreamResponse>,
} }
/// 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 { impl RpcClient {
pub async fn connect(addr: &'static str) -> Result<RpcClient, Box<dyn Error>> { pub async fn connect(addr: &'static str) -> Result<RpcClient, Box<dyn Error>> {
let endpoint = Endpoint::from_static(addr).connect_lazy(); let endpoint = Endpoint::from_static(addr).connect_lazy();
@ -79,7 +95,7 @@ impl RpcClient {
&mut self, &mut self,
path: &str, path: &str,
) -> Result<Option<&LibraryNode>, Box<dyn Error>> { ) -> Result<Option<&LibraryNode>, Box<dyn Error>> {
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)); return Ok(self.library_node_cache.get(path));
} }
let get_library_node_request = Request::new(GetLibraryNodeRequest { let get_library_node_request = Request::new(GetLibraryNodeRequest {
@ -90,6 +106,8 @@ impl RpcClient {
.get_library_node(get_library_node_request) .get_library_node(get_library_node_request)
.await?; .await?;
if let Some(library_node) = response.into_inner().node { 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 self.library_node_cache
.insert(path.to_string(), library_node); .insert(path.to_string(), library_node);
return Ok(self.library_node_cache.get(path)); return Ok(self.library_node_cache.get(path));
@ -298,3 +316,31 @@ impl RpcClient {
Ok(()) 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"));
}
}

View File

@ -1,5 +1,22 @@
# Implementation summaries # 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) ## youtube stream fetching (2026-07-21, follow-up)
The rustypipe swap fixed the decode problem but real playback then hit The rustypipe swap fixed the decode problem but real playback then hit