Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -220,7 +214,6 @@ fn event_loop(
state: &mut AppState,
rx: &Receiver<AppEvent>,
mut media_controls: Option<&mut MediaControls>,
client: &PlexClient,
) -> Result<()> {
loop {
terminal.draw(|f| crate::ui::draw(f, state))?;
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion src/app/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
}
}
Expand Down
1 change: 0 additions & 1 deletion src/audio/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
4 changes: 2 additions & 2 deletions src/cast/caster/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}]
Expand All @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion src/cast/caster/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ impl Position {

pub struct Caster {
plex: Arc<PlexClient>,
token: String,
events: SyncSender<PlayerEvent>,
pub outbox: Vec<(String, Payload, Option<u32>)>,
pub conn: Conn,
Expand All @@ -86,9 +87,10 @@ pub struct Caster {
}

impl Caster {
pub fn new(plex: Arc<PlexClient>, events: SyncSender<PlayerEvent>) -> Self {
pub fn new(plex: Arc<PlexClient>, token: String, events: SyncSender<PlayerEvent>) -> Self {
Self {
plex,
token,
events,
outbox: Vec::new(),
conn: Conn::Down,
Expand Down
5 changes: 4 additions & 1 deletion src/cast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ pub fn spawn(plex: Arc<PlexClient>, 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);
Expand All @@ -43,7 +46,7 @@ pub fn spawn(plex: Arc<PlexClient>, 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))
}
Expand Down
50 changes: 46 additions & 4 deletions src/media_controls.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::fs;
use std::path::PathBuf;
use std::sync::mpsc::SyncSender;
use std::thread::ScopedJoinHandle;
use std::time::Duration;
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -77,6 +90,27 @@ impl MediaControls {
self.apply(metadata, playback);
}

fn art_url(&mut self, c: &Cover) -> Option<String> {
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:?}");
Expand All @@ -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);
}
}
}
7 changes: 7 additions & 0 deletions src/plex/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ impl PlexClient {
Ok(Page { items, total })
}

#[cfg(feature = "cast")]
pub fn transient_token(&self) -> Result<String> {
let query = [("type", "delegation"), ("scope", "all")];
let c: Container<super::models::TransientToken> = self.get("/security/token", &query)?;
Ok(c.mc.token)
}

pub fn sections(&self) -> Result<Vec<Section>> {
let c: Container<Directories<Section>> = self.get("/library/sections", &[])?;
Ok(c.mc.items)
Expand Down
5 changes: 3 additions & 2 deletions src/plex/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: DeserializeOwned>(&self, path: &str, query: &[(&str, &str)]) -> Result<T> {
Expand Down
6 changes: 6 additions & 0 deletions src/plex/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ pub struct Container<T> {
pub mc: T,
}

#[cfg(feature = "cast")]
#[derive(Deserialize)]
pub struct TransientToken {
pub token: String,
}

#[derive(Deserialize)]
pub struct Directories<T> {
#[serde(rename = "Directory", default = "Vec::new")]
Expand Down
Loading