play local files to a DLNA Renderer
This commit is contained in:
@@ -1,3 +1,81 @@
|
|||||||
# racket-audio-dlna
|
# racket-audio-dlna
|
||||||
|
|
||||||
Playing to your DLNA/UPnP Renderer over the network
|
`racket-audio-dlna` plays local audio files on UPnP/DLNA media renderers.
|
||||||
|
It combines TagLib metadata from `racket-audio` with the HTTP server,
|
||||||
|
DIDL-Lite support, and renderer controls from `racket-upnp`.
|
||||||
|
|
||||||
|
```racket
|
||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket-audio-dlna
|
||||||
|
racket-upnp)
|
||||||
|
|
||||||
|
(define renderer
|
||||||
|
(get-media-renderer "Denon"))
|
||||||
|
|
||||||
|
(define player
|
||||||
|
(make-dlna-player renderer))
|
||||||
|
|
||||||
|
(dlna-player-play!
|
||||||
|
player
|
||||||
|
"/muziek/radiohead/in-rainbows.flac")
|
||||||
|
|
||||||
|
(dlna-player-set-next-file!
|
||||||
|
player
|
||||||
|
"/muziek/radiohead/bodysnatchers.flac")
|
||||||
|
```
|
||||||
|
|
||||||
|
The constructor determines the local address used to reach the renderer and
|
||||||
|
starts a media file server on port 8080. The address, port, and URL path can be
|
||||||
|
overridden when required:
|
||||||
|
|
||||||
|
```racket
|
||||||
|
(make-dlna-player
|
||||||
|
renderer
|
||||||
|
#:listen-ip "10.7.3.108"
|
||||||
|
#:port 8081
|
||||||
|
#:path "/music/")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
```racket
|
||||||
|
(dlna-player-pause! player)
|
||||||
|
(dlna-player-resume! player)
|
||||||
|
(dlna-player-seek! player 120)
|
||||||
|
(dlna-player-seek-percentage! player 50)
|
||||||
|
(dlna-player-volume! player 35)
|
||||||
|
(dlna-player-muted! player #t)
|
||||||
|
(dlna-player-stop! player)
|
||||||
|
```
|
||||||
|
|
||||||
|
`dlna-player-info` returns a cached snapshot:
|
||||||
|
|
||||||
|
```racket
|
||||||
|
(define info (dlna-player-info player))
|
||||||
|
|
||||||
|
(dlna-info-state info)
|
||||||
|
(dlna-info-position info)
|
||||||
|
(dlna-info-duration info)
|
||||||
|
(dlna-info-volume info)
|
||||||
|
(dlna-info-reachable? info)
|
||||||
|
```
|
||||||
|
|
||||||
|
Transport state and position are refreshed by a background thread. Position is
|
||||||
|
advanced locally between polls. Volume and mute state use a slower cache.
|
||||||
|
Commands update the cache immediately after the renderer accepts them.
|
||||||
|
|
||||||
|
Use `#:refresh? #t` for a synchronous refresh:
|
||||||
|
|
||||||
|
```racket
|
||||||
|
(dlna-player-info player #:refresh? #t)
|
||||||
|
```
|
||||||
|
|
||||||
|
Close the player when it is no longer needed:
|
||||||
|
|
||||||
|
```racket
|
||||||
|
(dlna-player-close! player)
|
||||||
|
```
|
||||||
|
|
||||||
|
Closing stops playback, removes published files, stops the web server, and
|
||||||
|
terminates the polling thread.
|
||||||
|
|||||||
+693
@@ -0,0 +1,693 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
;; Stateful local-file player built on racket-audio metadata and racket-upnp.
|
||||||
|
|
||||||
|
(require racket/file
|
||||||
|
racket/path
|
||||||
|
racket/string
|
||||||
|
racket/udp
|
||||||
|
racket-audio/taglib
|
||||||
|
racket-upnp)
|
||||||
|
|
||||||
|
(provide make-dlna-player
|
||||||
|
dlna-player?
|
||||||
|
dlna-player-play!
|
||||||
|
dlna-player-set-next-file!
|
||||||
|
dlna-player-pause!
|
||||||
|
dlna-player-resume!
|
||||||
|
dlna-player-stop!
|
||||||
|
dlna-player-seek!
|
||||||
|
dlna-player-seek-percentage!
|
||||||
|
dlna-player-volume!
|
||||||
|
dlna-player-muted!
|
||||||
|
dlna-player-info
|
||||||
|
dlna-player-close!
|
||||||
|
(struct-out dlna-track-info)
|
||||||
|
(struct-out dlna-info))
|
||||||
|
|
||||||
|
(struct dlna-track-info
|
||||||
|
(file title artist album genre year track duration
|
||||||
|
sample-rate bit-rate channels)
|
||||||
|
#:transparent)
|
||||||
|
|
||||||
|
(struct dlna-info
|
||||||
|
(state track uri next-track next-uri position duration
|
||||||
|
volume muted? reachable?)
|
||||||
|
#:transparent)
|
||||||
|
|
||||||
|
(struct publication
|
||||||
|
(track uri metadata)
|
||||||
|
#:transparent)
|
||||||
|
|
||||||
|
(struct dlna-player
|
||||||
|
(renderer
|
||||||
|
server
|
||||||
|
poll-seconds
|
||||||
|
volume-poll-seconds
|
||||||
|
transport-lock
|
||||||
|
cache-lock
|
||||||
|
[current #:mutable]
|
||||||
|
[next #:mutable]
|
||||||
|
[cached-info #:mutable]
|
||||||
|
[position-stamp #:mutable]
|
||||||
|
[volume-stamp #:mutable]
|
||||||
|
metadata-cache
|
||||||
|
[counter #:mutable]
|
||||||
|
[running? #:mutable]
|
||||||
|
[poll-thread #:mutable])
|
||||||
|
#:constructor-name make-dlna-player-state)
|
||||||
|
|
||||||
|
(define (now-ms)
|
||||||
|
(current-inexact-monotonic-milliseconds))
|
||||||
|
|
||||||
|
(define (check-player who player)
|
||||||
|
(unless (dlna-player? player)
|
||||||
|
(raise-argument-error who "dlna-player?" player))
|
||||||
|
(unless (dlna-player-running? player)
|
||||||
|
(raise-arguments-error who "DLNA player has been closed")))
|
||||||
|
|
||||||
|
(define (nonempty-string value)
|
||||||
|
(and (string? value)
|
||||||
|
(not (string=? (string-trim value) ""))
|
||||||
|
value))
|
||||||
|
|
||||||
|
(define (positive-tag-number value)
|
||||||
|
(and (exact-integer? value)
|
||||||
|
(positive? value)
|
||||||
|
value))
|
||||||
|
|
||||||
|
(define (tag-string value)
|
||||||
|
(cond
|
||||||
|
[(string? value) (nonempty-string value)]
|
||||||
|
[(and (list? value) (andmap string? value))
|
||||||
|
(nonempty-string (string-join value " / "))]
|
||||||
|
[else #f]))
|
||||||
|
|
||||||
|
(define (file-title path)
|
||||||
|
(let* ([name (path->string (file-name-from-path path))]
|
||||||
|
[without-extension
|
||||||
|
(regexp-replace #px"[.][^.]*$" name "")])
|
||||||
|
(if (string=? without-extension "")
|
||||||
|
name
|
||||||
|
without-extension)))
|
||||||
|
|
||||||
|
(define (fallback-track-info path)
|
||||||
|
(dlna-track-info path
|
||||||
|
(file-title path)
|
||||||
|
#f
|
||||||
|
#f
|
||||||
|
#f
|
||||||
|
#f
|
||||||
|
#f
|
||||||
|
#f
|
||||||
|
#f
|
||||||
|
#f
|
||||||
|
#f))
|
||||||
|
|
||||||
|
(define (read-track-info path)
|
||||||
|
(with-handlers ([exn:fail? (lambda (_) (fallback-track-info path))])
|
||||||
|
(call-with-id3-tags
|
||||||
|
path
|
||||||
|
(lambda (tags)
|
||||||
|
(if (tags-valid? tags)
|
||||||
|
(let* ([artist
|
||||||
|
(or (nonempty-string (tags-artist tags))
|
||||||
|
(tag-string (tags-album-artist tags)))]
|
||||||
|
[duration (positive-tag-number (tags-length tags))])
|
||||||
|
(dlna-track-info
|
||||||
|
path
|
||||||
|
(or (nonempty-string (tags-title tags))
|
||||||
|
(file-title path))
|
||||||
|
artist
|
||||||
|
(nonempty-string (tags-album tags))
|
||||||
|
(nonempty-string (tags-genre tags))
|
||||||
|
(positive-tag-number (tags-year tags))
|
||||||
|
(positive-tag-number (tags-track tags))
|
||||||
|
duration
|
||||||
|
(positive-tag-number (tags-sample-rate tags))
|
||||||
|
(positive-tag-number (tags-bit-rate tags))
|
||||||
|
(positive-tag-number (tags-channels tags))))
|
||||||
|
(fallback-track-info path))))))
|
||||||
|
|
||||||
|
(define (track-cache-key path)
|
||||||
|
(list path
|
||||||
|
(file-size path)
|
||||||
|
(file-or-directory-modify-seconds path)))
|
||||||
|
|
||||||
|
(define (player-track-info who player file)
|
||||||
|
(unless (path-string? file)
|
||||||
|
(raise-argument-error who "path-string?" file))
|
||||||
|
(let ([path (path->complete-path file)])
|
||||||
|
(unless (file-exists? path)
|
||||||
|
(raise-arguments-error
|
||||||
|
who
|
||||||
|
"audio file does not exist"
|
||||||
|
"file" file))
|
||||||
|
(let* ([key (track-cache-key path)]
|
||||||
|
[cached
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(hash-ref (dlna-player-metadata-cache player) key #f)))])
|
||||||
|
(or cached
|
||||||
|
(let ([info (read-track-info path)])
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(when (>= (hash-count
|
||||||
|
(dlna-player-metadata-cache player))
|
||||||
|
128)
|
||||||
|
(hash-clear! (dlna-player-metadata-cache player)))
|
||||||
|
(hash-set! (dlna-player-metadata-cache player) key info)))
|
||||||
|
info)))))
|
||||||
|
|
||||||
|
(define (file-extension path)
|
||||||
|
(let ([match
|
||||||
|
(regexp-match #px"(?i:[.]([a-z0-9]+))$"
|
||||||
|
(path->string path))])
|
||||||
|
(if match
|
||||||
|
(string-append "." (cadr match))
|
||||||
|
"")))
|
||||||
|
|
||||||
|
(define (next-publication-name player path)
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(let ([number (add1 (dlna-player-counter player))])
|
||||||
|
(set-dlna-player-counter! player number)
|
||||||
|
(format "track-~a~a" number (file-extension path))))))
|
||||||
|
|
||||||
|
(define (publish-track! player track)
|
||||||
|
(let* ([server (dlna-player-server player)]
|
||||||
|
[uri
|
||||||
|
(media-file-server-publish!
|
||||||
|
server
|
||||||
|
(dlna-track-info-file track)
|
||||||
|
(next-publication-name
|
||||||
|
player
|
||||||
|
(dlna-track-info-file track)))])
|
||||||
|
(with-handlers
|
||||||
|
([exn:fail?
|
||||||
|
(lambda (exception)
|
||||||
|
(media-file-server-unpublish! server uri)
|
||||||
|
(raise exception))])
|
||||||
|
(publication
|
||||||
|
track
|
||||||
|
uri
|
||||||
|
(media-file-server-didl-lite
|
||||||
|
server
|
||||||
|
uri
|
||||||
|
#:title (dlna-track-info-title track)
|
||||||
|
#:artist (dlna-track-info-artist track)
|
||||||
|
#:album (dlna-track-info-album track)
|
||||||
|
#:genre (dlna-track-info-genre track)
|
||||||
|
#:year (dlna-track-info-year track)
|
||||||
|
#:track (dlna-track-info-track track)
|
||||||
|
#:duration (dlna-track-info-duration track))))))
|
||||||
|
|
||||||
|
(define (safe-unpublish! player item)
|
||||||
|
(when item
|
||||||
|
(with-handlers ([exn:fail? (lambda (_) (void))])
|
||||||
|
(media-file-server-unpublish!
|
||||||
|
(dlna-player-server player)
|
||||||
|
(publication-uri item)))))
|
||||||
|
|
||||||
|
(define (update-cache! player proc #:position? [position? #f])
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(set-dlna-player-cached-info!
|
||||||
|
player
|
||||||
|
(proc (dlna-player-cached-info player)))
|
||||||
|
(when position?
|
||||||
|
(set-dlna-player-position-stamp! player (now-ms))))))
|
||||||
|
|
||||||
|
(define (set-reachable! player reachable?)
|
||||||
|
(update-cache!
|
||||||
|
player
|
||||||
|
(lambda (info)
|
||||||
|
(struct-copy dlna-info info [reachable? reachable?]))))
|
||||||
|
|
||||||
|
(define (estimated-info player)
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(let* ([info (dlna-player-cached-info player)]
|
||||||
|
[position (dlna-info-position info)]
|
||||||
|
[duration (dlna-info-duration info)]
|
||||||
|
[elapsed
|
||||||
|
(/ (- (now-ms) (dlna-player-position-stamp player))
|
||||||
|
1000.0)]
|
||||||
|
[estimated
|
||||||
|
(if (and (eq? (dlna-info-state info) 'playing)
|
||||||
|
(number? position))
|
||||||
|
(+ position (max 0 elapsed))
|
||||||
|
position)])
|
||||||
|
(struct-copy
|
||||||
|
dlna-info
|
||||||
|
info
|
||||||
|
[position
|
||||||
|
(if (and (number? duration) (number? estimated))
|
||||||
|
(min duration estimated)
|
||||||
|
estimated)])))))
|
||||||
|
|
||||||
|
(define (call-renderer player proc)
|
||||||
|
(check-player 'dlna-player player)
|
||||||
|
(with-handlers
|
||||||
|
([exn:fail?
|
||||||
|
(lambda (exception)
|
||||||
|
(set-reachable! player #f)
|
||||||
|
(raise exception))])
|
||||||
|
(let ([result
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-transport-lock player)
|
||||||
|
proc)])
|
||||||
|
(set-reachable! player #t)
|
||||||
|
result)))
|
||||||
|
|
||||||
|
(define (promote-or-clear-publications! player uri)
|
||||||
|
(let ([old-current #f]
|
||||||
|
[old-next #f])
|
||||||
|
(when (nonempty-string uri)
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(let ([current (dlna-player-current player)]
|
||||||
|
[next (dlna-player-next player)])
|
||||||
|
(cond
|
||||||
|
[(and next (string=? uri (publication-uri next)))
|
||||||
|
(set! old-current current)
|
||||||
|
(set-dlna-player-current! player next)
|
||||||
|
(set-dlna-player-next! player #f)]
|
||||||
|
[(and current
|
||||||
|
(not (string=? uri (publication-uri current))))
|
||||||
|
(set! old-current current)
|
||||||
|
(set! old-next next)
|
||||||
|
(set-dlna-player-current! player #f)
|
||||||
|
(set-dlna-player-next! player #f)])))))
|
||||||
|
(safe-unpublish! player old-current)
|
||||||
|
(safe-unpublish! player old-next)))
|
||||||
|
|
||||||
|
(define (volume-refresh-due? player)
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(>= (- (now-ms) (dlna-player-volume-stamp player))
|
||||||
|
(* 1000.0 (dlna-player-volume-poll-seconds player))))))
|
||||||
|
|
||||||
|
(define (refresh-volume! player #:force? [force? #f])
|
||||||
|
(when (or force? (volume-refresh-due? player))
|
||||||
|
(let ([renderer (dlna-player-renderer player)])
|
||||||
|
(with-handlers ([exn:fail? (lambda (_) (void))])
|
||||||
|
(let ([volume (media-renderer-volume renderer)])
|
||||||
|
(update-cache!
|
||||||
|
player
|
||||||
|
(lambda (info)
|
||||||
|
(struct-copy dlna-info info [volume volume])))))
|
||||||
|
(with-handlers ([exn:fail? (lambda (_) (void))])
|
||||||
|
(let ([muted? (media-renderer-muted? renderer)])
|
||||||
|
(update-cache!
|
||||||
|
player
|
||||||
|
(lambda (info)
|
||||||
|
(struct-copy dlna-info info [muted? muted?])))))
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(set-dlna-player-volume-stamp! player (now-ms)))))))
|
||||||
|
|
||||||
|
(define (refresh-player! player #:force-volume? [force-volume? #f])
|
||||||
|
(with-handlers
|
||||||
|
([exn:fail?
|
||||||
|
(lambda (_)
|
||||||
|
(set-reachable! player #f)
|
||||||
|
#f)])
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-transport-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(let* ([renderer (dlna-player-renderer player)]
|
||||||
|
[state (media-renderer-status renderer)]
|
||||||
|
[position
|
||||||
|
(with-handlers ([exn:fail? (lambda (_) #f)])
|
||||||
|
(media-renderer-position renderer))]
|
||||||
|
[uri (and position (transport-position-uri position))]
|
||||||
|
[seconds
|
||||||
|
(and position (transport-position-seconds position))]
|
||||||
|
[renderer-duration
|
||||||
|
(and position
|
||||||
|
(transport-position-duration position))])
|
||||||
|
(promote-or-clear-publications! player uri)
|
||||||
|
(let* ([current (dlna-player-current player)]
|
||||||
|
[next (dlna-player-next player)]
|
||||||
|
[track (and current (publication-track current))]
|
||||||
|
[track-duration
|
||||||
|
(and track (dlna-track-info-duration track))]
|
||||||
|
[duration
|
||||||
|
(if (and (number? renderer-duration)
|
||||||
|
(positive? renderer-duration))
|
||||||
|
renderer-duration
|
||||||
|
track-duration)])
|
||||||
|
(update-cache!
|
||||||
|
player
|
||||||
|
(lambda (info)
|
||||||
|
(struct-copy
|
||||||
|
dlna-info
|
||||||
|
info
|
||||||
|
[state state]
|
||||||
|
[track track]
|
||||||
|
[uri (or (nonempty-string uri)
|
||||||
|
(and current (publication-uri current)))]
|
||||||
|
[next-track (and next (publication-track next))]
|
||||||
|
[next-uri (and next (publication-uri next))]
|
||||||
|
[position (if (number? seconds)
|
||||||
|
seconds
|
||||||
|
(dlna-info-position info))]
|
||||||
|
[duration duration]
|
||||||
|
[reachable? #t]))
|
||||||
|
#:position? #t))
|
||||||
|
(refresh-volume! player #:force? force-volume?)
|
||||||
|
#t)))))
|
||||||
|
|
||||||
|
(define (poll-player player)
|
||||||
|
(let loop ()
|
||||||
|
(when (dlna-player-running? player)
|
||||||
|
(sleep (dlna-player-poll-seconds player))
|
||||||
|
(when (dlna-player-running? player)
|
||||||
|
(refresh-player! player)
|
||||||
|
(loop)))))
|
||||||
|
|
||||||
|
(define (renderer-local-address renderer)
|
||||||
|
(let ([socket (udp-open-socket)])
|
||||||
|
(dynamic-wind
|
||||||
|
void
|
||||||
|
(lambda ()
|
||||||
|
(udp-connect! socket (media-renderer-address renderer) 1900)
|
||||||
|
(let-values ([(local-host _local-port
|
||||||
|
_remote-host _remote-port)
|
||||||
|
(udp-addresses socket #t)])
|
||||||
|
local-host))
|
||||||
|
(lambda ()
|
||||||
|
(udp-close socket)))))
|
||||||
|
|
||||||
|
(define (server-base-url listen-ip port path)
|
||||||
|
(unless (and (exact-integer? port) (<= 1 port 65535))
|
||||||
|
(raise-argument-error 'make-dlna-player
|
||||||
|
"exact-integer? between 1 and 65535"
|
||||||
|
port))
|
||||||
|
(unless (string? path)
|
||||||
|
(raise-argument-error 'make-dlna-player "string?" path))
|
||||||
|
(let* ([with-leading
|
||||||
|
(if (string-prefix? path "/")
|
||||||
|
path
|
||||||
|
(string-append "/" path))]
|
||||||
|
[normalized
|
||||||
|
(if (string-suffix? with-leading "/")
|
||||||
|
with-leading
|
||||||
|
(string-append with-leading "/"))])
|
||||||
|
(format "http://~a:~a~a" listen-ip port normalized)))
|
||||||
|
|
||||||
|
(define (make-dlna-player renderer
|
||||||
|
#:listen-ip [listen-ip #f]
|
||||||
|
#:port [port 8080]
|
||||||
|
#:path [path "/racket-audio-dlna/"]
|
||||||
|
#:poll-seconds [poll-seconds 1.0]
|
||||||
|
#:volume-poll-seconds
|
||||||
|
[volume-poll-seconds 5.0])
|
||||||
|
(unless (media-renderer? renderer)
|
||||||
|
(raise-argument-error 'make-dlna-player "media-renderer?" renderer))
|
||||||
|
(unless (or (not listen-ip) (string? listen-ip))
|
||||||
|
(raise-argument-error 'make-dlna-player
|
||||||
|
"(or/c #f string?)"
|
||||||
|
listen-ip))
|
||||||
|
(unless (and (rational? poll-seconds) (positive? poll-seconds))
|
||||||
|
(raise-argument-error 'make-dlna-player
|
||||||
|
"positive-real?"
|
||||||
|
poll-seconds))
|
||||||
|
(unless (and (rational? volume-poll-seconds)
|
||||||
|
(positive? volume-poll-seconds))
|
||||||
|
(raise-argument-error 'make-dlna-player
|
||||||
|
"positive-real?"
|
||||||
|
volume-poll-seconds))
|
||||||
|
(let* ([local-address
|
||||||
|
(or listen-ip (renderer-local-address renderer))]
|
||||||
|
[server
|
||||||
|
(start-media-file-server
|
||||||
|
(server-base-url local-address port path)
|
||||||
|
#:listen-ip local-address)]
|
||||||
|
[player
|
||||||
|
(make-dlna-player-state
|
||||||
|
renderer
|
||||||
|
server
|
||||||
|
poll-seconds
|
||||||
|
volume-poll-seconds
|
||||||
|
(make-semaphore 1)
|
||||||
|
(make-semaphore 1)
|
||||||
|
#f
|
||||||
|
#f
|
||||||
|
(dlna-info 'unknown #f #f #f #f
|
||||||
|
0 #f #f #f #f)
|
||||||
|
(now-ms)
|
||||||
|
0
|
||||||
|
(make-hash)
|
||||||
|
0
|
||||||
|
#t
|
||||||
|
#f)])
|
||||||
|
(refresh-player! player #:force-volume? #t)
|
||||||
|
(set-dlna-player-poll-thread!
|
||||||
|
player
|
||||||
|
(thread (lambda () (poll-player player))))
|
||||||
|
player))
|
||||||
|
|
||||||
|
(define (dlna-player-play! player file)
|
||||||
|
(check-player 'dlna-player-play! player)
|
||||||
|
(let* ([track (player-track-info 'dlna-player-play! player file)]
|
||||||
|
[item (publish-track! player track)])
|
||||||
|
(with-handlers
|
||||||
|
([exn:fail?
|
||||||
|
(lambda (exception)
|
||||||
|
(safe-unpublish! player item)
|
||||||
|
(raise exception))])
|
||||||
|
(call-renderer
|
||||||
|
player
|
||||||
|
(lambda ()
|
||||||
|
(media-renderer-play-uri!
|
||||||
|
(dlna-player-renderer player)
|
||||||
|
(publication-uri item)
|
||||||
|
#:metadata (publication-metadata item))))
|
||||||
|
(let ([old-current #f]
|
||||||
|
[old-next #f])
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(set! old-current (dlna-player-current player))
|
||||||
|
(set! old-next (dlna-player-next player))
|
||||||
|
(set-dlna-player-current! player item)
|
||||||
|
(set-dlna-player-next! player #f)
|
||||||
|
(set-dlna-player-cached-info!
|
||||||
|
player
|
||||||
|
(dlna-info
|
||||||
|
'playing
|
||||||
|
track
|
||||||
|
(publication-uri item)
|
||||||
|
#f
|
||||||
|
#f
|
||||||
|
0
|
||||||
|
(dlna-track-info-duration track)
|
||||||
|
(dlna-info-volume (dlna-player-cached-info player))
|
||||||
|
(dlna-info-muted? (dlna-player-cached-info player))
|
||||||
|
#t))
|
||||||
|
(set-dlna-player-position-stamp! player (now-ms))))
|
||||||
|
(safe-unpublish! player old-current)
|
||||||
|
(safe-unpublish! player old-next))
|
||||||
|
(void))))
|
||||||
|
|
||||||
|
(define (dlna-player-set-next-file! player file)
|
||||||
|
(check-player 'dlna-player-set-next-file! player)
|
||||||
|
(let* ([track
|
||||||
|
(player-track-info
|
||||||
|
'dlna-player-set-next-file!
|
||||||
|
player
|
||||||
|
file)]
|
||||||
|
[item (publish-track! player track)])
|
||||||
|
(with-handlers
|
||||||
|
([exn:fail?
|
||||||
|
(lambda (exception)
|
||||||
|
(safe-unpublish! player item)
|
||||||
|
(raise exception))])
|
||||||
|
(call-renderer
|
||||||
|
player
|
||||||
|
(lambda ()
|
||||||
|
(media-renderer-set-next-uri!
|
||||||
|
(dlna-player-renderer player)
|
||||||
|
(publication-uri item)
|
||||||
|
#:metadata (publication-metadata item))))
|
||||||
|
(let ([old-next #f])
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(set! old-next (dlna-player-next player))
|
||||||
|
(set-dlna-player-next! player item)
|
||||||
|
(set-dlna-player-cached-info!
|
||||||
|
player
|
||||||
|
(struct-copy
|
||||||
|
dlna-info
|
||||||
|
(dlna-player-cached-info player)
|
||||||
|
[next-track track]
|
||||||
|
[next-uri (publication-uri item)]))))
|
||||||
|
(safe-unpublish! player old-next))
|
||||||
|
(void))))
|
||||||
|
|
||||||
|
(define (dlna-player-pause! player)
|
||||||
|
(check-player 'dlna-player-pause! player)
|
||||||
|
(let ([position (dlna-info-position (estimated-info player))])
|
||||||
|
(call-renderer
|
||||||
|
player
|
||||||
|
(lambda ()
|
||||||
|
(media-renderer-pause! (dlna-player-renderer player))))
|
||||||
|
(update-cache!
|
||||||
|
player
|
||||||
|
(lambda (info)
|
||||||
|
(struct-copy dlna-info
|
||||||
|
info
|
||||||
|
[state 'paused]
|
||||||
|
[position position]))
|
||||||
|
#:position? #t)
|
||||||
|
(void)))
|
||||||
|
|
||||||
|
(define (dlna-player-resume! player)
|
||||||
|
(check-player 'dlna-player-resume! player)
|
||||||
|
(call-renderer
|
||||||
|
player
|
||||||
|
(lambda ()
|
||||||
|
(media-renderer-play! (dlna-player-renderer player))))
|
||||||
|
(update-cache!
|
||||||
|
player
|
||||||
|
(lambda (info)
|
||||||
|
(struct-copy dlna-info info [state 'playing]))
|
||||||
|
#:position? #t)
|
||||||
|
(void))
|
||||||
|
|
||||||
|
(define (dlna-player-stop! player)
|
||||||
|
(check-player 'dlna-player-stop! player)
|
||||||
|
(call-renderer
|
||||||
|
player
|
||||||
|
(lambda ()
|
||||||
|
(media-renderer-stop! (dlna-player-renderer player))))
|
||||||
|
(update-cache!
|
||||||
|
player
|
||||||
|
(lambda (info)
|
||||||
|
(struct-copy dlna-info
|
||||||
|
info
|
||||||
|
[state 'stopped]
|
||||||
|
[position 0]))
|
||||||
|
#:position? #t)
|
||||||
|
(void))
|
||||||
|
|
||||||
|
(define (dlna-player-seek! player seconds)
|
||||||
|
(check-player 'dlna-player-seek! player)
|
||||||
|
(unless (and (rational? seconds) (not (negative? seconds)))
|
||||||
|
(raise-argument-error 'dlna-player-seek!
|
||||||
|
"nonnegative-real?"
|
||||||
|
seconds))
|
||||||
|
(call-renderer
|
||||||
|
player
|
||||||
|
(lambda ()
|
||||||
|
(media-renderer-seek!
|
||||||
|
(dlna-player-renderer player)
|
||||||
|
seconds)))
|
||||||
|
(update-cache!
|
||||||
|
player
|
||||||
|
(lambda (info)
|
||||||
|
(struct-copy dlna-info info [position seconds]))
|
||||||
|
#:position? #t)
|
||||||
|
(void))
|
||||||
|
|
||||||
|
(define (dlna-player-seek-percentage! player percentage)
|
||||||
|
(check-player 'dlna-player-seek-percentage! player)
|
||||||
|
(unless (rational? percentage)
|
||||||
|
(raise-argument-error 'dlna-player-seek-percentage!
|
||||||
|
"real?"
|
||||||
|
percentage))
|
||||||
|
(let* ([initial (dlna-player-info player)]
|
||||||
|
[info
|
||||||
|
(if (number? (dlna-info-duration initial))
|
||||||
|
initial
|
||||||
|
(dlna-player-info player #:refresh? #t))]
|
||||||
|
[duration (dlna-info-duration info)])
|
||||||
|
(unless (and (number? duration) (positive? duration))
|
||||||
|
(raise-arguments-error
|
||||||
|
'dlna-player-seek-percentage!
|
||||||
|
"track duration is not available"))
|
||||||
|
(dlna-player-seek!
|
||||||
|
player
|
||||||
|
(* duration (/ (min 100 (max 0 percentage)) 100.0)))))
|
||||||
|
|
||||||
|
(define (dlna-player-volume! player percentage)
|
||||||
|
(check-player 'dlna-player-volume! player)
|
||||||
|
(unless (rational? percentage)
|
||||||
|
(raise-argument-error 'dlna-player-volume! "real?" percentage))
|
||||||
|
(let ([volume
|
||||||
|
(min 100
|
||||||
|
(max 0
|
||||||
|
(inexact->exact (round percentage))))])
|
||||||
|
(call-renderer
|
||||||
|
player
|
||||||
|
(lambda ()
|
||||||
|
(media-renderer-set-volume!
|
||||||
|
(dlna-player-renderer player)
|
||||||
|
volume)))
|
||||||
|
(update-cache!
|
||||||
|
player
|
||||||
|
(lambda (info)
|
||||||
|
(struct-copy dlna-info info [volume volume])))
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-cache-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(set-dlna-player-volume-stamp! player (now-ms))))
|
||||||
|
(void)))
|
||||||
|
|
||||||
|
(define (dlna-player-muted! player muted?)
|
||||||
|
(check-player 'dlna-player-muted! player)
|
||||||
|
(unless (boolean? muted?)
|
||||||
|
(raise-argument-error 'dlna-player-muted! "boolean?" muted?))
|
||||||
|
(call-renderer
|
||||||
|
player
|
||||||
|
(lambda ()
|
||||||
|
(media-renderer-set-muted!
|
||||||
|
(dlna-player-renderer player)
|
||||||
|
muted?)))
|
||||||
|
(update-cache!
|
||||||
|
player
|
||||||
|
(lambda (info)
|
||||||
|
(struct-copy dlna-info info [muted? muted?])))
|
||||||
|
(void))
|
||||||
|
|
||||||
|
(define (dlna-player-info player #:refresh? [refresh? #f])
|
||||||
|
(check-player 'dlna-player-info player)
|
||||||
|
(unless (boolean? refresh?)
|
||||||
|
(raise-argument-error 'dlna-player-info "boolean?" refresh?))
|
||||||
|
(when refresh?
|
||||||
|
(refresh-player! player #:force-volume? #t))
|
||||||
|
(estimated-info player))
|
||||||
|
|
||||||
|
(define (dlna-player-close! player)
|
||||||
|
(unless (dlna-player? player)
|
||||||
|
(raise-argument-error 'dlna-player-close! "dlna-player?" player))
|
||||||
|
(when (dlna-player-running? player)
|
||||||
|
(set-dlna-player-running?! player #f)
|
||||||
|
(let ([poll-thread (dlna-player-poll-thread player)])
|
||||||
|
(when poll-thread
|
||||||
|
(kill-thread poll-thread)
|
||||||
|
(set-dlna-player-poll-thread! player #f)))
|
||||||
|
(with-handlers ([exn:fail? (lambda (_) (void))])
|
||||||
|
(call-with-semaphore
|
||||||
|
(dlna-player-transport-lock player)
|
||||||
|
(lambda ()
|
||||||
|
(media-renderer-stop! (dlna-player-renderer player)))))
|
||||||
|
(let ([current (dlna-player-current player)]
|
||||||
|
[next (dlna-player-next player)])
|
||||||
|
(safe-unpublish! player current)
|
||||||
|
(safe-unpublish! player next)
|
||||||
|
(set-dlna-player-current! player #f)
|
||||||
|
(set-dlna-player-next! player #f))
|
||||||
|
(media-file-server-stop! (dlna-player-server player)))
|
||||||
|
(void))
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket-audio-dlna
|
||||||
|
racket-upnp)
|
||||||
|
|
||||||
|
(define renderer
|
||||||
|
(get-media-renderer "Denon"))
|
||||||
|
|
||||||
|
(unless renderer
|
||||||
|
(error 'racket-audio-dlna "Denon renderer not found"))
|
||||||
|
|
||||||
|
(define player
|
||||||
|
(make-dlna-player renderer))
|
||||||
|
|
||||||
|
(dlna-player-play!
|
||||||
|
player
|
||||||
|
"/muziek/radiohead/in-rainbows.flac")
|
||||||
|
|
||||||
|
(define info
|
||||||
|
(dlna-player-info player))
|
||||||
|
|
||||||
|
(printf "State: ~a, position: ~a, duration: ~a, volume: ~a\n"
|
||||||
|
(dlna-info-state info)
|
||||||
|
(dlna-info-position info)
|
||||||
|
(dlna-info-duration info)
|
||||||
|
(dlna-info-volume info))
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#lang info
|
||||||
|
|
||||||
|
(define pkg-authors '(hnmdijkema))
|
||||||
|
(define version "0.1.0")
|
||||||
|
(define license 'GPL-2.0-or-later)
|
||||||
|
(define collection "racket-audio-dlna")
|
||||||
|
(define pkg-desc
|
||||||
|
"Play local racket-audio files through racket-upnp media renderers")
|
||||||
|
|
||||||
|
(define deps
|
||||||
|
'("base"
|
||||||
|
"net-lib"
|
||||||
|
"racket-audio"
|
||||||
|
("racket-upnp" #:version "0.1.3")))
|
||||||
|
|
||||||
|
(define build-deps
|
||||||
|
'("racket-doc"
|
||||||
|
"rackunit-lib"
|
||||||
|
"scribble-lib"))
|
||||||
|
|
||||||
|
(define scribblings
|
||||||
|
'(("scribblings/racket-audio-dlna.scrbl" (multi-page) (library))))
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require "dlna-player.rkt")
|
||||||
|
|
||||||
|
(provide
|
||||||
|
(all-from-out "dlna-player.rkt"))
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
#lang scribble/manual
|
||||||
|
|
||||||
|
@(require
|
||||||
|
(for-label
|
||||||
|
racket/base
|
||||||
|
racket/contract
|
||||||
|
racket-audio-dlna
|
||||||
|
racket-upnp))
|
||||||
|
|
||||||
|
@title{racket-audio-dlna}
|
||||||
|
@author{Hans Dijkema}
|
||||||
|
|
||||||
|
@defmodule[racket-audio-dlna]
|
||||||
|
|
||||||
|
This package plays local audio files on UPnP and DLNA media renderers. It
|
||||||
|
uses @racketmodname[racket-audio/taglib] for metadata and audio properties,
|
||||||
|
and @racketmodname[racket-upnp] for HTTP publication, DIDL-Lite, and renderer
|
||||||
|
control.
|
||||||
|
|
||||||
|
@section{Creating a player}
|
||||||
|
|
||||||
|
@defproc[(make-dlna-player
|
||||||
|
[renderer media-renderer?]
|
||||||
|
[#:listen-ip listen-ip (or/c #f string?) #f]
|
||||||
|
[#:port port exact-integer? 8080]
|
||||||
|
[#:path path string? "/racket-audio-dlna/"]
|
||||||
|
[#:poll-seconds poll-seconds (and/c rational? positive?) 1.0]
|
||||||
|
[#:volume-poll-seconds
|
||||||
|
volume-poll-seconds (and/c rational? positive?) 5.0])
|
||||||
|
dlna-player?]{
|
||||||
|
|
||||||
|
Creates a player for @racket[renderer] and starts its local HTTP media server.
|
||||||
|
When @racket[listen-ip] is @racket[#f], the address is selected from the
|
||||||
|
network route to the renderer.
|
||||||
|
|
||||||
|
The player polls transport state and position every @racket[poll-seconds].
|
||||||
|
Volume and mute state are refreshed every @racket[volume-poll-seconds].
|
||||||
|
}
|
||||||
|
|
||||||
|
@defproc[(dlna-player? [value any/c]) boolean?]
|
||||||
|
|
||||||
|
@section{Files and playback}
|
||||||
|
|
||||||
|
@defproc[(dlna-player-play!
|
||||||
|
[player dlna-player?]
|
||||||
|
[file path-string?])
|
||||||
|
void?]{
|
||||||
|
|
||||||
|
Reads the file with TagLib, publishes it through the internal HTTP server,
|
||||||
|
generates matching DIDL-Lite, installs the URI, and starts playback. Missing
|
||||||
|
titles fall back to the file name. Other missing tags are omitted.
|
||||||
|
}
|
||||||
|
|
||||||
|
@defproc[(dlna-player-set-next-file!
|
||||||
|
[player dlna-player?]
|
||||||
|
[file path-string?])
|
||||||
|
void?]{
|
||||||
|
|
||||||
|
Publishes and preloads the file with @tt{SetNextAVTransportURI}. Replacing the
|
||||||
|
next file removes the previous next-file publication.
|
||||||
|
}
|
||||||
|
|
||||||
|
@defproc[(dlna-player-pause! [player dlna-player?]) void?]
|
||||||
|
@defproc[(dlna-player-resume! [player dlna-player?]) void?]
|
||||||
|
@defproc[(dlna-player-stop! [player dlna-player?]) void?]
|
||||||
|
|
||||||
|
@defproc[(dlna-player-seek!
|
||||||
|
[player dlna-player?]
|
||||||
|
[seconds (and/c rational? (not/c negative?))])
|
||||||
|
void?]{
|
||||||
|
|
||||||
|
Seeks using the AVTransport @tt{REL_TIME} unit.
|
||||||
|
}
|
||||||
|
|
||||||
|
@defproc[(dlna-player-seek-percentage!
|
||||||
|
[player dlna-player?]
|
||||||
|
[percentage real?])
|
||||||
|
void?]{
|
||||||
|
|
||||||
|
Converts the clamped percentage to seconds using the cached track duration and
|
||||||
|
then seeks with @racket[dlna-player-seek!].
|
||||||
|
}
|
||||||
|
|
||||||
|
@section{Volume and mute}
|
||||||
|
|
||||||
|
@defproc[(dlna-player-volume!
|
||||||
|
[player dlna-player?]
|
||||||
|
[percentage real?])
|
||||||
|
void?]
|
||||||
|
|
||||||
|
@defproc[(dlna-player-muted!
|
||||||
|
[player dlna-player?]
|
||||||
|
[muted? boolean?])
|
||||||
|
void?]
|
||||||
|
|
||||||
|
Values are limited to the renderer range where applicable. A successful
|
||||||
|
command updates the cache immediately.
|
||||||
|
|
||||||
|
@section{Cached information}
|
||||||
|
|
||||||
|
@defproc[(dlna-player-info
|
||||||
|
[player dlna-player?]
|
||||||
|
[#:refresh? refresh? boolean? #f])
|
||||||
|
dlna-info?]{
|
||||||
|
|
||||||
|
Returns the cached player snapshot. While playing, the cached position is
|
||||||
|
advanced locally between renderer polls and limited to the known duration.
|
||||||
|
|
||||||
|
When @racket[refresh?] is true, transport, position, volume, and mute state are
|
||||||
|
queried synchronously before the snapshot is returned. A failed refresh keeps
|
||||||
|
the previous values and sets @racket[dlna-info-reachable?] to @racket[#f].
|
||||||
|
}
|
||||||
|
|
||||||
|
@defproc[(dlna-info-state [info dlna-info?]) symbol?]
|
||||||
|
@defproc[(dlna-info-track [info dlna-info?])
|
||||||
|
(or/c #f dlna-track-info?)]
|
||||||
|
@defproc[(dlna-info-uri [info dlna-info?]) (or/c #f string?)]
|
||||||
|
@defproc[(dlna-info-next-track [info dlna-info?])
|
||||||
|
(or/c #f dlna-track-info?)]
|
||||||
|
@defproc[(dlna-info-next-uri [info dlna-info?]) (or/c #f string?)]
|
||||||
|
@defproc[(dlna-info-position [info dlna-info?]) (or/c #f real?)]
|
||||||
|
@defproc[(dlna-info-duration [info dlna-info?]) (or/c #f real?)]
|
||||||
|
@defproc[(dlna-info-volume [info dlna-info?])
|
||||||
|
(or/c #f exact-nonnegative-integer?)]
|
||||||
|
@defproc[(dlna-info-muted? [info dlna-info?]) (or/c #f boolean?)]
|
||||||
|
@defproc[(dlna-info-reachable? [info dlna-info?]) boolean?]
|
||||||
|
|
||||||
|
@section{Track metadata}
|
||||||
|
|
||||||
|
@defproc[(dlna-track-info-file [track dlna-track-info?]) path?]
|
||||||
|
@defproc[(dlna-track-info-title [track dlna-track-info?]) string?]
|
||||||
|
@defproc[(dlna-track-info-artist [track dlna-track-info?])
|
||||||
|
(or/c #f string?)]
|
||||||
|
@defproc[(dlna-track-info-album [track dlna-track-info?])
|
||||||
|
(or/c #f string?)]
|
||||||
|
@defproc[(dlna-track-info-genre [track dlna-track-info?])
|
||||||
|
(or/c #f string?)]
|
||||||
|
@defproc[(dlna-track-info-year [track dlna-track-info?])
|
||||||
|
(or/c #f exact-positive-integer?)]
|
||||||
|
@defproc[(dlna-track-info-track [track dlna-track-info?])
|
||||||
|
(or/c #f exact-positive-integer?)]
|
||||||
|
@defproc[(dlna-track-info-duration [track dlna-track-info?])
|
||||||
|
(or/c #f exact-positive-integer?)]
|
||||||
|
@defproc[(dlna-track-info-sample-rate [track dlna-track-info?])
|
||||||
|
(or/c #f exact-positive-integer?)]
|
||||||
|
@defproc[(dlna-track-info-bit-rate [track dlna-track-info?])
|
||||||
|
(or/c #f exact-positive-integer?)]
|
||||||
|
@defproc[(dlna-track-info-channels [track dlna-track-info?])
|
||||||
|
(or/c #f exact-positive-integer?)]
|
||||||
|
|
||||||
|
TagLib results are cached by complete file name, size, and modification time.
|
||||||
|
Changing the file therefore invalidates its cached metadata.
|
||||||
|
|
||||||
|
@section{Closing}
|
||||||
|
|
||||||
|
@defproc[(dlna-player-close! [player dlna-player?]) void?]{
|
||||||
|
|
||||||
|
Stops playback, removes current and next publications, stops the HTTP server,
|
||||||
|
and terminates the polling thread. Repeated calls have no effect.
|
||||||
|
}
|
||||||
|
|
||||||
|
@section{Example}
|
||||||
|
|
||||||
|
@racketblock[
|
||||||
|
(define renderer
|
||||||
|
(get-media-renderer "Denon"))
|
||||||
|
|
||||||
|
(define player
|
||||||
|
(make-dlna-player renderer))
|
||||||
|
|
||||||
|
(dlna-player-play!
|
||||||
|
player
|
||||||
|
"/muziek/radiohead/in-rainbows.flac")
|
||||||
|
|
||||||
|
(dlna-player-set-next-file!
|
||||||
|
player
|
||||||
|
"/muziek/radiohead/bodysnatchers.flac")
|
||||||
|
|
||||||
|
(define info
|
||||||
|
(dlna-player-info player))
|
||||||
|
|
||||||
|
(printf "~a: ~a / ~a seconds, volume ~a\n"
|
||||||
|
(dlna-info-state info)
|
||||||
|
(dlna-info-position info)
|
||||||
|
(dlna-info-duration info)
|
||||||
|
(dlna-info-volume info))
|
||||||
|
]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require rackunit
|
||||||
|
"../main.rkt")
|
||||||
|
|
||||||
|
(define track
|
||||||
|
(dlna-track-info
|
||||||
|
"/music/test.flac"
|
||||||
|
"Test track"
|
||||||
|
"Test artist"
|
||||||
|
"Test album"
|
||||||
|
"Alternative"
|
||||||
|
2026
|
||||||
|
7
|
||||||
|
639
|
||||||
|
44100
|
||||||
|
900
|
||||||
|
2))
|
||||||
|
|
||||||
|
(define info
|
||||||
|
(dlna-info
|
||||||
|
'playing
|
||||||
|
track
|
||||||
|
"http://127.0.0.1:8080/music/track-1.flac"
|
||||||
|
#f
|
||||||
|
#f
|
||||||
|
12
|
||||||
|
639
|
||||||
|
35
|
||||||
|
#f
|
||||||
|
#t))
|
||||||
|
|
||||||
|
(check-true (dlna-track-info? track))
|
||||||
|
(check-equal? (dlna-track-info-title track) "Test track")
|
||||||
|
(check-equal? (dlna-track-info-duration track) 639)
|
||||||
|
(check-true (dlna-info? info))
|
||||||
|
(check-eq? (dlna-info-state info) 'playing)
|
||||||
|
(check-equal? (dlna-info-position info) 12)
|
||||||
|
(check-equal? (dlna-info-volume info) 35)
|
||||||
|
(check-true (dlna-info-reachable? info))
|
||||||
|
|
||||||
|
(check-exn exn:fail:contract?
|
||||||
|
(lambda () (make-dlna-player #f)))
|
||||||
Reference in New Issue
Block a user