From 8163b96b0bf7511def7cf3f908a9848c4f41d6cb Mon Sep 17 00:00:00 2001 From: defaultdino Date: Thu, 22 Oct 2026 22:56:31 +0200 Subject: [PATCH] fix: drop the dead 1.5 config migration; keep manual queue moves across shuffle toggles; hide non-help bindings from the help screen; match the playing artist by rating key; send playback reports on a dedicated ordered reporter thread; let the player sleep until its buffer drains instead of polling every 20 ms; build the stream agent lazily and read the hostname once; remove the F12 memory line, empty command option structs and other dead code --- README.md | 2 +- doc/rexamp.1 | 3 --- src/app/keys.rs | 5 ---- src/app/mod.rs | 43 +++++++++++++++++++++---------- src/app/now_playing.rs | 4 +-- src/app/playback.rs | 25 +++++++++---------- src/app/queue.rs | 5 ++++ src/app/reporting.rs | 2 +- src/app/state.rs | 14 +++++------ src/audio/decoder.rs | 14 ++++++++++- src/audio/mod.rs | 12 --------- src/audio/output.rs | 7 ++++-- src/audio/player.rs | 7 +++++- src/backend.rs | 5 ---- src/commands/login.rs | 31 +++++++++-------------- src/commands/logout.rs | 19 +++++--------- src/commands/mod.rs | 10 ++++---- src/commands/tui.rs | 43 +++++++++++++------------------ src/config.rs | 24 +++--------------- src/main.rs | 22 +--------------- src/plex/api.rs | 10 +++----- src/plex/client.rs | 50 +++++++++++++++++-------------------- src/plex/models.rs | 3 +++ src/ui/mod.rs | 12 ++++----- src/ui/player.rs | 12 +++------ src/ui/string_formatting.rs | 8 ++++++ 26 files changed, 172 insertions(+), 220 deletions(-) diff --git a/README.md b/README.md index 86b51cb..d90d50a 100644 --- a/README.md +++ b/README.md @@ -34,4 +34,4 @@ press `?` inside the player to see all keybindings rexamp reads its settings from `~/.config/rexamp/config.toml` (or `$XDG_CONFIG_HOME/rexamp/config.toml`). Every key is optional, and `config.example.toml` describes each one. -All rexamp state can be found in `~/.local/state/rexamp/state.json`. Coming from 1.5 or earlier, rexamp copies the login and server keys from `config.toml` there on first start; after that you can delete them from `config.toml`. +All rexamp state can be found in `~/.local/state/rexamp/state.json`. diff --git a/doc/rexamp.1 b/doc/rexamp.1 index 945fb17..27fe282 100644 --- a/doc/rexamp.1 +++ b/doc/rexamp.1 @@ -179,9 +179,6 @@ feature. .B ? Show help. .TP -.B F12 -Show memory usage. -.TP .B q Quit. .SH CASTING diff --git a/src/app/keys.rs b/src/app/keys.rs index 7ee6b34..6b993c1 100644 --- a/src/app/keys.rs +++ b/src/app/keys.rs @@ -33,7 +33,6 @@ pub enum Action { MoveUp, Clear, Help, - DebugLine, Quit, TogglePlayer, SearchSubmit, @@ -91,8 +90,6 @@ pub const QUEUE_KEYS: &[Action] = &[ Action::MoveUp, ]; -pub const MAXIMIZED_PLAYER_KEYS: &[Action] = &[]; - pub const SEARCH_INPUT_KEYS: &[Action] = &[Action::SearchSubmit, Action::SearchCancel]; pub const HELP_KEYS: &[Action] = &[Action::Help, Action::Quit, Action::Back]; @@ -141,7 +138,6 @@ impl Action { Action::MoveUp => "queue: move up", Action::Clear => "queue: clear", Action::Help => "help", - Action::DebugLine => "memory usage", Action::Quit => "quit", Action::Play => "play now (replace queue)", Action::PlayShuffle => "play shuffled (replace queue)", @@ -245,7 +241,6 @@ pub const BINDINGS: &[Binding] = &[ help_bar_only(bind(Action::MoveUp, &[key('K')])), help_bar_only(bind(Action::Clear, &[key('D')])), help_bar_only(bind(Action::Help, &[key('?')])), - bind(Action::DebugLine, &[code(KeyCode::F(12))]), bind(Action::Quit, &[key('q')]), help_bar_only(bind(Action::Play, &[key('P')])), help_bar_only(bind(Action::PlayShuffle, &[key('S')])), diff --git a/src/app/mod.rs b/src/app/mod.rs index 82ea94b..a2f8300 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,7 +8,7 @@ pub mod queue; mod reporting; pub mod state; -use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, sync_channel}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, channel, sync_channel}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -66,7 +66,6 @@ pub enum ApiRequest { view: u64, query: String, }, - Report(Vec), Cover { thumb: String, }, @@ -126,6 +125,10 @@ pub fn run( let (player_tx, player_rx) = player::spawn(client.clone(), cache, saved.volume)?; spawn_api_workers(client.clone(), section, saved, api_rx, tx.clone())?; + + let (report_tx, report_rx) = channel(); + let reports_done = spawn_reporter(client.clone(), report_rx)?; + spawn_forwarder(player_rx, tx.clone(), 0)?; #[cfg(unix)] @@ -138,6 +141,7 @@ pub fn run( let mut app = AppState::new( api_tx, + report_tx, player_tx, saved.volume, saved.queue.take(), @@ -148,7 +152,7 @@ pub fn run( ); app.set_accent(accent); - spawn_tui(rx, app, media_controls, &client, saved) + spawn_tui(rx, app, media_controls, reports_done, saved) } /// runs the ui on a scoped thread while the main thread runs the platform run loop @@ -156,7 +160,7 @@ fn spawn_tui( rx: Receiver, mut state: AppState, mut media_controls: Option, - client: &PlexClient, + reports_done: Receiver<()>, saved: &mut SavedState, ) -> Result<()> { thread::scope(|s| { @@ -168,7 +172,12 @@ fn spawn_tui( let result = event_loop(&mut terminal, &mut state, &rx, media_controls.as_mut()); ratatui::restore(); - shutdown(&mut state, client, saved)?; + let closed = shutdown(&mut state, saved); + + drop(state); + let _ = reports_done.recv_timeout(REPORT_TIMEOUT); + closed?; + result })?; run_main_loop(&tui); @@ -177,13 +186,12 @@ fn spawn_tui( } /// stops playback, sends the final playback reports and saves what the session changed -fn shutdown(state: &mut AppState, client: &PlexClient, saved: &mut SavedState) -> Result<()> { +fn shutdown(state: &mut AppState, saved: &mut SavedState) -> Result<()> { state.playback.stop_player(); #[cfg(feature = "cast")] let cast = state.close_cast(); - for r in state.playback.reporter.stopped() { - send_report(client, &r); - } + let stopped = state.playback.reporter.stopped(); + state.playback.report(stopped); let result = save_session(state, saved); #[cfg(feature = "cast")] cast::wait_closed(cast); @@ -332,6 +340,19 @@ fn send_report(c: &PlexClient, r: &Report) { } } +fn spawn_reporter(client: Arc, rx: Receiver>) -> Result> { + let (done_tx, done_rx) = channel::<()>(); + thread::Builder::new() + .name("reporter".into()) + .spawn(move || { + let _done = done_tx; + for r in rx.iter().flatten() { + send_report(&client, &r); + } + })?; + Ok(done_rx) +} + fn spawn_forwarder(rx: Receiver, tx: SyncSender, id: u64) -> Result<()> { thread::Builder::new() .name("player-events".into()) @@ -431,10 +452,6 @@ impl ApiContext { mode, result: self.call(|| fetch_tracks(c, &source)).map_err(err), }, - ApiRequest::Report(reports) => { - reports.iter().for_each(|r| send_report(c, r)); - return None; - } ApiRequest::Cover { thumb } => AppEvent::Cover { result: self.call(|| fetch_cover(c, &thumb)).map_err(err), thumb, diff --git a/src/app/now_playing.rs b/src/app/now_playing.rs index aefca26..9f38b59 100644 --- a/src/app/now_playing.rs +++ b/src/app/now_playing.rs @@ -15,10 +15,10 @@ pub struct NowPlaying { } impl NowPlaying { - pub fn artist_playing_now(&self, artist_title: &str) -> bool { + pub fn artist_playing_now(&self, artist_key: &str) -> bool { self.track .as_ref() - .is_some_and(|t| t.grandparent_title == *artist_title) + .is_some_and(|t| t.grandparent_rating_key == *artist_key) } pub fn album_playing_now(&self, album_key: &str) -> bool { diff --git a/src/app/playback.rs b/src/app/playback.rs index 01d731b..5b87c39 100644 --- a/src/app/playback.rs +++ b/src/app/playback.rs @@ -1,5 +1,5 @@ use std::sync::Arc; -use std::sync::mpsc::{SyncSender, TrySendError}; +use std::sync::mpsc::{Sender, SyncSender, TrySendError}; use std::time::Instant; use image::RgbImage; @@ -28,6 +28,7 @@ pub struct Cover { /// the queue, what is playing, and the player thread that plays it pub struct Playback { api: SyncSender, + reports: Sender>, player: SyncSender, pub queue: Queue, pub now: NowPlaying, @@ -49,12 +50,14 @@ pub struct Playback { impl Playback { pub fn new( api: SyncSender, + reports: Sender>, player: SyncSender, volume: f32, saved: Option, ) -> Self { let mut playback = Self { api, + reports, player, queue: Queue::default(), now: NowPlaying { @@ -140,25 +143,21 @@ impl Playback { } } - /// one request per event so a worker sends them in order (e.g. stopped before playing) - fn report(&self, reports: Vec) { - if !reports.is_empty() && self.api.try_send(ApiRequest::Report(reports)).is_err() { - warn!("dropping playback reports, API queue full"); + pub(super) fn report(&self, reports: Vec) { + if !reports.is_empty() && self.reports.send(reports).is_err() { + warn!("playback reporter is gone"); } } pub fn play_index(&mut self, i: usize) { - if i >= self.queue.len() { + let Some(e) = self.queue.entries.get(i) else { return; - } + }; + let (id, track) = (e.id, e.track.clone()); let stopped = self.reporter.stopped(); self.report(stopped); self.media_controls_dirty = true; - let Some(e) = self.queue.entries.get(i) else { - return; - }; - let track = e.track.clone(); - self.loading = Some((e.id, track.rating_key.clone())); + self.loading = Some((id, track.rating_key.clone())); self.queue.current = Some(i); self.enqueued = None; self.resume_ms = None; @@ -451,7 +450,7 @@ impl Playback { return self.play_index(0); } AddMode::PlayShuffled => { - self.queue.set_shuffle(true); + self.queue.shuffle = true; self.queue.replace(tracks, None); return self.play_index(0); } diff --git a/src/app/queue.rs b/src/app/queue.rs index 7c8f181..47488e9 100644 --- a/src/app/queue.rs +++ b/src/app/queue.rs @@ -192,6 +192,11 @@ impl Queue { return None; } self.entries.swap(i, j); + + if !self.shuffle { + self.unshuffled.swap(i, j); + } + self.current = self.current.map(|c| match c { c if c == i => j, c if c == j => i, diff --git a/src/app/reporting.rs b/src/app/reporting.rs index 4702e61..194b090 100644 --- a/src/app/reporting.rs +++ b/src/app/reporting.rs @@ -22,7 +22,7 @@ impl PlayState { } } -#[derive(Debug, PartialEq)] +#[derive(Debug)] pub enum Report { Timeline { rating_key: String, diff --git a/src/app/state.rs b/src/app/state.rs index 1a8ee1b..38e6bb7 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,4 +1,4 @@ -use std::sync::mpsc::SyncSender; +use std::sync::mpsc::{Sender, SyncSender}; use std::time::{Duration, Instant}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -6,11 +6,11 @@ use ratatui::style::Color; use super::browser::{Browser, ListKind, Selected, View}; use super::keys::{ - self, Action, GLOBAL_PLAYER_KEYS, HELP_KEYS, LIST_KEYS, MAXIMIZED_PLAYER_KEYS, NAV_KEYS, - QUEUE_KEYS, SEARCH_INPUT_KEYS, + self, Action, GLOBAL_PLAYER_KEYS, HELP_KEYS, LIST_KEYS, NAV_KEYS, QUEUE_KEYS, SEARCH_INPUT_KEYS, }; use super::playback::Playback; use super::queue::SavedQueue; +use super::reporting::Report; use super::{AddMode, ApiRequest, TrackSource}; use crate::backend::PlayerCmd; #[cfg(feature = "cast")] @@ -72,7 +72,6 @@ pub struct AppState { quit_armed: Option, /// new server address found by rediscovery, saved to the state on exit pub server_url: Option, - pub debug_line: bool, /// colour for focus, selection and highlights, from `accent_color` in the config pub accent: Color, #[cfg(feature = "cast")] @@ -87,6 +86,7 @@ pub struct AppState { impl AppState { pub fn new( api: SyncSender, + reports: Sender>, player: SyncSender, volume: f32, saved: Option, @@ -102,10 +102,9 @@ impl AppState { #[cfg(feature = "cast")] static_cast_devices, backend: 0, - playback: Playback::new(api, player, volume, saved), + playback: Playback::new(api, reports, player, volume, saved), quit_armed: None, server_url: None, - debug_line: false, accent: Color::Cyan, message: None, quit: false, @@ -158,7 +157,7 @@ impl AppState { (Some(Modal::Help), _) => &[HELP_KEYS], #[cfg(feature = "cast")] (Some(Modal::Cast(_)), _) => &[keys::CAST_KEYS], - (None, Screen::Player) => &[MAXIMIZED_PLAYER_KEYS, GLOBAL_PLAYER_KEYS], + (None, Screen::Player) => &[GLOBAL_PLAYER_KEYS], (None, Screen::Browse(Focus::Sidebar)) => &[NAV_KEYS, GLOBAL_PLAYER_KEYS], (None, Screen::Browse(Focus::List)) => match self.browser.view().map(|v| &v.kind) { Some(ListKind::Queue) => &[QUEUE_KEYS, NAV_KEYS, GLOBAL_PLAYER_KEYS], @@ -240,7 +239,6 @@ impl AppState { self.modal = Some(Modal::Search(String::new())); } Action::Help => self.modal = Some(Modal::Help), - Action::DebugLine => self.debug_line = !self.debug_line, Action::Quit => self.request_quit(), // Play is always shuffled when playing on Artist level Action::Play => self.add_selected(AddMode::PlayFromStart), diff --git a/src/audio/decoder.rs b/src/audio/decoder.rs index 7b01fa6..623bec8 100644 --- a/src/audio/decoder.rs +++ b/src/audio/decoder.rs @@ -89,7 +89,7 @@ impl Decoder { buf.copy_to_vec_interleaved(&mut self.scratch); let skip = (self.skip_frames * self.channels).min(self.scratch.len()); self.skip_frames -= skip / self.channels.max(1); - super::to_stereo(&self.scratch[skip..], self.channels, out); + to_stereo(&self.scratch[skip..], self.channels, out); return Ok(true); } } @@ -114,3 +114,15 @@ impl Decoder { Ok(required.as_millis().max(0) as u64) } } + +fn to_stereo(src: &[f32], channels: usize, out: &mut Vec) { + match channels { + 0 => {} + 1 => out.extend(src.iter().flat_map(|&s| [s, s])), + 2 => out.extend_from_slice(src), + n => out.extend(src.chunks_exact(n).flat_map(|f| { + let m = f.iter().sum::() / n as f32; + [m, m] + })), + } +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 266437d..39eb360 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -3,15 +3,3 @@ pub mod output; pub mod player; pub mod resample; pub mod source; - -pub fn to_stereo(src: &[f32], channels: usize, out: &mut Vec) { - match channels { - 0 => {} - 1 => out.extend(src.iter().flat_map(|&s| [s, s])), - 2 => out.extend_from_slice(src), - n => out.extend(src.chunks_exact(n).flat_map(|f| { - let m = f.iter().sum::() / n as f32; - [m, m] - })), - } -} diff --git a/src/audio/output.rs b/src/audio/output.rs index 825fe61..3dc68dd 100644 --- a/src/audio/output.rs +++ b/src/audio/output.rs @@ -9,8 +9,6 @@ use cpal::{FromSample, SampleFormat, SizedSample, Stream, StreamConfig}; use rtrb::{Consumer, Producer, RingBuffer}; use tracing::{debug, error, info}; -use crate::backend::perceptual_gain; - #[derive(Default)] struct Shared { played: AtomicU64, @@ -193,3 +191,8 @@ where )?; Ok(stream) } + +fn perceptual_gain(volume: f32) -> f32 { + let v = volume.clamp(0.0, 1.0); + v * v * v +} diff --git a/src/audio/player.rs b/src/audio/player.rs index c2a3104..7232a63 100644 --- a/src/audio/player.rs +++ b/src/audio/player.rs @@ -112,7 +112,7 @@ impl Player { cmds.try_recv() .map_err(|e| matches!(e, TryRecvError::Disconnected)) } else if self.active() && !self.paused { - cmds.recv_timeout(IDLE_POLL) + cmds.recv_timeout(self.idle_wait()) .map_err(|e| matches!(e, RecvTimeoutError::Disconnected)) } else { cmds.recv().map_err(|_| true) @@ -196,6 +196,11 @@ impl Player { && self.out.free() >= self.out.capacity() / 4 } + fn idle_wait(&self) -> Duration { + let frames = (self.out.capacity() / 4).saturating_sub(self.out.free()) / 2; + Duration::from_millis(frames as u64 * 1000 / self.out.rate as u64).max(IDLE_POLL) + } + fn active(&self) -> bool { self.playing.is_some() || self.decoding.is_some() diff --git a/src/backend.rs b/src/backend.rs index 42395b1..399f0ab 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -43,8 +43,3 @@ pub struct DeviceVolume { pub level: f32, pub muted: bool, } - -pub fn perceptual_gain(volume: f32) -> f32 { - let v = volume.clamp(0.0, 1.0); - v * v * v -} diff --git a/src/commands/login.rs b/src/commands/login.rs index 954890c..bf02f27 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -1,25 +1,18 @@ use std::process::ExitCode; -use clap::Parser; - use crate::config::SavedState; use crate::plex::auth; -#[derive(Parser, Debug)] -pub(super) struct Options {} - -impl Options { - pub fn run(self, saved: &mut SavedState) -> anyhow::Result { - // reset not only auth settings but - // also user state - saved.token = Some(auth::login(saved)?); - saved.server_url = None; - saved.server_id = None; - saved.server_token = None; - saved.music_section = None; - saved.save()?; - let client = auth::connect(saved)?; - println!("Signed in. Using {}.", client.base()); - Ok(ExitCode::SUCCESS) - } +pub(super) fn run(saved: &mut SavedState) -> anyhow::Result { + // reset not only auth settings but + // also user state + saved.token = Some(auth::login(saved)?); + saved.server_url = None; + saved.server_id = None; + saved.server_token = None; + saved.music_section = None; + saved.save()?; + let client = auth::connect(saved)?; + println!("Signed in. Using {}.", client.base()); + Ok(ExitCode::SUCCESS) } diff --git a/src/commands/logout.rs b/src/commands/logout.rs index 5626f79..d8135ec 100644 --- a/src/commands/logout.rs +++ b/src/commands/logout.rs @@ -1,18 +1,11 @@ use std::process::ExitCode; -use clap::Parser; - use crate::config::SavedState; -#[derive(Parser, Debug)] -pub(super) struct Options {} - -impl Options { - pub fn run(self, saved: &mut SavedState) -> anyhow::Result { - saved.token = None; - saved.server_token = None; - saved.save()?; - println!("Logged out."); - Ok(ExitCode::SUCCESS) - } +pub(super) fn run(saved: &mut SavedState) -> anyhow::Result { + saved.token = None; + saved.server_token = None; + saved.save()?; + println!("Logged out."); + Ok(ExitCode::SUCCESS) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3dd594a..7423d31 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -14,9 +14,9 @@ mod tui; #[derive(Parser, Debug)] enum Subcommand { /// Sign in to Plex and choose a server and music library - Login(self::login::Options), + Login, /// Remove saved tokens - Logout(self::logout::Options), + Logout, /// Switch media libraries Switch(self::switch::Options), } @@ -34,10 +34,10 @@ impl Options { pub fn run(self, cfg: Config, saved: &mut SavedState) -> anyhow::Result { use Subcommand as S; match self.subcommand { - Some(S::Login(c)) => c.run(saved), - Some(S::Logout(c)) => c.run(saved), + Some(S::Login) => self::login::run(saved), + Some(S::Logout) => self::logout::run(saved), Some(S::Switch(c)) => c.run(saved), - None => self::tui::Options::default().run( + None => self::tui::run( saved, cfg.accent_color.as_deref(), #[cfg(feature = "cast")] diff --git a/src/commands/tui.rs b/src/commands/tui.rs index 6ccedd4..b8902bc 100644 --- a/src/commands/tui.rs +++ b/src/commands/tui.rs @@ -2,36 +2,29 @@ use std::process::ExitCode; use std::sync::Arc; use anyhow::Result; -use clap::Parser; #[cfg(feature = "cast")] use crate::cast::Device; use crate::config::SavedState; use crate::plex::auth; -#[derive(Parser, Debug, Default)] -pub(super) struct Options {} - -impl Options { - pub fn run( - self, - saved: &mut SavedState, - accent: Option<&str>, - #[cfg(feature = "cast")] static_cast_devices: Vec, - ) -> Result { - if saved.token.is_none() && saved.server_url.is_none() { - saved.token = Some(auth::login(saved)?); - saved.save()?; - } - println!("Connecting..."); - let client = auth::connect(saved)?; - crate::app::run( - Arc::new(client), - saved, - accent, - #[cfg(feature = "cast")] - static_cast_devices, - )?; - Ok(ExitCode::SUCCESS) +pub(super) fn run( + saved: &mut SavedState, + accent: Option<&str>, + #[cfg(feature = "cast")] static_cast_devices: Vec, +) -> Result { + if saved.token.is_none() && saved.server_url.is_none() { + saved.token = Some(auth::login(saved)?); + saved.save()?; } + println!("Connecting..."); + let client = auth::connect(saved)?; + crate::app::run( + Arc::new(client), + saved, + accent, + #[cfg(feature = "cast")] + static_cast_devices, + )?; + Ok(ExitCode::SUCCESS) } diff --git a/src/config.rs b/src/config.rs index 075bd62..b9d0463 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,13 +17,6 @@ pub struct Config { pub accent_color: Option, #[cfg(feature = "cast")] pub static_cast_devices: Option>, - client_identifier: Option, - token: Option, - server_url: Option, - server_id: Option, - server_token: Option, - music_section: Option, - volume: Option, } #[derive(Serialize, Deserialize)] @@ -96,23 +89,12 @@ impl Config { } impl SavedState { - /// loads the saved state - /// on first run, seeds it from the keys older versions kept in the config - pub fn load(cfg: &Config) -> Result { + /// loads the saved state, or a fresh one on first run + pub fn load() -> Result { if let Some(saved) = Self::read()? { return Ok(saved); } - let d = Self::default(); - let saved = Self { - client_identifier: cfg.client_identifier.clone().unwrap_or(d.client_identifier), - token: cfg.token.clone(), - server_url: cfg.server_url.clone(), - server_id: cfg.server_id.clone(), - server_token: cfg.server_token.clone(), - music_section: cfg.music_section.clone(), - volume: cfg.volume.unwrap_or(d.volume), - queue: None, - }; + let saved = Self::default(); saved.save()?; Ok(saved) } diff --git a/src/main.rs b/src/main.rs index c8fdb41..138f9c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,28 +18,8 @@ fn main() -> anyhow::Result { setup_logging(options.log_level)?; let cfg = config::Config::load()?; - let mut saved = config::SavedState::load(&cfg)?; + let mut saved = config::SavedState::load()?; tracing::info!(version = env!("CARGO_PKG_VERSION"), "starting"); options.run(cfg, &mut saved) } - -pub fn fmt_ms(ms: u64) -> String { - let secs = ms / 1000; - match secs / 3600 { - 0 => format!("{}:{:02}", secs / 60, secs % 60), - h => format!("{h}:{:02}:{:02}", secs / 60 % 60, secs % 60), - } -} - -/// resident set size of this process in kB, 0 if unavailable -pub fn rss_kb() -> u64 { - std::fs::read_to_string("/proc/self/status") - .ok() - .and_then(|s| { - s.lines() - .find_map(|l| l.strip_prefix("VmRSS:")) - .and_then(|v| v.trim().trim_end_matches(" kB").parse().ok()) - }) - .unwrap_or(0) -} diff --git a/src/plex/api.rs b/src/plex/api.rs index 2bf7625..43fd98a 100644 --- a/src/plex/api.rs +++ b/src/plex/api.rs @@ -29,7 +29,7 @@ impl PlexClient { start: u32, size: u32, ) -> Result> { - let c: Container = self.get_range(path, query, start, size)?; + let c: Container = self.get_json(path, query, Some((start, size)), true)?; let items: Vec = c.mc.items.into_iter().map(T::from).collect(); let total = c.mc.total_size.unwrap_or(start + items.len() as u32); Ok(Page { items, total }) @@ -42,13 +42,9 @@ impl PlexClient { Ok(c.mc.token) } - pub fn sections(&self) -> Result> { - let c: Container> = self.get("/library/sections", &[])?; - Ok(c.mc.items) - } - pub fn music_sections(&self) -> Result> { - let mut s = self.sections()?; + let c: Container> = self.get("/library/sections", &[])?; + let mut s = c.mc.items; s.retain(|s| s.kind == "artist"); Ok(s) } diff --git a/src/plex/client.rs b/src/plex/client.rs index 27751a0..a30f2f6 100644 --- a/src/plex/client.rs +++ b/src/plex/client.rs @@ -1,4 +1,4 @@ -use std::sync::RwLock; +use std::sync::{OnceLock, RwLock}; use std::thread; use std::time::Duration; @@ -18,12 +18,20 @@ pub struct PlexClient { agent: Agent, /// kept separate from `agent` so media downloads aren't cut off by its global timeout, /// and long-lived so its connection pool skips a TCP/TLS handshake per track - stream_agent: Agent, + stream_agent: OnceLock, /// swapped in place when the server is rediscovered at another address base: RwLock, token: String, client_id: String, - device: String, +} + +fn device_name() -> &'static str { + static NAME: OnceLock = OnceLock::new(); + NAME.get_or_init(|| { + std::fs::read_to_string("/proc/sys/kernel/hostname") + .map(|h| h.trim().to_owned()) + .unwrap_or_else(|_| "rexamp".into()) + }) } impl PlexClient { @@ -32,22 +40,12 @@ impl PlexClient { .timeout_global(Some(timeout)) .build() .into(); - // no per-read timeout here; the audio source restarts stalled downloads itself - let stream_agent = Agent::config_builder() - .timeout_connect(Some(Duration::from_secs(10))) - .timeout_recv_response(Some(Duration::from_secs(15))) - .build() - .into(); - let device = std::fs::read_to_string("/proc/sys/kernel/hostname") - .map(|h| h.trim().to_owned()) - .unwrap_or_else(|_| "rexamp".into()); Self { agent, - stream_agent, + stream_agent: OnceLock::new(), base: RwLock::new(base.trim_end_matches('/').to_owned()), token: token.to_owned(), client_id: client_id.to_owned(), - device, } } @@ -76,7 +74,7 @@ impl PlexClient { .header("X-Plex-Version", env!("CARGO_PKG_VERSION")) .header("X-Plex-Client-Identifier", &self.client_id) .header("X-Plex-Platform", "Linux") - .header("X-Plex-Device-Name", &self.device); + .header("X-Plex-Device-Name", device_name()); if !self.token.is_empty() { r = r.header("X-Plex-Token", &self.token); } @@ -96,17 +94,7 @@ impl PlexClient { self.get_json(path, query, None, false) } - pub fn get_range( - &self, - path: &str, - query: &[(&str, &str)], - start: u32, - size: u32, - ) -> Result { - self.get_json(path, query, Some((start, size)), true) - } - - fn get_json( + pub(super) fn get_json( &self, path: &str, query: &[(&str, &str)], @@ -134,7 +122,15 @@ impl PlexClient { /// streaming GET for media downloads starting at byte `from` pub fn stream(&self, path: &str, from: u64) -> Result { - let mut req = self.headers(self.stream_agent.get(self.url(path)), &[]); + // no per-read timeout here; the audio source restarts stalled downloads itself + let agent = self.stream_agent.get_or_init(|| { + Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(10))) + .timeout_recv_response(Some(Duration::from_secs(15))) + .build() + .into() + }); + let mut req = self.headers(agent.get(self.url(path)), &[]); if from > 0 { req = req.header("Range", format!("bytes={from}-")); } diff --git a/src/plex/models.rs b/src/plex/models.rs index 6dcdad0..e690ea9 100644 --- a/src/plex/models.rs +++ b/src/plex/models.rs @@ -57,6 +57,7 @@ pub struct Track { pub rating_key: String, pub title: String, pub grandparent_title: String, + pub grandparent_rating_key: String, pub parent_title: String, pub parent_rating_key: String, /// album art path, falling back to the track's own thumbnail @@ -85,6 +86,7 @@ pub(super) struct RawItem { title: String, parent_title: String, grandparent_title: String, + grandparent_rating_key: String, parent_rating_key: String, thumb: String, parent_thumb: String, @@ -149,6 +151,7 @@ impl From for Track { rating_key: r.rating_key, title: r.title, grandparent_title: r.grandparent_title, + grandparent_rating_key: r.grandparent_rating_key, parent_title: r.parent_title, parent_rating_key: r.parent_rating_key, thumb: if r.parent_thumb.is_empty() { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e466a83..2562ea9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,7 +13,7 @@ use crate::app::state::{AppState, Focus, Modal, Screen}; use crate::plex::models::{Album, Artist, Track}; use crate::ui::help_bar::draw_help_bar; use crate::ui::player::{draw_maximized_player, draw_minimized_player}; -use crate::ui::string_formatting::{fit, truncate_left}; +use crate::ui::string_formatting::{fit, fmt_ms, truncate_left}; mod help_bar; mod player; @@ -237,7 +237,7 @@ fn render_line_marker(v: &View, i: usize, is_playing: bool) -> &'static str { fn item_is_playing(v: &View, i: usize, now_playing: &NowPlaying, queue: &Queue) -> bool { match &v.items { - Items::Artists(a) => now_playing.artist_playing_now(&a[i].title), + Items::Artists(a) => now_playing.artist_playing_now(&a[i].rating_key), Items::Albums(a) => now_playing.album_playing_now(&a[i].rating_key), Items::Tracks(ts) => match v.kind { ListKind::Queue => { @@ -248,7 +248,7 @@ fn item_is_playing(v: &View, i: usize, now_playing: &NowPlaying, queue: &Queue) }, Items::Search(items) => match &items[i] { SearchItem::Header(_) => false, - SearchItem::Artist(a) => now_playing.artist_playing_now(&a.title), + SearchItem::Artist(a) => now_playing.artist_playing_now(&a.rating_key), SearchItem::Album(a) => now_playing.album_playing_now(&a.rating_key), SearchItem::Track(t) => now_playing.track_playing_now(&t.rating_key), }, @@ -286,7 +286,7 @@ pub(super) fn track_row(list_kind: &ListKind, t: &Track, multi_disc: bool) -> (S } _ => format!("{} — {}", t.title, t.grandparent_title), }; - let right = t.duration_ms.map_or(String::new(), crate::fmt_ms); + let right = t.duration_ms.map_or(String::new(), fmt_ms); (left, right) } @@ -308,7 +308,7 @@ fn row(v: &View, i: usize, queue: &Queue, multi_disc: bool) -> (String, String) Items::Playlists(p) => { let p = &p[i]; let count = p.leaf_count.map(|n| format!("{n} tracks")); - let dur = p.duration_ms.map(crate::fmt_ms); + let dur = p.duration_ms.map(fmt_ms); let right = [count, dur].into_iter().flatten().collect::>(); (p.title.clone(), right.join(" · ")) } @@ -317,7 +317,7 @@ fn row(v: &View, i: usize, queue: &Queue, multi_disc: bool) -> (String, String) fn draw_help(f: &mut Frame) { let mut groups: Vec<(&str, Vec)> = Vec::new(); - for b in BINDINGS { + for b in BINDINGS.iter().filter(|b| b.show_in_help) { let desc = b.action.describe(); match groups.iter_mut().find(|(d, _)| *d == desc) { Some((_, keys)) => keys.push(key_name(b)), diff --git a/src/ui/player.rs b/src/ui/player.rs index 3eb8a61..6ff58f4 100644 --- a/src/ui/player.rs +++ b/src/ui/player.rs @@ -13,7 +13,7 @@ use crate::app::now_playing::NowPlaying; use crate::app::queue::Repeat; use crate::app::state::AppState; use crate::plex::models::Track; -use crate::ui::string_formatting::{fit, truncate}; +use crate::ui::string_formatting::{fit, fmt_ms, truncate}; use crate::ui::track_row; fn player_title(action: &str, accent: Color) -> Vec> { @@ -166,10 +166,10 @@ fn info_lines(s: &AppState, width: usize) -> Vec> { fn progress_line(now: &NowPlaying, track: &Track, width: usize, accent: Color) -> Line<'static> { let icon = if now.paused { "‖" } else { "▶" }; - let left = format!("{icon} {} ", crate::fmt_ms(now.position_ms)); + let left = format!("{icon} {} ", fmt_ms(now.position_ms)); let right = track .duration_ms - .map_or(String::new(), |d| format!(" {}", crate::fmt_ms(d))); + .map_or(String::new(), |d| format!(" {}", fmt_ms(d))); let bar_width = width.saturating_sub(left.width() + right.width()); let filled = match track.duration_ms { Some(d) if d > 0 => { @@ -209,11 +209,5 @@ fn status_line(s: &AppState) -> Line<'static> { (true, Some(d)) => format!(" · device vol {:.0}%", d.level * 100.0), }; spans.push(Span::raw(vol)); - if s.debug_line { - spans.push(Span::raw(format!( - " · rss {:.1} MB", - crate::rss_kb() as f64 / 1024.0 - ))); - } Line::from(spans) } diff --git a/src/ui/string_formatting.rs b/src/ui/string_formatting.rs index f6a3d19..f43b209 100644 --- a/src/ui/string_formatting.rs +++ b/src/ui/string_formatting.rs @@ -48,3 +48,11 @@ pub fn truncate_left(s: &str, width: usize) -> String { out.push('…'); out.iter().rev().collect() } + +pub fn fmt_ms(ms: u64) -> String { + let secs = ms / 1000; + match secs / 3600 { + 0 => format!("{}:{:02}", secs / 60, secs % 60), + h => format!("{h}:{:02}:{:02}", secs / 60 % 60, secs % 60), + } +}