# 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:`. - 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_`) 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/` 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:/` | | `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/`, 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.