multi player / per user playing and translation

This commit is contained in:
2026-08-28 16:53:05 +02:00
parent 2743dfb4c0
commit e10f2cf7d7
17 changed files with 1432 additions and 482 deletions
+61 -37
View File
@@ -8,9 +8,10 @@ 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.
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
@@ -29,7 +30,7 @@ flowchart LR
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.
the browser stores neither playlists nor language preferences.
## 3. Runtime structure
@@ -42,7 +43,7 @@ flowchart TB
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<br/>browser UI]
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]
@@ -120,11 +121,12 @@ 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 the selected renderer;
- discovered renderers and per-renderer session ownership;
- 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;
- 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.
@@ -140,19 +142,29 @@ 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.
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.
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.
Network outputs use the small `private/dlna-playback.rkt` adapter. It owns only
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
@@ -168,9 +180,9 @@ 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 the selected renderer closes the existing backend and resets playback
state. The replacement backend remains lazy and is created only when it is
needed.
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
@@ -200,7 +212,7 @@ thread so that the initial API call can return immediately. It:
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.
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.
@@ -215,6 +227,8 @@ serves static assets from [`public/`](public/) and exposes these API endpoints:
| `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. |
@@ -234,6 +248,14 @@ header. Command failures are returned as HTTP 400 JSON responses with an
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.
@@ -309,16 +331,15 @@ sequenceDiagram
The application uses two semaphores with separate responsibilities:
- `command-lock` serializes commands and shutdown, preventing overlapping state
transitions and backend operations.
- `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. 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`.
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
@@ -333,6 +354,7 @@ 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]`;
@@ -342,9 +364,9 @@ file:
Command-line network settings override INI values. Library paths from both
sources are combined and de-duplicated.
Playlist tabs use the SQLite-backed `keystore` module at
`data/playlists.keystore`. Output discovery, transport state, playback position
and sessions still reset when the process restarts.
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
@@ -353,8 +375,9 @@ 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 are isolated by username, while
renderer selection and transport state remain shared.
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
@@ -371,18 +394,19 @@ 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.
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. Tests in `private/player.rkt`
validation, and per-user language storage. Tests in `private/player.rkt`
cover initial state, browser navigation, repeat mode, persistent playlist-tab
operations, unknown commands, and clean shutdown. The current suite does not
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.
@@ -398,9 +422,9 @@ The main extension points are:
## 9. Architectural constraints and trade-offs
- **Per-user playlists, shared transport:** playlist collections are isolated
by username, while the renderer and transport remain shared. A playlist
command from another user explicitly takes over that shared player.
- **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