343 lines
16 KiB
Racket
343 lines
16 KiB
Racket
#lang scribble/manual
|
||
|
||
@(require (for-label racket/base
|
||
racket/contract
|
||
rkt-web-player
|
||
rkt-web-player/player-agent
|
||
rkt-web-player/set-user
|
||
rkt-web-player/users))
|
||
|
||
@title{RKT Web Player}
|
||
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
|
||
|
||
@defmodule[rkt-web-player]
|
||
|
||
RKT Web Player is a central music server for a trusted network. It makes one
|
||
or more configured music libraries available through a web interface and
|
||
plays that music on different kinds of renderers: the server's own audio
|
||
device, UPnP devices, Sonos groups, and player agents. The package also
|
||
provides those player agents as GUI and headless CLI applications, so a
|
||
computer without UPnP support can become a network renderer of its own. The
|
||
central server and each agent communicate over HTTP; the agent downloads its
|
||
assigned tracks and plays them on its local audio device.
|
||
|
||
@section{What the player provides}
|
||
|
||
The central server is the heart of the application. Its main features are:
|
||
|
||
@itemlist[
|
||
@item{Multiple named music libraries, including local and UNC/network paths.}
|
||
@item{Lazy directory browsing: startup reads only the library roots, while
|
||
recursive contents and audio metadata are read when needed.}
|
||
@item{Playback of the central library through the server's local audio
|
||
device, discovered UPnP renderers, Sonos groups, or registered agents.}
|
||
@item{Playlist tabs with adding, removing, reordering, renaming and
|
||
drag-and-drop support. Tracks from different libraries can share a
|
||
playlist. The playlist toolbar shows the selected playlist's track count
|
||
and total known playing time, rounded to minutes and expressed in hours
|
||
and minutes. Tracks without duration metadata contribute zero to the total.}
|
||
@item{Gapless network playback where the renderer or playback agent supports
|
||
preloading the next track, with a server-side fallback.}
|
||
@item{Transport controls, seeking, volume, repeat mode and live playback
|
||
status.}
|
||
@item{Embedded album art and adjacent @tt{cover}, @tt{folder} and
|
||
@tt{front} JPEG/PNG artwork.}
|
||
@item{Per-user playlists, language preference and playback pipeline, with
|
||
shared physical outputs that can be transferred between users.}
|
||
@item{Dutch, English, German, French, Spanish, Italian, Swedish, Norwegian,
|
||
Finnish and Icelandic interfaces.}
|
||
@item{Optional Argon2id browser authentication and a separate default-deny
|
||
allowlist for playback agents.}
|
||
]
|
||
|
||
The browser is a thin remote control, not a second player. It polls player
|
||
status once per second and sends commands to the central server;
|
||
playlists, preferences and playback state are kept there. The server exposes
|
||
the browser application under the root URL and its JSON API under @tt{/api}.
|
||
The API includes state, discovery, playback commands, preferences, artwork and
|
||
the agent registration, polling and media routes.
|
||
|
||
@subsection{Playlist synchronization}
|
||
|
||
State responses include an opaque @tt{playlistVersion} for the selected
|
||
playlist. The browser sends its last received version as the
|
||
@tt{playlistVersion} query parameter on @tt{/api/state}, @tt{/api/discover}
|
||
and @tt{/api/command/}@italic{command}. When that version still matches,
|
||
@tt{tracks} is JSON @tt{null}; otherwise the response includes the complete
|
||
track array. Requests without a version continue to receive all tracks.
|
||
The version and track data are returned together in one consistent snapshot.
|
||
|
||
The server caches serialized tracks, count and total duration per playlist.
|
||
Changing the track list invalidates that snapshot and generates a fresh version
|
||
when it is next requested. Renaming a tab or updating playback position does
|
||
not invalidate the tracks. Versions are renewed after a server restart and
|
||
are distinct between playlists and users. Changes from another browser are
|
||
picked up by the next poll.
|
||
|
||
The browser keeps the active playlist in memory and serializes state-producing
|
||
requests through @tt{PlayerStateClient}, so a delayed response cannot replace
|
||
newer state. Unchanged playlist rows are reused; changing the interface language
|
||
still rebuilds their translated labels.
|
||
|
||
@subsection{Saved playlists in the library}
|
||
|
||
The library has @italic{Folders} and @italic{Playlists} tabs. The playlist
|
||
toolbar's @italic{Save playlist} button names and saves the current tab in
|
||
the user's library. The library row's @tt{+} button opens that playlist as
|
||
its own tab, or selects its existing tab. @tt{▶} does the same and starts
|
||
playback. These actions never append tracks to or replace another tab.
|
||
|
||
A saved playlist and its open tab share one UUID and one track list. Track
|
||
edits and renaming therefore update the saved playlist automatically. Closing
|
||
the tab keeps it in the library. Closing the last saved tab leaves a new empty
|
||
default tab. Existing tabs remain open and appear in the library only after
|
||
the user explicitly saves them.
|
||
|
||
The keystore retains @tt{playlists-for-}@italic{username} for the ordered open
|
||
tabs and adds @tt{saved-playlists-for-}@italic{username} for library playlists.
|
||
Each UUID has one stored value, shared by both indexes. The private storage
|
||
procedure @tt{load-user-playlists} reads the open index by default and the saved
|
||
index with @tt{#:saved? #t}. @tt{save-user-playlists!} accepts the saved collection
|
||
through @tt{#:saved} and updates both indexes atomically. Values absent from
|
||
both indexes are removed. Legacy stores without a saved index need no migration.
|
||
|
||
Browser state includes @tt{savedPlaylists} summaries without track arrays and
|
||
a @tt{saved} flag on each tab. @tt{playlist-save} takes an open tab's @tt{id}
|
||
and a @tt{name}; @tt{playlist-open} and @tt{playlist-play} take a saved playlist's
|
||
@tt{id}. All three commands operate within the requesting user's collection.
|
||
|
||
@subsection{Player agents}
|
||
|
||
A player agent is an additional renderer implemented by this package. It runs
|
||
on a computer connected to speakers, registers with the central server, polls
|
||
for commands, downloads the selected music and plays it locally. Agents do not
|
||
open an inbound network port. The package supplies two forms:
|
||
|
||
@itemlist[
|
||
@item{The GUI agent, with a desktop window and system-tray integration.}
|
||
@item{The headless CLI agent, suitable for a small always-on computer or a
|
||
machine without a graphical desktop.}
|
||
]
|
||
|
||
The server treats each registered agent as an output alongside UPnP and Sonos
|
||
renderers. The server can prefetch the next track to an agent for gapless
|
||
transitions. A 256-bit application ID identifies the agent; the server's
|
||
@tt{[playback-agents]} allowlist decides which agents may connect.
|
||
|
||
@defproc[(run-web-player
|
||
[music-paths (listof (or/c path-string?
|
||
(list/c string? path-string?)))]
|
||
[#:allowed-agent-ids allowed-agent-ids (listof string?) null]
|
||
[#:users users (listof (cons/c string? string?)) null]
|
||
[#:trusted-proxies trusted-proxies (listof string?)]
|
||
[#:session-seconds session-seconds exact-positive-integer? 604800]
|
||
[#:listen-ip listen-ip string? "127.0.0.1"]
|
||
[#:port port exact-positive-integer? 8080]
|
||
[#:dlna-port dlna-port exact-positive-integer? 8734]
|
||
[#:local-output? local-output? boolean? #t]
|
||
[#:playlist-keystore playlist-keystore (or/c path-string? #f)]
|
||
[#:log-file log-file path-string?]
|
||
[#:log-retention-days log-retention-days exact-positive-integer? 7]
|
||
[#:log-level log-level symbol? 'debug]
|
||
[#:launch-browser? launch-browser? boolean? #t]) any/c]{
|
||
|
||
Treats every entry in @racket[music-paths] as a separate music library. A
|
||
two-element list supplies an explicit display name and path. The
|
||
@racket[allowed-agent-ids] list contains the 64-character application IDs that
|
||
may register as polling playback agents. All agents are denied when it is empty.
|
||
The function
|
||
starts the web interface on @racket[listen-ip] and @racket[port]. Directory
|
||
contents are browsed one level at a time. Metadata and recursive contents are
|
||
only read when the user adds or plays a selected item.
|
||
|
||
The @racket[dlna-port] is used to publish local audio files to a selected
|
||
network renderer. Set @racket[local-output?] to @racket[#f] to omit the
|
||
server's own @tt{racket-audio} output. When no other renderer is available, the
|
||
player waits for discovery or playback-agent registration. Playlist tabs are
|
||
atomically persisted in
|
||
@racket[playlist-keystore]; @racket[#f] disables playlist persistence. Player
|
||
resources are closed when the web server exits. The default is
|
||
@tt{data/playlists.keystore} below the installed rkt-web-player collection.
|
||
Each username owns an ordered GUID index and separate playlist values.
|
||
It also owns an independent playback pipeline and a durable interface-language
|
||
preference. Different outputs can play concurrently. Selecting an output that
|
||
another user owns stops the previous backend and transfers that output. The web
|
||
interface supports Dutch, English, German, French, Spanish, Italian, Swedish,
|
||
Norwegian, Finnish and Icelandic; browser language is the initial default and a
|
||
manual choice is stored per username.
|
||
When @racket[users] is non-empty, every browser client must authenticate.
|
||
Sessions have a sliding idle timeout; an active browser cookie is renewed once
|
||
half of @racket[session-seconds] has elapsed. Playback-agent endpoints continue
|
||
to use their separate application-ID allowlist.
|
||
|
||
Logging is written to @racket[log-file]. Files are rotated after
|
||
@racket[log-retention-days] days, and @racket[log-level] selects the
|
||
@tt{simple-log} level. The default log file is
|
||
@tt{data/rkt-web-player.log} below the installed collection. The
|
||
@racket[playlist-keystore], @racket[log-file] and @racket[log-level] arguments
|
||
are useful when embedding the player; the command-line entry point can also
|
||
read these values from an INI file.
|
||
|
||
The default listen address only exposes the interface to the local computer.
|
||
Use a LAN address deliberately if other devices should control the player.
|
||
}
|
||
|
||
@section{Command line and INI configuration}
|
||
|
||
The package entry point accepts one or more music directories:
|
||
|
||
@verbatim{
|
||
racket main.rkt MUSIC-DIRECTORY [...]
|
||
racket main.rkt --config rkt-web-player.ini --no-browser
|
||
}
|
||
|
||
The @tt{--config} option reads defaults from an INI file. The file is normally
|
||
named @tt{rkt-web-player.ini} and is read from the current directory when the
|
||
server is started with @tt{--config}. Relative paths are interpreted in the
|
||
process's working directory.
|
||
|
||
Command-line values for @tt{--listen-ip}, @tt{--port}, @tt{--dlna-port},
|
||
@tt{--playlist-keystore}, @tt{--log-file} and @tt{--no-browser} override the
|
||
corresponding defaults. Logging retention, log level, authentication and the
|
||
playback-agent allowlist are configured in the INI file. Libraries configured
|
||
under @tt{[libraries]} use the INI key as their display name and the value as
|
||
the root directory. The legacy @tt{[library] paths} setting accepts a
|
||
semicolon-separated list and is also supported. A complete example is:
|
||
|
||
@verbatim{
|
||
[server]
|
||
listen-ip=127.0.0.1
|
||
port=8080
|
||
|
||
[player]
|
||
dlna-port=8734
|
||
local-output=true
|
||
playlist-keystore=data/playlists.keystore
|
||
|
||
[logging]
|
||
log-file=data/rkt-web-player.log
|
||
log-retention-days=7
|
||
log-level=debug
|
||
|
||
[authentication]
|
||
trusted-proxies=127.0.0.0/8;::1/128
|
||
session-seconds=604800
|
||
|
||
[playback-agents]
|
||
; 64 hexadecimal characters, copied from an agent
|
||
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef=true
|
||
|
||
[users]
|
||
; Generate hashes with make-password-hash or set-user.
|
||
hans=$argon2id$v=19$m=19456,t=2,p=1$...
|
||
}
|
||
|
||
The @tt{[server]} section controls the browser server. The default
|
||
@tt{listen-ip} is @tt{127.0.0.1}, so the interface is local-only by default;
|
||
@tt{0.0.0.0} or a LAN address makes it reachable from other machines.
|
||
@tt{port} defaults to @tt{8080}.
|
||
|
||
The @tt{[player]} section controls playback. @tt{dlna-port} defaults to
|
||
@tt{8734} and must be reachable by network renderers when they play files
|
||
published by the server. @tt{local-output=true} (the default) adds the
|
||
server's own audio device as an output. @tt{playlist-keystore} selects the
|
||
keystore for playlist tabs and language preferences; omission uses
|
||
@tt{data/playlists.keystore} below the installed collection. When embedding
|
||
the player through @racket[run-web-player], passing @racket[#f] disables
|
||
persistence.
|
||
|
||
The @tt{[logging]} section controls the rotating log file. The default file is
|
||
@tt{data/rkt-web-player.log}, retention defaults to seven days, and the default
|
||
level is @tt{debug}. The level is passed to @tt{simple-log}; use a level
|
||
supported by that package.
|
||
|
||
The @tt{[playback-agents]} section is a default-deny allowlist. Each key must
|
||
be the complete 64-character hexadecimal application ID of an agent and a
|
||
value other than @tt{false} permits registration. An empty section rejects all
|
||
agents. This ID is a shared bearer credential, not a replacement for HTTPS.
|
||
|
||
The @tt{[authentication]} section enables browser login when @tt{[users]} has
|
||
at least one entry. Each user value is an Argon2id hash, not a plaintext
|
||
password. @tt{session-seconds} defaults to seven days and is a sliding idle
|
||
timeout. @tt{trusted-proxies} is a semicolon-separated list of IP addresses or
|
||
CIDR ranges whose forwarded client address may be trusted; keep it limited to
|
||
the actual reverse proxy. The application does not terminate TLS, so use
|
||
HTTPS at the reverse proxy before exposing an authenticated instance beyond a
|
||
trusted LAN.
|
||
|
||
Create a password hash with @racket[make-password-hash], or run
|
||
@racket[set-user] in the directory containing the INI file. The
|
||
@racket[set-user] procedure updates @tt{rkt-web-player.ini} interactively.
|
||
|
||
@subsection{Playback-agent INI file}
|
||
|
||
The GUI and CLI agents use a separate private INI file named
|
||
@tt{rkt-web-player-agent.ini} in the user's Racket configuration directory.
|
||
It contains the following values:
|
||
|
||
@verbatim{
|
||
[server]
|
||
url=http://127.0.0.1:8080
|
||
|
||
[agent]
|
||
name=My playback
|
||
app-id=64 hexadecimal characters
|
||
}
|
||
|
||
The agent generates @tt{app-id} once and reuses it so the server allowlist
|
||
continues to work. The GUI writes the server URL and display name; the CLI can
|
||
override them with @tt{--server} and @tt{--name}. Use @tt{--config} with the
|
||
CLI to select another agent INI file and therefore another identity. Agents
|
||
make only outbound HTTP requests: they register and poll the server, download
|
||
assigned tracks, and play them locally. They do not open an inbound port.
|
||
|
||
@defmodule[rkt-web-player/users]
|
||
|
||
@defproc[(make-password-hash [password string?]) string?] {
|
||
|
||
Creates a salted Argon2id password hash suitable for a value in the INI
|
||
@tt{[users]} section. Passwords must contain at least twelve characters.
|
||
}
|
||
|
||
@defproc[(password-hash-valid? [password string?]
|
||
[encoded string?]) boolean?] {
|
||
|
||
Checks a password against an encoded Argon2id hash.
|
||
}
|
||
|
||
@defmodule[rkt-web-player/set-user]
|
||
|
||
@defproc[(set-user) void?] {
|
||
|
||
Interactively reads a username and password and writes the corresponding
|
||
Argon2id hash to the @tt{[users]} section of @filepath{rkt-web-player.ini} in
|
||
the current directory. The password must contain at least twelve characters.
|
||
}
|
||
|
||
@defmodule[rkt-web-player/player-agent]
|
||
|
||
@defproc[(run-player-agent) any/c] {
|
||
|
||
Starts the graphical polling playback agent. The agent keeps a generated
|
||
256-bit application identifier in a user-specific INI file, registers with the
|
||
configured RKT Web Player server, downloads assigned tracks over HTTP, and
|
||
plays them with @tt{racket-audio}. Its configured display name is authoritative
|
||
and is followed by the server. Importing the module does not start the GUI; the
|
||
function must be called explicitly.
|
||
The GUI and its @tt{racket-tray} system tray support the same ten languages
|
||
based on the operating-system language, with English as fallback. Closing or
|
||
minimizing the window hides it in the tray. The tray menu restores the window
|
||
or shuts down the agent; no SDL3 runtime is required.
|
||
}
|
||
|
||
@defproc[(run-player-agent-cli [#:server-url server-url
|
||
(or/c string? #f) #f]
|
||
[#:name name (or/c string? #f) #f]
|
||
[#:config-file config-file
|
||
(or/c path-string? #f) #f]) void?] {
|
||
|
||
Starts a headless playback agent using the same audio and gapless-prefetch
|
||
runtime as the GUI. Missing keyword values are read from the normal agent INI
|
||
file. The procedure runs until interrupted.
|
||
}
|