Files
rkt-web-player/ARCHITECTURE.md
T

441 lines
20 KiB
Markdown

# RKT Web Player Architecture
## 1. Purpose and scope
RKT Web Player is a single-process audio player with a browser-based user
interface. It exposes local music directories as lazily browsed libraries and
can play selected files either on the host machine or on a discovered UPnP or
Sonos renderer.
The application is designed as a personal, stateful desktop or LAN service. A
single server-side player instance owns shared libraries, discovery and output
inventory. Each username owns its playlists, preferences and playback session.
Different users can therefore play concurrently when they select different
physical outputs.
## 2. System context
```mermaid
flowchart LR
User[User] --> Browser[Web browser]
Browser <-->|HTTP + JSON| Server[RKT Web Player]
Server -->|Read directories and tags| Files[(Local music files)]
Server -->|Audio output| Local[Local audio device]
Server -->|SSDP / UPnP discovery| Network[UPnP and Sonos devices]
Server -->|HTTP media publication + DLNA control| Network
Agent[Windows playback agent] -->|Registration, polling and state| Server
Server -->|Track download| Agent
Agent -->|Audio output| AgentAudio[Client audio device]
```
The browser is a thin client: it renders a complete server-provided state
snapshot and sends commands back to the server. The server is authoritative;
the browser stores neither playlists nor language preferences.
## 3. Runtime structure
```mermaid
flowchart TB
Main[main.rkt<br/>configuration and lifecycle]
Server[private/server.rkt<br/>HTTP adapter]
Users[private/users.rkt<br/>users, trusted proxies and sessions]
Player[private/player.rkt<br/>application state and commands]
Playlists[private/playlists.rkt<br/>durable playlist tabs]
DLNAAdapter[private/dlna-playback.rkt<br/>playlist transition orchestration]
Library[private/library.rkt<br/>filesystem and metadata]
UI[public/index.html + styles.css + app.js + translate.js<br/>browser UI]
Audio[racket-audio<br/>local backend]
Discovery[racket-upnp + racket-sonos<br/>device discovery]
DLNA[racket-audio-dlna<br/>transport, seeking and media publication]
AgentGUI[private/player-agent-gui.rkt<br/>GUI adapter]
AgentCLI[player-agent-cli.rkt<br/>CLI adapter]
AgentCore[private/player-agent-core.rkt<br/>polling and audio runtime]
Main --> Server
Main --> Player
Main --> Library
Server --> Player
Server --> Users
Server --> UI
Player --> Library
Player --> Playlists
Player --> Audio
Player --> Discovery
Player --> DLNAAdapter
DLNAAdapter --> DLNA
AgentGUI --> AgentCore
AgentCLI --> AgentCore
AgentCore --> Server
AgentCore --> Audio
```
### 3.1 Entrypoint and lifecycle
[`main.rkt`](main.rkt) is both the command-line entrypoint and the public Racket
API. It performs the following work:
1. Reads command-line options and optional INI defaults.
2. Combines and de-duplicates configured music paths.
3. Creates immutable library descriptors with `make-music-libraries`.
4. Creates the single mutable player instance with `make-player`.
5. Starts the web server with `serve-player`.
6. Closes the active audio backend through `player-close!` when the server
exits, using `dynamic-wind` to guarantee cleanup.
The exported `run-web-player` function provides the same lifecycle to programs
that embed the package instead of invoking its command line.
### 3.2 Library subsystem
[`private/library.rkt`](private/library.rkt) isolates filesystem access and
audio metadata extraction. Its principal domain types are:
- `music-library`: a stable generated ID, display name, and absolute root path;
- `browser-entry`: the name, kind, and root-relative path of a visible item;
- `track`: the absolute source file and its display/playback metadata;
- `artwork`: MIME type and bytes read on demand from embedded tags or a
conventional cover file beside the track.
Library loading is deliberately lazy:
- Startup validates each root and lists only the first library's root
directory.
- Browsing reads one directory level and does not inspect audio tags.
- Selecting a track for play or addition reads its metadata.
- Selecting a directory for play or addition recursively walks that subtree
and reads metadata for every supported track.
Directories are shown before tracks and entries are sorted case-insensitively.
Hidden directories and unsupported files are omitted. Metadata failures degrade
to the filename, an empty artist and album, an unknown duration, and the MIME
type inferred from the extension.
The HTTP API never accepts filesystem paths. It accepts library IDs and indexes
from the latest state snapshot; the player resolves these to entries whose
relative paths originated from directory listings below a configured root.
### 3.3 Player and application state
[`private/player.rkt`](private/player.rkt) contains the main application logic.
The mutable `player` structure is the aggregate root for:
- configured libraries and the current browser location;
- playlist tabs, the selected tab, and their durably stored tracks;
- discovered renderers and per-renderer session ownership;
- registered HTTP playback agents and their pending command queues;
- a playback session per username, containing its selected renderer, lazy
backend, current track, transport state, audio properties, volume and repeat
mode;
- one shared DLNA media-file server used by all network backends;
- current error, discovery, and shutdown status;
- synchronization primitives and the DLNA publication port.
Commands are expressed as strings with a JSON-compatible payload. The player
validates each command, mutates its state, delegates to a playback backend where
required, and returns a complete JSON-compatible state snapshot. Commands cover
library navigation, playlist/tab editing, output selection, transport, seeking,
volume, and repeat mode.
Selecting, deleting, or creating a tab stops playback. Tracks are de-duplicated
by normalized source path when they are appended. Every playlist mutation is
written in one `keystore` transaction. `playlists-for-<username>` contains the
ordered playlist GUIDs; each GUID key contains that playlist's name and tracks.
Loading validates every stored track independently against all configured
library roots, so one playlist can safely combine multiple libraries.
`language-for-<username>` stores the user's selected interface language in the
same keystore.
### 3.4 Playback backends
The player exposes local, UPnP, and Sonos outputs as renderers with a common
logical interface, but dispatches explicitly based on backend kind.
Each username has an independent playback pipeline. Separate outputs can be
active concurrently. Outputs remain shared resources: selecting an output
claims it for that session. If another session owns it, that backend is closed,
the former session becomes stopped, and ownership transfers to the selecting
user. This deliberately avoids renderer ACLs and durable reservations.
The optional `local` renderer uses `racket-audio`. It is included by default
and can be omitted with `[player] local-output=false`. Its backend is created
on the first playback-related command, not at startup. State and end-of-track
callbacks update the player and automatically advance the playlist. The UI's
linear 0-100 volume is squared before being sent to the local audio library to
provide a more useful perceived volume curve.
Network outputs use the small `private/dlna-playback.rkt` adapter. All adapters
share one `racket-audio-dlna` media-file server, while each adapter owns only
the playlist transition state machine; all transport commands, seeking, HTTP
publication, UPnP calls and cached renderer information are delegated to
`racket-audio-dlna`. After starting a track, the adapter asks that package to
publish the following track and set it as the renderer's
`NextAVTransportURI`.
The adapter polls the package's cached renderer information independently of
browser requests. A changed current URI promotes the prepared playlist index
and immediately prepares its successor. If a renderer does not perform the
prepared transition, a confirmed natural stop advances through a
server-driven fallback. Explicit stops and failed play requests are tracked
separately and never trigger that fallback. A seek uses
`dlna-player-seek-percentage!` directly; its synchronously updated cached
position is pushed to the application state immediately.
Changing a session's selected renderer closes that session's existing backend
and resets its playback state. The replacement backend remains lazy and is
created only when needed.
Registered playback agents form a fourth renderer kind. An agent keeps the
client/server direction unchanged: it registers and polls the server, while the
server never opens a connection to the agent. Server commands have monotonically
increasing IDs and remain queued until acknowledged. Each poll acknowledges the
last completed command and reports playback state. For a play command, the
server creates an opaque media token; the agent downloads that track to a
temporary file and uses `racket-audio` for local playback. A stable 256-bit
application ID and the agent-owned display name are persisted in its local INI
file. The server follows the name advertised by the agent and does not own a
separate name mapping. Registration is default-deny: the application ID must
be present in the server's `[playback-agents]` INI section. Unknown IDs receive
HTTP 403 and cannot register, poll, or download agent media.
The server sends the next playlist item as a prefetch command. The agent keeps
only the current and next downloads and queues the prefetched decoder at EOF,
keeping network and polling latency outside the gapless transition.
### 3.5 Device discovery
Discovery is explicitly initiated from the UI and runs on a separate Racket
thread so that the initial API call can return immediately. It:
1. Queries all visible UPnP devices.
2. Filters media renderers.
3. Reads Sonos group topology when available.
4. Represents each Sonos group as one logical renderer.
5. Removes individual UPnP devices that are already members of those groups.
6. Sorts the resulting outputs by display name and retains local output as the
first option when local output is enabled.
The `discovering` state lets polling clients show progress. Discovery failures
are recorded in the shared player error field.
### 3.6 HTTP and browser layers
[`private/server.rkt`](private/server.rkt) uses Racket's servlet web server. It
serves static assets from [`public/`](public/) and exposes these API endpoints:
| Method | Route | Responsibility |
| --- | --- | --- |
| `GET` | `/api/state` | Return the complete current state; also refresh network-renderer information. |
| `POST` | `/api/discover` | Start asynchronous discovery and return the current state. |
| `POST` | `/api/command/:command` | Execute a command with its JSON request body and return the updated state. |
| `GET` | `/api/preferences` | Return the current user's durable UI preferences. |
| `POST` | `/api/preferences` | Persist the current user's UI language. |
| `POST` | `/api/agent/register` | Register or refresh a polling playback agent. |
| `POST` | `/api/agent/poll` | Accept agent state and acknowledgements and return its next command. |
| `GET` | `/api/agent/media/:app-id/:token` | Download the track currently assigned to an agent. |
| `GET` | `/api/artwork/:artwork-id` | Return embedded or adjacent artwork for a playlist track. |
JSON responses use `Cache-Control: no-store`; artwork has a private cache
header. Command failures are returned as HTTP 400 JSON responses with an
`error` property. Unauthorized agents receive HTTP 403 with the stable
`agent-not-authorized` error code.
[`public/app.js`](public/app.js) implements a framework-free client. It:
- fetches a full state snapshot once per second;
- temporarily suppresses polling while a browser-initiated command is active;
- immediately renders the state returned by successful commands;
- renders library navigation, playlists, tabs, transport status, and output
selection;
- implements keyboard actions and playlist drag-and-drop in the browser.
[`public/translate.js`](public/translate.js) follows the key-based translation
model used by rktplayer. It supports Dutch, English, German, French and Spanish
with English fallback. Browser preferences select the initial language; a
manual selection is stored server-side per username and therefore follows the
user across browsers. The native agent uses the equivalent
[`private/translate.rkt`](private/translate.rkt) module and the operating-system
language.
DOM signatures prevent rebuilding unchanged library, tab, and playlist
collections on every poll. Playback status and other small values are updated
on every render.
### 3.7 User authentication
Authentication is enabled by adding Argon2id password hashes under `[users]`.
All browser clients then authenticate, including clients on the local network.
A forwarded address is accepted only when the direct peer belongs to
`[authentication] trusted-proxies`; the rightmost `X-Forwarded-For` value is
used so an untrusted client cannot prepend a forged address.
Successful logins create opaque 256-bit session tokens. Only the token is sent
to the browser in a `Secure`, `HttpOnly`, `SameSite=Strict` cookie; server-side
session state has an idle timeout and is intentionally volatile. Login failures
are rate-limited per effective client address. Authenticated requests move the
server-side idle deadline. Once half the configured lifetime has elapsed, the
cookie is reissued with a fresh lifetime; this avoids a `Set-Cookie` header on
every one-second state poll. Agent endpoints are outside user sessions and
retain their separate application-ID authorization.
## 4. Key runtime flows
### 4.1 Browse and play a directory
```mermaid
sequenceDiagram
participant B as Browser
participant H as HTTP server
participant P as Player
participant L as Library
participant A as Audio backend
B->>H: POST /api/command/item-play {index}
H->>P: player-command!("item-play", data)
P->>L: Resolve entry and recursively collect tracks
L-->>P: Tracks with metadata
P->>P: Stop playback and replace current playlist
P->>A: Lazily create backend if needed
P->>A: Play first track
P-->>H: Full state snapshot
H-->>B: JSON response
```
### 4.2 Poll network playback state
```mermaid
sequenceDiagram
participant B as Browser
participant H as HTTP server
participant P as Player
participant A as DLNA playback adapter
participant L as racket-audio-dlna
participant D as DLNA renderer
loop Browser state polling
B->>H: GET /api/state
H->>P: player-state->jsexpr
P-->>H: Full state snapshot
H-->>B: JSON response
end
loop DLNA adapter polling
L->>D: Poll transport and position
D-->>L: Current renderer information
A->>L: Read cached state
L-->>A: DLNA info
A-->>P: Update track, transport and time state
end
```
## 5. Concurrency and consistency
The application uses two semaphores with separate responsibilities:
- `command-lock` serializes commands, state snapshots, agent transitions,
discovery commits and shutdown, preventing overlapping backend operations.
- `state-lock` protects short reads and mutations of the shared player fields
performed by HTTP requests, audio callbacks, and the discovery thread.
Potentially slow discovery runs outside the state lock. Backend calls are also
generally performed outside it, with their results committed in short locked
sections. Audio callbacks use only the short-lived state lock and update the
playback session captured when their backend was created.
The server module stores the player in a module-level `current-player` variable.
This matches the intended one-player-per-process deployment, but it prevents
multiple independent player instances from being served safely within the same
Racket process.
## 6. Configuration and deployment
The application accepts settings from command-line options and an optional INI
file:
- web listen address, defaulting to `127.0.0.1`;
- web port, defaulting to `8080`;
- DLNA media publication port, defaulting to `8734`;
- optional server-local audio output under `[player]`, enabled by default;
- named library root paths under `[libraries]` (the legacy semicolon-separated
setting remains supported);
- allowed 256-bit playback-agent IDs under `[playback-agents]`;
- the optional playlist-keystore override under `[player]`;
- Argon2id user hashes, trusted proxies, and session timeout.
Command-line network settings override INI values. Library paths from both
sources are combined and de-duplicated.
Playlist tabs and per-user language preferences use the SQLite-backed
`keystore` module at `data/playlists.keystore`. Output discovery, transport
state, playback position and sessions still reset when the process restarts.
## 7. Security and operational boundaries
The browser API has optional user authentication but no TLS termination.
Authentication must be enabled before exposing it to an untrusted network, and
a reverse proxy must provide HTTPS because session cookies are always marked
`Secure`. Forwarded client addresses are security-sensitive configuration:
only known reverse-proxy peers may be trusted, and the application port should
remain firewalled from the internet. Playlists, preferences, renderer selection
and transport state are isolated by username. Physical outputs are shared and
can be transferred between sessions.
Playback-agent registration and polling are authorized against a default-deny
INI allowlist. Media URLs additionally contain an opaque per-track token. The
random application ID therefore acts as a shared bearer credential, but must
not be treated as strong authentication when transported over unencrypted HTTP.
The GUI and CLI playback agents share one headless runtime. The GUI only adapts
configuration and state to widgets. Optional tray integration is loaded
dynamically through SDL3, so CLI use and the default GUI installation do not
acquire a mandatory SDL dependency.
The configured library roots define the intended filesystem boundary. Clients
operate on opaque indexes instead of sending paths directly. The DLNA backend
must make a selected local file reachable by the network renderer, so its media
port also needs to be accessible on the relevant trusted network.
There is no durable job queue or retry policy. Discovery failures are shared;
playback failures belong to the affected user's session. Renderer state is
retried naturally by subsequent polling requests.
## 8. Testing and extension points
Unit tests embedded in `private/library.rkt` cover root creation, filtering, and
directory ordering. `private/playlists.rkt` tests transactional round trips,
per-user GUID indexes, multiple libraries, deletion and library-boundary
validation, and per-user language storage. Tests in `private/player.rkt`
cover initial state, browser navigation, repeat mode, persistent playlist-tab
operations, concurrent user sessions, renderer takeover, unknown commands, and
clean shutdown. The current suite does not
exercise real audio devices, network discovery, DLNA renderers, HTTP routing,
or browser behavior.
The main extension points are:
- add media formats through the capabilities exposed by `racket-audio`;
- add a renderer kind by extending discovery, backend creation/cleanup, command
dispatch, and state refresh in `private/player.rkt`;
- add an API operation by defining its player command first and exposing it
through the generic command endpoint;
- replace polling with server-pushed updates while keeping the current state
snapshot as the synchronization model.
## 9. Architectural constraints and trade-offs
- **Per-user pipelines, shared outputs:** playlist and transport state are
isolated by username. Distinct outputs run concurrently; selecting an
occupied output explicitly stops its previous owner and transfers it.
- **Full-state snapshots:** a small and predictable client protocol, at the cost
of repeatedly transferring all tracks and browser entries.
- **One-second polling:** robust and dependency-free, but introduces periodic
traffic and up to one second of display latency.
- **Lazy filesystem and backend initialization:** fast startup and low idle
resource usage, while the first recursive selection or playback command can
be comparatively slow.
- **Keystore playlists:** transactional recovery after restart without a custom
database layer, with the SQLite-backed keystore remaining a single-node
resource.
- **Explicit backend branching:** easy to follow for the current small set of
outputs, but adding renderer types touches several player functions rather
than one formal backend interface.