a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv
chromecast
mpris
navidrome
jellyfin
upnp
tui
34 kB
731 lines
1# fin
2
3[](https://flakehub.com/flake/tsirysndr/fin)
4[](https://github.com/tsirysndr/fin/actions/workflows/release.yml)
5
6> a Jellyfin & Subsonic client for the terminal — powered by `rockbox-playback`, `mpv`, Chromecast, and UPnP
7
8
9
10`fin` is a Rust TUI + one-shot CLI that talks to your **Jellyfin** or
11**Subsonic** server (Navidrome, Airsonic, Gonic, Astiga, … — the flavour is
12auto-detected at login), searches your library, manages playlists, and pushes
13streams to your local machine
14(**rockbox-playback** for audio, **mpv** for video), any **Chromecast** on your
15network, or any **UPnP MediaRenderer** (Sonos, Kodi, Roon endpoints, Samsung/LG
16TVs, gmediarender, …). Local playback is now audio-native — HTTP streaming,
17decoding, resampling, and output all run in-process, and audio never touches
18mpv. Remote playback is fully queued, with client-side auto-advance.
19
20## Contents
21
22- [Features](#features)
23- [Install](#install)
24 - [npm — `npx @tsiry/fin`](#npm--npx-tsiryfin)
25 - [macOS / Linux — Homebrew](#macos--linux--homebrew)
26 - [Debian / Ubuntu — `.deb`](#debian--ubuntu--deb)
27 - [Fedora / RHEL / openSUSE — `.rpm`](#fedora--rhel--opensuse--rpm)
28 - [Arch — from AUR / source](#arch--from-aur--source)
29 - [Prebuilt tarballs](#prebuilt-tarballs)
30 - [From source](#from-source)
31 - [Nix](#nix)
32- [Getting started](#getting-started)
33- [Renderer selection](#renderer-selection)
34- [UPnP MediaRenderer — casting *to* fin](#upnp-mediarenderer--casting-to-fin)
35- [MPRIS — desktop media controls](#mpris--desktop-media-controls)
36- [All settings](#all-settings)
37- [Multiple servers](#multiple-servers)
38- [Sub-commands](#sub-commands)
39- [Keybindings (TUI)](#keybindings-tui)
40- [Playback modes & effects](#playback-modes--effects)
41 - [Shuffle](#shuffle)
42 - [Repeat](#repeat)
43 - [ReplayGain](#replaygain)
44 - [Crossfade](#crossfade)
45 - [Equalizer](#equalizer)
46 - [Bass & treble](#bass--treble)
47- [Queue persistence](#queue-persistence)
48- [Remote-renderer queue](#remote-renderer-queue)
49- [Supported audio formats](#supported-audio-formats)
50- [Streams & transcoding](#streams--transcoding)
51- [Development](#development)
52- [License](#license)
53
54## Features
55
56- **Ratatui-based TUI** with a neon-electric palette (teal / cyan / violet).
57- **In-process audio** — HTTP streaming + `rockbox-playback` decode (MP3,
58 FLAC, AAC, Opus, Vorbis, ALAC, WAV, and [many more](#supported-audio-formats))
59 + resampling + `cpal` output, using Rockbox's own codecs and DSP. mpv is
60 used only for video.
61- **fzf-style instant search** — results update on every keystroke.
62- **Drill-in navigation** — Enter on an album lists its tracks, Enter on a
63 series lists its episodes, Enter on a playlist lists its items. `x`
64 plays the whole container in one go, `Shift+X` does the same shuffled.
65- **No list truncation** — Music, Videos, and Playlists fetch every item
66 the server has, so nothing stays hidden past an arbitrary limit.
67- **Three renderers**, one interface:
68 - **local** (default) — rockbox-playback for audio, mpv for video, spawned
69 only when needed.
70 - **chromecast** — device discovery via mDNS, playback through the Default
71 Media Receiver, with a **local queue** that auto-advances on `FINISHED`.
72 - **upnp** — SSDP discovery of any UPnP AV MediaRenderer, playback via
73 AVTransport (`SetAVTransportURI` / `Play` / `Pause` / `Stop` / `Seek`)
74 and volume via RenderingControl. Same auto-advancing queue.
75- **Cast *to* fin** — while the TUI runs, fin advertises itself as a
76 **UPnP MediaRenderer** on the LAN. Push streams at it from BubbleUPnP,
77 Kodi, Jellyfin's "Play On", or any other control point: audio decodes
78 in-process via rockbox-playback, video opens in mpv, and the pushed track lands
79 in the Now Playing bar with a `⇊ UPnP` badge. On by default; opt out with
80 `--no-media-renderer` or `media_renderer.enabled = false`
81 (see [UPnP MediaRenderer](#upnp-mediarenderer--casting-to-fin)).
82- **MPRIS** (Linux & the BSDs) — while the TUI runs, fin registers on the
83 D-Bus session bus as `org.mpris.MediaPlayer2.fin`, so media keys,
84 GNOME/KDE applets, waybar, and `playerctl` control playback — whichever
85 renderer is active, Chromecast and UPnP included
86 (see [MPRIS](#mpris--desktop-media-controls)).
87- **Playback modes** — shuffle, repeat-off/all/one, [ReplayGain](#replaygain)
88 (track / album), [crossfade](#crossfade) between adjacent tracks
89 (traditional cosine curves *or* additive DJ-mixed), and a
90 10-band [equalizer](#equalizer) powered by the Rockbox DSP pipeline.
91- **Queue persistence** — the audio queue, shuffle/repeat state, and the exact
92 playhead within the current track survive restarts. Restore lands paused;
93 `Space` picks up where you left off.
94- **Real queue management** — enqueue, play next, jump between tracks, remove
95 a single entry, clear the whole queue, and see the queue in its own tab
96 with a `▶` marker on the actually-playing track.
97- **Playlists** — browse, open, and play the playlists you've saved on the
98 server.
99- **Now Playing bar** with title, subtitle, elapsed / total time, a neon
100 progress gauge, volume, and mode badges (shuffle ⇄, repeat ↻/↺,
101 ReplayGain, crossfade ⋈/≈).
102- **CLI shortcuts** for scripting: `fin play "kind of blue"`,
103 `fin queue --chromecast "Living Room" "wednesday"`,
104 `fin play --upnp "Kitchen Speaker" "solaris"`, `fin devices`.
105- **All settings** are available as **CLI flags _or_ TOML keys** — one
106 workflow scales from ad-hoc invocation to per-machine config.
107- **Pure Rust TLS** (`rustls`) everywhere — no OpenSSL required.
108
109## Install
110
111Local audio needs no extra binaries — everything is baked into the `fin`
112binary. **`mpv`** is only needed on `$PATH` when you actually play video
113locally. Every install path below either bundles it or pulls it in as a
114soft dependency.
115
116### npm — `npx @tsiry/fin`
117
118```bash
119# Run without installing
120npx @tsiry/fin
121
122# Or install globally
123npm install -g @tsiry/fin
124```
125
126Works on macOS / Linux / FreeBSD / NetBSD / OpenBSD (x64 and arm64; OpenBSD is
127x64 only). The prebuilt binary matching your platform is downloaded from the
128GitHub release and SHA-256 verified on install. Install **`mpv`** separately if
129you want local video playback.
130
131### macOS / Linux — Homebrew
132
133```bash
134brew install tsirysndr/tap/fin
135```
136
137The formula pulls in `mpv` automatically.
138
139### Debian / Ubuntu — `.deb`
140
141Download the `.deb` for your architecture from the
142[latest release](https://github.com/tsirysndr/fin/releases/latest) and:
143
144```bash
145# amd64
146curl -LO https://github.com/tsirysndr/fin/releases/latest/download/fin_0.6.0_amd64.deb
147sudo apt install ./fin_0.6.0_amd64.deb
148
149# arm64 (Raspberry Pi 4/5, Apple-silicon VM, …)
150curl -LO https://github.com/tsirysndr/fin/releases/latest/download/fin_0.6.0_arm64.deb
151sudo apt install ./fin_0.6.0_arm64.deb
152```
153
154`apt` will pull in `libasound2` (ALSA runtime for cpal) and `mpv` automatically.
155
156Or add the Gemfury apt repo once and `apt install` normally:
157
158```bash
159echo "deb [trusted=yes] https://apt.fury.io/tsiry/ /" \
160 | sudo tee /etc/apt/sources.list.d/tsiry.list
161sudo apt update && sudo apt install fin
162```
163
164### Fedora / RHEL / openSUSE — `.rpm`
165
166```bash
167sudo dnf install \
168 https://github.com/tsirysndr/fin/releases/latest/download/fin-0.6.0-1.x86_64.rpm
169```
170
171Or via the Gemfury yum repo:
172
173```bash
174sudo tee /etc/yum.repos.d/tsiry.repo <<'EOF'
175[tsiry]
176name=tsiry
177baseurl=https://yum.fury.io/tsiry/
178enabled=1
179gpgcheck=0
180EOF
181sudo dnf install fin
182```
183
184### Arch — from AUR / source
185
186`mpv` from the official repos, then:
187
188```bash
189sudo pacman -S mpv alsa-lib
190cargo install --git https://github.com/tsirysndr/fin --bin fin
191```
192
193### Prebuilt tarballs
194
195For any other platform, grab the tarball for your arch from the
196[releases page](https://github.com/tsirysndr/fin/releases/latest):
197
198- `fin-<version>-linux-amd64.tar.gz`
199- `fin-<version>-linux-aarch64.tar.gz`
200- `fin-<version>-macos-amd64.tar.gz`
201- `fin-<version>-macos-aarch64.tar.gz`
202
203Each includes the `fin` binary + README + LICENSE. Install runtime deps
204yourself:
205
206```bash
207# macOS
208brew install mpv # video only; audio is in-process
209# Debian / Ubuntu
210sudo apt install libasound2 mpv
211# Arch
212sudo pacman -S alsa-lib mpv
213```
214
215### From source
216
217```bash
218git clone https://github.com/tsirysndr/fin
219cd fin
220cargo install --path crates/fin
221```
222
223Build-time on Linux needs `libasound2-dev` + `pkg-config` (cpal's ALSA
224backend); on macOS the Core Audio SDK is already in the toolchain.
225
226### Nix
227
228A flake is provided — mpv is baked into the wrapper, so no extra install
229step is needed:
230
231```bash
232# Use Cache
233cachix use tsirysndr
234
235# One-off run:
236nix run github:tsirysndr/fin
237
238# Install into your user profile:
239nix profile install github:tsirysndr/fin
240
241# Dev shell (rust toolchain + mpv + alsa-lib + clippy + rust-analyzer):
242nix develop
243```
244
245## Getting started
246
247```bash
248# 1. Sign in
249fin login https://media.example.com
250
251# 2. Launch the TUI (default sub-command)
252fin
253
254# 3. Or drive it entirely from the shell
255fin search "daft punk"
256fin play "kind of blue"
257fin queue "wednesday season 1"
258fin devices # list Chromecasts + UPnP renderers on your LAN
259fin play --chromecast "Living Room" "solaris"
260fin play --upnp "Kitchen" "solaris"
261```
262
263## Renderer selection
264
265Three ways to choose a renderer — all equivalent:
266
267| Shortcut flag | Long flag | Config key |
268|----------------------------------------|---------------------------|--------------------------------|
269| `--mpv` | `--renderer mpv` | `renderer = "mpv"` |
270| `--chromecast "Living Room"` | `--renderer chromecast` | `renderer = "chromecast"` |
271| `--upnp "Kitchen Speaker"` | `--renderer upnp` | `renderer = "upnp"` |
272| _(none — falls back to local)_ | | |
273
274The `--mpv` / `renderer = "mpv"` flag name is historical; it selects the
275**local** renderer, which uses rockbox-playback for audio and mpv for video.
276
277When you pass `--chromecast NAME` or `--upnp NAME`, the renderer is switched
278to that protocol automatically and the named device is preferred on connect.
279If the name is not found on the network, fin picks the first device discovered.
280
281## UPnP MediaRenderer — casting *to* fin
282
283The renderer table above is about fin *sending* streams elsewhere. This is
284the opposite direction: while the TUI runs, fin also shows up on the LAN as
285a **UPnP AV MediaRenderer** device, so any control point — BubbleUPnP,
286Kodi, Jellyfin's "Play On", gupnp tools, another fin casting with
287`--upnp`, … — can push media at this machine.
288
289Incoming streams take the same local path as everything else: **audio is
290decoded in-process by rockbox-playback** (never mpv), **video is handed to mpv**.
291The pushed track appears in the Now Playing bar with a violet `⇊ UPnP`
292badge and a one-shot status line (`⇊ receiving UPnP cast — <title>`), joins
293the queue like any other item, and answers the normal transport keys —
294`Space` pauses it, `s` stops it, `+`/`-` change volume. The control point
295sees those changes reflected back through AVTransport/RenderingControl
296state and GENA `LastChange` events.
297
298It's **on by default**. Three equivalent ways to turn it off:
299
300```bash
301fin --no-media-renderer # this run only
302FIN_NO_MEDIA_RENDERER=1 fin # environment
303```
304
305```toml
306# config.toml — persistent
307[media_renderer]
308enabled = false
309```
310
311Optional keys under the same table:
312
313```toml
314[media_renderer]
315enabled = true
316friendly_name = "office fin" # picker name; default: fin (<hostname>)
317port = 47899 # description/control port; 0 = ephemeral
318# uuid is generated on first launch and persisted so control points
319# recognize the device across restarts — no need to set it by hand.
320```
321
322Playback pushed by a control point is **not scrobbled** — the item ids are
323foreign to your media server, so session reports/scrobbles are skipped for
324cast-in tracks.
325
326## MPRIS — desktop media controls
327
328On **Linux and the BSDs**, fin registers itself on the D-Bus session bus as
329`org.mpris.MediaPlayer2.fin` while the TUI runs. That's the standard
330desktop media-player interface, so it works with whatever already speaks
331MPRIS:
332
333- **hardware media keys** (play/pause/next/previous) — even when the
334 terminal isn't focused,
335- **desktop applets** — GNOME's calendar-dropdown media widget, KDE's
336 Media Player plasmoid and lock-screen controls,
337- **bars** — waybar's `mpris` module and friends,
338- **`playerctl`** for scripting:
339
340```bash
341playerctl -p fin play-pause
342playerctl -p fin next
343playerctl -p fin metadata # title, artist, duration, cover art
344playerctl -p fin position 30 # absolute seek (seconds)
345playerctl -p fin volume 0.5
346playerctl -p fin shuffle On
347playerctl -p fin loop Playlist # = repeat all
348```
349
350MPRIS drives whatever renderer is currently active — switch to a
351Chromecast or UPnP device in the TUI and your media keys transparently
352control the cast session. Track metadata (title, artist, duration, cover
353art) is pushed to the desktop as it changes.
354
355There's nothing to configure and no system dependency to install (the
356D-Bus protocol is spoken natively — no `libdbus`). On a headless box with
357no session bus, fin simply logs a warning and carries on. A second fin
358instance registers as `org.mpris.MediaPlayer2.fin.instance<pid>`, per the
359MPRIS spec.
360
361## All settings
362
363Every setting exists as both a CLI flag and a TOML key. Flags win.
364
365| CLI flag | Env var | TOML key | Default |
366|-----------------------|---------------------|---------------------------|------------------|
367| `--server URL` | `FIN_SERVER` | `servers[].url` | _(none)_ |
368| `--server-name NAME` | `FIN_SERVER_NAME` | `current_server` | _(latest login)_ |
369| `--token TOKEN` | `FIN_TOKEN` | `servers[].access_token` | _(from login)_ |
370| `--user-id ID` | `FIN_USER_ID` | `servers[].user_id` | _(from login)_ |
371| `--user-name NAME` | | `servers[].user_name` | _(from login)_ |
372| `--device-id ID` | `FIN_DEVICE_ID` | `servers[].device_id` | random UUID |
373| `--renderer <mpv/chromecast/upnp>` | `FIN_RENDERER` | `renderer` | `mpv` |
374| `--mpv` | | `renderer = "mpv"` | |
375| `--chromecast [NAME]` | `FIN_CHROMECAST` | `last_chromecast` | |
376| `--upnp [NAME]` | `FIN_UPNP` | `last_upnp` | |
377| `--no-media-renderer` | `FIN_NO_MEDIA_RENDERER` | `media_renderer.enabled` | `true` |
378| `--media-renderer` | | `media_renderer.enabled = true` | |
379| | | `media_renderer.friendly_name` | `fin (<hostname>)` |
380| | | `media_renderer.port` | `0` _(ephemeral)_ |
381| | | `media_renderer.uuid` | _(generated once)_ |
382| `-v`, `-vv` | | _(log level)_ | `warn` |
383
384Audio-side playback settings live under TOML sub-tables and are toggled from
385the TUI (see [Playback modes & effects](#playback-modes--effects)):
386
387| TOML | Default | Notes |
388|-----------------------------------|----------------|----------------------------------------------------------|
389| `replaygain.mode` | `off` | `off` / `track` / `album` |
390| `replaygain.preamp_db` | `0.0` | additive in dB before clip guard |
391| `replaygain.prevent_clip` | `true` | caps gain so `linear * peak <= 1.0` |
392| `crossfade.mode` | `off` | `off` / `crossfade` / `mixed` |
393| `crossfade.duration_secs` | `5.0` | overlap window in seconds |
394| `eq_enabled` | `false` | toggle the Rockbox 10-band EQ pipeline |
395| `[[eq_band_settings]]` | ISO octave | 10 bands (see [Equalizer](#equalizer)); Rockbox-compatible |
396| `bass` | `0` | bass shelf gain in whole dB (−24…+24) |
397| `treble` | `0` | treble shelf gain in whole dB (−24…+24) |
398| `bass_cutoff` | `0` | bass shelf cutoff in Hz (`0` = Rockbox default 200) |
399| `treble_cutoff` | `0` | treble shelf cutoff in Hz (`0` = Rockbox default 3500) |
400
401Find the on-disk config with `fin config --path`; print it with
402`fin config --show`.
403
404While the TUI runs, logs are written to `fin.log` in the cache directory
405(next to `queue.json`) rather than stderr, so they can't corrupt the
406display; one-shot CLI commands log to stderr as usual. `-v` / `-vv` and
407`RUST_LOG` control the level either way.
408
409## Multiple servers
410
411fin authenticates against as many servers as you like — Jellyfin and
412Subsonic mixed freely — and keeps their credentials side-by-side in one
413config file:
414
415```bash
416fin login https://home.example.com --name home
417fin login https://work.example.com --name work
418fin login https://mom.dyndns.example --name mom
419
420fin server # list all servers (▍ marks the current one)
421fin server switch work # make `work` the active server
422fin server rm mom # remove one
423fin server rename home casa # rename `home` → `casa`
424
425# One-off — hit `work` without changing the current pointer:
426fin --server-name work search "spirited away"
427fin --server-name work play "spirited away"
428```
429
430Inside the TUI, the Settings screen shows every saved server; **Enter** on
431one switches to it. **`t`** anywhere in the TUI cycles to the next server
432without leaving the current screen.
433
434## Sub-commands
435
436```
437fin # launch the TUI (default)
438fin login <url> [--name N] # sign in and save credentials for server `N`
439fin logout [--name N] # remove server `N` (defaults to the current one)
440fin server # list saved servers
441fin server switch <name> # change the active server
442fin server rm <name> # remove one
443fin server rename <a> <b> # rename
444fin search <query> # print matches from the active library
445fin play <query> # search + play the top hit
446fin queue <query> # search + append to the current queue
447fin devices # list Chromecasts + UPnP MediaRenderers on the local network
448fin playlists # list playlists
449fin playlists --list <id> # dump items of a playlist
450fin config --show|--path # inspect config
451```
452
453## Keybindings (TUI)
454
455Tab order — the default screen is **Music**:
456
457 `1` Music • `2` Videos • `3` Playlists • `4` Favorites • `5` Queue • `6` Search • `7` Devices • `8` Settings
458
459The **Videos** tab is Jellyfin-only — Subsonic has no video API, so on a
460Subsonic server the tab is hidden, `Tab`/`Shift+Tab` skip past it, and `2`
461shows a hint instead. The other number keys keep their meanings.
462
463| Key | Action |
464|------------------------------|-------------------------------------|
465| `?` | show / hide the full keyboard-shortcuts help modal |
466| `Tab` / `Shift+Tab` | next / prev screen |
467| `1`…`8` | jump to Music / Videos / Playlists / Favorites / Queue / Search / Devices / Settings |
468| `/` | jump to Search & focus input |
469| `↑` `↓` / `k` `j` | move selection |
470| `PgUp` / `PgDown` | jump 10 rows |
471| `Enter` | **drill in** on a container (album, series, playlist) — plays a leaf (track, episode, movie); on Queue → **jump** the playhead to the selected entry; on Devices → connect to the selected Chromecast / UPnP renderer; on Settings → switch server |
472| `x` | play the highlighted container as one queue **without** drilling in (album → all tracks, playlist → all items) |
473| `Shift+X` | **shuffle-play** — same pool as `x` for a highlighted container; on flat views (Favorites, Videos, open album/playlist) the whole list, in random order, with shuffle mode switched on |
474| `a` | enqueue the highlighted item |
475| `n` | play the highlighted item **next** |
476| `Shift+L` / `Shift+D` | **like / dislike** — add or remove the highlighted item (or the playing track) from Favorites; Jellyfin favorites, Subsonic stars |
477| `z` | toggle shuffle |
478| `Shift+R` | cycle repeat mode (off → all → one) |
479| `g` | cycle ReplayGain (off → track → album) |
480| `f` / `Shift+F` | cycle crossfade mode / cycle crossfade duration (3, 5, 8, 12 s) |
481| `Shift+E` | toggle 10-band Rockbox EQ |
482| `[` / `]` | (Settings) select previous / next EQ band |
483| `Shift+↑` / `Shift+↓` | (Settings) nudge the selected EQ band's gain by ±1 dB |
484| `b` / `Shift+B` | bass shelf −1 dB / +1 dB |
485| `y` / `Shift+Y` | treble shelf −1 dB / +1 dB |
486| `Space` or `p` | pause / resume |
487| `s` | stop |
488| `<` / `>` or `h` / `l` | previous / next track |
489| `+` / `-` | volume up / down |
490| `m` | switch to local renderer |
491| `t` | cycle to the next saved server |
492| `d` | (Queue screen) remove the highlighted entry |
493| `Shift+C` | (Queue screen) clear the entire queue |
494| `Esc` | pop the current drill-in (back to the parent list) |
495| `r` | refresh the current screen |
496| `Esc` | leave the search input / close open playlist |
497| `q` / `Ctrl-C` | quit |
498
499## Playback modes & effects
500
501**Shuffle and repeat work on every renderer** — the queue lives on the
502client, so they apply while casting to Chromecast or UPnP too. The audio
503*effects* (ReplayGain, crossfade, EQ, bass & treble) run only on the local
504renderer; Chromecast and UPnP receivers do their own DSP, so those toggles
505no-op there. Settings persist to `config.toml` and are mirrored back on
506next launch.
507
508### Shuffle
509
510`z` toggles shuffle. Enabling it reshuffles every item **after** the
511currently-playing track using a Fisher-Yates permutation — the playing track
512stays put so the audio doesn't jump.
513
514### Repeat
515
516`Shift+R` cycles the repeat mode off → all → one → off. `all` wraps in both
517directions (Prev at row 0 goes to the last item); `one` sticks on the
518current track until the mode changes.
519
520### ReplayGain
521
522`g` cycles Off → Track → Album → Off. Reads `REPLAYGAIN_TRACK_GAIN`,
523`REPLAYGAIN_ALBUM_GAIN`, and the matching peak tags off decoded tracks
524(Vorbis-comment or ID3v2), computes a linear gain multiplier
525(`10^((gain + preamp) / 20)`), and folds it into the sample push loop. If
526the requested scope's tag is missing, the other scope is used as a fallback.
527Clip prevention is on by default — it caps the multiplier so peaks stay ≤ 1.0.
528
529### Crossfade
530
531`f` cycles Off → **Crossfade** → **Mixed** → Off.
532
533- **Crossfade** — cosine/sine curves (out² + in² = 1); perceived loudness
534 stays constant across the overlap.
535- **Mixed** — no curves; both tracks play at full volume during the overlap
536 and sum additively (louder DJ-style mix).
537
538`Shift+F` cycles the duration through 3, 5, 8, 12 s, preserving the current
539mode. Duration is also editable directly in `config.toml`.
540
541Under the hood, `fin` runs a second decoder + cpal output stream during the
542overlap and the OS mixer sums them. The overlap kicks in both on natural
543end-of-track transitions AND when you Play a new album or jump to a new
544queue entry — so switching tracks manually still fades cleanly.
545
546### Equalizer
547
548`fin` links the Rockbox DSP pipeline
549([`rockbox-dsp`](https://crates.io/crates/rockbox-dsp)) for a fixed-point
55010-band EQ with high-quality biquad filters — band 0 is a low shelf, band 9
551a high shelf, bands 1–8 are peaking filters. On the Settings screen you'll
552see 10 vertical sliders with `dB` labels above and cutoff frequency labels
553below. The controls:
554
555| Key | Action |
556|----------------|-----------------------------------------------|
557| `E` | toggle EQ on / off |
558| `[` / `]` | move the highlighted band left / right |
559| `Shift+↑` / `↓`| bump the highlighted band's gain by ±1 dB |
560
561Adjustments persist to `config.toml` immediately. Fresh installs get the
562ISO-octave flat preset (32 Hz, 63, 125, 250, 500, 1 kHz, 2, 4, 8, 16 kHz,
563Q 7.0, 0 dB across the board) so the DSP is a bit-exact bypass until you
564start tweaking. Values in `[[eq_band_settings]]` use Rockbox tenths — `q =
56570` means Q 7.0, `gain = -125` means −12.5 dB — so a Rockbox preset drops
566in unchanged.
567
568**License note:** enabling EQ links the Rockbox DSP C sources (GPL-2.0-or-later),
569which makes the resulting `fin` binary GPL. The rest of `fin` remains MPL-2.0
570in source form.
571
572Behavioral notes:
573
574- **Only local playback runs through EQ.** Chromecast and UPnP receivers each
575 do their own DSP; the toggle is a no-op there.
576- During a crossfade the outgoing *and* incoming tracks both route through
577 the same Rockbox DSP config. The biquad delay lines get briefly stirred
578 when the two tracks alternate through the pipeline — the audible transient
579 is well under a millisecond at 48 kHz.
580
581### Bass & treble
582
583The Rockbox tone-control stage runs in the same DSP pipeline as the EQ —
584shelving filters at fixed cutoffs (default 200 Hz bass, 3500 Hz treble).
585Adjust with `b` / `Shift+B` for bass and `y` / `Shift+Y` for treble; every
586press is a 1 dB step in the ±24 dB range and is persisted to `config.toml`
587immediately. The Settings screen shows the current values, and the player
588bar shows a compact `B+3/T-2` badge whenever either is non-zero.
589
590Custom shelf cutoffs can be set in `config.toml` via `bass_cutoff` /
591`treble_cutoff` (Hz); `0` means the Rockbox defaults. The keys and this
592stage go through the same singleton pipeline as EQ, so the licensing note
593above applies.
594
595## Queue persistence
596
597The audio queue, shuffle/repeat state, and the exact playhead within the
598currently-playing track are written to `cache_dir/queue.json` on every
599mutation and every ~3 s while playing. Writes are debounced and atomic
600(rename-in-place), so a crash mid-write can't leave a truncated file.
601
602On startup, `fin` reads the snapshot and restores the queue paused at the
603saved position. `Space` (or `p`) resumes from exactly where you left off.
604Video items in a saved queue are filtered out silently — the persistence
605path lives on the audio side; the mpv-driven video path is transient.
606
607Find the file with `fin config --path` (adjacent to the config dir).
608
609## Remote-renderer queue
610
611For both Chromecast and UPnP, `fin` maintains the queue **on the client**,
612polls the device for its current transport state, and loads the next item
613automatically the moment the current one finishes (Chromecast:
614`IDLE / FINISHED`; UPnP: `STOPPED` after having been `PLAYING`). That means:
615
616- `a` (queue) and `n` (play-next) do the right thing while something is
617 already streaming.
618- Skipping (`>` / `<`) triggers a `load` for the next queue item
619 immediately — no waiting for the current one to finish.
620- Shuffle (`z`) and repeat (`Shift+R`) act on this client-side queue, so
621 they work while casting exactly like local playback.
622- Stopping clears the local queue and stops the receiver's playback.
623
624UPnP renderers without a `RenderingControl` service (rare, but it happens)
625still work for transport — volume changes are just no-ops on the device.
626
627## Supported audio formats
628
629Local audio is decoded in-process by
630[`rockbox-playback`](https://crates.io/crates/rockbox-playback), which uses
631Rockbox's own firmware codecs — the full 29-codec static-link set, all
632enabled. Any of these plays gapless, with ReplayGain, crossfade and the EQ
633straight through the same path:
634
635### Lossless
636
637| Format | Typical extension | Notes |
638| -------------------- | ----------------- | ----------------------------- |
639| FLAC | `.flac` | |
640| Apple Lossless | `.m4a` | ALAC |
641| WavPack | `.wv` | |
642| Monkey's Audio | `.ape` | |
643| True Audio | `.tta` | |
644| Shorten | `.shn` | |
645
646### Lossy
647
648| Format | Typical extension | Notes |
649| -------------------- | ----------------- | ----------------------------- |
650| MP3 / MP2 / MP1 | `.mp3` `.mp2` | MPEG audio (incl. MP3-in-ASF) |
651| AAC / HE-AAC | `.m4a` `.aac` | incl. AAC+ / SBR |
652| Ogg Vorbis | `.ogg` | |
653| Opus | `.opus` | |
654| Musepack | `.mpc` | SV7 / SV8 |
655| Speex | `.spx` | |
656| WMA v1 / v2 | `.wma` | in ASF |
657| WMA Professional | `.wma` | |
658| AC3 (A/52) | `.ac3` | |
659| RealAudio Cook | `.ra` `.rm` | |
660| AAC in RealMedia | `.rm` | |
661| ATRAC3 | `.oma` `.aa3` | Sony OMA + RealMedia |
662
663### Uncompressed / PCM
664
665| Format | Typical extension | Notes |
666| -------------------- | ----------------- | ----------------------------- |
667| WAV | `.wav` | PCM + the ADPCM family |
668| AIFF | `.aiff` `.aif` | |
669| Sun AU / SND | `.au` `.snd` | |
670| Sony Wave64 | `.w64` | |
671| SMAF | `.mmf` | Yamaha mobile |
672| Dialogic VOX | `.vox` | ADPCM |
673
674### Specialized / game audio
675
676| Format | Typical extension | Notes |
677| -------------------- | ----------------- | ----------------------------- |
678| CRI ADX | `.adx` | |
679| Amiga MOD | `.mod` | |
680
681> Chiptune emulator formats (SPC, SID, NSF/GBS/HES/KSS/AY/SGC/VGM/VTX, SAP)
682> are **not** supported — they need Rockbox's one-codec-at-a-time model and
683> are excluded from the static-link set fin builds against.
684
685## Streams & transcoding
686
687- Local **audio** uses the original stream — `rockbox-playback` decodes it
688 in-process with Rockbox's codecs (see
689 [Supported audio formats](#supported-audio-formats)), resampled to the
690 output device's rate. No transcoding round-trip; no mpv on the audio
691 path.
692- Local **video** shells out to **mpv** with `Static=true` — the fastest
693 direct-stream path, mpv handles any container Jellyfin can hand it.
694- **Chromecast** playback defaults to Jellyfin's HLS output (`main.m3u8`),
695 because the Default Media Receiver's codec matrix is much narrower than
696 mpv's. Jellyfin will transcode when it needs to.
697- **UPnP** playback uses the direct stream by default — most UPnP
698 MediaRenderers speak MP3 / AAC / FLAC natively and the direct path avoids
699 the transcode. Fall back to HLS with `--hls` if your renderer needs it.
700- Force one or the other from the CLI with `--hls` on `play`/`queue`.
701
702## Development
703
704```bash
705cargo check --workspace
706cargo build --release -p fin
707./target/release/fin --help
708cargo test --workspace
709```
710
711The workspace layout:
712
713```
714fin/
715├── crates/
716│ ├── fin/ # binary — clap CLI + startup
717│ ├── fin-config/ # TOML config file, credentials, mode enums
718│ ├── fin-jellyfin/ # Jellyfin HTTP API client
719│ ├── fin-subsonic/ # Subsonic HTTP API client (Navidrome, Airsonic, …)
720│ ├── fin-media/ # MediaClient trait over both backends + login probe
721│ ├── fin-mediarenderer/ # built-in UPnP MediaRenderer — casting *to* fin
722│ ├── fin-player/ # Renderer trait, queue, rockbox-playback audio path,
723│ │ # mpv video, Chromecast + UPnP, replaygain,
724│ │ # crossfade, queue persistence
725│ └── fin-tui/ # Ratatui neon TUI
726└── Cargo.toml # workspace + shared deps (rustls only, no openssl)
727```
728
729## License
730
731`fin` is released under the [MPL-2.0](LICENSE).