384 lines
17 KiB
Markdown
384 lines
17 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 the active browser location, playlists,
|
|
playback state, output selection, and audio backend. All connected browser
|
|
windows observe and control that same instance.
|
|
|
|
## 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;
|
|
there is no browser-side persistence or independent playback state.
|
|
|
|
## 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, networks and sessions]
|
|
Player[private/player.rkt<br/>application state and commands]
|
|
Library[private/library.rkt<br/>filesystem and metadata]
|
|
UI[public/index.html + styles.css + app.js<br/>browser UI]
|
|
Audio[racket-audio<br/>local backend]
|
|
Discovery[racket-upnp + racket-sonos<br/>device discovery]
|
|
DLNA[racket-audio-dlna<br/>network backend and media server]
|
|
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 --> Audio
|
|
Player --> Discovery
|
|
Player --> 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;
|
|
- in-memory playlist tabs and the selected tab's tracks;
|
|
- discovered renderers and the selected renderer;
|
|
- registered HTTP playback agents and their pending command queues;
|
|
- the lazily created local or network backend;
|
|
- transport state, current track, position, duration, audio properties, volume,
|
|
and repeat mode;
|
|
- 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.
|
|
|
|
Playlist tabs exist only in memory. Selecting, deleting, or creating a tab
|
|
stops playback. Tracks are de-duplicated by normalized source path when they are
|
|
appended.
|
|
|
|
### 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.
|
|
|
|
The default `local` renderer uses `racket-audio`. 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 `racket-audio-dlna`. The backend controls the chosen media
|
|
renderer and publishes local files over HTTP on the configured DLNA port and
|
|
path. Unlike the callback-driven local backend, network playback information is
|
|
refreshed by calling the renderer whenever a state snapshot is requested.
|
|
|
|
Changing the selected renderer closes the existing backend and resets playback
|
|
state. The replacement backend remains lazy and is created only when it is
|
|
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.
|
|
|
|
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. |
|
|
| `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.
|
|
|
|
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]`.
|
|
Requests whose effective client address belongs to a configured local network
|
|
bypass login. 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 local 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. 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 D as DLNA renderer
|
|
|
|
loop Every second
|
|
B->>H: GET /api/state
|
|
H->>P: player-state->jsexpr
|
|
P->>D: Query transport, position, track and volume
|
|
D-->>P: Current renderer information
|
|
P-->>H: Full state snapshot
|
|
H-->>B: JSON response
|
|
end
|
|
```
|
|
|
|
## 5. Concurrency and consistency
|
|
|
|
The application uses two semaphores with separate responsibilities:
|
|
|
|
- `command-lock` serializes commands and shutdown, preventing overlapping state
|
|
transitions and 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. A state request is not serialized by `command-lock`; it may observe
|
|
the last committed state while a command is performing external I/O, but its
|
|
snapshot is internally protected by `state-lock`.
|
|
|
|
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`;
|
|
- named library root paths under `[libraries]` (the legacy semicolon-separated
|
|
setting remains supported);
|
|
- allowed 256-bit playback-agent IDs under `[playback-agents]`.
|
|
- Argon2id user hashes, local networks, trusted proxies, and session timeout.
|
|
|
|
Command-line network settings override INI values. Library paths from both
|
|
sources are combined and de-duplicated.
|
|
|
|
There is no database, migration process, user account, or persistent playlist
|
|
store. Restarting the process resets playlists, output discovery, transport
|
|
state, and all other mutable state.
|
|
|
|
## 7. Security and operational boundaries
|
|
|
|
The browser API has optional user authentication but no TLS termination or
|
|
per-user player state. 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`. Local-network bypass and 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.
|
|
|
|
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 and renderer failures
|
|
are surfaced as shared UI errors, and 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. Tests in `private/player.rkt` cover initial state, browser
|
|
navigation, repeat mode, playlist-tab operations, 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;
|
|
- add persistence behind playlist-tab and player initialization without
|
|
changing the browser's snapshot-oriented protocol;
|
|
- replace polling with server-pushed updates while keeping the current state
|
|
snapshot as the synchronization model.
|
|
|
|
## 9. Architectural constraints and trade-offs
|
|
|
|
- **Single shared state:** simple coordination and UI synchronization, but no
|
|
multi-user isolation or horizontal scaling.
|
|
- **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.
|
|
- **In-memory playlists:** minimal operational complexity, but no recovery after
|
|
restart.
|
|
- **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.
|