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).
Fix EQ+crossfade noise + expand unit tests
Two changes in one commit — they share the tree touched by the
recent EQ / tone / crossfade work.
DSP transients during overlap
- The previous shared-DSP approach routed both tracks through the same
`CODEC_IDX_AUDIO` config every tick. Rockbox biquad delay lines are
per-config, so alternating between the two tracks corrupted the state
and produced the "small noises" the user reported.
- Add a `VoiceDsp` wrapper around Rockbox's second config
(`CODEC_IDX_VOICE`). Coefficients (`dsp_set_eq_coefs`, `tone_*`) are
global — same EQ curve on both configs — but the delay lines are
independent, so the two tracks stop stepping on each other's state.
- A new `DspProcess` trait lets `decode_one_packet` and `apply_dsp`
accept either config without duplicating the packet loop.
- Both configs are flushed at crossfade preload start and at promotion
so stale delay-line state doesn't leak between tracks.
Unit tests (+52 across the workspace, 73→125)
- fin-player::queue: RepeatMode label/next cycle, advance()/back()
under Off/One/All, peek_next_item across all repeat modes, shuffle
preserves the current item, reshuffle_all pins current at index 0.
- fin-player::persist: PersistedQueue round-trip via write_atomically
+ load, malformed / missing file returns None, legacy JSON with
missing fields loads with #[serde(default)] filled in, no `.tmp`
sibling left behind.
- fin-config: RepeatMode / ReplayGainMode / CrossfadeMode label + cycle
+ is_active helpers; default_eq_band_settings has 10 monotonic ISO
bands; ensure_eq_bands fills empty and pads short lists, leaves full
lists alone.
- fin-tui::screens: Screen::ALL matches tab order, next/prev wrap and
are inverses; icon/label lookups; fmt_dur across the H:MM:SS
threshold; RowLayout sums to total width, hides subtitle on narrow
terminals, gives title ~55% of the middle; truncate honors width
budgets and produces bare "…" at width 1.