ini file support and cover art

This commit is contained in:
2026-08-27 13:33:34 +02:00
parent 744a2815f1
commit 84891adb89
13 changed files with 422 additions and 79 deletions
+29 -17
View File
@@ -81,7 +81,9 @@ audio metadata extraction. Its principal domain types are:
- `music-library`: a stable generated ID, display name, and absolute root path; - `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; - `browser-entry`: the name, kind, and root-relative path of a visible item;
- `track`: the absolute source file and its display/playback metadata. - `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: Library loading is deliberately lazy:
@@ -155,7 +157,13 @@ 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 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 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 file. The server follows the name advertised by the agent and does not own a
separate name mapping. 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 ### 3.5 Device discovery
@@ -176,7 +184,7 @@ are recorded in the shared player error field.
### 3.6 HTTP and browser layers ### 3.6 HTTP and browser layers
[`private/server.rkt`](private/server.rkt) uses Racket's servlet web server. It [`private/server.rkt`](private/server.rkt) uses Racket's servlet web server. It
serves static assets from [`public/`](public/) and exposes three JSON endpoints: serves static assets from [`public/`](public/) and exposes these API endpoints:
| Method | Route | Responsibility | | Method | Route | Responsibility |
| --- | --- | --- | | --- | --- | --- |
@@ -186,10 +194,12 @@ serves static assets from [`public/`](public/) and exposes three JSON endpoints:
| `POST` | `/api/agent/register` | Register or refresh a polling playback agent. | | `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. | | `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/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. |
Responses use `Cache-Control: no-store`. Command failures are returned as HTTP JSON responses use `Cache-Control: no-store`; artwork has a private cache
400 JSON responses with an `error` property. Unexpected failures are currently header. Command failures are returned as HTTP 400 JSON responses with an
reported through the same client-facing mechanism. `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: [`public/app.js`](public/app.js) implements a framework-free client. It:
@@ -274,7 +284,9 @@ file:
- web listen address, defaulting to `127.0.0.1`; - web listen address, defaulting to `127.0.0.1`;
- web port, defaulting to `8080`; - web port, defaulting to `8080`;
- DLNA media publication port, defaulting to `8734`; - DLNA media publication port, defaulting to `8734`;
- one or more library root paths. - named library root paths under `[libraries]` (the legacy semicolon-separated
setting remains supported);
- allowed 256-bit playback-agent IDs under `[playback-agents]`.
Command-line network settings override INI values. Library paths from both Command-line network settings override INI values. Library paths from both
sources are combined and de-duplicated. sources are combined and de-duplicated.
@@ -285,17 +297,17 @@ state, and all other mutable state.
## 7. Security and operational boundaries ## 7. Security and operational boundaries
The service has no authentication, authorization, TLS termination, CSRF The browser API has no user authentication, TLS termination, CSRF protection,
protection, or per-user state. Anyone who can reach the HTTP port can inspect or per-user state. Anyone who can reach the HTTP port can inspect the exposed
the exposed library names and control the shared player. The default localhost library names and control the shared player. The default localhost binding is
binding is therefore an important security boundary. Binding to a LAN address therefore an important security boundary. Binding to a LAN address should be an
should be an explicit deployment decision and should use an external trusted explicit deployment decision and should use an external trusted network
network boundary or authenticated reverse proxy when untrusted clients are boundary or authenticated reverse proxy when untrusted clients are possible.
possible.
Playback-agent IDs are random identifiers and media URLs additionally contain Playback-agent registration and polling are authorized against a default-deny
an opaque per-track token. They prevent accidental cross-agent media access but INI allowlist. Media URLs additionally contain an opaque per-track token. The
must not be treated as authentication when transported over unencrypted HTTP. random application ID therefore acts as a shared bearer credential, but must
not be treated as strong authentication when transported over unencrypted HTTP.
The configured library roots define the intended filesystem boundary. Clients The configured library roots define the intended filesystem boundary. Clients
operate on opaque indexes instead of sending paths directly. The DLNA backend operate on opaque indexes instead of sending paths directly. The DLNA backend
+25 -11
View File
@@ -33,12 +33,18 @@ port=8080
[player] [player]
dlna-port=8734 dlna-port=8734
[library] [libraries]
paths=D:\Muziek;D:\Podcasts muziek=D:\Muziek
podcasts=D:\Podcasts
[playback-agents]
7b4776ef27104e8eb9f7ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true
``` ```
Start dat bestand met `racket main.rkt --config rkt-web-player.ini`. Meerdere Start dat bestand met `racket main.rkt --config rkt-web-player.ini`. Iedere key
bibliotheekpaden worden met een puntkomma gescheiden. onder `[libraries]` is de zichtbare bibliotheeknaam; de waarde is de lokale of
UNC-rootmap. Het oudere `[library] paths=D:\Muziek;D:\Podcasts` blijft eveneens
ondersteund.
## Werking ## Werking
@@ -47,7 +53,9 @@ responsive en geschikt voor muis, toetsenbord en touch. Links staat de lazy
mappenbrowser met trackinformatie; rechts staan playlisttabs en de huidige mappenbrowser met trackinformatie; rechts staan playlisttabs en de huidige
playlist. Een map wordt pas recursief gelezen wanneer die met **afspelen** of playlist. Een map wordt pas recursief gelezen wanneer die met **afspelen** of
**toevoegen** wordt gekozen. Trackmetadata wordt eveneens pas op dat moment **toevoegen** wordt gekozen. Trackmetadata wordt eveneens pas op dat moment
geladen. geladen. Bij de huidige track toont de webinterface embedded album-art uit de
audio-tags. Als die ontbreekt, worden naast het audiobestand ook `cover`,
`folder` en `front` met een JPEG- of PNG-extensie geprobeerd.
De webinterface praat met een kleine JSON-API onder `/api`. Lokale weergave De webinterface praat met een kleine JSON-API onder `/api`. Lokale weergave
wordt pas geïnitialiseerd bij het eerste afspeelcommando. De knop naast de wordt pas geïnitialiseerd bij het eerste afspeelcommando. De knop naast de
@@ -60,7 +68,8 @@ drag-and-drop verplaatst. De tabs zijn in deze versie alleen in het geheugen
aanwezig en worden niet na een herstart hersteld. aanwezig en worden niet na een herstart hersteld.
De server luistert standaard alleen op localhost. Geef alleen bewust een De server luistert standaard alleen op localhost. Geef alleen bewust een
LAN-adres aan `--listen-ip`; de huidige opzet bevat geen authenticatie. LAN-adres aan `--listen-ip`; de webinterface bevat nog geen
gebruikersauthenticatie.
## Windows playback agent ## Windows playback agent
@@ -90,7 +99,11 @@ De GUI bewaart de server-URL, de gekozen naam en een eenmalig gegenereerde
Racket-configuratiemap. Vul als server bijvoorbeeld `http://192.168.1.10:8080` Racket-configuratiemap. Vul als server bijvoorbeeld `http://192.168.1.10:8080`
in. De naam wordt in dezelfde agent-GUI ingesteld. Na registratie verschijnt in. De naam wordt in dezelfde agent-GUI ingesteld. Na registratie verschijnt
de agent met die naam als uitvoer van type `AGENT`; de server volgt latere de agent met die naam als uitvoer van type `AGENT`; de server volgt latere
naamswijzigingen bij registratie en polling. naamswijzigingen bij registratie en polling. De server laat een agent alleen
toe wanneer zijn volledige applicatie-ID vooraf als key onder
`[playback-agents]` staat en de waarde niet `false` is. Een onbekende ID krijgt
HTTP 403 en wordt niet als uitvoerpunt aangemaakt. De agent toont in dat geval
zijn ID en meldt dat de serverbeheerder deze eerst aan de INI moet toevoegen.
Onder **Afspelen** toont de agent het playlistnummer, de huidige track en Onder **Afspelen** toont de agent het playlistnummer, de huidige track en
bestandsnaam, afspeeltoestand, verstreken en totale tijd, bitdiepte, bestandsnaam, afspeeltoestand, verstreken en totale tijd, bitdiepte,
@@ -110,10 +123,11 @@ opgeruimd zodra ze niet meer nodig zijn en bij afsluiten van de agent.
Een systeemvakfunctie is voorlopig niet opgenomen. De agent start geen Een systeemvakfunctie is voorlopig niet opgenomen. De agent start geen
PowerShell-proces of andere externe tray-helper. PowerShell-proces of andere externe tray-helper.
De applicatie-ID identificeert de agent en begrenst toegang tot zijn tijdelijke De allowlist voorkomt dat een onbekende agent zich als uitvoerpunt registreert
media-URL, maar vervangt geen transportbeveiliging of authenticatie. Gebruik de of opdrachten en tijdelijke media ontvangt. Het applicatie-ID is een gedeeld
agent en server alleen op een vertrouwd LAN zolang de HTTP-server geen TLS en toegangstoken en vervangt geen transportbeveiliging of gebruikersauthenticatie.
gebruikersauthenticatie heeft. Gebruik de agent en server alleen op een vertrouwd LAN zolang de HTTP-server
geen TLS heeft.
## Controleren ## Controleren
+41 -10
View File
@@ -3,6 +3,7 @@
(require racket/cmdline (require racket/cmdline
racket/contract racket/contract
racket/list racket/list
racket/mpair
racket/string racket/string
simple-ini simple-ini
simple-log simple-log
@@ -14,29 +15,47 @@
(sl-def-log rkt-web-player) (sl-def-log rkt-web-player)
(define library-spec/c
(or/c path-string?
(list/c string? path-string?)))
(define (ini-section-key-values config section-name)
(let ((section (assoc section-name (mcdr config))))
(if section
(for/list ((line (in-list (cdr section)))
#:when (and (pair? line)
(eq? (car line) 'keyval)))
(cons (symbol->string (cadr line))
(caddr line)))
'())))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Provided functions ;; Provided functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Start the audio player and its web interface. ; goal : Start the audio player and its web interface.
; pre : The ports are valid TCP ports; every music path is an existing directory. ; pre : Ports are valid; every library spec names an existing directory.
; post : Libraries are browsed lazily; player resources close with the server. ; post : Libraries are browsed lazily; player resources close with the server.
; result : The result returned by serve/servlet. ; result : The result returned by serve/servlet.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define/contract (run-web-player music-paths (define/contract (run-web-player music-paths
#:allowed-agent-ids [allowed-agent-ids '()]
#:listen-ip [listen-ip "127.0.0.1"] #:listen-ip [listen-ip "127.0.0.1"]
#:port [port 8080] #:port [port 8080]
#:dlna-port [dlna-port 8734] #:dlna-port [dlna-port 8734]
#:launch-browser? [launch-browser? #t]) #:launch-browser? [launch-browser? #t])
(->* ((listof path-string?)) (->* ((listof library-spec/c))
(#:listen-ip string? (#:allowed-agent-ids (listof string?)
#:listen-ip string?
#:port exact-positive-integer? #:port exact-positive-integer?
#:dlna-port exact-positive-integer? #:dlna-port exact-positive-integer?
#:launch-browser? boolean?) #:launch-browser? boolean?)
any) any)
(let* ((libraries (make-music-libraries music-paths)) (let* ((libraries (make-music-libraries music-paths))
(player (make-player libraries #:dlna-port dlna-port))) (player (make-player libraries
#:allowed-agent-ids allowed-agent-ids
#:dlna-port dlna-port)))
(info-rkt-web-player (info-rkt-web-player
"Starting with ~a music library/libraries on http://~a:~a/" "Starting with ~a music library/libraries on http://~a:~a/"
(length libraries) (length libraries)
@@ -103,15 +122,27 @@
(string-split configured-paths-value ";"))) (string-split configured-paths-value ";")))
(else '()))) (else '())))
(define all-paths (define configured-libraries
(remove-duplicates (for/list ((entry (in-list
(append music-paths (ini-section-key-values config 'libraries)))
configured-paths) #:when (path-string? (cdr entry)))
equal?)) (list (car entry) (cdr entry))))
(define allowed-agent-ids
(for/list ((entry (in-list
(ini-section-key-values config 'playback-agents)))
#:when (not (eq? (cdr entry) #f)))
(car entry)))
(define all-libraries
(append configured-libraries
music-paths
configured-paths))
(sl-log-to-display) (sl-log-to-display)
(run-web-player (run-web-player
all-paths all-libraries
#:allowed-agent-ids allowed-agent-ids
#:listen-ip (or listen-ip #:listen-ip (or listen-ip
(ini-get config 'server 'listen-ip "127.0.0.1")) (ini-get config 'server 'listen-ip "127.0.0.1"))
#:port (or port #:port (or port
+95 -10
View File
@@ -10,9 +10,11 @@
(provide (struct-out music-library) (provide (struct-out music-library)
(struct-out browser-entry) (struct-out browser-entry)
(struct-out track) (struct-out track)
(struct-out artwork)
make-music-libraries make-music-libraries
browse-library browse-library
browser-entry->tracks) browser-entry->tracks
track-artwork)
(struct music-library (struct music-library
(id name root) (id name root)
@@ -26,6 +28,10 @@
(file title artist album duration mime-type) (file title artist album duration mime-type)
#:transparent) #:transparent)
(struct artwork
(mime-type data)
#:transparent)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Supporting functions ;; Supporting functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -33,6 +39,11 @@
(define supported-extensions (define supported-extensions
(audio-supported-extensions)) (audio-supported-extensions))
(define cover-file-names
'("cover.jpg" "cover.jpeg" "cover.png"
"folder.jpg" "folder.jpeg" "folder.png"
"front.jpg" "front.jpeg" "front.png"))
(define (audio-file? file) (define (audio-file? file)
(let ((extension (path-get-extension file))) (let ((extension (path-get-extension file)))
(and extension (and extension
@@ -128,20 +139,38 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Turn configured directory paths into music libraries. ; goal : Turn configured directory paths into music libraries.
; pre : Every value is a path-string naming an existing directory. ; pre : Every value is a path or a (display-name path) list.
; post : No directory contents or audio metadata have been read. ; post : No directory contents or audio metadata have been read.
; result : Libraries in configuration order, without duplicate roots. ; result : Libraries in configuration order, without duplicate roots.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (make-music-libraries paths) (define (make-music-libraries specifications)
(define (specification-values specification)
(if (and (list? specification)
(= (length specification) 2)
(string? (car specification))
(path-string? (cadr specification)))
(values (string-trim (car specification))
(cadr specification))
(if (path-string? specification)
(values #f specification)
(raise-argument-error
'make-music-libraries
"(or/c path-string? (list/c string? path-string?))"
specification))))
(let ((roots (let ((roots
(remove-duplicates (remove-duplicates
(map (λ (path) (for/list ((specification (in-list specifications)))
(let-values (((name path)
(specification-values specification)))
(list name
(normal-case-path (normal-case-path
(path->complete-path path))) (path->complete-path path)))))
paths) (lambda (first second)
equal?))) (equal? (cadr first) (cadr second))))))
(for/list ((root (in-list roots)) (for/list ((named-root (in-list roots))
(index (in-naturals))) (index (in-naturals)))
(define configured-name (car named-root))
(define root (cadr named-root))
(unless (directory-exists? root) (unless (directory-exists? root)
(raise-arguments-error (raise-arguments-error
'make-music-libraries 'make-music-libraries
@@ -150,11 +179,56 @@
(let ((name (file-name-from-path root))) (let ((name (file-name-from-path root)))
(music-library (music-library
(format "library-~a" index) (format "library-~a" index)
(if (and configured-name
(not (string=? configured-name "")))
configured-name
(if name (if name
(path->string name) (path->string name)
(path->string root)) (path->string root)))
root))))) root)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Read the artwork associated with a track.
; pre : Item names a local audio file.
; post : The audio file and optional neighbouring image remain unchanged.
; result : Embedded artwork, a conventional folder cover, or #f.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (track-artwork item)
(define embedded
(with-handlers ((exn:fail? (λ (_) #f)))
(call-with-id3-tags
(track-file item)
(λ (tags)
(let ((picture (and (tags-valid? tags)
(tags-picture tags))))
(and picture
(artwork (let ((mime (id3-picture-mimetype picture)))
(if (and (string? mime)
(not (string=? mime "")))
mime
"application/octet-stream"))
(id3-picture-bytes picture))))))))
(or embedded
(with-handlers ((exn:fail? (λ (_) #f)))
(let* ((directory (or (path-only (track-file item))
(current-directory)))
(cover
(findf
(λ (candidate)
(let ((name (file-name-from-path candidate)))
(and name
(file-exists? candidate)
(member (path->string name)
cover-file-names
string-ci=?))))
(directory-list directory #:build? #t))))
(and cover
(let ((mime (mimetype-for-ext cover)))
(artwork (if (string? mime)
mime
"application/octet-stream")
(file->bytes cover))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : List the immediate folders and supported audio files in a library. ; goal : List the immediate folders and supported audio files in a library.
; pre : Relative-path was produced by a previous browse result. ; pre : Relative-path was produced by a previous browse result.
@@ -217,6 +291,17 @@
(check-equal? (browser-entry-name (car entries)) "Album") (check-equal? (browser-entry-name (car entries)) "Album")
(check-eq? (browser-entry-kind (car entries)) 'container) (check-eq? (browser-entry-kind (car entries)) 'container)
(check-equal? (browser-entry-name (cadr entries)) "track.mp3") (check-equal? (browser-entry-name (cadr entries)) "track.mp3")
(check-eq? (browser-entry-kind (cadr entries)) 'track))) (check-eq? (browser-entry-kind (cadr entries)) 'track)
(check-equal?
(music-library-name
(car (make-music-libraries
(list (list "Luisterkamer" root)))))
"Luisterkamer")
(check-equal?
(artwork-mime-type
(track-artwork
(track (build-path root "track.mp3")
"Track" "" "" #f "audio/mpeg")))
"image/jpeg")))
(λ () (λ ()
(delete-directory/files root)))) (delete-directory/files root))))
+36 -2
View File
@@ -18,6 +18,9 @@
(provide run-player-agent-gui) (provide run-player-agent-gui)
(struct exn:fail:agent-denied exn:fail ()
#:transparent)
(define (input-field label init-val panel) (define (input-field label init-val panel)
(let ((tf (new text-field% (let ((tf (new text-field%
@@ -91,8 +94,14 @@
(let ((response (read-json input))) (let ((response (read-json input)))
(when (and (hash? response) (when (and (hash? response)
(string? (hash-ref response 'error #f))) (string? (hash-ref response 'error #f)))
(if (equal? (hash-ref response 'code #f)
"agent-not-authorized")
(raise
(exn:fail:agent-denied
(hash-ref response 'error)
(current-continuation-marks)))
(error 'player-agent (error 'player-agent
(hash-ref response 'error))) (hash-ref response 'error))))
response)) response))
(λ () (λ ()
(close-input-port input))))) (close-input-port input)))))
@@ -122,6 +131,7 @@
(define command-worker #f) (define command-worker #f)
(define executing-command-id 0) (define executing-command-id 0)
(define running? #f) (define running? #f)
(define authorization-notified? #f)
(define audio #f) (define audio #f)
(define temporary-media #f) (define temporary-media #f)
(define current-media-key #f) (define current-media-key #f)
@@ -509,7 +519,30 @@
(define (poll-loop) (define (poll-loop)
(with-handlers (with-handlers
((exn:fail? ((exn:fail:agent-denied?
(λ (exception)
(define message
(string-append
"Deze playback agent is niet toegelaten door de server. "
"Voeg het volgende applicatie-ID toe aan [playback-agents] "
"in de server-INI:\n\n"
app-id))
(warn-player-agent "Agent authorization refused: ~a"
(exn-message exception))
(set-agent-error! message)
(show-status! "Niet geautoriseerd — applicatie-ID staat niet in de server-INI")
(unless authorization-notified?
(set! authorization-notified? #t)
(queue-callback
(λ ()
(message-box "Playback agent niet toegestaan"
message
frame
'(ok stop)))))
(when running?
(sleep 3)
(poll-loop))))
(exn:fail?
(λ (exception) (λ (exception)
(warn-player-agent "Connection cycle failed: ~a" (warn-player-agent "Connection cycle failed: ~a"
(exn-message exception)) (exn-message exception))
@@ -588,6 +621,7 @@
(define (reconnect!) (define (reconnect!)
(stop-worker!) (stop-worker!)
(set! authorization-notified? #f)
(set! server-url (string-trim (send server-field get-value))) (set! server-url (string-trim (send server-field get-value)))
(let ((new-name (string-trim (send name-field get-value)))) (let ((new-name (string-trim (send name-field get-value))))
(set! assigned-name (set! assigned-name
+81 -15
View File
@@ -19,6 +19,8 @@
player-agent-register! player-agent-register!
player-agent-poll! player-agent-poll!
player-agent-media player-agent-media
player-track-artwork
exn:fail:agent-denied?
player-close!) player-close!)
(sl-def-log web-player) (sl-def-log web-player)
@@ -38,12 +40,16 @@
[ended-counter #:mutable]) [ended-counter #:mutable])
#:transparent) #:transparent)
(struct exn:fail:agent-denied exn:fail ()
#:transparent)
(struct playlist-tab (struct playlist-tab
(id [name #:mutable] [tracks #:mutable]) (id [name #:mutable] [tracks #:mutable])
#:transparent) #:transparent)
(struct player (struct player
(libraries (libraries
allowed-agent-ids
[agents #:mutable] [agents #:mutable]
[current-library-id #:mutable] [current-library-id #:mutable]
[browser-path #:mutable] [browser-path #:mutable]
@@ -103,6 +109,25 @@
(and (string? value) (and (string? value)
(regexp-match? #px"^[0-9a-fA-F]{64}$" value))) (regexp-match? #px"^[0-9a-fA-F]{64}$" value)))
(define (normal-agent-id value)
(and (valid-agent-id? value)
(string-downcase value)))
(define (authorized-agent-id? value app-id)
(and (normal-agent-id app-id)
(member (normal-agent-id app-id)
(player-allowed-agent-ids value))
#t))
(define (require-authorized-agent! value app-id)
(unless (authorized-agent-id? value app-id)
(raise
(exn:fail:agent-denied
(format
"Playback agent ~a is niet toegestaan; voeg het applicatie-ID eerst toe aan [playback-agents] in de server-INI"
(or app-id "(ontbreekt)"))
(current-continuation-marks)))))
(define (fresh-media-token) (define (fresh-media-token)
(bytes->hex-string (crypto-random-bytes 32))) (bytes->hex-string (crypto-random-bytes 32)))
@@ -736,6 +761,7 @@
'album (track-album item) 'album (track-album item)
'duration (or (track-duration item) 'null) 'duration (or (track-duration item) 'null)
'mimeType (track-mime-type item) 'mimeType (track-mime-type item)
'artworkId (track-cache-key item)
'source (path->string 'source (path->string
(or (file-name-from-path (track-file item)) (or (file-name-from-path (track-file item))
(track-file item))))) (track-file item)))))
@@ -1011,11 +1037,21 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Create a player for lazily browsed music libraries. ; goal : Create a player for lazily browsed music libraries.
; pre : Libraries is a list of music-library values; DLNA port is positive. ; pre : Libraries are valid; allowed agent IDs are 256-bit hex strings.
; post : Only the selected root directory has been listed; no backend exists. ; post : Only the selected root directory has been listed; no backend exists.
; result : A player that initially selects local playback. ; result : A player that initially selects local playback.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (make-player libraries #:dlna-port [dlna-port 8734]) (define (make-player libraries
#:allowed-agent-ids [allowed-agent-ids '()]
#:dlna-port [dlna-port 8734])
(define normalized-agent-ids
(for/list ((app-id (in-list allowed-agent-ids)))
(unless (valid-agent-id? app-id)
(raise-argument-error
'make-player
"64-character hexadecimal playback agent id"
app-id))
(string-downcase app-id)))
(let* ((library (and (pair? libraries) (car libraries))) (let* ((library (and (pair? libraries) (car libraries)))
(browser-entries (browser-entries
(if library (if library
@@ -1023,6 +1059,7 @@
'())) '()))
(tab (playlist-tab "default" "Default" '()))) (tab (playlist-tab "default" "Default" '())))
(player libraries (player libraries
(remove-duplicates normalized-agent-ids string=?)
'() '()
(and library (music-library-id library)) (and library (music-library-id library))
'() '()
@@ -1184,12 +1221,13 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Register or refresh one polling playback agent. ; goal : Register or refresh one polling playback agent.
; pre : Data contains a 256-bit hexadecimal application id. ; pre : Data contains an allowlisted 256-bit hexadecimal application id.
; post : The agent is available as a renderer under its advertised name. ; post : The agent is available as a renderer under its advertised name.
; result : Agent configuration for the polling client. ; result : Agent configuration for the polling client.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (player-agent-register! value data) (define (player-agent-register! value data)
(let* ((app-id (json-string data 'appId #f)) (let* ((supplied-app-id (json-string data 'appId #f))
(app-id (normal-agent-id supplied-app-id))
(suggested-name (suggested-name
(string-trim (string-trim
(or (json-string data 'name #f) (or (json-string data 'name #f)
@@ -1198,11 +1236,12 @@
(if (string=? suggested-name "") (if (string=? suggested-name "")
"RKT playback agent" "RKT playback agent"
suggested-name))) suggested-name)))
(unless (valid-agent-id? app-id) (unless app-id
(raise-arguments-error (raise-arguments-error
'player-agent-register! 'player-agent-register!
"appId must contain exactly 64 hexadecimal characters" "appId must contain exactly 64 hexadecimal characters"
"appId" app-id)) "appId" supplied-app-id))
(require-authorized-agent! value app-id)
(with-state-lock (with-state-lock
value value
(λ () (λ ()
@@ -1247,13 +1286,15 @@
; result : Poll response containing the current agent name and optional command. ; result : Poll response containing the current agent name and optional command.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (player-agent-poll! value data) (define (player-agent-poll! value data)
(let ((app-id (json-string data 'appId #f)) (let* ((supplied-app-id (json-string data 'appId #f))
(app-id (normal-agent-id supplied-app-id))
(name (json-string data 'name #f))) (name (json-string data 'name #f)))
(unless (valid-agent-id? app-id) (unless app-id
(raise-arguments-error (raise-arguments-error
'player-agent-poll! 'player-agent-poll!
"appId must contain exactly 64 hexadecimal characters" "appId must contain exactly 64 hexadecimal characters"
"appId" app-id)) "appId" supplied-app-id))
(require-authorized-agent! value app-id)
(call-with-semaphore (call-with-semaphore
(player-command-lock value) (player-command-lock value)
(λ () (λ ()
@@ -1324,12 +1365,30 @@
(with-state-lock (with-state-lock
value value
(λ () (λ ()
(let ((agent (and (valid-agent-id? app-id) (let* ((normalized (normal-agent-id app-id))
(agent-by-id value app-id)))) (agent (and normalized
(authorized-agent-id? value normalized)
(agent-by-id value normalized))))
(and agent (and agent
(string? token) (string? token)
(hash-ref (playback-agent-media agent) token #f)))))) (hash-ref (playback-agent-media agent) token #f))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Resolve an opaque playlist artwork id.
; pre : Artwork id came from player-state->jsexpr.
; post : Player state remains unchanged.
; result : Artwork bytes and MIME type, or #f when unavailable.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (player-track-artwork value artwork-id)
(let ((item
(with-state-lock
value
(λ ()
(findf (λ (candidate)
(string=? (track-cache-key candidate) artwork-id))
(player-tracks value))))))
(and item (track-artwork item))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Stop playback and release all player resources. ; goal : Stop playback and release all player resources.
; pre : Value was created with make-player. ; pre : Value was created with make-player.
@@ -1358,7 +1417,10 @@
(λ () (λ ()
(make-directory (build-path root "Album")) (make-directory (build-path root "Album"))
(let* ((libraries (make-music-libraries (list root))) (let* ((libraries (make-music-libraries (list root)))
(example-player (make-player libraries)) (test-agent-id (make-string 64 #\a))
(example-player
(make-player libraries
#:allowed-agent-ids (list test-agent-id)))
(initial-state (initial-state
(player-state->jsexpr example-player))) (player-state->jsexpr example-player)))
(check-equal? (hash-ref initial-state 'state) "stopped") (check-equal? (hash-ref initial-state 'state) "stopped")
@@ -1423,9 +1485,6 @@
(check-equal? (length (hash-ref deleted-state 'tabs)) 1) (check-equal? (length (hash-ref deleted-state 'tabs)) 1)
(define test-agent-id
(make-string 64 #\a))
(define registration (define registration
(player-agent-register! (player-agent-register!
example-player example-player
@@ -1433,6 +1492,13 @@
'name "Test laptop"))) 'name "Test laptop")))
(check-equal? (hash-ref registration 'name) "Test laptop") (check-equal? (hash-ref registration 'name) "Test laptop")
(check-exn
exn:fail:agent-denied?
(λ ()
(player-agent-register!
example-player
(hasheq 'appId (make-string 64 #\b)
'name "Unknown laptop"))))
(define agent-renderer-state (define agent-renderer-state
(player-command! (player-command!
+31 -2
View File
@@ -10,6 +10,7 @@
web-server/http web-server/http
web-server/http/json web-server/http/json
web-server/servlet-env web-server/servlet-env
"library.rkt"
"player.rkt") "player.rkt")
(provide serve-player) (provide serve-player)
@@ -33,6 +34,12 @@
(hasheq 'error (exn-message exception)) (hasheq 'error (exn-message exception))
#:code 400)) #:code 400))
(define (agent-error-response exception)
(json-response
(hasheq 'error (exn-message exception)
'code "agent-not-authorized")
#:code 403))
(define (request-jsexpr request) (define (request-jsexpr request)
(let ((body (request-post-data/raw request))) (let ((body (request-post-data/raw request)))
(if (and body (positive? (bytes-length body))) (if (and body (positive? (bytes-length body)))
@@ -57,7 +64,8 @@
(define (agent-register-handler request) (define (agent-register-handler request)
(with-handlers (with-handlers
((exn:fail? error-response)) ((exn:fail:agent-denied? agent-error-response)
(exn:fail? error-response))
(json-response (json-response
(player-agent-register! (player-agent-register!
current-player current-player
@@ -65,7 +73,8 @@
(define (agent-poll-handler request) (define (agent-poll-handler request)
(with-handlers (with-handlers
((exn:fail? error-response)) ((exn:fail:agent-denied? agent-error-response)
(exn:fail? error-response))
(json-response (json-response
(player-agent-poll! (player-agent-poll!
current-player current-player
@@ -95,6 +104,25 @@
(hasheq 'error "media token is invalid or expired") (hasheq 'error "media token is invalid or expired")
#:code 404)))) #:code 404))))
(define (artwork-handler _request artwork-id)
(let ((value (player-track-artwork current-player artwork-id)))
(if value
(response/output
(λ (output)
(write-bytes (artwork-data value) output))
#:mime-type
(string->bytes/utf-8 (artwork-mime-type value))
#:headers
(list
(header #"Content-Length"
(string->bytes/utf-8
(number->string
(bytes-length (artwork-data value)))))
(header #"Cache-Control" #"private, max-age=3600")))
(json-response
(hasheq 'error "track artwork is unavailable")
#:code 404))))
(define-values (dispatch _url) (define-values (dispatch _url)
(dispatch-rules (dispatch-rules
[("api" "state") #:method "get" state-handler] [("api" "state") #:method "get" state-handler]
@@ -104,6 +132,7 @@
[("api" "agent" "media" (string-arg) (string-arg)) [("api" "agent" "media" (string-arg) (string-arg))
#:method "get" #:method "get"
agent-media-handler] agent-media-handler]
[("api" "artwork" (string-arg)) #:method "get" artwork-handler]
[("api" "command" (string-arg)) [("api" "command" (string-arg))
#:method "post" #:method "post"
command-handler])) command-handler]))
+26
View File
@@ -18,6 +18,8 @@ const elements = {
libraryEntries: document.querySelector("#library-entries"), libraryEntries: document.querySelector("#library-entries"),
title: document.querySelector("#now-title"), title: document.querySelector("#now-title"),
meta: document.querySelector("#now-meta"), meta: document.querySelector("#now-meta"),
coverImage: document.querySelector("#cover-image"),
coverPlaceholder: document.querySelector("#cover-placeholder"),
tabs: document.querySelector("#tabs"), tabs: document.querySelector("#tabs"),
tabAdd: document.querySelector("#tab-add"), tabAdd: document.querySelector("#tab-add"),
count: document.querySelector("#track-count"), count: document.querySelector("#track-count"),
@@ -335,6 +337,20 @@ function renderPlayer(nextState) {
? [current.artist, current.album].filter(Boolean).join(" · ") || current.source ? [current.artist, current.album].filter(Boolean).join(" · ") || current.source
: "Voeg een track toe vanuit de bibliotheek"; : "Voeg een track toe vanuit de bibliotheek";
const artworkId = current?.artworkId || "";
if (elements.coverImage.dataset.artworkId !== artworkId) {
elements.coverImage.dataset.artworkId = artworkId;
if (artworkId) {
elements.coverImage.hidden = false;
elements.coverPlaceholder.hidden = true;
elements.coverImage.src = `/api/artwork/${encodeURIComponent(artworkId)}`;
} else {
elements.coverImage.removeAttribute("src");
elements.coverImage.hidden = true;
elements.coverPlaceholder.hidden = false;
}
}
const playing = nextState.state === "playing" || nextState.state === "starting"; const playing = nextState.state === "playing" || nextState.state === "starting";
elements.play.textContent = playing ? "Ⅱ" : "▶"; elements.play.textContent = playing ? "Ⅱ" : "▶";
elements.play.setAttribute("aria-label", playing ? "Pauzeren" : "Afspelen"); elements.play.setAttribute("aria-label", playing ? "Pauzeren" : "Afspelen");
@@ -366,6 +382,16 @@ function renderPlayer(nextState) {
elements.source.textContent = nextState.source || "—"; elements.source.textContent = nextState.source || "—";
} }
elements.coverImage.addEventListener("error", () => {
elements.coverImage.hidden = true;
elements.coverPlaceholder.hidden = false;
});
elements.coverImage.addEventListener("load", () => {
elements.coverImage.hidden = false;
elements.coverPlaceholder.hidden = true;
});
function render(nextState) { function render(nextState) {
state = nextState; state = nextState;
renderSelectors(nextState); renderSelectors(nextState);
+4 -1
View File
@@ -60,16 +60,19 @@
<nav id="breadcrumb" class="breadcrumb" aria-label="Huidige map"></nav> <nav id="breadcrumb" class="breadcrumb" aria-label="Huidige map"></nav>
<div id="library-empty" class="empty-state" hidden> <div id="library-empty" class="empty-state" hidden>
<p>Geen bibliotheek geconfigureerd.</p> <p>Geen bibliotheek geconfigureerd.</p>
<code>[library] paths=D:\Muziek</code> <code>[libraries] muziek=D:\Muziek</code>
</div> </div>
<ul id="library-entries" class="library-list" aria-label="Mapinhoud"></ul> <ul id="library-entries" class="library-list" aria-label="Mapinhoud"></ul>
</section> </section>
<section class="now-playing-pane panel"> <section class="now-playing-pane panel">
<div class="cover-art" aria-hidden="true"> <div class="cover-art" aria-hidden="true">
<div id="cover-placeholder" class="cover-placeholder">
<div class="record"></div> <div class="record"></div>
<span>RKT</span> <span>RKT</span>
</div> </div>
<img id="cover-image" alt="" hidden>
</div>
<div class="track-summary"> <div class="track-summary">
<span class="panel-kicker">NU AAN HET SPELEN</span> <span class="panel-kicker">NU AAN HET SPELEN</span>
<h1 id="now-title">Nog niets gekozen</h1> <h1 id="now-title">Nog niets gekozen</h1>
+18 -1
View File
@@ -398,6 +398,23 @@ input[type="range"] {
#2b2b29; #2b2b29;
} }
.cover-placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.cover-placeholder[hidden] {
display: none;
}
.cover-art img {
width: 100%;
height: 100%;
object-fit: cover;
}
.record { .record {
width: 72%; width: 72%;
aspect-ratio: 1; aspect-ratio: 1;
@@ -408,7 +425,7 @@ input[type="range"] {
box-shadow: 0 12px 30px rgb(0 0 0 / 45%); box-shadow: 0 12px 30px rgb(0 0 0 / 45%);
} }
.cover-art > span { .cover-placeholder > span {
position: absolute; position: absolute;
color: #111; color: #111;
font-size: 8px; font-size: 8px;
+15
View File
@@ -0,0 +1,15 @@
[server]
listen-ip=10.7.3.252
port=8764
[player]
dlna-port=8734
[libraries]
muziek=/mnt/music
; muziek=D:\Muziek
; nas=\\server\muziek
[playback-agents]
; Copy the 64-character ID shown by the GUI agent here before it may connect.
; 7b4776ef27104e8eb9f7ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true
+7 -2
View File
@@ -5,5 +5,10 @@ port=8080
[player] [player]
dlna-port=8734 dlna-port=8734
[library] [libraries]
paths= ; muziek=D:\Muziek
; nas=\\server\muziek
[playback-agents]
; Copy the 64-character ID shown by the GUI agent here before it may connect.
; 7b4776ef27104e8eb9f7ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true
+8 -2
View File
@@ -16,13 +16,19 @@ with UPnP and Sonos discovery and playback through
browser by Racket's web server. browser by Racket's web server.
@defproc[(run-web-player @defproc[(run-web-player
[music-paths (listof path-string?)] [music-paths (listof (or/c path-string?
(list/c string? path-string?)))]
[#:allowed-agent-ids allowed-agent-ids (listof string?) null]
[#:listen-ip listen-ip string? "127.0.0.1"] [#:listen-ip listen-ip string? "127.0.0.1"]
[#:port port exact-positive-integer? 8080] [#:port port exact-positive-integer? 8080]
[#:dlna-port dlna-port exact-positive-integer? 8734] [#:dlna-port dlna-port exact-positive-integer? 8734]
[#:launch-browser? launch-browser? boolean? #t]) any/c] { [#:launch-browser? launch-browser? boolean? #t]) any/c] {
Treats every entry in @racket[music-paths] as a separate music library and 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 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 contents are browsed one level at a time. Metadata and recursive contents are
only read when the user adds or plays a selected item. only read when the user adds or plays a selected item.