diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 249fa7b..300394a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -81,7 +81,9 @@ 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. +- `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: @@ -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 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. +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 @@ -176,7 +184,7 @@ 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: +serves static assets from [`public/`](public/) and exposes these API endpoints: | 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/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. | -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. +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`](public/app.js) implements a framework-free client. It: @@ -274,7 +284,9 @@ 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. +- 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 sources are combined and de-duplicated. @@ -285,17 +297,17 @@ 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. +The browser API has no user authentication, 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. +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 configured library roots define the intended filesystem boundary. Clients operate on opaque indexes instead of sending paths directly. The DLNA backend diff --git a/README.md b/README.md index ba6a6b6..286fa0e 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,18 @@ port=8080 [player] dlna-port=8734 -[library] -paths=D:\Muziek;D:\Podcasts +[libraries] +muziek=D:\Muziek +podcasts=D:\Podcasts + +[playback-agents] +7b4776ef27104e8eb9f7ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true ``` -Start dat bestand met `racket main.rkt --config rkt-web-player.ini`. Meerdere -bibliotheekpaden worden met een puntkomma gescheiden. +Start dat bestand met `racket main.rkt --config rkt-web-player.ini`. Iedere key +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 @@ -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 playlist. Een map wordt pas recursief gelezen wanneer die met **afspelen** of **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 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. 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 @@ -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` 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 -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 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 PowerShell-proces of andere externe tray-helper. -De applicatie-ID identificeert de agent en begrenst toegang tot zijn tijdelijke -media-URL, maar vervangt geen transportbeveiliging of authenticatie. Gebruik de -agent en server alleen op een vertrouwd LAN zolang de HTTP-server geen TLS en -gebruikersauthenticatie heeft. +De allowlist voorkomt dat een onbekende agent zich als uitvoerpunt registreert +of opdrachten en tijdelijke media ontvangt. Het applicatie-ID is een gedeeld +toegangstoken en vervangt geen transportbeveiliging of gebruikersauthenticatie. +Gebruik de agent en server alleen op een vertrouwd LAN zolang de HTTP-server +geen TLS heeft. ## Controleren diff --git a/main.rkt b/main.rkt index 142b24e..4eabf5c 100644 --- a/main.rkt +++ b/main.rkt @@ -3,6 +3,7 @@ (require racket/cmdline racket/contract racket/list + racket/mpair racket/string simple-ini simple-log @@ -14,29 +15,47 @@ (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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; 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. ; result : The result returned by serve/servlet. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define/contract (run-web-player music-paths + #:allowed-agent-ids [allowed-agent-ids '()] #:listen-ip [listen-ip "127.0.0.1"] #:port [port 8080] #:dlna-port [dlna-port 8734] #:launch-browser? [launch-browser? #t]) - (->* ((listof path-string?)) - (#:listen-ip string? + (->* ((listof library-spec/c)) + (#:allowed-agent-ids (listof string?) + #:listen-ip string? #:port exact-positive-integer? #:dlna-port exact-positive-integer? #:launch-browser? boolean?) any) (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 "Starting with ~a music library/libraries on http://~a:~a/" (length libraries) @@ -103,15 +122,27 @@ (string-split configured-paths-value ";"))) (else '()))) - (define all-paths - (remove-duplicates - (append music-paths - configured-paths) - equal?)) + (define configured-libraries + (for/list ((entry (in-list + (ini-section-key-values config 'libraries))) + #:when (path-string? (cdr entry))) + (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) (run-web-player - all-paths + all-libraries + #:allowed-agent-ids allowed-agent-ids #:listen-ip (or listen-ip (ini-get config 'server 'listen-ip "127.0.0.1")) #:port (or port diff --git a/private/library.rkt b/private/library.rkt index 1a79971..5f0d358 100644 --- a/private/library.rkt +++ b/private/library.rkt @@ -10,9 +10,11 @@ (provide (struct-out music-library) (struct-out browser-entry) (struct-out track) + (struct-out artwork) make-music-libraries browse-library - browser-entry->tracks) + browser-entry->tracks + track-artwork) (struct music-library (id name root) @@ -26,6 +28,10 @@ (file title artist album duration mime-type) #:transparent) +(struct artwork + (mime-type data) + #:transparent) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Supporting functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -33,6 +39,11 @@ (define 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) (let ((extension (path-get-extension file))) (and extension @@ -128,20 +139,38 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; 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. ; 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 (remove-duplicates - (map (λ (path) - (normal-case-path - (path->complete-path path))) - paths) - equal?))) - (for/list ((root (in-list roots)) + (for/list ((specification (in-list specifications))) + (let-values (((name path) + (specification-values specification))) + (list name + (normal-case-path + (path->complete-path path))))) + (lambda (first second) + (equal? (cadr first) (cadr second)))))) + (for/list ((named-root (in-list roots)) (index (in-naturals))) + (define configured-name (car named-root)) + (define root (cadr named-root)) (unless (directory-exists? root) (raise-arguments-error 'make-music-libraries @@ -150,11 +179,56 @@ (let ((name (file-name-from-path root))) (music-library (format "library-~a" index) - (if name - (path->string name) - (path->string root)) + (if (and configured-name + (not (string=? configured-name ""))) + configured-name + (if name + (path->string name) + (path->string 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. ; pre : Relative-path was produced by a previous browse result. @@ -217,6 +291,17 @@ (check-equal? (browser-entry-name (car entries)) "Album") (check-eq? (browser-entry-kind (car entries)) 'container) (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)))) diff --git a/private/player-agent-gui.rkt b/private/player-agent-gui.rkt index 66770fd..64c4fe0 100644 --- a/private/player-agent-gui.rkt +++ b/private/player-agent-gui.rkt @@ -18,6 +18,9 @@ (provide run-player-agent-gui) +(struct exn:fail:agent-denied exn:fail () + #:transparent) + (define (input-field label init-val panel) (let ((tf (new text-field% @@ -91,8 +94,14 @@ (let ((response (read-json input))) (when (and (hash? response) (string? (hash-ref response 'error #f))) - (error 'player-agent - (hash-ref response 'error))) + (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 + (hash-ref response 'error)))) response)) (λ () (close-input-port input))))) @@ -122,6 +131,7 @@ (define command-worker #f) (define executing-command-id 0) (define running? #f) + (define authorization-notified? #f) (define audio #f) (define temporary-media #f) (define current-media-key #f) @@ -509,7 +519,30 @@ (define (poll-loop) (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) (warn-player-agent "Connection cycle failed: ~a" (exn-message exception)) @@ -588,6 +621,7 @@ (define (reconnect!) (stop-worker!) + (set! authorization-notified? #f) (set! server-url (string-trim (send server-field get-value))) (let ((new-name (string-trim (send name-field get-value)))) (set! assigned-name diff --git a/private/player.rkt b/private/player.rkt index 33a9cd2..35bc17b 100644 --- a/private/player.rkt +++ b/private/player.rkt @@ -19,6 +19,8 @@ player-agent-register! player-agent-poll! player-agent-media + player-track-artwork + exn:fail:agent-denied? player-close!) (sl-def-log web-player) @@ -38,12 +40,16 @@ [ended-counter #:mutable]) #:transparent) +(struct exn:fail:agent-denied exn:fail () + #:transparent) + (struct playlist-tab (id [name #:mutable] [tracks #:mutable]) #:transparent) (struct player (libraries + allowed-agent-ids [agents #:mutable] [current-library-id #:mutable] [browser-path #:mutable] @@ -103,6 +109,25 @@ (and (string? 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) (bytes->hex-string (crypto-random-bytes 32))) @@ -736,6 +761,7 @@ 'album (track-album item) 'duration (or (track-duration item) 'null) 'mimeType (track-mime-type item) + 'artworkId (track-cache-key item) 'source (path->string (or (file-name-from-path (track-file item)) (track-file item))))) @@ -1011,11 +1037,21 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; 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. ; 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))) (browser-entries (if library @@ -1023,6 +1059,7 @@ '())) (tab (playlist-tab "default" "Default" '()))) (player libraries + (remove-duplicates normalized-agent-ids string=?) '() (and library (music-library-id library)) '() @@ -1184,12 +1221,13 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; 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. ; result : Agent configuration for the polling client. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (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 (string-trim (or (json-string data 'name #f) @@ -1198,11 +1236,12 @@ (if (string=? suggested-name "") "RKT playback agent" suggested-name))) - (unless (valid-agent-id? app-id) + (unless app-id (raise-arguments-error 'player-agent-register! "appId must contain exactly 64 hexadecimal characters" - "appId" app-id)) + "appId" supplied-app-id)) + (require-authorized-agent! value app-id) (with-state-lock value (λ () @@ -1247,13 +1286,15 @@ ; result : Poll response containing the current agent name and optional command. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (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))) - (unless (valid-agent-id? app-id) + (unless app-id (raise-arguments-error 'player-agent-poll! "appId must contain exactly 64 hexadecimal characters" - "appId" app-id)) + "appId" supplied-app-id)) + (require-authorized-agent! value app-id) (call-with-semaphore (player-command-lock value) (λ () @@ -1324,12 +1365,30 @@ (with-state-lock value (λ () - (let ((agent (and (valid-agent-id? app-id) - (agent-by-id value app-id)))) + (let* ((normalized (normal-agent-id app-id)) + (agent (and normalized + (authorized-agent-id? value normalized) + (agent-by-id value normalized)))) (and agent (string? token) (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. ; pre : Value was created with make-player. @@ -1358,7 +1417,10 @@ (λ () (make-directory (build-path root "Album")) (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 (player-state->jsexpr example-player))) (check-equal? (hash-ref initial-state 'state) "stopped") @@ -1423,9 +1485,6 @@ (check-equal? (length (hash-ref deleted-state 'tabs)) 1) - (define test-agent-id - (make-string 64 #\a)) - (define registration (player-agent-register! example-player @@ -1433,6 +1492,13 @@ '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 (player-command! diff --git a/private/server.rkt b/private/server.rkt index 93c07f6..e5bd73d 100644 --- a/private/server.rkt +++ b/private/server.rkt @@ -10,6 +10,7 @@ web-server/http web-server/http/json web-server/servlet-env + "library.rkt" "player.rkt") (provide serve-player) @@ -33,6 +34,12 @@ (hasheq 'error (exn-message exception)) #:code 400)) +(define (agent-error-response exception) + (json-response + (hasheq 'error (exn-message exception) + 'code "agent-not-authorized") + #:code 403)) + (define (request-jsexpr request) (let ((body (request-post-data/raw request))) (if (and body (positive? (bytes-length body))) @@ -57,7 +64,8 @@ (define (agent-register-handler request) (with-handlers - ((exn:fail? error-response)) + ((exn:fail:agent-denied? agent-error-response) + (exn:fail? error-response)) (json-response (player-agent-register! current-player @@ -65,7 +73,8 @@ (define (agent-poll-handler request) (with-handlers - ((exn:fail? error-response)) + ((exn:fail:agent-denied? agent-error-response) + (exn:fail? error-response)) (json-response (player-agent-poll! current-player @@ -95,6 +104,25 @@ (hasheq 'error "media token is invalid or expired") #: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) (dispatch-rules [("api" "state") #:method "get" state-handler] @@ -104,6 +132,7 @@ [("api" "agent" "media" (string-arg) (string-arg)) #:method "get" agent-media-handler] + [("api" "artwork" (string-arg)) #:method "get" artwork-handler] [("api" "command" (string-arg)) #:method "post" command-handler])) diff --git a/public/app.js b/public/app.js index 63cf742..20748d9 100644 --- a/public/app.js +++ b/public/app.js @@ -18,6 +18,8 @@ const elements = { libraryEntries: document.querySelector("#library-entries"), title: document.querySelector("#now-title"), meta: document.querySelector("#now-meta"), + coverImage: document.querySelector("#cover-image"), + coverPlaceholder: document.querySelector("#cover-placeholder"), tabs: document.querySelector("#tabs"), tabAdd: document.querySelector("#tab-add"), count: document.querySelector("#track-count"), @@ -335,6 +337,20 @@ function renderPlayer(nextState) { ? [current.artist, current.album].filter(Boolean).join(" · ") || current.source : "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"; elements.play.textContent = playing ? "Ⅱ" : "▶"; elements.play.setAttribute("aria-label", playing ? "Pauzeren" : "Afspelen"); @@ -366,6 +382,16 @@ function renderPlayer(nextState) { 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) { state = nextState; renderSelectors(nextState); diff --git a/public/index.html b/public/index.html index a97be72..b74471a 100644 --- a/public/index.html +++ b/public/index.html @@ -60,15 +60,18 @@
NU AAN HET SPELEN diff --git a/public/styles.css b/public/styles.css index a333a91..1f3440e 100644 --- a/public/styles.css +++ b/public/styles.css @@ -398,6 +398,23 @@ input[type="range"] { #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 { width: 72%; aspect-ratio: 1; @@ -408,7 +425,7 @@ input[type="range"] { box-shadow: 0 12px 30px rgb(0 0 0 / 45%); } -.cover-art > span { +.cover-placeholder > span { position: absolute; color: #111; font-size: 8px; diff --git a/rkt-web-player.ini b/rkt-web-player.ini new file mode 100644 index 0000000..b458f76 --- /dev/null +++ b/rkt-web-player.ini @@ -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 diff --git a/rkt-web-player.ini.example b/rkt-web-player.ini.example index b051fdb..b438f58 100644 --- a/rkt-web-player.ini.example +++ b/rkt-web-player.ini.example @@ -5,5 +5,10 @@ port=8080 [player] dlna-port=8734 -[library] -paths= +[libraries] +; 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 diff --git a/scribblings/rkt-web-player.scrbl b/scribblings/rkt-web-player.scrbl index 5754ea7..26d3abc 100644 --- a/scribblings/rkt-web-player.scrbl +++ b/scribblings/rkt-web-player.scrbl @@ -16,13 +16,19 @@ with UPnP and Sonos discovery and playback through browser by Racket's web server. @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"] [#:port port exact-positive-integer? 8080] [#:dlna-port dlna-port exact-positive-integer? 8734] [#: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 contents are browsed one level at a time. Metadata and recursive contents are only read when the user adds or plays a selected item.