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).