#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))