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.

Show server backend (Jellyfin/Subsonic) in header and Settings list

The header now carries a "◈ Jellyfin" / "≋ Subsonic" badge for the
active server, and each row in the Settings server list shows its
backend between the name and URL — so it's obvious at a glance which
kind the current/other servers speak.

Also adds a getStarred2 deserialization regression test covering the
real-world response shape (status key after the payload, starred
artists present but dropped).

+52 -1
+14
crates/fin-subsonic/src/lib.rs
··· 790 790 } 791 791 792 792 #[test] 793 + fn deserialize_starred2_maps_albums_and_songs() { 794 + // Real-world shape: `getStarred2` wraps its payload in 795 + // `subsonic-response`, with `status` appearing *after* the payload 796 + // (key order is server-defined). Starred artists are present but 797 + // deliberately dropped by `starred()`. 798 + let raw = r#"{"subsonic-response":{"starred2":{"album":[],"artist":[{"id":"ar-1","name":"070 Shake"}],"song":[{"id":"so-1","title":"Catch 1","album":"X","albumId":"al-1","artist":"42 Dugg","track":11,"discNumber":1,"duration":169,"year":2024},{"id":"so-2","title":"Megan","artist":"42 Dugg","duration":155}]},"status":"ok","type":"smolsonic","version":"1.16.1"}}"#; 799 + let r: StarredResp = serde_json::from_str(raw).expect("deserialize starred"); 800 + r.check().expect("check ok"); 801 + let s = r.starred2().expect("has starred2"); 802 + assert_eq!(s.album.unwrap_or_default().len(), 0); 803 + assert_eq!(s.song.unwrap_or_default().len(), 2); 804 + } 805 + 806 + #[test] 793 807 fn deserialize_error_response_surfaces_code_and_message() { 794 808 let raw = r#"{"subsonic-response": {"status": "failed", "error": {"code": 40, "message": "Wrong username or password"}}}"#; 795 809 let r: PingResp = serde_json::from_str(raw).unwrap();
+38 -1
crates/fin-tui/src/app.rs
··· 1591 1591 } 1592 1592 } 1593 1593 1594 + /// Human-facing name for a server backend. `ServerKind::label()` returns the 1595 + /// lowercase wire form (`"jellyfin"`); this is the title-cased UI variant. 1596 + fn server_kind_label(kind: fin_config::ServerKind) -> &'static str { 1597 + match kind { 1598 + fin_config::ServerKind::Jellyfin => "Jellyfin", 1599 + fin_config::ServerKind::Subsonic => "Subsonic", 1600 + } 1601 + } 1602 + 1603 + /// A small glyph that distinguishes the two backends at a glance. 1604 + fn server_kind_icon(kind: fin_config::ServerKind) -> &'static str { 1605 + match kind { 1606 + fin_config::ServerKind::Jellyfin => "◈", 1607 + fin_config::ServerKind::Subsonic => "≋", 1608 + } 1609 + } 1610 + 1594 1611 fn draw_header(f: &mut Frame<'_>, area: Rect, app: &App) { 1595 1612 let pulse = ((app.logo_pulse as f32 * 0.15).sin() * 0.5 + 0.5) * 60.0 + 195.0; 1596 1613 let r = 0u8; 1597 1614 let g = pulse as u8; 1598 1615 let b = (pulse * 0.85) as u8; 1599 1616 let subtitle_col = ratatui::style::Color::Rgb(r, g, b); 1600 - let (server, user, server_name, servers_total) = { 1617 + let (server, user, server_name, server_kind, servers_total) = { 1601 1618 let cfg = app.config.lock(); 1602 1619 let cur = cfg.current(); 1603 1620 ( ··· 1606 1623 cur.map(|s| s.user_name.clone()) 1607 1624 .unwrap_or_else(|| "guest".into()), 1608 1625 cur.map(|s| s.name.clone()).unwrap_or_default(), 1626 + cur.map(|s| s.server_kind), 1609 1627 cfg.servers.len(), 1610 1628 ) 1611 1629 }; 1630 + let kind_badge = server_kind 1631 + .map(|k| format!(" {} {}", server_kind_icon(k), server_kind_label(k))) 1632 + .unwrap_or_default(); 1612 1633 let servers_badge = if servers_total > 1 { 1613 1634 format!(" ⇄ {} servers", servers_total) 1614 1635 } else { ··· 1639 1660 format!("[{}]", server_name) 1640 1661 }, 1641 1662 Style::default().fg(Palette::HIGHLIGHT), 1663 + ), 1664 + Span::styled( 1665 + kind_badge, 1666 + Style::default() 1667 + .fg(Palette::ACCENT) 1668 + .add_modifier(Modifier::BOLD), 1642 1669 ), 1643 1670 Span::styled(servers_badge, Style::default().fg(Palette::SKY)), 1644 1671 ]); ··· 2332 2359 .add_modifier(Modifier::BOLD), 2333 2360 ), 2334 2361 Span::styled(format!("{:<18}", s.name), name_style), 2362 + Span::styled( 2363 + format!( 2364 + "{} {:<9}", 2365 + server_kind_icon(s.server_kind), 2366 + server_kind_label(s.server_kind) 2367 + ), 2368 + Style::default() 2369 + .fg(Palette::ACCENT) 2370 + .add_modifier(Modifier::BOLD), 2371 + ), 2335 2372 Span::styled(format!(" {} ", s.url), Style::default().fg(Palette::SKY)), 2336 2373 Span::styled( 2337 2374 format!("as {}", s.user_name),