a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
0

Configure Feed

Select the types of activity you want to include in your feed.

Guarantee scrobble/session errors stay warn-only

Two belt-and-braces tightenings so failed session-report calls can never
crash the process:

- The transition arm in emit_scrobble_events used to unwrap
state.now_playing after the derivation proved it was Some. The proof
was sound but implicit; swap it for an explicit `if let Some(...)` so
the safety is textual and survives a future refactor of `now_playing_id`.
- SubsonicClient held its username/password in std::sync::Mutex, which
panics on `.lock().unwrap()` if a prior holder aborted while holding
the guard. Extremely narrow window, but a real panic path. Switch to
parking_lot::Mutex — no poisoning, no unwrap.

Every network call already ran under tokio::spawn with `warn!(?e, ...)`
on Err, so failed reports were already logged instead of propagating.
These two changes remove the last two theoretical panics on the path.

Release build + all 140 tests pass.

+27 -20
+1
Cargo.lock
··· 697 697 "async-trait", 698 698 "fin-jellyfin", 699 699 "md5", 700 + "parking_lot", 700 701 "percent-encoding", 701 702 "rand", 702 703 "reqwest",
+3
crates/fin-subsonic/Cargo.toml
··· 17 17 url.workspace = true 18 18 uuid.workspace = true 19 19 percent-encoding.workspace = true 20 + # parking_lot::Mutex over std — no poisoning means the getters can't panic 21 + # if a prior lock holder aborted. 22 + parking_lot.workspace = true 20 23 21 24 # Subsonic auth: salt + MD5(password + salt) is the standard scheme. 22 25 md5 = "0.7"
+8 -9
crates/fin-subsonic/src/lib.rs
··· 9 9 //! Auth uses the standard salt + MD5(password + salt) token scheme — no 10 10 //! plain password ever hits the wire once the client is logged in. 11 11 12 - use std::sync::{Arc, Mutex}; 12 + use std::sync::Arc; 13 13 14 14 use anyhow::{anyhow, Context, Result}; 15 + use parking_lot::Mutex; 15 16 use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; 16 17 use rand::Rng; 17 18 use serde::Deserialize; ··· 55 56 ) -> Result<Self> { 56 57 let mut c = Self::new(base_url)?; 57 58 c.device_id = device_id.into(); 58 - *c.username.lock().unwrap() = Some(username.into()); 59 - *c.password.lock().unwrap() = Some(password.into()); 59 + *c.username.lock() = Some(username.into()); 60 + *c.password.lock() = Some(password.into()); 60 61 Ok(c) 61 62 } 62 63 ··· 67 68 &self.device_id 68 69 } 69 70 pub fn username(&self) -> Option<String> { 70 - self.username.lock().unwrap().clone() 71 + self.username.lock().clone() 71 72 } 72 73 pub fn password(&self) -> Option<String> { 73 - self.password.lock().unwrap().clone() 74 + self.password.lock().clone() 74 75 } 75 76 76 77 /// Verify credentials by hitting `ping.view`. Populates the client's 77 78 /// state and returns an `AuthResult` shaped like Jellyfin's so 78 79 /// downstream code doesn't have to branch on server kind. 79 80 pub async fn login(&mut self, username: &str, password: &str) -> Result<AuthResult> { 80 - *self.username.lock().unwrap() = Some(username.to_string()); 81 - *self.password.lock().unwrap() = Some(password.to_string()); 81 + *self.username.lock() = Some(username.to_string()); 82 + *self.password.lock() = Some(password.to_string()); 82 83 self.ping().await?; 83 84 Ok(AuthResult { 84 85 access_token: password.to_string(), ··· 265 266 let user = self 266 267 .username 267 268 .lock() 268 - .unwrap() 269 269 .clone() 270 270 .ok_or_else(|| anyhow!("subsonic client not logged in"))?; 271 271 let password = self 272 272 .password 273 273 .lock() 274 - .unwrap() 275 274 .clone() 276 275 .ok_or_else(|| anyhow!("subsonic client has no password"))?; 277 276 let salt = random_salt();
+15 -11
crates/fin-tui/src/app.rs
··· 974 974 } 975 975 }); 976 976 } 977 - let item = state.now_playing.as_ref().unwrap(); 978 - let base_item = queue_item_to_base_item(item); 979 - let client_c = client.clone(); 980 - let session_id_c = session_id.clone(); 981 - tokio::spawn(async move { 982 - if let Err(e) = client_c.report_started(&base_item, &session_id_c).await { 983 - warn!(?e, "scrobble started failed"); 984 - } 985 - }); 986 - app.scrobble_reported_id = Some(cur.clone()); 987 - app.scrobble_last_progress = Instant::now(); 977 + // The match arm's `Some(cur)` guarantees state.now_playing is 978 + // Some, but check anyway so this stays a warn-only path even 979 + // if the derivation ever changes. 980 + if let Some(item) = state.now_playing.as_ref() { 981 + let base_item = queue_item_to_base_item(item); 982 + let client_c = client.clone(); 983 + let session_id_c = session_id.clone(); 984 + tokio::spawn(async move { 985 + if let Err(e) = client_c.report_started(&base_item, &session_id_c).await { 986 + warn!(?e, "scrobble started failed"); 987 + } 988 + }); 989 + app.scrobble_reported_id = Some(cur.clone()); 990 + app.scrobble_last_progress = Instant::now(); 991 + } 988 992 } 989 993 (Some(prev_id), None) => { 990 994 // Playback ended — fire the closing report.