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.

Add Subsonic backend with auto-detection alongside Jellyfin

Two new crates plus a backend abstraction. `fin login <url>` probes the
URL to detect Jellyfin vs Subsonic and picks the right client without the
user having to say. Both backends now flow through the same `MediaClient`
trait so the TUI, CLI, and player don't have to branch on server kind.

Crates
- fin-subsonic: SubsonicClient speaking Subsonic REST v1.16.1. Salt +
MD5(password + salt) auth (no plaintext ever hits the wire once logged
in). Endpoints: ping, getAlbumList2, getAlbum, getPlaylists,
getPlaylist, search3, stream, getCoverArt, scrobble. Album / Song /
Playlist responses are mapped into fin_jellyfin::BaseItem so downstream
widgets stay backend-blind. Compatible with Airsonic, Navidrome, Gonic,
Astiga, and every server that speaks the standard vocabulary.
- fin-media: hosts the MediaClient async trait plus a `probe_server(url)`
that races GET /System/Info/Public and GET /rest/ping.view — first to
answer wins, so a healthy server responds in one round-trip. Impls for
both JellyfinClient and SubsonicClient live here (orphan rules — the
trait's crate owns them). `login_any(url, user, pass)` bundles probe +
login into a single call that returns Arc<dyn MediaClient>.
`client_from_stored(...)` reconstructs the right concrete client from a
ServerConfig after loading config.toml on startup.

Config
- fin_config: new ServerKind enum (Jellyfin | Subsonic), and ServerConfig
grows a `server_kind` field with #[serde(default)] = Jellyfin so old
configs load unchanged. Legacy single-server migration slots into
Jellyfin.

fin binary
- cmd_login now uses `fin_media::login_any` for auto-detection and stores
the detected server_kind on the ServerConfig. The "signed in as" line
advertises the backend so it's obvious which one was picked.
- make_client returns Arc<dyn MediaClient>, dispatched off server_kind.
- content_type_for_url etc. still use the JellyfinClient statics — they
don't need the abstraction.

fin-tui
- App::jellyfin is now Arc<Mutex<Arc<dyn MediaClient>>>. Zero-change to
every jf().items(...) call site — trait method signatures match.
- switch_server rebuilds via client_from_stored, so switching from a
Jellyfin server to a Subsonic one (or vice versa) picks the right
concrete client.

Scrobble / session-report hooks
- MediaClient trait gains report_started / report_progress /
report_stopped with no-op defaults.
- JellyfinClient impl delegates to /Sessions/Playing[/Progress|/Stopped].
- SubsonicClient impl fires /rest/scrobble.view — submission=false on
started, submission=true on stopped. progress is a Subsonic no-op
(there's no per-tick endpoint in the standard vocabulary).
- fin-tui event loop tracks the last-reported item id + a 10 s throttle
and emits started / progress / stopped as playback transitions. Only
fires for local audio playback; Chromecast / UPnP receivers manage
their own reporting server-side.

Verified: cargo test --workspace passes 140 tests across 13 crates
(previous baseline was 133 in 12 crates — the +7 are Subsonic-side
MD5/salt/JSON round-trip tests plus the fin-media ServerKind test).

+1434 -17
+44
Cargo.lock
··· 604 604 "directories", 605 605 "fin-config", 606 606 "fin-jellyfin", 607 + "fin-media", 607 608 "fin-player", 608 609 "fin-tui", 609 610 "rpassword", ··· 646 647 ] 647 648 648 649 [[package]] 650 + name = "fin-media" 651 + version = "0.3.0" 652 + dependencies = [ 653 + "anyhow", 654 + "async-trait", 655 + "fin-config", 656 + "fin-jellyfin", 657 + "fin-subsonic", 658 + "reqwest", 659 + "serde", 660 + "serde_json", 661 + "tokio", 662 + "tracing", 663 + ] 664 + 665 + [[package]] 649 666 name = "fin-player" 650 667 version = "0.3.0" 651 668 dependencies = [ ··· 673 690 ] 674 691 675 692 [[package]] 693 + name = "fin-subsonic" 694 + version = "0.3.0" 695 + dependencies = [ 696 + "anyhow", 697 + "async-trait", 698 + "fin-jellyfin", 699 + "md5", 700 + "percent-encoding", 701 + "rand", 702 + "reqwest", 703 + "serde", 704 + "serde_json", 705 + "tokio", 706 + "tracing", 707 + "url", 708 + "uuid", 709 + ] 710 + 711 + [[package]] 676 712 name = "fin-tui" 677 713 version = "0.3.0" 678 714 dependencies = [ ··· 682 718 "crossterm", 683 719 "fin-config", 684 720 "fin-jellyfin", 721 + "fin-media", 685 722 "fin-player", 686 723 "futures", 687 724 "parking_lot", ··· 692 729 "tokio", 693 730 "tracing", 694 731 "unicode-width 0.2.0", 732 + "uuid", 695 733 ] 696 734 697 735 [[package]] ··· 1360 1398 dependencies = [ 1361 1399 "regex-automata", 1362 1400 ] 1401 + 1402 + [[package]] 1403 + name = "md5" 1404 + version = "0.7.0" 1405 + source = "registry+https://github.com/rust-lang/crates.io-index" 1406 + checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" 1363 1407 1364 1408 [[package]] 1365 1409 name = "mdns-sd"
+2
Cargo.toml
··· 40 40 41 41 fin-config = { path = "crates/fin-config" } 42 42 fin-jellyfin = { path = "crates/fin-jellyfin" } 43 + fin-media = { path = "crates/fin-media" } 43 44 fin-player = { path = "crates/fin-player" } 45 + fin-subsonic = { path = "crates/fin-subsonic" } 44 46 fin-tui = { path = "crates/fin-tui" } 45 47 46 48 [profile.release]
+27
crates/fin-config/src/lib.rs
··· 94 94 pub user_name: String, 95 95 pub access_token: String, 96 96 pub device_id: String, 97 + /// Which backend the URL points at. `#[serde(default)]` = Jellyfin so 98 + /// old configs (which don't have this field) keep loading. 99 + #[serde(default)] 100 + pub server_kind: ServerKind, 101 + } 102 + 103 + /// Which media backend a stored server speaks. Mirrors `fin_media::ServerKind`; 104 + /// duplicated here to keep fin-config as a leaf crate with zero player deps. 105 + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] 106 + #[serde(rename_all = "lowercase")] 107 + pub enum ServerKind { 108 + #[default] 109 + Jellyfin, 110 + Subsonic, 111 + } 112 + 113 + impl ServerKind { 114 + pub fn label(&self) -> &'static str { 115 + match self { 116 + Self::Jellyfin => "jellyfin", 117 + Self::Subsonic => "subsonic", 118 + } 119 + } 97 120 } 98 121 99 122 /// The old, single-server shape we used before multi-server support landed. ··· 117 140 user_name: l.user_name, 118 141 access_token: l.access_token, 119 142 device_id: l.device_id, 143 + // Legacy configs pre-date multi-backend support — they were 144 + // always Jellyfin. 145 + server_kind: ServerKind::Jellyfin, 120 146 } 121 147 } 122 148 } ··· 457 483 user_name: "user".into(), 458 484 access_token: "tok".into(), 459 485 device_id: "dev".into(), 486 + server_kind: ServerKind::default(), 460 487 } 461 488 } 462 489
+2 -1
crates/fin-jellyfin/src/lib.rs
··· 3 3 4 4 pub use client::{JellyfinClient, StreamFormat}; 5 5 pub use models::{ 6 - AuthResult, BaseItem, ItemKind, Playlist, PlaylistItem, SearchHint, SearchResult, UserView, 6 + AuthResult, AuthUser, BaseItem, ItemKind, Playlist, PlaylistItem, SearchHint, SearchResult, 7 + UserView, 7 8 };
+20
crates/fin-media/Cargo.toml
··· 1 + [package] 2 + name = "fin-media" 3 + version.workspace = true 4 + edition.workspace = true 5 + authors.workspace = true 6 + license.workspace = true 7 + repository.workspace = true 8 + 9 + [dependencies] 10 + anyhow.workspace = true 11 + async-trait.workspace = true 12 + serde.workspace = true 13 + serde_json.workspace = true 14 + tokio.workspace = true 15 + tracing.workspace = true 16 + reqwest.workspace = true 17 + 18 + fin-config.workspace = true 19 + fin-jellyfin.workspace = true 20 + fin-subsonic.workspace = true
+376
crates/fin-media/src/lib.rs
··· 1 + //! Backend-agnostic media client abstraction. 2 + //! 3 + //! `MediaClient` is the trait the TUI and CLI talk to; both 4 + //! [`fin_jellyfin::JellyfinClient`] and [`fin_subsonic::SubsonicClient`] 5 + //! implement it. `probe_server(url)` pings both flavours in parallel so 6 + //! `fin login <url>` can figure out which one is at the other end without 7 + //! the user having to say. 8 + 9 + use std::sync::Arc; 10 + use std::time::Duration; 11 + 12 + use anyhow::{anyhow, Context, Result}; 13 + use async_trait::async_trait; 14 + 15 + pub use fin_config::ServerKind; 16 + pub use fin_jellyfin::{AuthResult, BaseItem, ItemKind, StreamFormat}; 17 + pub use fin_jellyfin::JellyfinClient; 18 + pub use fin_subsonic::SubsonicClient; 19 + 20 + /// Common browse / search / stream surface. Both backends model the same 21 + /// concepts a little differently on the wire; the trait paves over the 22 + /// differences so `fin-tui` doesn't have to branch on server kind. 23 + #[async_trait] 24 + pub trait MediaClient: Send + Sync { 25 + fn kind(&self) -> ServerKind; 26 + fn base_url(&self) -> &str; 27 + fn device_id(&self) -> &str; 28 + fn access_token(&self) -> Option<&str>; 29 + fn user_id(&self) -> Option<&str>; 30 + 31 + async fn items( 32 + &self, 33 + parent: Option<&str>, 34 + kinds: &[&str], 35 + recursive: bool, 36 + sort_by: Option<&str>, 37 + limit: Option<u32>, 38 + ) -> Result<Vec<BaseItem>>; 39 + 40 + async fn search(&self, query: &str, kinds: &[&str], limit: u32) -> Result<Vec<BaseItem>>; 41 + async fn playlists(&self) -> Result<Vec<BaseItem>>; 42 + async fn playlist_items(&self, id: &str) -> Result<Vec<BaseItem>>; 43 + 44 + fn stream_url(&self, item: &BaseItem, format: StreamFormat) -> Result<String>; 45 + fn image_url(&self, item_id: &str, tag: &str, width: u32) -> String; 46 + 47 + /// Tell the server a track just started playing (Jellyfin's 48 + /// `PlaybackStarted`, Subsonic's `scrobble?submission=false`). 49 + async fn report_started(&self, _item: &BaseItem, _session_id: &str) -> Result<()> { 50 + Ok(()) 51 + } 52 + 53 + /// Send a progress tick — Jellyfin only. Subsonic doesn't have a 54 + /// per-progress endpoint; its scrobble stream fires once at completion. 55 + async fn report_progress( 56 + &self, 57 + _item: &BaseItem, 58 + _position_secs: u64, 59 + _is_paused: bool, 60 + _session_id: &str, 61 + ) -> Result<()> { 62 + Ok(()) 63 + } 64 + 65 + /// Tell the server a track finished / stopped (Jellyfin's 66 + /// `PlaybackStopped`, Subsonic's `scrobble?submission=true` — which is 67 + /// what most Last.fm scrobblers listen to). 68 + async fn report_stopped( 69 + &self, 70 + _item: &BaseItem, 71 + _position_secs: u64, 72 + _session_id: &str, 73 + ) -> Result<()> { 74 + Ok(()) 75 + } 76 + } 77 + 78 + // ---- adapters ------------------------------------------------------------- 79 + 80 + #[async_trait] 81 + impl MediaClient for JellyfinClient { 82 + fn kind(&self) -> ServerKind { 83 + ServerKind::Jellyfin 84 + } 85 + fn base_url(&self) -> &str { 86 + JellyfinClient::base_url(self) 87 + } 88 + fn device_id(&self) -> &str { 89 + JellyfinClient::device_id(self) 90 + } 91 + fn access_token(&self) -> Option<&str> { 92 + JellyfinClient::access_token(self) 93 + } 94 + fn user_id(&self) -> Option<&str> { 95 + JellyfinClient::user_id(self) 96 + } 97 + 98 + async fn items( 99 + &self, 100 + parent: Option<&str>, 101 + kinds: &[&str], 102 + recursive: bool, 103 + sort_by: Option<&str>, 104 + limit: Option<u32>, 105 + ) -> Result<Vec<BaseItem>> { 106 + JellyfinClient::items(self, parent, kinds, recursive, sort_by, limit).await 107 + } 108 + async fn search(&self, query: &str, kinds: &[&str], limit: u32) -> Result<Vec<BaseItem>> { 109 + JellyfinClient::search(self, query, kinds, limit).await 110 + } 111 + async fn playlists(&self) -> Result<Vec<BaseItem>> { 112 + JellyfinClient::playlists(self).await 113 + } 114 + async fn playlist_items(&self, id: &str) -> Result<Vec<BaseItem>> { 115 + JellyfinClient::playlist_items(self, id).await 116 + } 117 + 118 + fn stream_url(&self, item: &BaseItem, format: StreamFormat) -> Result<String> { 119 + JellyfinClient::stream_url(self, item, format) 120 + } 121 + fn image_url(&self, item_id: &str, tag: &str, width: u32) -> String { 122 + JellyfinClient::image_url(self, item_id, tag, width) 123 + } 124 + async fn report_started(&self, item: &BaseItem, session_id: &str) -> Result<()> { 125 + JellyfinClient::report_started(self, item, session_id).await 126 + } 127 + async fn report_progress( 128 + &self, 129 + item: &BaseItem, 130 + position_secs: u64, 131 + is_paused: bool, 132 + session_id: &str, 133 + ) -> Result<()> { 134 + // JellyfinClient expects ticks (100 ns units). Convert here. 135 + let ticks = (position_secs as i64) * 10_000_000; 136 + JellyfinClient::report_progress(self, item, session_id, ticks, is_paused).await 137 + } 138 + async fn report_stopped( 139 + &self, 140 + item: &BaseItem, 141 + position_secs: u64, 142 + session_id: &str, 143 + ) -> Result<()> { 144 + let ticks = (position_secs as i64) * 10_000_000; 145 + JellyfinClient::report_stopped(self, item, session_id, ticks).await 146 + } 147 + } 148 + 149 + #[async_trait] 150 + impl MediaClient for SubsonicClient { 151 + fn kind(&self) -> ServerKind { 152 + ServerKind::Subsonic 153 + } 154 + fn base_url(&self) -> &str { 155 + SubsonicClient::base_url(self) 156 + } 157 + fn device_id(&self) -> &str { 158 + SubsonicClient::device_id(self) 159 + } 160 + fn access_token(&self) -> Option<&str> { 161 + // Subsonic auth is per-request; there's no long-lived token. 162 + None 163 + } 164 + fn user_id(&self) -> Option<&str> { 165 + None 166 + } 167 + 168 + async fn items( 169 + &self, 170 + parent: Option<&str>, 171 + kinds: &[&str], 172 + recursive: bool, 173 + sort_by: Option<&str>, 174 + limit: Option<u32>, 175 + ) -> Result<Vec<BaseItem>> { 176 + SubsonicClient::items(self, parent, kinds, recursive, sort_by, limit).await 177 + } 178 + async fn search(&self, query: &str, kinds: &[&str], limit: u32) -> Result<Vec<BaseItem>> { 179 + SubsonicClient::search(self, query, kinds, limit).await 180 + } 181 + async fn playlists(&self) -> Result<Vec<BaseItem>> { 182 + SubsonicClient::playlists(self).await 183 + } 184 + async fn playlist_items(&self, id: &str) -> Result<Vec<BaseItem>> { 185 + SubsonicClient::playlist_items(self, id).await 186 + } 187 + 188 + fn stream_url(&self, item: &BaseItem, format: StreamFormat) -> Result<String> { 189 + SubsonicClient::stream_url(self, item, format) 190 + } 191 + fn image_url(&self, item_id: &str, tag: &str, width: u32) -> String { 192 + SubsonicClient::image_url(self, item_id, tag, width) 193 + } 194 + async fn report_started(&self, item: &BaseItem, _session_id: &str) -> Result<()> { 195 + SubsonicClient::scrobble_now_playing(self, item).await 196 + } 197 + // report_progress: default no-op — Subsonic has no per-progress ping. 198 + async fn report_stopped( 199 + &self, 200 + item: &BaseItem, 201 + _position_secs: u64, 202 + _session_id: &str, 203 + ) -> Result<()> { 204 + SubsonicClient::scrobble_submission(self, item).await 205 + } 206 + } 207 + 208 + // ---- server detection ----------------------------------------------------- 209 + 210 + /// Probe `url` to figure out whether the server is Jellyfin or Subsonic. 211 + /// 212 + /// Fires both probes concurrently — the first to answer wins, so a healthy 213 + /// server responds in one round-trip. If neither replies, returns an error 214 + /// with both underlying failures so the user can tell what went wrong. 215 + pub async fn probe_server(url: &str) -> Result<ServerKind> { 216 + let base = url.trim_end_matches('/'); 217 + let http = reqwest::Client::builder() 218 + .connect_timeout(Duration::from_secs(5)) 219 + .timeout(Duration::from_secs(8)) 220 + .build() 221 + .context("building probe HTTP client")?; 222 + 223 + let jf = probe_jellyfin(&http, base); 224 + let ss = probe_subsonic(&http, base); 225 + // Race the two — first Ok wins. 226 + tokio::select! { 227 + biased; 228 + r = jf => { 229 + if let Ok(k) = r { 230 + return Ok(k); 231 + } 232 + } 233 + r = ss => { 234 + if let Ok(k) = r { 235 + return Ok(k); 236 + } 237 + } 238 + } 239 + // If the winner failed, run the other to completion so we can report 240 + // its result too. 241 + let (jf, ss) = tokio::join!( 242 + probe_jellyfin(&http, base), 243 + probe_subsonic(&http, base), 244 + ); 245 + if jf.is_ok() { 246 + return Ok(ServerKind::Jellyfin); 247 + } 248 + if ss.is_ok() { 249 + return Ok(ServerKind::Subsonic); 250 + } 251 + Err(anyhow!( 252 + "could not detect server type at {}:\n jellyfin probe: {}\n subsonic probe: {}", 253 + base, 254 + jf.err() 255 + .map(|e| e.to_string()) 256 + .unwrap_or_else(|| "n/a".into()), 257 + ss.err() 258 + .map(|e| e.to_string()) 259 + .unwrap_or_else(|| "n/a".into()) 260 + )) 261 + } 262 + 263 + async fn probe_jellyfin(http: &reqwest::Client, base: &str) -> Result<ServerKind> { 264 + let url = format!("{}/System/Info/Public", base); 265 + let resp = http.get(&url).send().await?.error_for_status()?; 266 + let text = resp.text().await?; 267 + // Jellyfin returns JSON with `ProductName: "Jellyfin Server"`. 268 + if text.contains("Jellyfin") { 269 + Ok(ServerKind::Jellyfin) 270 + } else { 271 + Err(anyhow!("/System/Info/Public didn't look like Jellyfin")) 272 + } 273 + } 274 + 275 + async fn probe_subsonic(http: &reqwest::Client, base: &str) -> Result<ServerKind> { 276 + // `ping.view` doesn't require auth — servers return status "failed" for 277 + // unauthenticated calls but still 200 OK with valid JSON, so a bare 278 + // response body inspection is enough. We also accept the older `ping` 279 + // form (some Airsonic derivatives drop the `.view` suffix). 280 + let url = format!("{}/rest/ping.view?c=fin&v=1.16.1&f=json", base); 281 + let resp = http.get(&url).send().await?; 282 + let status = resp.status(); 283 + let text = resp.text().await.unwrap_or_default(); 284 + if status.is_success() && text.contains("subsonic-response") { 285 + Ok(ServerKind::Subsonic) 286 + } else { 287 + Err(anyhow!( 288 + "/rest/ping.view returned {}: {}", 289 + status, 290 + text.chars().take(80).collect::<String>() 291 + )) 292 + } 293 + } 294 + 295 + // ---- login helpers -------------------------------------------------------- 296 + 297 + /// One-shot auth wrapper: probe the server, log in with the right client, 298 + /// and return everything wired up as `Arc<dyn MediaClient>`. 299 + pub async fn login_any( 300 + url: &str, 301 + username: &str, 302 + password: &str, 303 + ) -> Result<LoggedIn> { 304 + let kind = probe_server(url).await?; 305 + match kind { 306 + ServerKind::Jellyfin => { 307 + let mut client = JellyfinClient::new(url)?; 308 + let auth = client.login(username, password).await?; 309 + let device_id = client.device_id().to_string(); 310 + let arc: Arc<dyn MediaClient> = Arc::new(client); 311 + Ok(LoggedIn { 312 + kind, 313 + auth, 314 + device_id, 315 + client: arc, 316 + }) 317 + } 318 + ServerKind::Subsonic => { 319 + let mut client = SubsonicClient::new(url)?; 320 + let auth = client.login(username, password).await?; 321 + let device_id = client.device_id().to_string(); 322 + let arc: Arc<dyn MediaClient> = Arc::new(client); 323 + Ok(LoggedIn { 324 + kind, 325 + auth, 326 + device_id, 327 + client: arc, 328 + }) 329 + } 330 + } 331 + } 332 + 333 + pub struct LoggedIn { 334 + pub kind: ServerKind, 335 + pub auth: AuthResult, 336 + pub device_id: String, 337 + pub client: Arc<dyn MediaClient>, 338 + } 339 + 340 + /// Build a stored-credentials client without re-logging in (i.e. after 341 + /// loading `ServerConfig` from disk). 342 + pub fn client_from_stored( 343 + kind: ServerKind, 344 + url: &str, 345 + device_id: &str, 346 + user_id: &str, 347 + user_name: &str, 348 + access_token: &str, 349 + ) -> Result<Arc<dyn MediaClient>> { 350 + match kind { 351 + ServerKind::Jellyfin => { 352 + let c = JellyfinClient::with_credentials(url, device_id, user_id, access_token)?; 353 + Ok(Arc::new(c)) 354 + } 355 + ServerKind::Subsonic => { 356 + // For Subsonic we stashed the password in `access_token` at 357 + // login time and the username in `user_name`. 358 + let c = SubsonicClient::with_credentials(url, device_id, user_name, access_token)?; 359 + Ok(Arc::new(c)) 360 + } 361 + } 362 + } 363 + 364 + #[cfg(test)] 365 + mod tests { 366 + use super::*; 367 + 368 + #[test] 369 + fn server_kind_labels_are_stable() { 370 + assert_eq!(ServerKind::Jellyfin.label(), "jellyfin"); 371 + assert_eq!(ServerKind::Subsonic.label(), "subsonic"); 372 + // Default is Jellyfin so old configs — which lack the field — 373 + // load with the correct backend. 374 + assert_eq!(ServerKind::default(), ServerKind::Jellyfin); 375 + } 376 + }
+25
crates/fin-subsonic/Cargo.toml
··· 1 + [package] 2 + name = "fin-subsonic" 3 + version.workspace = true 4 + edition.workspace = true 5 + authors.workspace = true 6 + license.workspace = true 7 + repository.workspace = true 8 + 9 + [dependencies] 10 + anyhow.workspace = true 11 + async-trait.workspace = true 12 + serde.workspace = true 13 + serde_json.workspace = true 14 + tokio.workspace = true 15 + tracing.workspace = true 16 + reqwest.workspace = true 17 + url.workspace = true 18 + uuid.workspace = true 19 + percent-encoding.workspace = true 20 + 21 + # Subsonic auth: salt + MD5(password + salt) is the standard scheme. 22 + md5 = "0.7" 23 + rand = "0.9" 24 + 25 + fin-jellyfin.workspace = true
+726
crates/fin-subsonic/src/lib.rs
··· 1 + //! Minimal Subsonic REST API client. 2 + //! 3 + //! Speaks Subsonic REST v1.16.1 and hands results back as 4 + //! `fin_jellyfin::BaseItem` / `AuthResult` so the TUI widgets don't have 5 + //! to know which backend they came from. Compatible with Airsonic, 6 + //! Navidrome, Gonic, Astiga, and other servers that speak the standard 7 + //! Subsonic vocabulary. 8 + //! 9 + //! Auth uses the standard salt + MD5(password + salt) token scheme — no 10 + //! plain password ever hits the wire once the client is logged in. 11 + 12 + use std::sync::{Arc, Mutex}; 13 + 14 + use anyhow::{anyhow, Context, Result}; 15 + use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; 16 + use rand::Rng; 17 + use serde::Deserialize; 18 + use uuid::Uuid; 19 + 20 + use fin_jellyfin::{AuthResult, AuthUser, BaseItem, StreamFormat}; 21 + 22 + const API_VERSION: &str = "1.16.1"; 23 + const CLIENT_NAME: &str = "fin"; 24 + 25 + pub struct SubsonicClient { 26 + http: reqwest::Client, 27 + base_url: String, 28 + device_id: String, 29 + /// In-memory credential store. Never persisted outside of what the 30 + /// caller already writes into `ServerConfig.access_token`. 31 + username: Arc<Mutex<Option<String>>>, 32 + password: Arc<Mutex<Option<String>>>, 33 + } 34 + 35 + impl SubsonicClient { 36 + pub fn new(base_url: impl Into<String>) -> Result<Self> { 37 + let http = reqwest::Client::builder() 38 + .user_agent(format!("fin/{}", env!("CARGO_PKG_VERSION"))) 39 + .build() 40 + .context("building HTTP client")?; 41 + Ok(Self { 42 + http, 43 + base_url: base_url.into().trim_end_matches('/').to_string(), 44 + device_id: Uuid::new_v4().to_string(), 45 + username: Arc::new(Mutex::new(None)), 46 + password: Arc::new(Mutex::new(None)), 47 + }) 48 + } 49 + 50 + pub fn with_credentials( 51 + base_url: impl Into<String>, 52 + device_id: impl Into<String>, 53 + username: impl Into<String>, 54 + password: impl Into<String>, 55 + ) -> Result<Self> { 56 + let mut c = Self::new(base_url)?; 57 + c.device_id = device_id.into(); 58 + *c.username.lock().unwrap() = Some(username.into()); 59 + *c.password.lock().unwrap() = Some(password.into()); 60 + Ok(c) 61 + } 62 + 63 + pub fn base_url(&self) -> &str { 64 + &self.base_url 65 + } 66 + pub fn device_id(&self) -> &str { 67 + &self.device_id 68 + } 69 + pub fn username(&self) -> Option<String> { 70 + self.username.lock().unwrap().clone() 71 + } 72 + pub fn password(&self) -> Option<String> { 73 + self.password.lock().unwrap().clone() 74 + } 75 + 76 + /// Verify credentials by hitting `ping.view`. Populates the client's 77 + /// state and returns an `AuthResult` shaped like Jellyfin's so 78 + /// downstream code doesn't have to branch on server kind. 79 + 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()); 82 + self.ping().await?; 83 + Ok(AuthResult { 84 + access_token: password.to_string(), 85 + server_id: self.base_url.clone(), 86 + user: AuthUser { 87 + id: username.to_string(), 88 + name: username.to_string(), 89 + }, 90 + }) 91 + } 92 + 93 + /// GET /rest/ping.view. Cheap credential + reachability check. 94 + pub async fn ping(&self) -> Result<()> { 95 + let resp: PingResp = self.get_json("ping", &[]).await?; 96 + resp.check() 97 + } 98 + 99 + /// Browse. `parent` = album id returns its tracks; otherwise this 100 + /// returns the alphabetical album list. `kinds` selects which subsonic 101 + /// endpoint to hit (`Audio`/`MusicAlbum` → albums, `Playlist` → 102 + /// playlists). Video kinds are silently no-op'd — Subsonic has no 103 + /// video model. 104 + pub async fn items( 105 + &self, 106 + parent: Option<&str>, 107 + kinds: &[&str], 108 + _recursive: bool, 109 + _sort_by: Option<&str>, 110 + limit: Option<u32>, 111 + ) -> Result<Vec<BaseItem>> { 112 + if let Some(album_id) = parent { 113 + return self.album_tracks(album_id).await; 114 + } 115 + if kinds.iter().any(|k| *k == "Playlist") { 116 + return self.playlists().await; 117 + } 118 + if kinds.iter().any(|k| matches!(*k, "Audio" | "MusicAlbum")) { 119 + let size = limit.unwrap_or(500).min(500); 120 + return self.album_list(size).await; 121 + } 122 + Ok(Vec::new()) 123 + } 124 + 125 + async fn album_list(&self, size: u32) -> Result<Vec<BaseItem>> { 126 + let resp: AlbumListResp = self 127 + .get_json( 128 + "getAlbumList2", 129 + &[ 130 + ("type", "alphabeticalByName".to_string()), 131 + ("size", size.to_string()), 132 + ], 133 + ) 134 + .await?; 135 + resp.check()?; 136 + Ok(resp 137 + .album_list2() 138 + .map(|a| a.album) 139 + .unwrap_or_default() 140 + .into_iter() 141 + .map(album_to_base_item) 142 + .collect()) 143 + } 144 + 145 + async fn album_tracks(&self, album_id: &str) -> Result<Vec<BaseItem>> { 146 + let resp: AlbumResp = self 147 + .get_json("getAlbum", &[("id", album_id.to_string())]) 148 + .await?; 149 + resp.check()?; 150 + let album = resp 151 + .album() 152 + .ok_or_else(|| anyhow!("album {} not found", album_id))?; 153 + Ok(album.song.into_iter().map(song_to_base_item).collect()) 154 + } 155 + 156 + pub async fn search( 157 + &self, 158 + query: &str, 159 + _kinds: &[&str], 160 + limit: u32, 161 + ) -> Result<Vec<BaseItem>> { 162 + let per = limit.min(50); 163 + let resp: SearchResp = self 164 + .get_json( 165 + "search3", 166 + &[ 167 + ("query", query.to_string()), 168 + ("songCount", per.to_string()), 169 + ("albumCount", per.to_string()), 170 + ("artistCount", per.to_string()), 171 + ], 172 + ) 173 + .await?; 174 + resp.check()?; 175 + let sr = resp.search_result3().unwrap_or_default(); 176 + let mut out = Vec::new(); 177 + for a in sr.album.unwrap_or_default() { 178 + out.push(album_to_base_item(a)); 179 + } 180 + for s in sr.song.unwrap_or_default() { 181 + out.push(song_to_base_item(s)); 182 + } 183 + Ok(out) 184 + } 185 + 186 + pub async fn playlists(&self) -> Result<Vec<BaseItem>> { 187 + let resp: PlaylistsResp = self.get_json("getPlaylists", &[]).await?; 188 + resp.check()?; 189 + Ok(resp 190 + .playlists() 191 + .map(|p| p.playlist) 192 + .unwrap_or_default() 193 + .into_iter() 194 + .map(playlist_to_base_item) 195 + .collect()) 196 + } 197 + 198 + pub async fn playlist_items(&self, id: &str) -> Result<Vec<BaseItem>> { 199 + let resp: PlaylistResp = self 200 + .get_json("getPlaylist", &[("id", id.to_string())]) 201 + .await?; 202 + resp.check()?; 203 + let p = resp 204 + .playlist() 205 + .ok_or_else(|| anyhow!("playlist {} not found", id))?; 206 + Ok(p.entry.unwrap_or_default().into_iter().map(song_to_base_item).collect()) 207 + } 208 + 209 + /// Report that a track just started playing (`submission=false` — the 210 + /// Subsonic vocabulary calls this "now playing"). Best-effort — the 211 + /// server-side scrobble log is what most listeners care about, and we 212 + /// still fire that on report_stopped. 213 + pub async fn scrobble_now_playing(&self, item: &BaseItem) -> Result<()> { 214 + self.scrobble(&item.id, false).await 215 + } 216 + 217 + /// Report a completed listen. `submission=true` tells the server to 218 + /// record the track as played (this is what Last.fm-style scrobblers 219 + /// pick up when they follow the Subsonic scrobble stream). 220 + pub async fn scrobble_submission(&self, item: &BaseItem) -> Result<()> { 221 + self.scrobble(&item.id, true).await 222 + } 223 + 224 + async fn scrobble(&self, id: &str, submission: bool) -> Result<()> { 225 + let resp: PingResp = self 226 + .get_json( 227 + "scrobble", 228 + &[ 229 + ("id", id.to_string()), 230 + ("submission", submission.to_string()), 231 + ], 232 + ) 233 + .await?; 234 + resp.check() 235 + } 236 + 237 + /// Build a direct stream URL. Subsonic's `stream` endpoint serves 238 + /// the container the server picks — `format` is accepted for API 239 + /// symmetry with Jellyfin but ignored here. 240 + pub fn stream_url(&self, item: &BaseItem, _format: StreamFormat) -> Result<String> { 241 + let params = self.auth_params()?; 242 + let mut qs = params 243 + .into_iter() 244 + .map(|(k, v)| format!("{}={}", k, esc(&v))) 245 + .collect::<Vec<_>>() 246 + .join("&"); 247 + qs.push_str(&format!("&id={}", esc(&item.id))); 248 + Ok(format!("{}/rest/stream.view?{}", self.base_url, qs)) 249 + } 250 + 251 + pub fn image_url(&self, item_id: &str, _tag: &str, size: u32) -> String { 252 + let auth = self.auth_params().unwrap_or_default(); 253 + let mut qs = auth 254 + .into_iter() 255 + .map(|(k, v)| format!("{}={}", k, esc(&v))) 256 + .collect::<Vec<_>>() 257 + .join("&"); 258 + qs.push_str(&format!("&id={}&size={}", esc(item_id), size)); 259 + format!("{}/rest/getCoverArt.view?{}", self.base_url, qs) 260 + } 261 + 262 + // ------------------------------------------------------------------ 263 + 264 + fn auth_params(&self) -> Result<Vec<(&'static str, String)>> { 265 + let user = self 266 + .username 267 + .lock() 268 + .unwrap() 269 + .clone() 270 + .ok_or_else(|| anyhow!("subsonic client not logged in"))?; 271 + let password = self 272 + .password 273 + .lock() 274 + .unwrap() 275 + .clone() 276 + .ok_or_else(|| anyhow!("subsonic client has no password"))?; 277 + let salt = random_salt(); 278 + let token = md5_hex(&format!("{password}{salt}")); 279 + Ok(vec![ 280 + ("u", user), 281 + ("t", token), 282 + ("s", salt), 283 + ("v", API_VERSION.to_string()), 284 + ("c", CLIENT_NAME.to_string()), 285 + ("f", "json".to_string()), 286 + ]) 287 + } 288 + 289 + async fn get_json<T: for<'de> Deserialize<'de>>( 290 + &self, 291 + endpoint: &str, 292 + extra_params: &[(&str, String)], 293 + ) -> Result<T> { 294 + let mut params = self.auth_params()?; 295 + for (k, v) in extra_params { 296 + params.push((*k, v.clone())); 297 + } 298 + let qs = params 299 + .into_iter() 300 + .map(|(k, v)| format!("{}={}", k, esc(&v))) 301 + .collect::<Vec<_>>() 302 + .join("&"); 303 + let url = format!("{}/rest/{}.view?{}", self.base_url, endpoint, qs); 304 + let text = self 305 + .http 306 + .get(&url) 307 + .send() 308 + .await? 309 + .error_for_status()? 310 + .text() 311 + .await?; 312 + serde_json::from_str(&text) 313 + .with_context(|| format!("parsing {} response", endpoint)) 314 + } 315 + } 316 + 317 + fn esc(s: &str) -> String { 318 + utf8_percent_encode(s, NON_ALPHANUMERIC).to_string() 319 + } 320 + 321 + fn random_salt() -> String { 322 + let mut rng = rand::rng(); 323 + (0..8) 324 + .map(|_| { 325 + let n: u8 = rng.random_range(0..36); 326 + if n < 10 { 327 + (b'0' + n) as char 328 + } else { 329 + (b'a' + (n - 10)) as char 330 + } 331 + }) 332 + .collect() 333 + } 334 + 335 + fn md5_hex(input: &str) -> String { 336 + let digest = md5::compute(input.as_bytes()); 337 + format!("{:x}", digest) 338 + } 339 + 340 + // ---- API response shapes -------------------------------------------------- 341 + // 342 + // Every Subsonic endpoint wraps its payload in 343 + // `{"subsonic-response": { "status": "ok"|"failed", "error": {...}, <payload> }}`. 344 + // We flatten that: each response struct carries the outer status/error and 345 + // its own payload fields at the top level. 346 + 347 + fn default_status() -> String { 348 + "failed".into() 349 + } 350 + 351 + #[derive(Deserialize, Default)] 352 + struct SubsonicError { 353 + #[serde(default)] 354 + code: Option<i32>, 355 + #[serde(default)] 356 + message: Option<String>, 357 + } 358 + 359 + fn check_status(status: &str, error: Option<&SubsonicError>) -> Result<()> { 360 + if status == "ok" { 361 + return Ok(()); 362 + } 363 + let (code, msg) = error 364 + .map(|e| (e.code.unwrap_or(-1), e.message.as_deref().unwrap_or("unknown"))) 365 + .unwrap_or((-1, "unknown")); 366 + Err(anyhow!("subsonic error {}: {}", code, msg)) 367 + } 368 + 369 + // Every response is `{"subsonic-response": {"status": ..., "error"?: ..., 370 + // <payload fields>}}`. Rather than macro-generate identically-named inner 371 + // types, spell each shape out — the extra lines pay off in readable 372 + // compiler errors. 373 + 374 + #[derive(Deserialize)] 375 + struct PingResp { 376 + #[serde(rename = "subsonic-response")] 377 + r: PingInner, 378 + } 379 + #[derive(Deserialize)] 380 + struct PingInner { 381 + #[serde(default = "default_status")] 382 + status: String, 383 + #[serde(default)] 384 + error: Option<SubsonicError>, 385 + } 386 + impl PingResp { 387 + fn check(&self) -> Result<()> { 388 + check_status(&self.r.status, self.r.error.as_ref()) 389 + } 390 + } 391 + 392 + #[derive(Deserialize)] 393 + struct AlbumListResp { 394 + #[serde(rename = "subsonic-response")] 395 + r: AlbumListInner, 396 + } 397 + #[derive(Deserialize)] 398 + #[serde(rename_all = "camelCase")] 399 + struct AlbumListInner { 400 + #[serde(default = "default_status")] 401 + status: String, 402 + #[serde(default)] 403 + error: Option<SubsonicError>, 404 + #[serde(default)] 405 + album_list2: Option<AlbumList2>, 406 + } 407 + #[derive(Deserialize, Default)] 408 + struct AlbumList2 { 409 + #[serde(default)] 410 + album: Vec<Album>, 411 + } 412 + impl AlbumListResp { 413 + fn check(&self) -> Result<()> { 414 + check_status(&self.r.status, self.r.error.as_ref()) 415 + } 416 + fn album_list2(self) -> Option<AlbumList2> { 417 + self.r.album_list2 418 + } 419 + } 420 + 421 + #[derive(Deserialize)] 422 + struct AlbumResp { 423 + #[serde(rename = "subsonic-response")] 424 + r: AlbumInner, 425 + } 426 + #[derive(Deserialize)] 427 + struct AlbumInner { 428 + #[serde(default = "default_status")] 429 + status: String, 430 + #[serde(default)] 431 + error: Option<SubsonicError>, 432 + #[serde(default)] 433 + album: Option<AlbumWithSongs>, 434 + } 435 + #[derive(Deserialize, Default)] 436 + struct AlbumWithSongs { 437 + #[serde(default)] 438 + song: Vec<Song>, 439 + } 440 + impl AlbumResp { 441 + fn check(&self) -> Result<()> { 442 + check_status(&self.r.status, self.r.error.as_ref()) 443 + } 444 + fn album(self) -> Option<AlbumWithSongs> { 445 + self.r.album 446 + } 447 + } 448 + 449 + #[derive(Deserialize)] 450 + struct SearchResp { 451 + #[serde(rename = "subsonic-response")] 452 + r: SearchInner, 453 + } 454 + #[derive(Deserialize)] 455 + #[serde(rename_all = "camelCase")] 456 + struct SearchInner { 457 + #[serde(default = "default_status")] 458 + status: String, 459 + #[serde(default)] 460 + error: Option<SubsonicError>, 461 + #[serde(default)] 462 + search_result3: Option<SearchResult3>, 463 + } 464 + #[derive(Deserialize, Default)] 465 + struct SearchResult3 { 466 + #[serde(default)] 467 + album: Option<Vec<Album>>, 468 + #[serde(default)] 469 + song: Option<Vec<Song>>, 470 + } 471 + impl SearchResp { 472 + fn check(&self) -> Result<()> { 473 + check_status(&self.r.status, self.r.error.as_ref()) 474 + } 475 + fn search_result3(self) -> Option<SearchResult3> { 476 + self.r.search_result3 477 + } 478 + } 479 + 480 + #[derive(Deserialize)] 481 + struct PlaylistsResp { 482 + #[serde(rename = "subsonic-response")] 483 + r: PlaylistsInner, 484 + } 485 + #[derive(Deserialize)] 486 + struct PlaylistsInner { 487 + #[serde(default = "default_status")] 488 + status: String, 489 + #[serde(default)] 490 + error: Option<SubsonicError>, 491 + #[serde(default)] 492 + playlists: Option<Playlists>, 493 + } 494 + #[derive(Deserialize, Default)] 495 + struct Playlists { 496 + #[serde(default)] 497 + playlist: Vec<Playlist>, 498 + } 499 + impl PlaylistsResp { 500 + fn check(&self) -> Result<()> { 501 + check_status(&self.r.status, self.r.error.as_ref()) 502 + } 503 + fn playlists(self) -> Option<Playlists> { 504 + self.r.playlists 505 + } 506 + } 507 + 508 + #[derive(Deserialize)] 509 + struct PlaylistResp { 510 + #[serde(rename = "subsonic-response")] 511 + r: PlaylistInner, 512 + } 513 + #[derive(Deserialize)] 514 + struct PlaylistInner { 515 + #[serde(default = "default_status")] 516 + status: String, 517 + #[serde(default)] 518 + error: Option<SubsonicError>, 519 + #[serde(default)] 520 + playlist: Option<PlaylistWithEntries>, 521 + } 522 + #[derive(Deserialize, Default)] 523 + struct PlaylistWithEntries { 524 + #[serde(default)] 525 + entry: Option<Vec<Song>>, 526 + } 527 + impl PlaylistResp { 528 + fn check(&self) -> Result<()> { 529 + check_status(&self.r.status, self.r.error.as_ref()) 530 + } 531 + fn playlist(self) -> Option<PlaylistWithEntries> { 532 + self.r.playlist 533 + } 534 + } 535 + 536 + #[derive(Deserialize, Clone, Default)] 537 + #[serde(rename_all = "camelCase")] 538 + struct Album { 539 + id: String, 540 + name: String, 541 + #[serde(default)] 542 + artist: Option<String>, 543 + #[serde(default)] 544 + year: Option<i32>, 545 + #[serde(default)] 546 + duration: Option<u64>, 547 + #[serde(default)] 548 + song_count: Option<u32>, 549 + #[serde(default)] 550 + cover_art: Option<String>, 551 + } 552 + 553 + #[derive(Deserialize, Default)] 554 + #[serde(rename_all = "camelCase")] 555 + struct Song { 556 + id: String, 557 + title: String, 558 + #[serde(default)] 559 + album: Option<String>, 560 + #[serde(default)] 561 + album_id: Option<String>, 562 + #[serde(default)] 563 + artist: Option<String>, 564 + #[serde(default)] 565 + track: Option<i32>, 566 + #[serde(default)] 567 + disc_number: Option<i32>, 568 + #[serde(default)] 569 + year: Option<i32>, 570 + #[serde(default)] 571 + duration: Option<u64>, 572 + } 573 + 574 + #[derive(Deserialize, Default)] 575 + #[serde(rename_all = "camelCase")] 576 + struct Playlist { 577 + id: String, 578 + name: String, 579 + #[serde(default)] 580 + song_count: Option<u32>, 581 + #[serde(default)] 582 + duration: Option<u64>, 583 + } 584 + 585 + fn album_to_base_item(a: Album) -> BaseItem { 586 + BaseItem { 587 + id: a.id, 588 + name: a.name, 589 + type_: "MusicAlbum".into(), 590 + album: None, 591 + album_id: None, 592 + album_artist: a.artist, 593 + artists: None, 594 + series_name: None, 595 + production_year: a.year, 596 + run_time_ticks: a.duration.map(|s| (s as i64) * 10_000_000), 597 + media_type: None, 598 + container: None, 599 + index_number: a.song_count.map(|n| n as i32), 600 + parent_index_number: None, 601 + image_tags: a.cover_art.map(|c| serde_json::json!({ "Primary": c })), 602 + is_folder: Some(true), 603 + overview: None, 604 + } 605 + } 606 + 607 + fn song_to_base_item(s: Song) -> BaseItem { 608 + BaseItem { 609 + id: s.id, 610 + name: s.title, 611 + type_: "Audio".into(), 612 + album: s.album, 613 + album_id: s.album_id, 614 + album_artist: None, 615 + artists: s.artist.map(|a| vec![a]), 616 + series_name: None, 617 + production_year: s.year, 618 + run_time_ticks: s.duration.map(|d| (d as i64) * 10_000_000), 619 + media_type: Some("Audio".into()), 620 + container: None, 621 + index_number: s.track, 622 + parent_index_number: s.disc_number, 623 + image_tags: None, 624 + is_folder: Some(false), 625 + overview: None, 626 + } 627 + } 628 + 629 + fn playlist_to_base_item(p: Playlist) -> BaseItem { 630 + BaseItem { 631 + id: p.id, 632 + name: p.name, 633 + type_: "Playlist".into(), 634 + album: None, 635 + album_id: None, 636 + album_artist: None, 637 + artists: None, 638 + series_name: None, 639 + production_year: None, 640 + run_time_ticks: p.duration.map(|d| (d as i64) * 10_000_000), 641 + media_type: None, 642 + container: None, 643 + index_number: p.song_count.map(|n| n as i32), 644 + parent_index_number: None, 645 + image_tags: None, 646 + is_folder: Some(true), 647 + overview: None, 648 + } 649 + } 650 + 651 + #[cfg(test)] 652 + mod tests { 653 + use super::*; 654 + 655 + #[test] 656 + fn md5_hex_matches_reference_vectors() { 657 + // Canonical MD5 test vectors from RFC 1321. 658 + assert_eq!(md5_hex(""), "d41d8cd98f00b204e9800998ecf8427e"); 659 + assert_eq!(md5_hex("abc"), "900150983cd24fb0d6963f7d28e17f72"); 660 + } 661 + 662 + #[test] 663 + fn random_salt_is_eight_alnum_chars() { 664 + let s = random_salt(); 665 + assert_eq!(s.len(), 8); 666 + for c in s.chars() { 667 + assert!(c.is_ascii_lowercase() || c.is_ascii_digit(), "bad char {c}"); 668 + } 669 + } 670 + 671 + #[test] 672 + fn album_maps_to_music_album_base_item() { 673 + let a = Album { 674 + id: "a1".into(), 675 + name: "Kind of Blue".into(), 676 + artist: Some("Miles Davis".into()), 677 + year: Some(1959), 678 + duration: Some(2712), 679 + song_count: Some(5), 680 + cover_art: Some("cov-a1".into()), 681 + }; 682 + let bi = album_to_base_item(a); 683 + assert_eq!(bi.id, "a1"); 684 + assert_eq!(bi.type_, "MusicAlbum"); 685 + assert_eq!(bi.production_year, Some(1959)); 686 + assert_eq!(bi.album_artist.as_deref(), Some("Miles Davis")); 687 + // 2712 seconds → run_time_ticks in Jellyfin's 100-ns unit. 688 + assert_eq!(bi.run_time_ticks, Some(27_120_000_000)); 689 + } 690 + 691 + #[test] 692 + fn song_maps_disc_and_track_number() { 693 + let s = Song { 694 + id: "s1".into(), 695 + title: "So What".into(), 696 + album: Some("Kind of Blue".into()), 697 + album_id: Some("a1".into()), 698 + artist: Some("Miles Davis".into()), 699 + track: Some(1), 700 + disc_number: Some(2), 701 + year: Some(1959), 702 + duration: Some(556), 703 + }; 704 + let bi = song_to_base_item(s); 705 + assert_eq!(bi.type_, "Audio"); 706 + assert_eq!(bi.index_number, Some(1)); 707 + assert_eq!(bi.parent_index_number, Some(2)); 708 + assert_eq!(bi.album.as_deref(), Some("Kind of Blue")); 709 + } 710 + 711 + #[test] 712 + fn deserialize_ok_ping_response() { 713 + let raw = r#"{"subsonic-response": {"status": "ok", "version": "1.16.1"}}"#; 714 + let r: PingResp = serde_json::from_str(raw).unwrap(); 715 + r.check().unwrap(); 716 + } 717 + 718 + #[test] 719 + fn deserialize_error_response_surfaces_code_and_message() { 720 + let raw = r#"{"subsonic-response": {"status": "failed", "error": {"code": 40, "message": "Wrong username or password"}}}"#; 721 + let r: PingResp = serde_json::from_str(raw).unwrap(); 722 + let err = r.check().unwrap_err().to_string(); 723 + assert!(err.contains("40")); 724 + assert!(err.contains("Wrong username")); 725 + } 726 + }
+2
crates/fin-tui/Cargo.toml
··· 20 20 unicode-width.workspace = true 21 21 parking_lot.workspace = true 22 22 chrono.workspace = true 23 + uuid.workspace = true 23 24 fin-config.workspace = true 24 25 fin-jellyfin.workspace = true 26 + fin-media.workspace = true 25 27 fin-player.workspace = true
+189 -6
crates/fin-tui/src/app.rs
··· 21 21 22 22 use fin_config::{Config, RendererPref}; 23 23 use fin_jellyfin::{BaseItem, ItemKind, JellyfinClient, StreamFormat}; 24 + use fin_media::MediaClient; 24 25 use fin_player::{ 25 26 discover_chromecasts, discover_upnp_renderers, CastDevice, ChromecastRenderer, LocalRenderer, 26 27 PlaybackState, QueueItem, Renderer, RendererKind, UpnpDevice, UpnpRenderer, ··· 77 78 /// Everything the render loop needs. 78 79 pub struct App { 79 80 pub config: Arc<Mutex<Config>>, 80 - pub jellyfin: Arc<Mutex<Arc<JellyfinClient>>>, 81 + pub jellyfin: Arc<Mutex<Arc<dyn MediaClient>>>, 81 82 pub renderer: Arc<Mutex<Arc<dyn Renderer>>>, 82 83 pub renderer_kind: Arc<Mutex<RendererKind>>, 83 84 pub renderer_label: Arc<Mutex<String>>, ··· 107 108 /// Toggled by `?`. When true, every other key is swallowed by the modal 108 109 /// and the underlying UI is dimmed by the popup's own background fill. 109 110 help_open: bool, 111 + /// Scrobble bookkeeping — the item id we last told the server about, 112 + /// plus the moment we sent the last progress ping so we can throttle. 113 + scrobble_reported_id: Option<String>, 114 + scrobble_last_progress: Instant, 115 + scrobble_session_id: String, 110 116 should_quit: bool, 111 117 logo_pulse: u8, 112 118 } 113 119 114 120 impl App { 115 - pub fn new(config: Config, jellyfin: JellyfinClient, renderer: Arc<dyn Renderer>) -> Self { 121 + pub fn new( 122 + config: Config, 123 + jellyfin: Arc<dyn MediaClient>, 124 + renderer: Arc<dyn Renderer>, 125 + ) -> Self { 116 126 let kind = renderer.kind(); 117 127 let label = match kind { 118 128 RendererKind::Mpv => "this machine".into(), ··· 123 133 list_state.select(Some(0)); 124 134 Self { 125 135 config: Arc::new(Mutex::new(config)), 126 - jellyfin: Arc::new(Mutex::new(Arc::new(jellyfin))), 136 + jellyfin: Arc::new(Mutex::new(jellyfin)), 127 137 renderer: Arc::new(Mutex::new(renderer)), 128 138 renderer_kind: Arc::new(Mutex::new(kind)), 129 139 renderer_label: Arc::new(Mutex::new(label)), ··· 147 157 playback_state: Arc::new(Mutex::new(PlaybackState::default())), 148 158 eq_selected_band: 0, 149 159 help_open: false, 160 + scrobble_reported_id: None, 161 + scrobble_last_progress: Instant::now(), 162 + // One session id per fin process — Jellyfin uses it to correlate 163 + // Start / Progress / Stopped events, Subsonic ignores it. 164 + scrobble_session_id: uuid::Uuid::new_v4().to_string(), 150 165 should_quit: false, 151 166 logo_pulse: 0, 152 167 } ··· 154 169 155 170 /// Handy accessor — the current Jellyfin client. Swapped out atomically 156 171 /// when the user switches servers. 157 - fn jf(&self) -> Arc<JellyfinClient> { 172 + fn jf(&self) -> Arc<dyn MediaClient> { 158 173 self.jellyfin.lock().clone() 159 174 } 160 175 ··· 757 772 let Some(server) = server else { 758 773 return Err(anyhow!("no active server after switch")); 759 774 }; 760 - let client = JellyfinClient::with_credentials( 775 + let client = fin_media::client_from_stored( 776 + server.server_kind, 761 777 &server.url, 762 778 &server.device_id, 763 779 &server.user_id, 780 + &server.user_name, 764 781 &server.access_token, 765 782 )?; 766 - *self.jellyfin.lock() = Arc::new(client); 783 + *self.jellyfin.lock() = client; 767 784 // Clear cached content — belongs to the *previous* server. 768 785 self.music_items.lock().clear(); 769 786 self.video_items.lock().clear(); ··· 853 870 // Refresh live playback state each tick. 854 871 *app.playback_state.lock() = app.renderer.lock().state(); 855 872 873 + emit_scrobble_events(app).await; 874 + 856 875 terminal.draw(|f| draw(f, app))?; 857 876 858 877 if let Some(ev) = events.recv().await { ··· 868 887 } 869 888 } 870 889 Ok(()) 890 + } 891 + 892 + /// Emit Jellyfin session events / Subsonic scrobbles for track transitions. 893 + /// Detects three edges from `playback_state`: 894 + /// - `now_playing` changed to a new item → `report_stopped` on the old one, 895 + /// `report_started` on the new one. 896 + /// - Same item still playing → send `report_progress` every ~10 s (Jellyfin; 897 + /// Subsonic's default impl is a no-op). 898 + /// - Track dropped to None → `report_stopped` on whatever was reported. 899 + /// 900 + /// Every network call is a best-effort spawn: failure is logged and the 901 + /// TUI keeps drawing. Reports only fire for audio items — Chromecast and 902 + /// UPnP receivers already send their own session events server-side. 903 + async fn emit_scrobble_events(app: &mut App) { 904 + let state = app.playback_state.lock().clone(); 905 + let session_id = app.scrobble_session_id.clone(); 906 + let client = app.jellyfin.lock().clone(); 907 + let renderer_kind = *app.renderer_kind.lock(); 908 + // Only report for local audio playback — remote renderers manage their 909 + // own reporting on the server side. 910 + if !matches!(renderer_kind, fin_player::RendererKind::Mpv) { 911 + return; 912 + } 913 + 914 + let now_playing_id = state 915 + .now_playing 916 + .as_ref() 917 + .filter(|it| !it.is_video) 918 + .map(|it| it.id.clone()); 919 + 920 + match (&app.scrobble_reported_id, &now_playing_id) { 921 + (Some(prev), Some(cur)) if prev == cur => { 922 + // Same track — throttled progress ping. 923 + if state.status == fin_player::PlaybackStatus::Playing 924 + && app.scrobble_last_progress.elapsed() >= Duration::from_secs(10) 925 + { 926 + if let Some(item) = state.now_playing.as_ref() { 927 + let base_item = queue_item_to_base_item(item); 928 + let client = client.clone(); 929 + let session_id = session_id.clone(); 930 + let pos = state.position_secs.max(0.0) as u64; 931 + tokio::spawn(async move { 932 + if let Err(e) = client 933 + .report_progress(&base_item, pos, false, &session_id) 934 + .await 935 + { 936 + warn!(?e, "scrobble progress failed"); 937 + } 938 + }); 939 + app.scrobble_last_progress = Instant::now(); 940 + } 941 + } 942 + } 943 + (prev_opt, Some(cur)) => { 944 + // Track transition: stop the previous, start the new. 945 + if let Some(prev_id) = prev_opt.clone() { 946 + let client_c = client.clone(); 947 + let session_id_c = session_id.clone(); 948 + let stopped_pos = state.position_secs.max(0.0) as u64; 949 + let stub = BaseItem { 950 + id: prev_id.clone(), 951 + name: prev_id, 952 + type_: "Audio".into(), 953 + album: None, 954 + album_id: None, 955 + album_artist: None, 956 + artists: None, 957 + series_name: None, 958 + production_year: None, 959 + run_time_ticks: None, 960 + media_type: Some("Audio".into()), 961 + container: None, 962 + index_number: None, 963 + parent_index_number: None, 964 + image_tags: None, 965 + is_folder: Some(false), 966 + overview: None, 967 + }; 968 + tokio::spawn(async move { 969 + if let Err(e) = client_c 970 + .report_stopped(&stub, stopped_pos, &session_id_c) 971 + .await 972 + { 973 + warn!(?e, "scrobble stopped failed"); 974 + } 975 + }); 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(); 988 + } 989 + (Some(prev_id), None) => { 990 + // Playback ended — fire the closing report. 991 + let prev_id = prev_id.clone(); 992 + let client_c = client.clone(); 993 + let session_id_c = session_id.clone(); 994 + let stopped_pos = state.position_secs.max(0.0) as u64; 995 + let stub = BaseItem { 996 + id: prev_id.clone(), 997 + name: prev_id, 998 + type_: "Audio".into(), 999 + album: None, 1000 + album_id: None, 1001 + album_artist: None, 1002 + artists: None, 1003 + series_name: None, 1004 + production_year: None, 1005 + run_time_ticks: None, 1006 + media_type: Some("Audio".into()), 1007 + container: None, 1008 + index_number: None, 1009 + parent_index_number: None, 1010 + image_tags: None, 1011 + is_folder: Some(false), 1012 + overview: None, 1013 + }; 1014 + tokio::spawn(async move { 1015 + if let Err(e) = client_c 1016 + .report_stopped(&stub, stopped_pos, &session_id_c) 1017 + .await 1018 + { 1019 + warn!(?e, "scrobble stopped failed"); 1020 + } 1021 + }); 1022 + app.scrobble_reported_id = None; 1023 + } 1024 + (None, None) => {} 1025 + } 1026 + } 1027 + 1028 + /// Adapt a `QueueItem` (what the renderer holds) into a `BaseItem` for the 1029 + /// scrobble APIs. The id is all any server actually needs to correlate. 1030 + fn queue_item_to_base_item(q: &fin_player::QueueItem) -> BaseItem { 1031 + BaseItem { 1032 + id: q.id.clone(), 1033 + name: q.title.clone(), 1034 + type_: if q.is_video { "Video".into() } else { "Audio".into() }, 1035 + album: None, 1036 + album_id: None, 1037 + album_artist: None, 1038 + artists: if q.subtitle.is_empty() { 1039 + None 1040 + } else { 1041 + Some(vec![q.subtitle.clone()]) 1042 + }, 1043 + series_name: None, 1044 + production_year: None, 1045 + run_time_ticks: q.duration_secs.map(|s| (s * 10_000_000) as i64), 1046 + media_type: Some(if q.is_video { "Video".into() } else { "Audio".into() }), 1047 + container: None, 1048 + index_number: None, 1049 + parent_index_number: None, 1050 + image_tags: None, 1051 + is_folder: Some(false), 1052 + overview: None, 1053 + } 871 1054 } 872 1055 873 1056 async fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> {
+1
crates/fin/Cargo.toml
··· 25 25 rustls.workspace = true 26 26 fin-config.workspace = true 27 27 fin-jellyfin.workspace = true 28 + fin-media.workspace = true 28 29 fin-player.workspace = true 29 30 fin-tui.workspace = true
+20 -10
crates/fin/src/main.rs
··· 127 127 .clone() 128 128 .or_else(|| existing.as_ref().map(|s| s.device_id.clone())) 129 129 .unwrap_or_default(), 130 + server_kind: existing 131 + .as_ref() 132 + .map(|s| s.server_kind) 133 + .unwrap_or_default(), 130 134 }; 131 135 cfg.add_or_update_server(merged); 132 136 } else { ··· 169 173 Ok(cfg) 170 174 } 171 175 172 - fn make_client(cfg: &Config) -> Result<JellyfinClient> { 176 + fn make_client(cfg: &Config) -> Result<Arc<dyn fin_media::MediaClient>> { 173 177 let server = cfg.require_current()?; 174 - JellyfinClient::with_credentials( 178 + fin_media::client_from_stored( 179 + server.server_kind, 175 180 &server.url, 176 181 &server.device_id, 177 182 &server.user_id, 183 + &server.user_name, 178 184 &server.access_token, 179 185 ) 180 186 } ··· 295 301 Some(p) => p, 296 302 None => prompt_password("Password: ")?, 297 303 }; 298 - let mut client = JellyfinClient::new(&url)?; 299 - let auth = client.login(&username, &password).await?; 304 + // Auto-detect Jellyfin vs Subsonic — `login_any` probes both and 305 + // picks whichever answers first. 306 + let logged_in = fin_media::login_any(&url, &username, &password).await?; 300 307 let server = ServerConfig { 301 308 name: server_name.clone(), 302 309 url, 303 - user_id: auth.user.id.clone(), 304 - user_name: auth.user.name.clone(), 305 - access_token: auth.access_token.clone(), 306 - device_id: client.device_id().to_string(), 310 + user_id: logged_in.auth.user.id.clone(), 311 + user_name: logged_in.auth.user.name.clone(), 312 + access_token: logged_in.auth.access_token.clone(), 313 + device_id: logged_in.device_id.clone(), 314 + server_kind: logged_in.kind, 307 315 }; 308 316 config.add_or_update_server(server); 309 317 config.save()?; 310 318 println!( 311 - "✓ signed in as {} on `{}` — now the active server", 312 - auth.user.name, server_name 319 + "✓ signed in as {} on `{}` ({}) — now the active server", 320 + logged_in.auth.user.name, 321 + server_name, 322 + logged_in.kind.label() 313 323 ); 314 324 if config.servers.len() > 1 { 315 325 println!(