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:
parent
973bb7bbc6
commit
c84dec9ca2
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue