docs/architecture.md
Rama main · réplica de lectura del repositorio original.
# Backend Architecture (Developer Notes)
How the RSPlayer backend is put together: crate layout, the command/event
model, the audio pipeline, and storage. Multiroom sync has its own deep-dive
in [Multiroom Architecture](multiroom_architecture.md); this page covers
everything else. Module-level rustdoc (`//!` headers) in each source file
carries the per-file detail — this page is the map.
## Crate Map
| Crate | Responsibility |
|-------|----------------|
| `crates/api_models` | Shared serde data model: commands, events, settings, songs/albums. Crosses the WebSocket as JSON and is persisted — schema changes must stay backward-compatible |
| `crates/server` | The `rsplayer` binary: composition root, axum HTTP/WS server, command dispatch, network mounts |
| `crates/config` | `Settings` persistence (one JSON blob in fjall) with in-memory cache and schema migrations; first launch persists platform-aware defaults supplied by the server (from `hardware::platform`) |
| `crates/playback` | The audio engine: Symphonia decode loop, cpal output (`AudioOutput`), DSD path, VU, multiroom tee/sink |
| `crates/metadata` | Library scanner, fjall repositories (songs/albums/stats/loudness), queue, playlists, radio metadata and saved radio stations, APE/DSF/SACD Symphonia plugins |
| `crates/podcast` | Podcast subscriptions: directory search (iTunes / Podcast Index), RSS/Atom feed refresh (`feed-rs`), episode cache in fjall, per-episode resume position and played state |
| `crates/dsp` | Parametric EQ (biquads, CamillaDSP-derived) with a lock-free config handoff to the audio thread |
| `crates/sync` | Multiroom leader/follower over iroh QUIC — see the dedicated doc |
| `crates/hardware` | Volume-control devices (ALSA/PipeWire/software/firmware), USB front-panel link, LIRC remote, platform/sandbox detection with first-launch playback defaults |
| `crates/wire` | `no_std` protocol shared with the front-panel firmware repo (postcard + COBS over USB serial) |
| `crates/desktop` | Tauri wrapper: embeds the backend in-process, webview UI, OS media keys |
| `web-ui` | Dioxus web frontend (not covered here) |
Dependency direction (roughly): `server → {sync, playback, metadata, podcast, hardware, config, dsp} → api_models`. `playback` depends on `metadata` (probe/codec registries, loudness) and `dsp`; `sync` depends on `playback` (tee, sink); `hardware` depends on `wire`. `podcast` depends only on `api_models` and `config`; the server adapts it to playback's `ResumePositionProvider` so the two never depend on each other.
## Process Shape
One process, one tokio runtime, plus dedicated OS threads where blocking or
latency demands it:
- **tokio tasks**: axum HTTP/HTTPS servers, WebSocket fan-out, the two
command handlers, the multiroom sync service, USB/LIRC glue.
- **Playback thread** (per play, `player_threads_priority`, never `Min` —
`Min` starved audio on single-core devices): runs the decode loop in
`symphonia.rs`.
- **cpal audio callback** (driver thread): drains the ring buffer, applies
software volume.
- **Scanner thread** (per rescan) and the **loudness pool** (half the cores,
pauses itself during playback).
- **Multiroom sink thread** (grouped follower) — same priority rule as
playback.
### Desktop and Android wrapper
`crates/desktop` is a Tauri 2 shell around the same backend, built as a
library (`rsplayer_desktop_lib::run`) with a thin `main.rs` for desktop and
the Tauri mobile entry point for Android:
- It moves the cwd to a per-user data dir (`dirs::config_dir()/rsplayer` on
desktop, `RSPLAYER_DATA_DIR` handed over by the Kotlin `Application` on
Android), picks a free port, exports `PORT`/`RSPLAYER_DESKTOP`, and runs
`rsplayer::run_backend` as a tokio task while the webview shows
`loading.html` until the port opens, then loads `http://localhost:<port>`.
- Desktop only: `tray` adds a system tray icon (Show, Previous,
Play/Pause, Next, Quit). While `desktop_settings.close_to_tray` is on and
the tray was created, the close request hides the window instead of
shutting the backend down; the server mirrors the setting into
`rsplayer::close_to_tray()` at startup and on every settings save.
Tray creation is guarded with `catch_unwind` because libappindicator
panics when its library is missing on Linux. `tauri-plugin-single-instance`
runs before `setup`, which is where the desktop backend is started, so a
second launch shows the running window and exits before it can open the
same database.
- Desktop only: `tauri-plugin-window-state` saves the window's size,
position and maximized state (not visibility or decorations) to
`.window-state.json` in the app config dir on exit and when the window
is hidden to the tray, and restores them when the window is created and
when it is shown again from the tray (GTK maps a re-shown window at its
initial size).
`maximize_on_small_monitor` only runs when no saved state exists yet.
The plugin is pinned to 2.4 because 2.5 requires tauri 2.12.
- Linux only, with `desktop_settings.custom_titlebar` on (the default):
the window is undecorated and the web UI's nav bar is the title bar. An
initialization script defines `window.__RSPLAYER_WINDOW__` (minimize /
toggle maximize / close / `setDecorations` through Tauri's window
commands); the UI shows its window buttons and marks the nav
`data-tauri-drag-region` only when that object exists and the setting is
on, so browsers and the other platforms are unaffected. The window starts
undecorated and applies the saved setting (`rsplayer::custom_titlebar()`)
once the backend port is open; toggling it in Settings switches the
decorations live. `capabilities/titlebar.json` grants just those window
permissions to the `http://localhost` origin the UI is served from; tao
provides edge resizing for undecorated windows.
- `run_backend` is idempotent about process-global setup (crypto provider,
logger) because "Restart RSPlayer" relaunches the executable on desktop but
restarts the backend **in-process** on Android.
- Android: the backend is process-wide (`android::backend()`), started by
the Tauri entry point or, with no activity (Android Auto, media button),
by the Kotlin `PlaybackService` through `NativeBackend.start`. Whoever is
first registers the app context with `ndk-context` (cpal's AAudio host and
iroh's interface discovery need it): tao when an activity exists, else
`android.rs`, which hands over to tao when an activity appears later. Media keys come from souvlaki on desktop; on Android
the Kotlin `PlaybackService` (media3 `MediaSessionService`, foreground
while playing) mirrors the backend over its WebSocket — the same JSON
`UserCommand`/`StateChangeEvent` frames the web UI uses — and handles
audio focus and the Wi-Fi multicast lock.
- Android has no ALSA: the server is built with `--no-default-features`, the
platform profile is `TargetOs::Android` (cpal "System Default" device,
software volume, shared Music folder pre-seeded as the library) and the
output stream uses cpal's default buffer size.
## Command / Event Model
The system is a unidirectional loop: **commands in → state changes out**.
```
web UI / desktop media keys / USB panel / IR remote
│ UserCommand (JSON over WS, or direct mpsc)
▼
mpsc channel ──► command_handler (one at a time)
│ │ calls services (player, queue, metadata…)
▼ ▼
SystemCommand StateChangeEvent ──► broadcast channel
(volume/power) │
▼ ▼
AudioInterfaceService every WS client + USB panel + sync tee
```
- `UserCommand` (in `api_models::common`) is the single command entry enum;
`command_handler.rs` routes to per-domain modules (`player_commands.rs`,
`queue_commands.rs`, …) sharing one `CommandContext`.
- Queries are answered via the same broadcast `StateChangeEvent` channel —
there are no request/response pairs, so all clients converge on identical
state.
- Volume/power run on a separate `SystemCommand` channel against the
hardware crate.
- While the instance is a grouped multiroom follower, an `AtomicBool`
(`multiroom_follower_active`) makes `player_commands` reject local
transport commands.
## Audio Pipeline
```
source ──► Symphonia decode ──► [multiroom tee] ──► AudioOutput.write()
│ per-format writer:
│ resample (rubato FFT)
│ → EQ (dsp crate)
│ → VU meter
▼
SPSC ring buffer (ring_buffer_size_ms)
▼ blocking push = backpressure
cpal callback: drain + software volume
▼
ALSA / PipeWire / CoreAudio / WASAPI / ASIO
```
Key points:
- **`AudioOutput`** (`playback/src/rsp/audio_output.rs`) is the single output
path for every platform and both local + multiroom-sink playback. The name
`alsa_output.rs` was retired in 2026-07 — it is pure cpal.
- **Source resolution** (`audio_source.rs`): local paths against the music
dirs; APE and SACD-ISO virtual tracks (`…#SACD_<n>`) via the custom readers
in the metadata crate. HTTP URLs are fetched once with `Icy-Metadata: 1`
and `Range: bytes=0-`: a response carrying `icy-*` headers is a live radio
stream (ICY title updates flow out as events, not seekable; stations with a
metadata service, QuantumCast and Radiosphere, are polled by
`NowPlayingPoller` instead and their ICY titles ignored); a host that
honours byte ranges (podcast episodes, direct file links) becomes an
`HttpRangeSource` — a seekable `MediaSource` that re-requests from the new
offset on seek and reconnects at the current offset on dropped
connections, so Symphonia reports a real duration and user seeks work;
anything else plays as a plain non-seekable stream. Stream agents carry no
global timeout (it would also bound body reads and cut long tracks).
Instead, network sources (HTTP and remote-library) are opened and read on
a `source-io` worker thread (`interruptible_source`): the playback thread
waits for each open/read/seek in 100 ms slices, so Stop/Next interrupt a
connecting or stalled stream at once, and an operation that takes over
20 s fails as a stall and goes through the stream retry path.
`stop_current_song` waits at most 3 s for the playback thread (it runs
on the async command handler); each thread has its own stop flag, so one
left behind stays stopped and does not report state once a newer one runs.
- **Remote libraries** (`metadata::remote_store`): S3-compatible storage
configured in `MetadataStoreSettings.remote_libraries`. Nothing is mounted:
the scanner lists objects (ListObjectsV2, via the sans-IO `rusty-s3`) and
reads tags through presigned `GET` URLs wrapped in `HttpRangeSource`;
playback opens the same way, with a fresh presigned URL per attempt (24 h
expiry). A listing page gets three tries; a listing that still fails keeps
the library's songs instead of deleting them and is named in the scan
summary. Scan reads ride out network outages: chunk reads retry after a
short pause, and after a failed file read the workers share an
`OutageGate` (`metadata::outage_gate`). One worker checks whether the
storage answers at all (`remote_store::is_reachable`) and, while it does
not, keeps checking with growing pauses (up to 10 s apart, 5 min in all)
while the others wait instead of failing the rest of the file list; only
failures while the storage answers count against a file. Files left
unread are reported as "not read (storage unreachable)". APE and SACD ISO are skipped (their readers need a file), and remote
songs are not loudness-analysed (that would download them in full).
`/music/<rs3 key>` answers with a 307 to the presigned URL, and secrets are
masked (`SECRET_MASK`) in `GET /api/settings` and restored on POST.
- **Playback source**: `PlayerService` plays from a `PlaybackSource`
(`api_models::playback_source`): `Queue`, `Radio(RadioStation)` or
`Podcast { podcast_id, episode_id }`. Stations and episodes are played
directly, never added to the queue. A `SourceNavigator`
(`server/src/source_navigator.rs`, wired in the composition root) turns
a source into the `Song` to decode and answers Next/Prev: radio cycles
through `MetadataService::get_zap_stations` (favorites, then hand-added
stations), podcasts walk the show's episodes by publish date
(`PodcastService::adjacent_episode`); at the end of an episode the next
newer unplayed one follows, a live stream that ends just stops. The
source is persisted in `player_state` and broadcast as
`PlaybackSourceEvent`. Leaving the queue saves its progress
(`queue_resume_secs`) for `return_to_queue`; any queue play
(`play_from_beginning`, `play_song`) switches back to `Queue`.
- **Resume hook**: before each track starts, `PlayerService` asks an optional
`ResumePositionProvider` (the podcast service, wired in the composition
root) for a start offset and arms `skip_to_time` with it. This is how a
half-heard episode continues from any entry path (play button, next/prev,
queue click) without the player knowing what a podcast is.
- **Failures**: only HTTP sources are retried, 3 times with 1/2/4 s
backoff and one "reconnecting" notification per outage; the retry resumes
at the position reached (`PlaybackContext::position_secs`, ignored by
non-seekable radio), and a stream that played 30 s before failing gets a
fresh set of retries. A song that still fails is skipped with a
notification. `OutputUnavailable` (device missing, busy or rejecting the
format) is never retried or skipped: it stops playback with `ERROR`, as do
3 failed songs in a row.
- **Sample-format negotiation**: the device is opened at the source rate if
it supports it, otherwise the resampler targets an integer multiple
(cleanest ratio) or the closest supported rate; retry ladders handle
drivers that reject advertised configs.
- **DSD** never touches the PCM chain (no EQ, no VU, no software volume, no
multiroom): `dsd.rs` types pass the raw bit stream to native-DSD cpal
formats.
- **EQ handoff**: the command thread builds a fresh `Equalizer` and parks it
in `DspHandle::pending`; the audio path swaps it in between writes —
no lock contention on the hot path (`dsp/src/dsp_processor.rs`).
- **Loudness normalization**: measured in the background (EBU R128,
`ebur128`), stored per song, applied as a gain filter inside the EQ chain;
the gain also rides multiroom `StreamStart` so followers match.
- **Volume**: `VolumeCrtlType` selects ALSA mixer / PipeWire / software /
firmware. Software volume is a cubic curve applied in the cpal callback
(post-ring), so changes take effect within one device buffer.
## Storage
Everything persists in **one fjall LSM database** (`rsplayer.db`, cwd-relative
— the desktop app cds into the OS config dir first), one keyspace per
concern:
| Keyspace | Contents |
|----------|----------|
| `configuration` | `Settings` as one JSON value (see below) |
| `songs` | `Song` JSON keyed by library-relative path; songs from a remote library are keyed `rs3:<library id>/<path below prefix>` |
| `albums` | Albums keyed by normalized `artist\|album` |
| `play_statistics` | Play/skip/like counters per song key; `radio_uuid_*` keys hold the liked radio-browser stations |
| `radio_stations` | Stations added by hand in the UI (`RadioStation` JSON keyed by a generated uuid), plus the details of liked radio-browser stations (keyed `fav_{stationuuid}`, `radio_browser_uuid` set) so Next/Prev can switch between favorites |
| `loudness` | Integrated LUFS per song key |
| `scan_state` | `running` marker while a library scan runs (a leftover one at startup means the scan was interrupted) and the last scan's `ScanSummary` JSON, sent to clients on `QueryScanStatus` |
| `queue`, `queue_status`, `queue_random_history` | Queue items (insertion-ordered ids), current position/mode, random-mode history |
| `playlist`, `playlist_list` | Saved playlist items (`{name}_{index}`) and headers |
| `player_state` | Pause flag + last position for resume-on-restart, current `PlaybackSource`, queue progress saved when switching to radio/podcast |
| `podcasts` | Subscribed feeds (`Podcast` JSON keyed by feed-URL hash) incl. refresh validators (`ETag`/`Last-Modified`) |
| `podcast_episodes` | `Episode` JSON keyed `{podcast_id}/{inverted publish ts}/{episode_id}` — a prefix scan is newest-first; carries `position_secs`/`played` |
| `podcast_episode_index` | `id:{episode_id}` and `url:{audio_url}` → episode key, for lookups from playback events |
| `multiroom` | The iroh endpoint secret key |
The settings blob round-trips **whole** through `GET/POST /api/settings`;
there is no merge. A stale client posting an old schema drops newer sections
— that is why every newer field has `#[serde(default)]` and serialized names
are frozen (the `alsa_*` settings names predate cross-platform support and
now configure cpal everywhere).
## HTTP / WebSocket Server
- axum; web UI embedded via rust-embed (debug builds read `dist/` from disk).
- `/api/ws`: commands in, every `StateChangeEvent` broadcast out to all
clients.
- `/api/settings` (whole-struct GET/POST + validation), `/api/artwork/<id>`,
range-capable audio streaming for local-browser playback, and an optional
HTTPS listener.
- Audio-card enumeration is cached at startup: probing drivers (ASIO
especially) can disrupt a live stream, so rescan only happens on explicit
`?rescan=true`.
- **Degraded mode**: if the audio device cannot be opened at startup, only
the settings/UI server starts so the device can be fixed remotely.
## Hardware Extras
- **USB front panel**: `hardware::usb` speaks the `wire` protocol (postcard
+ COBS, `no_std`-shared with the firmware repo) over USB serial — display
mirroring out, knob/button commands in, auto-reconnect.
- **IR remote**: `ir_service` (feature `lirc`) reads the lircd socket.
- **Desktop app**: Tauri window around the same backend, free-port
selection, souvlaki media-key integration (MPRIS2/MediaRemote/SMTC),
graceful shutdown on window close.
## Testing
- `cargo make test_ci` (creates the `dist/web-ui/public/index.html` stub
that rust-embed needs, then `cargo test`).
- Metadata services are tested against the `ports` traits with in-memory
fakes (`metadata/src/ports/fakes.rs`); repositories get standalone fjall
instances in temp dirs.
- Playback/sync unit tests cover the pure parts (resampling stretch, clock
estimator, protocol round-trips); end-to-end audio verification is manual
— see the release-testing notes in the multiroom doc.