ini file support and cover art
This commit is contained in:
+29
-17
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+95
-10
@@ -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)
|
||||
(for/list ((specification (in-list specifications)))
|
||||
(let-values (((name path)
|
||||
(specification-values specification)))
|
||||
(list name
|
||||
(normal-case-path
|
||||
(path->complete-path path)))
|
||||
paths)
|
||||
equal?)))
|
||||
(for/list ((root (in-list roots))
|
||||
(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 (and configured-name
|
||||
(not (string=? configured-name "")))
|
||||
configured-name
|
||||
(if name
|
||||
(path->string name)
|
||||
(path->string root))
|
||||
(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))))
|
||||
|
||||
@@ -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)))
|
||||
(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)))
|
||||
(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
|
||||
|
||||
+81
-15
@@ -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!
|
||||
|
||||
+31
-2
@@ -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]))
|
||||
|
||||
@@ -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);
|
||||
|
||||
+4
-1
@@ -60,16 +60,19 @@
|
||||
<nav id="breadcrumb" class="breadcrumb" aria-label="Huidige map"></nav>
|
||||
<div id="library-empty" class="empty-state" hidden>
|
||||
<p>Geen bibliotheek geconfigureerd.</p>
|
||||
<code>[library] paths=D:\Muziek</code>
|
||||
<code>[libraries] muziek=D:\Muziek</code>
|
||||
</div>
|
||||
<ul id="library-entries" class="library-list" aria-label="Mapinhoud"></ul>
|
||||
</section>
|
||||
|
||||
<section class="now-playing-pane panel">
|
||||
<div class="cover-art" aria-hidden="true">
|
||||
<div id="cover-placeholder" class="cover-placeholder">
|
||||
<div class="record"></div>
|
||||
<span>RKT</span>
|
||||
</div>
|
||||
<img id="cover-image" alt="" hidden>
|
||||
</div>
|
||||
<div class="track-summary">
|
||||
<span class="panel-kicker">NU AAN HET SPELEN</span>
|
||||
<h1 id="now-title">Nog niets gekozen</h1>
|
||||
|
||||
+18
-1
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user