Files
rkt-web-player/ARCHITECTURE.md
T
2026-08-27 17:10:33 +02:00

17 KiB

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

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

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 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 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 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 uses Racket's servlet web server. It serves static assets from 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 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

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

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.