From 05ddc656c6614213cf700a963f305369b4fb12a8 Mon Sep 17 00:00:00 2001 From: defaultdino Date: Thu, 22 Oct 2026 21:59:31 +0200 Subject: [PATCH] fix: drop rating_key from stream cache filenames; pass media controls a local cover file instead of a tokened url; cast with a transient delegation token instead of the account token --- src/app/mod.rs | 13 +++-------- src/app/playback.rs | 5 +++- src/audio/player.rs | 1 - src/cast/caster/media.rs | 4 ++-- src/cast/caster/mod.rs | 4 +++- src/cast/mod.rs | 5 +++- src/media_controls.rs | 50 ++++++++++++++++++++++++++++++++++++---- src/plex/api.rs | 7 ++++++ src/plex/client.rs | 5 ++-- src/plex/models.rs | 6 +++++ 10 files changed, 78 insertions(+), 22 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index b2b94e9..82ea94b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3,7 +3,7 @@ pub mod browser; pub mod cast; pub mod keys; pub mod now_playing; -mod playback; +pub mod playback; pub mod queue; mod reporting; pub mod state; @@ -165,13 +165,7 @@ fn spawn_tui( .spawn_scoped(s, move || { // ratatui::init installs a panic hook that restores the terminal let mut terminal = ratatui::init(); - let result = event_loop( - &mut terminal, - &mut state, - &rx, - media_controls.as_mut(), - client, - ); + let result = event_loop(&mut terminal, &mut state, &rx, media_controls.as_mut()); ratatui::restore(); shutdown(&mut state, client, saved)?; @@ -220,7 +214,6 @@ fn event_loop( state: &mut AppState, rx: &Receiver, mut media_controls: Option<&mut MediaControls>, - client: &PlexClient, ) -> Result<()> { loop { terminal.draw(|f| crate::ui::draw(f, state))?; @@ -241,7 +234,7 @@ fn event_loop( if playback.media_controls_dirty { playback.media_controls_dirty = false; if let Some(m) = media_controls.as_deref_mut() { - m.update(&playback.now, client); + m.update(&playback.now, playback.cover.as_ref()); } } if state.quit { diff --git a/src/app/playback.rs b/src/app/playback.rs index 8405c32..01d731b 100644 --- a/src/app/playback.rs +++ b/src/app/playback.rs @@ -539,7 +539,10 @@ impl Playback { return; }; match result { - Ok(img) => c.art = Some(img), + Ok(img) => { + c.art = Some(img); + self.media_controls_dirty = true; + } Err(e) => warn!("cover art: {e}"), } } diff --git a/src/audio/player.rs b/src/audio/player.rs index 5e99616..c2a3104 100644 --- a/src/audio/player.rs +++ b/src/audio/player.rs @@ -489,7 +489,6 @@ fn open( if track.part_key.is_empty() { anyhow::bail!("{} has no playable media", track.title); } - // never put server-supplied strings in the path: rating_key could be "../../x" let path = cache_dir.join(SEQ.fetch_add(1, Ordering::Relaxed).to_string()); let ev = events.clone(); let on_buffering = Arc::new(move |b: bool| { diff --git a/src/cast/caster/media.rs b/src/cast/caster/media.rs index 607d012..d71f5e6 100644 --- a/src/cast/caster/media.rs +++ b/src/cast/caster/media.rs @@ -20,7 +20,7 @@ impl Caster { })?; let images = (!track.thumb.is_empty()).then(|| { vec![Image { - url: self.plex.url_with_token(&track.thumb), + url: self.plex.url_with_token(&track.thumb, &self.token), height: None, width: None, }] @@ -35,7 +35,7 @@ impl Caster { ..Default::default() }; Ok(MediaInformation { - content_id: self.plex.url_with_token(&track.part_key), + content_id: self.plex.url_with_token(&track.part_key, &self.token), content_type: content_type.into(), stream_type: StreamType::Buffered, duration: track.duration_ms.map(|d| d as f64 / 1000.0), diff --git a/src/cast/caster/mod.rs b/src/cast/caster/mod.rs index 95b09b1..186a9dd 100644 --- a/src/cast/caster/mod.rs +++ b/src/cast/caster/mod.rs @@ -65,6 +65,7 @@ impl Position { pub struct Caster { plex: Arc, + token: String, events: SyncSender, pub outbox: Vec<(String, Payload, Option)>, pub conn: Conn, @@ -86,9 +87,10 @@ pub struct Caster { } impl Caster { - pub fn new(plex: Arc, events: SyncSender) -> Self { + pub fn new(plex: Arc, token: String, events: SyncSender) -> Self { Self { plex, + token, events, outbox: Vec::new(), conn: Conn::Down, diff --git a/src/cast/mod.rs b/src/cast/mod.rs index f45763c..a315f97 100644 --- a/src/cast/mod.rs +++ b/src/cast/mod.rs @@ -25,6 +25,9 @@ pub fn spawn(plex: Arc, ip: IpAddr) -> Result<(Channels, Receiver<() if loopback(&server) { bail!("rexamp reaches plex at {server}, which the device can't"); } + let token = plex + .transient_token() + .context("getting a cast token from plex")?; let first = block_on(io::connect(ip)).with_context(|| format!("casting to {ip}"))?; let (cmd_tx, cmd_rx) = sync_channel(32); let (ev_tx, ev_rx) = sync_channel(256); @@ -43,7 +46,7 @@ pub fn spawn(plex: Arc, ip: IpAddr) -> Result<(Channels, Receiver<() let (done_tx, done_rx) = std_channel::<()>(); thread::Builder::new().name("cast".into()).spawn(move || { let _done = done_tx; - block_on(io::run(Caster::new(plex, ev_tx), ip, first, fwd_rx)) + block_on(io::run(Caster::new(plex, token, ev_tx), ip, first, fwd_rx)) })?; Ok(((cmd_tx, ev_rx), done_rx)) } diff --git a/src/media_controls.rs b/src/media_controls.rs index c1bf55d..b649324 100644 --- a/src/media_controls.rs +++ b/src/media_controls.rs @@ -1,3 +1,5 @@ +use std::fs; +use std::path::PathBuf; use std::sync::mpsc::SyncSender; use std::thread::ScopedJoinHandle; use std::time::Duration; @@ -10,10 +12,13 @@ use tracing::warn; use crate::app::AppEvent; use crate::app::now_playing::NowPlaying; -use crate::plex::PlexClient; +use crate::app::playback::Cover; pub struct MediaControls { controls: souvlaki::MediaControls, + art_dir: PathBuf, + art: Option<(String, PathBuf)>, + saved: u64, } /// blocks the main thread on the run loop macOS uses for media key and Control Center commands @@ -53,14 +58,22 @@ impl MediaControls { warn!("media controls unavailable: {e:?}"); return None; } - Some(Self { controls }) + let art_dir = crate::config::dirs().ok()?.cache_dir().to_path_buf(); + Some(Self { + controls, + art_dir, + art: None, + saved: 0, + }) } - pub fn update(&mut self, now: &NowPlaying, client: &PlexClient) { + pub fn update(&mut self, now: &NowPlaying, cover: Option<&Cover>) { let Some(t) = &now.track else { return self.apply(MediaMetadata::default(), MediaPlayback::Stopped); }; - let art = (!t.thumb.is_empty()).then(|| client.url_with_token(&t.thumb)); + let art = cover + .filter(|c| c.thumb == t.thumb) + .and_then(|c| self.art_url(c)); let metadata = MediaMetadata { title: Some(&t.title), artist: Some(&t.grandparent_title), @@ -77,6 +90,27 @@ impl MediaControls { self.apply(metadata, playback); } + fn art_url(&mut self, c: &Cover) -> Option { + let img = c.art.as_ref()?; + if self.art.as_ref().is_none_or(|(thumb, _)| *thumb != c.thumb) { + self.saved += 1; + let path = self.art_dir.join(format!("cover-{}.png", self.saved)); + if let Err(e) = fs::create_dir_all(&self.art_dir) + .map_err(Into::into) + .and_then(|()| img.save(&path)) + { + warn!("saving cover for media controls: {e}"); + return None; + } + if let Some((_, old)) = self.art.replace((c.thumb.clone(), path)) { + let _ = fs::remove_file(old); + } + } + self.art + .as_ref() + .map(|(_, p)| format!("file://{}", p.display())) + } + fn apply(&mut self, metadata: MediaMetadata, playback: MediaPlayback) { if let Err(e) = self.controls.set_metadata(metadata) { warn!("media controls metadata: {e:?}"); @@ -86,3 +120,11 @@ impl MediaControls { } } } + +impl Drop for MediaControls { + fn drop(&mut self) { + if let Some((_, path)) = &self.art { + let _ = fs::remove_file(path); + } + } +} diff --git a/src/plex/api.rs b/src/plex/api.rs index 402ccec..2bf7625 100644 --- a/src/plex/api.rs +++ b/src/plex/api.rs @@ -35,6 +35,13 @@ impl PlexClient { Ok(Page { items, total }) } + #[cfg(feature = "cast")] + pub fn transient_token(&self) -> Result { + let query = [("type", "delegation"), ("scope", "all")]; + let c: Container = self.get("/security/token", &query)?; + Ok(c.mc.token) + } + pub fn sections(&self) -> Result> { let c: Container> = self.get("/library/sections", &[])?; Ok(c.mc.items) diff --git a/src/plex/client.rs b/src/plex/client.rs index 44b56f3..27751a0 100644 --- a/src/plex/client.rs +++ b/src/plex/client.rs @@ -178,10 +178,11 @@ impl PlexClient { .map_err(|e| describe(e, &format!("GET {}", self.url(path)))) } + #[cfg(feature = "cast")] /// absolute URL for `path` with the token in the query, for consumers that can't send headers - pub fn url_with_token(&self, path: &str) -> String { + pub fn url_with_token(&self, path: &str, token: &str) -> String { let sep = if path.contains('?') { '&' } else { '?' }; - format!("{}{path}{sep}X-Plex-Token={}", self.base(), self.token) + format!("{}{path}{sep}X-Plex-Token={token}", self.base()) } pub fn post(&self, path: &str, query: &[(&str, &str)]) -> Result { diff --git a/src/plex/models.rs b/src/plex/models.rs index c7b188c..6dcdad0 100644 --- a/src/plex/models.rs +++ b/src/plex/models.rs @@ -8,6 +8,12 @@ pub struct Container { pub mc: T, } +#[cfg(feature = "cast")] +#[derive(Deserialize)] +pub struct TransientToken { + pub token: String, +} + #[derive(Deserialize)] pub struct Directories { #[serde(rename = "Directory", default = "Vec::new")]