Adding a local player agent
This commit is contained in:
+344
@@ -0,0 +1,344 @@
|
||||
# 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]
|
||||
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]
|
||||
Agent[player-agent.rkt<br/>polling Windows renderer]
|
||||
|
||||
Main --> Server
|
||||
Main --> Player
|
||||
Main --> Library
|
||||
Server --> Player
|
||||
Server --> UI
|
||||
Player --> Library
|
||||
Player --> Audio
|
||||
Player --> Discovery
|
||||
Player --> DLNA
|
||||
Agent --> Server
|
||||
Agent --> 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.
|
||||
|
||||
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.
|
||||
|
||||
### 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 three JSON 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. |
|
||||
|
||||
Responses use `Cache-Control: no-store`. Command failures are returned as HTTP
|
||||
400 JSON responses with an `error` property. Unexpected failures are currently
|
||||
reported through the same client-facing mechanism.
|
||||
|
||||
[`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.
|
||||
|
||||
## 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`;
|
||||
- one or more library root paths.
|
||||
|
||||
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 service has no authentication, authorization, TLS termination, CSRF
|
||||
protection, or per-user state. Anyone who can reach the HTTP port can inspect
|
||||
the exposed library names and control the shared player. The default localhost
|
||||
binding is therefore an important security boundary. Binding to a LAN address
|
||||
should be an explicit deployment decision and should use an external trusted
|
||||
network boundary or authenticated reverse proxy when untrusted clients are
|
||||
possible.
|
||||
|
||||
Playback-agent IDs are random identifiers and media URLs additionally contain
|
||||
an opaque per-track token. They prevent accidental cross-agent media access but
|
||||
must not be treated as authentication when transported over unencrypted HTTP.
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user