Compare commits
5 Commits
348fe07b48
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 59291bd8bb | |||
| 3fda5c2485 | |||
| 5f0d835501 | |||
| f7f4d440b8 | |||
| e751e1e17c |
@@ -0,0 +1,120 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/async-channel)
|
||||||
|
|
||||||
|
(provide call-with-http-timeout)
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Bound HTTP inactivity while allowing transfers of any duration.
|
||||||
|
; pre : Seconds is positive. Proc consumes its HTTP ports before returning
|
||||||
|
; and calls progress! whenever new response bytes arrive.
|
||||||
|
; post : The worker has exited and its HTTP resources have been closed.
|
||||||
|
; result : Proc's values, or its exception, or an inactivity timeout error.
|
||||||
|
; internals: Activity restarts sync/timeout. On cancellation, a break unwinds
|
||||||
|
; semaphore and dynamic-wind cleanup before the custodian closes
|
||||||
|
; remaining ports. No timed-out worker can continue changing state.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (call-with-http-timeout seconds proc)
|
||||||
|
(let* ((custodian (make-custodian))
|
||||||
|
(activity (make-async-channel 1))
|
||||||
|
(result (make-async-channel))
|
||||||
|
(finished? #f)
|
||||||
|
(worker
|
||||||
|
(parameterize ((current-custodian custodian))
|
||||||
|
(thread
|
||||||
|
(λ ()
|
||||||
|
(with-handlers ((exn:break? void))
|
||||||
|
(parameterize-break #t
|
||||||
|
(with-handlers
|
||||||
|
((exn? (λ (exception)
|
||||||
|
(async-channel-put result (cons #f exception)))))
|
||||||
|
(let ((values-list
|
||||||
|
(call-with-values
|
||||||
|
(λ () (proc (λ () (async-channel-put activity #t))))
|
||||||
|
list)))
|
||||||
|
(async-channel-put result (cons #t values-list)))))))))))
|
||||||
|
(dynamic-wind
|
||||||
|
void
|
||||||
|
(λ ()
|
||||||
|
(let loop ()
|
||||||
|
(let ((event (sync/timeout seconds
|
||||||
|
(handle-evt result (λ (value) (cons 'result value)))
|
||||||
|
(handle-evt activity (λ (_) 'progress)))))
|
||||||
|
(cond
|
||||||
|
((not event)
|
||||||
|
(error 'http-transfer "No HTTP progress for ~a seconds" seconds))
|
||||||
|
((eq? event 'progress) (loop))
|
||||||
|
(else
|
||||||
|
(set! finished? #t)
|
||||||
|
(if (cadr event)
|
||||||
|
(apply values (cddr event))
|
||||||
|
(raise (cddr event))))))))
|
||||||
|
(λ ()
|
||||||
|
(unless (or finished? (thread-dead? worker))
|
||||||
|
(break-thread worker))
|
||||||
|
(thread-wait worker)
|
||||||
|
(custodian-shutdown-all custodian)))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Tests for module http-transfer.rkt
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(module+ test
|
||||||
|
(require rackunit racket/file)
|
||||||
|
|
||||||
|
(check-equal? (call-with-values
|
||||||
|
(λ () (call-with-http-timeout 1 (λ (_) (values 1 2)))) list)
|
||||||
|
'(1 2))
|
||||||
|
(check-exn #rx"original failure"
|
||||||
|
(λ () (call-with-http-timeout 1 (λ (_) (error "original failure")))))
|
||||||
|
|
||||||
|
(test-case "continuing progress allows a transfer longer than its idle limit"
|
||||||
|
(check-equal?
|
||||||
|
(call-with-http-timeout
|
||||||
|
0.5
|
||||||
|
(λ (progress!)
|
||||||
|
(let loop ((chunks 6))
|
||||||
|
(unless (zero? chunks)
|
||||||
|
(sleep 0.15)
|
||||||
|
(progress!)
|
||||||
|
(loop (- chunks 1))))
|
||||||
|
'downloaded))
|
||||||
|
'downloaded))
|
||||||
|
|
||||||
|
(test-case "stalled HTTP workers release locks and close their ports"
|
||||||
|
(let ((lock (make-semaphore 1))
|
||||||
|
(output #f)
|
||||||
|
(target (make-temporary-file "http-stall-~a")))
|
||||||
|
(dynamic-wind
|
||||||
|
void
|
||||||
|
(λ ()
|
||||||
|
(check-exn
|
||||||
|
#rx"No HTTP progress"
|
||||||
|
(λ ()
|
||||||
|
(parameterize-break #f
|
||||||
|
(call-with-http-timeout
|
||||||
|
0.1
|
||||||
|
(λ (_)
|
||||||
|
(set! output (open-output-file target #:exists 'truncate))
|
||||||
|
(call-with-semaphore lock (λ () (sync never-evt))))))))
|
||||||
|
(check-true (semaphore-try-wait? lock))
|
||||||
|
(check-true (port-closed? output)))
|
||||||
|
(λ () (delete-file target)))))
|
||||||
|
|
||||||
|
(test-case "breaking the caller also unwinds its HTTP worker"
|
||||||
|
(let* ((ready (make-semaphore))
|
||||||
|
(lock (make-semaphore 1))
|
||||||
|
(caller
|
||||||
|
(thread
|
||||||
|
(λ ()
|
||||||
|
(with-handlers ((exn:break? void))
|
||||||
|
(call-with-http-timeout
|
||||||
|
5
|
||||||
|
(λ (_)
|
||||||
|
(call-with-semaphore
|
||||||
|
lock
|
||||||
|
(λ () (semaphore-post ready) (sync never-evt))))))))))
|
||||||
|
(semaphore-wait ready)
|
||||||
|
(break-thread caller)
|
||||||
|
(check-not-false (sync/timeout 2 caller))
|
||||||
|
(check-true (semaphore-try-wait? lock)))))
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
racket/port
|
racket/port
|
||||||
racket/string
|
racket/string
|
||||||
simple-log
|
simple-log
|
||||||
"player-agent-translate.rkt")
|
"player-agent-translate.rkt"
|
||||||
|
"http-transfer.rkt")
|
||||||
|
|
||||||
(provide (struct-out player-agent-runtime)
|
(provide (struct-out player-agent-runtime)
|
||||||
make-player-agent-runtime)
|
make-player-agent-runtime)
|
||||||
@@ -44,27 +45,30 @@
|
|||||||
;;; Posts JSON to an agent API endpoint and reads its JSON response.
|
;;; Posts JSON to an agent API endpoint and reads its JSON response.
|
||||||
;;; Authorization failures receive a distinct exception for poll-loop.
|
;;; Authorization failures receive a distinct exception for poll-loop.
|
||||||
(define (post-json base path data)
|
(define (post-json base path data)
|
||||||
(let ((input
|
(call-with-http-timeout
|
||||||
(post-pure-port
|
30
|
||||||
(endpoint-url base path)
|
(λ (_progress!)
|
||||||
(jsexpr->bytes data)
|
(let ((input
|
||||||
(list "Content-Type: application/json"
|
(post-pure-port
|
||||||
"Cache-Control: no-store"))))
|
(endpoint-url base path)
|
||||||
(dynamic-wind
|
(jsexpr->bytes data)
|
||||||
void
|
(list "Content-Type: application/json"
|
||||||
(λ ()
|
"Cache-Control: no-store"))))
|
||||||
(let ((response (read-json input)))
|
(dynamic-wind
|
||||||
(when (and (hash? response)
|
void
|
||||||
(string? (hash-ref response 'error #f)))
|
(λ ()
|
||||||
(if (equal? (hash-ref response 'code #f)
|
(let ((response (read-json input)))
|
||||||
"agent-not-authorized")
|
(when (and (hash? response)
|
||||||
(raise
|
(string? (hash-ref response 'error #f)))
|
||||||
(exn:fail:agent-denied
|
(if (equal? (hash-ref response 'code #f)
|
||||||
(hash-ref response 'error)
|
"agent-not-authorized")
|
||||||
(current-continuation-marks)))
|
(raise
|
||||||
(error 'player-agent (hash-ref response 'error))))
|
(exn:fail:agent-denied
|
||||||
response))
|
(hash-ref response 'error)
|
||||||
(λ () (close-input-port input)))))
|
(current-continuation-marks)))
|
||||||
|
(error 'player-agent (hash-ref response 'error))))
|
||||||
|
response))
|
||||||
|
(λ () (close-input-port input)))))))
|
||||||
|
|
||||||
;;; Converts racket-audio states to the state names sent to the web player.
|
;;; Converts racket-audio states to the state names sent to the web player.
|
||||||
(define (normal-state state)
|
(define (normal-state state)
|
||||||
@@ -84,6 +88,21 @@
|
|||||||
(exn-message exception)))))
|
(exn-message exception)))))
|
||||||
(delete-file file))))
|
(delete-file file))))
|
||||||
|
|
||||||
|
;;; Copies media in bounded chunks, reporting bytes written without waiting
|
||||||
|
;;; for a full buffer; slow network streams therefore still show progress.
|
||||||
|
(define (copy-media-port input output progress! #:idle-timeout [idle-timeout 60])
|
||||||
|
(let ((buffer (make-bytes 65536)))
|
||||||
|
(let loop ((received 0))
|
||||||
|
(unless (sync/timeout idle-timeout input)
|
||||||
|
(error 'player-agent
|
||||||
|
"Download timeout: no data received for ~a seconds (~a bytes received)"
|
||||||
|
idle-timeout received))
|
||||||
|
(let ((count (read-bytes-avail! buffer input)))
|
||||||
|
(unless (eof-object? count)
|
||||||
|
(write-bytes buffer output 0 count)
|
||||||
|
(progress! (+ received count))
|
||||||
|
(loop (+ received count)))))))
|
||||||
|
|
||||||
;;; Evicts least recently used files until the cache fits its byte budget.
|
;;; Evicts least recently used files until the cache fits its byte budget.
|
||||||
;;; Entries contain path, byte size and last use; active files remain protected.
|
;;; Entries contain path, byte size and last use; active files remain protected.
|
||||||
(define (trim-media-cache! cache protected [limit 1000000000])
|
(define (trim-media-cache! cache protected [limit 1000000000])
|
||||||
@@ -151,6 +170,7 @@
|
|||||||
(pending-auto-music-id #f)
|
(pending-auto-music-id #f)
|
||||||
(music-tracks (make-hash))
|
(music-tracks (make-hash))
|
||||||
(current-track-value #f)
|
(current-track-value #f)
|
||||||
|
(download-progress 'null)
|
||||||
(acknowledged-command 0)
|
(acknowledged-command 0)
|
||||||
(ended-counter 0)
|
(ended-counter 0)
|
||||||
(logical-volume 50)
|
(logical-volume 50)
|
||||||
@@ -173,9 +193,11 @@
|
|||||||
(define (state-value value fallback)
|
(define (state-value value fallback)
|
||||||
(if (eq? value #f) fallback value))
|
(if (eq? value #f) fallback value))
|
||||||
|
|
||||||
;;; Returns the latest audio state while holding the state semaphore.
|
;;; Combines audio and download state under the semaphore so audio callbacks
|
||||||
|
;;; cannot erase progress while the next track is being prefetched.
|
||||||
(define (snapshot)
|
(define (snapshot)
|
||||||
(with-agent-state (λ () agent-state)))
|
(with-agent-state
|
||||||
|
(λ () (hash-set agent-state 'download download-progress))))
|
||||||
|
|
||||||
;;; Returns the currently audible track while holding the state semaphore.
|
;;; Returns the currently audible track while holding the state semaphore.
|
||||||
(define (current-track)
|
(define (current-track)
|
||||||
@@ -210,7 +232,7 @@
|
|||||||
'format (let ((decoder (hash-ref full-state 'decoder #f)))
|
'format (let ((decoder (hash-ref full-state 'decoder #f)))
|
||||||
(if decoder (format "~a" decoder) ""))
|
(if decoder (format "~a" decoder) ""))
|
||||||
'volume logical-volume
|
'volume logical-volume
|
||||||
'error 'null))
|
'error (hash-ref agent-state 'error 'null)))
|
||||||
(when (and pending-auto-music-id
|
(when (and pending-auto-music-id
|
||||||
(number? audible-music-id)
|
(number? audible-music-id)
|
||||||
(= pending-auto-music-id audible-music-id))
|
(= pending-auto-music-id audible-music-id))
|
||||||
@@ -243,7 +265,7 @@
|
|||||||
|
|
||||||
;;; Downloads one protected media resource to a temporary local file.
|
;;; Downloads one protected media resource to a temporary local file.
|
||||||
;;; A failed download closes its port and removes its partial file.
|
;;; A failed download closes its port and removes its partial file.
|
||||||
(define (download-media! token filename)
|
(define (download-media-once! token filename size)
|
||||||
(let* ((started (current-inexact-monotonic-milliseconds))
|
(let* ((started (current-inexact-monotonic-milliseconds))
|
||||||
(extension
|
(extension
|
||||||
(or (path-get-extension (string->path filename)) #""))
|
(or (path-get-extension (string->path filename)) #""))
|
||||||
@@ -253,24 +275,65 @@
|
|||||||
(bytes->string/utf-8 extension))))
|
(bytes->string/utf-8 extension))))
|
||||||
(path (format "/api/agent/media/~a/~a" app-id token))
|
(path (format "/api/agent/media/~a/~a" app-id token))
|
||||||
(input #f))
|
(input #f))
|
||||||
(with-handlers ((exn:fail?
|
(with-handlers ((exn?
|
||||||
(λ (exception)
|
(λ (exception)
|
||||||
(when input (close-input-port input))
|
(when input (close-input-port input))
|
||||||
(safe-delete-file target)
|
(safe-delete-file target)
|
||||||
|
(with-agent-state
|
||||||
|
(λ () (set! download-progress 'null)))
|
||||||
(raise exception))))
|
(raise exception))))
|
||||||
(info-player-agent "Downloading ~a" filename)
|
(info-player-agent "Downloading ~a" filename)
|
||||||
(set! input (get-pure-port (endpoint-url server-url path)))
|
(with-agent-state
|
||||||
(call-with-output-file
|
(λ ()
|
||||||
target
|
(set! download-progress
|
||||||
(λ (output) (copy-port input output))
|
(hasheq 'filename filename 'received 0
|
||||||
#:exists 'truncate/replace)
|
'total (if (exact-nonnegative-integer? size)
|
||||||
(close-input-port input)
|
size 'null)))))
|
||||||
|
(call-with-http-timeout
|
||||||
|
30
|
||||||
|
(λ (progress!)
|
||||||
|
(set! input (get-pure-port (endpoint-url server-url path)))
|
||||||
|
(call-with-output-file
|
||||||
|
target
|
||||||
|
(λ (output)
|
||||||
|
(copy-media-port
|
||||||
|
input output
|
||||||
|
(λ (received)
|
||||||
|
(progress!)
|
||||||
|
(with-agent-state
|
||||||
|
(λ ()
|
||||||
|
(set! download-progress
|
||||||
|
(hash-set download-progress 'received received)))))))
|
||||||
|
#:exists 'truncate/replace)
|
||||||
|
(close-input-port input)))
|
||||||
|
(when (and (exact-nonnegative-integer? size)
|
||||||
|
(not (= (file-size target) size)))
|
||||||
|
(error 'player-agent
|
||||||
|
"Incomplete media download: received ~a of ~a bytes"
|
||||||
|
(file-size target) size))
|
||||||
|
(with-agent-state (λ () (set! download-progress 'null)))
|
||||||
(info-player-agent "Downloaded ~a: ~a bytes in ~a ms"
|
(info-player-agent "Downloaded ~a: ~a bytes in ~a ms"
|
||||||
filename (file-size target)
|
filename (file-size target)
|
||||||
(round (- (current-inexact-monotonic-milliseconds)
|
(round (- (current-inexact-monotonic-milliseconds)
|
||||||
started)))
|
started)))
|
||||||
target)))
|
target)))
|
||||||
|
|
||||||
|
;;; Retries interrupted downloads twice before reporting a final failure.
|
||||||
|
;;; Each attempt removes its partial file; the media token stays unacknowledged.
|
||||||
|
(define (download-media! token filename size)
|
||||||
|
(let loop ((attempt 1))
|
||||||
|
(with-handlers
|
||||||
|
((exn:fail?
|
||||||
|
(λ (exception)
|
||||||
|
(if (< attempt 3)
|
||||||
|
(begin
|
||||||
|
(warn-player-agent "Download attempt ~a failed for ~a: ~a; retrying"
|
||||||
|
attempt filename (exn-message exception))
|
||||||
|
(sleep 1)
|
||||||
|
(loop (+ attempt 1)))
|
||||||
|
(raise exception)))))
|
||||||
|
(download-media-once! token filename size))))
|
||||||
|
|
||||||
;;; Scopes media identity to its server, source path, size and modification time.
|
;;; Scopes media identity to its server, source path, size and modification time.
|
||||||
;;; Older servers without version metadata only reuse the same media token.
|
;;; Older servers without version metadata only reuse the same media token.
|
||||||
(define (command-cache-key data)
|
(define (command-cache-key data)
|
||||||
@@ -299,12 +362,8 @@
|
|||||||
(let ((downloaded
|
(let ((downloaded
|
||||||
(download-media!
|
(download-media!
|
||||||
(hash-ref data 'mediaToken)
|
(hash-ref data 'mediaToken)
|
||||||
(hash-ref data 'filename "track"))))
|
(hash-ref data 'filename "track")
|
||||||
(when (and (hash-has-key? data 'mediaSize)
|
(hash-ref data 'mediaSize #f))))
|
||||||
(not (= (file-size downloaded)
|
|
||||||
(hash-ref data 'mediaSize))))
|
|
||||||
(safe-delete-file downloaded)
|
|
||||||
(error 'player-agent "Downloaded media size does not match the server"))
|
|
||||||
(with-agent-state
|
(with-agent-state
|
||||||
(λ ()
|
(λ ()
|
||||||
(when found (safe-delete-file (car found)))
|
(when found (safe-delete-file (car found)))
|
||||||
@@ -507,21 +566,39 @@
|
|||||||
(set! command-worker
|
(set! command-worker
|
||||||
(thread
|
(thread
|
||||||
(λ ()
|
(λ ()
|
||||||
(with-handlers
|
(with-handlers ((exn:break? void))
|
||||||
((exn:fail?
|
(parameterize-break #t
|
||||||
(λ (exception)
|
(with-handlers
|
||||||
(warn-player-agent "Command failed: ~a"
|
((exn:fail?
|
||||||
(exn-message exception))
|
(λ (exception)
|
||||||
(with-agent-state
|
(warn-player-agent "Command failed: ~a"
|
||||||
(λ ()
|
(exn-message exception))
|
||||||
(set! loading-media-key #f)
|
(when (and audio
|
||||||
(trim-cache!)))
|
(equal? (hash-ref command 'action) "play"))
|
||||||
(set-agent-error! (exn-message exception)))))
|
(with-handlers
|
||||||
(clear-agent-error!)
|
((exn:fail?
|
||||||
(execute-command! command))
|
(λ (stop-exception)
|
||||||
(set! acknowledged-command (hash-ref command 'id))
|
(warn-player-agent
|
||||||
(set! executing-command-id 0)
|
"Could not stop audio after failed play: ~a"
|
||||||
(set! command-worker #f))))))
|
(exn-message stop-exception)))))
|
||||||
|
(audio-stop! audio)))
|
||||||
|
(with-agent-state
|
||||||
|
(λ ()
|
||||||
|
(set! loading-media-key #f)
|
||||||
|
(when (equal? (hash-ref command 'action) "play")
|
||||||
|
(set! agent-state
|
||||||
|
(hash-set* agent-state
|
||||||
|
'state "stopped"
|
||||||
|
'position 0)))
|
||||||
|
(trim-cache!)))
|
||||||
|
(set-agent-error! (exn-message exception)))))
|
||||||
|
;; A queued prefetch must not erase a failed play's error.
|
||||||
|
(unless (equal? (hash-ref command 'action) "prefetch")
|
||||||
|
(clear-agent-error!))
|
||||||
|
(execute-command! command))
|
||||||
|
(set! acknowledged-command (hash-ref command 'id))
|
||||||
|
(set! executing-command-id 0)
|
||||||
|
(set! command-worker #f))))))))
|
||||||
(sleep 1)
|
(sleep 1)
|
||||||
(loop)))))
|
(loop)))))
|
||||||
|
|
||||||
@@ -530,17 +607,26 @@
|
|||||||
(unless running
|
(unless running
|
||||||
(set! running #t)
|
(set! running #t)
|
||||||
(status-callback (tr 'connecting))
|
(status-callback (tr 'connecting))
|
||||||
(set! worker (thread poll-loop))))
|
(set! worker
|
||||||
|
(thread
|
||||||
|
(λ ()
|
||||||
|
(with-handlers ((exn:break? void))
|
||||||
|
(parameterize-break #t (poll-loop))))))))
|
||||||
|
|
||||||
;;; Stops polling and command workers and clears their lifecycle state.
|
;;; Stops polling and command workers and clears their lifecycle state.
|
||||||
(define (stop!)
|
(define (stop!)
|
||||||
(set! running #f)
|
(set! running #f)
|
||||||
(when (and worker (not (thread-dead? worker)))
|
(let ((poll-worker worker)
|
||||||
(kill-thread worker))
|
(active-command command-worker))
|
||||||
(when (and command-worker (not (thread-dead? command-worker)))
|
(when (and poll-worker (not (thread-dead? poll-worker)))
|
||||||
(kill-thread command-worker))
|
(break-thread poll-worker)
|
||||||
|
(thread-wait poll-worker))
|
||||||
|
(when (and active-command (not (thread-dead? active-command)))
|
||||||
|
(break-thread active-command)
|
||||||
|
(thread-wait active-command)))
|
||||||
(set! worker #f)
|
(set! worker #f)
|
||||||
(set! command-worker #f)
|
(set! command-worker #f)
|
||||||
|
(with-agent-state (λ () (set! download-progress 'null)))
|
||||||
(set! executing-command-id 0))
|
(set! executing-command-id 0))
|
||||||
|
|
||||||
;;; Restarts the runtime with a new normalized server address and name.
|
;;; Restarts the runtime with a new normalized server address and name.
|
||||||
@@ -577,7 +663,131 @@
|
|||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(module+ test
|
(module+ test
|
||||||
(require rackunit)
|
(require rackunit
|
||||||
|
racket/async-channel
|
||||||
|
web-server/http
|
||||||
|
web-server/http/json
|
||||||
|
web-server/web-server
|
||||||
|
(prefix-in lift: web-server/dispatchers/dispatch-lift))
|
||||||
|
|
||||||
|
;; Exercise real polling after three truncated downloads. A queued prefetch
|
||||||
|
;; must preserve the error, and a subsequent Stop must still be processed.
|
||||||
|
(test-case "failed download reports stopped and accepts subsequent commands"
|
||||||
|
(let* ((ready (make-async-channel))
|
||||||
|
(done (make-semaphore 0))
|
||||||
|
(attempts 0)
|
||||||
|
(prefetch-attempts 0)
|
||||||
|
(reports (make-hash))
|
||||||
|
(runtime #f)
|
||||||
|
(stop-server
|
||||||
|
(serve
|
||||||
|
#:port 0 #:listen-ip "127.0.0.1"
|
||||||
|
#:confirmation-channel ready #:connection-close? #t
|
||||||
|
#:dispatch
|
||||||
|
(lift:make
|
||||||
|
(λ (request)
|
||||||
|
(let ((path (url->string (request-uri request))))
|
||||||
|
(cond
|
||||||
|
((equal? path "/api/agent/register")
|
||||||
|
(response/jsexpr (hasheq)))
|
||||||
|
((equal? path "/api/agent/poll")
|
||||||
|
(let* ((data (bytes->jsexpr (request-post-data/raw request)))
|
||||||
|
(ack (hash-ref data 'ack))
|
||||||
|
(command
|
||||||
|
(case ack
|
||||||
|
((0) (hasheq 'id 1 'action "play"
|
||||||
|
'data (hasheq 'mediaToken "broken"
|
||||||
|
'filename "broken.flac"
|
||||||
|
'mediaSize 8)))
|
||||||
|
((1) (hasheq 'id 2 'action "prefetch"
|
||||||
|
'data (hasheq 'mediaToken "next"
|
||||||
|
'filename "next.flac"
|
||||||
|
'mediaSize 4)))
|
||||||
|
((2) (hasheq 'id 3 'action "stop"))
|
||||||
|
(else 'null))))
|
||||||
|
(hash-set! reports ack (hash-ref data 'state))
|
||||||
|
(when (= ack 3) (semaphore-post done))
|
||||||
|
(response/jsexpr (hasheq 'command command))))
|
||||||
|
(else
|
||||||
|
(when (regexp-match? #rx"broken$" path)
|
||||||
|
(set! attempts (+ attempts 1)))
|
||||||
|
(when (regexp-match? #rx"next$" path)
|
||||||
|
(set! prefetch-attempts (+ prefetch-attempts 1)))
|
||||||
|
(let ((body (if (and (regexp-match? #rx"next$" path)
|
||||||
|
(= prefetch-attempts 1))
|
||||||
|
#"12" #"1234")))
|
||||||
|
(response/output
|
||||||
|
(λ (output) (write-bytes body output))
|
||||||
|
#:headers
|
||||||
|
(list (header #"Content-Length"
|
||||||
|
(string->bytes/utf-8
|
||||||
|
(number->string (bytes-length body)))))))))))))))
|
||||||
|
(dynamic-wind
|
||||||
|
void
|
||||||
|
(λ ()
|
||||||
|
(let ((port (async-channel-get ready)))
|
||||||
|
(when (exn? port) (raise port))
|
||||||
|
(set! runtime
|
||||||
|
(make-player-agent-runtime
|
||||||
|
(format "http://127.0.0.1:~a" port) "Test" (make-string 64 #\a)))
|
||||||
|
((player-agent-runtime-start! runtime))
|
||||||
|
(check-not-false (sync/timeout 15 done))
|
||||||
|
(check-equal? attempts 3)
|
||||||
|
(check-equal? prefetch-attempts 2)
|
||||||
|
(let ((failed (hash-ref reports 1 (hasheq)))
|
||||||
|
(prefetched (hash-ref reports 2 (hasheq)))
|
||||||
|
(stopped (hash-ref reports 3 (hasheq))))
|
||||||
|
(check-equal? (hash-ref failed 'state #f) "stopped")
|
||||||
|
(check-regexp-match #rx"received 4 of 8 bytes"
|
||||||
|
(hash-ref failed 'error ""))
|
||||||
|
(check-equal? (hash-ref failed 'download #f) 'null)
|
||||||
|
(check-equal? (hash-ref prefetched 'error #f)
|
||||||
|
(hash-ref failed 'error ""))
|
||||||
|
(check-equal? (hash-ref stopped 'error #f) 'null)
|
||||||
|
(check-true ((player-agent-runtime-running? runtime))))))
|
||||||
|
(λ ()
|
||||||
|
(when runtime ((player-agent-runtime-shutdown! runtime)))
|
||||||
|
(stop-server)))))
|
||||||
|
|
||||||
|
;; An open stream that stops delivering bytes must not occupy the worker forever.
|
||||||
|
(let-values (((input writer) (make-pipe)))
|
||||||
|
(dynamic-wind
|
||||||
|
void
|
||||||
|
(λ ()
|
||||||
|
(check-exn #rx"Download timeout: no data received"
|
||||||
|
(λ ()
|
||||||
|
(copy-media-port input (open-output-bytes) void
|
||||||
|
#:idle-timeout 0.02))))
|
||||||
|
(λ () (close-input-port input) (close-output-port writer))))
|
||||||
|
|
||||||
|
;; Progress must arrive before EOF, even when a slow server has sent much
|
||||||
|
;; less than one buffer. Binary data must reach the output unchanged.
|
||||||
|
(let-values (((input writer) (make-pipe)))
|
||||||
|
(let* ((output (open-output-bytes))
|
||||||
|
(updates (make-channel))
|
||||||
|
(worker
|
||||||
|
(thread
|
||||||
|
(λ ()
|
||||||
|
(copy-media-port input output
|
||||||
|
(λ (received)
|
||||||
|
(channel-put updates received)))))))
|
||||||
|
(dynamic-wind
|
||||||
|
void
|
||||||
|
(λ ()
|
||||||
|
(write-bytes #"\0\1\377" writer)
|
||||||
|
(flush-output writer)
|
||||||
|
(check-equal? (sync/timeout 5 updates) 3)
|
||||||
|
(check-equal? (get-output-bytes output) #"\0\1\377")
|
||||||
|
(write-bytes #"next" writer)
|
||||||
|
(close-output-port writer)
|
||||||
|
(check-equal? (sync/timeout 5 updates) 7)
|
||||||
|
(check-not-false (sync/timeout 5 worker))
|
||||||
|
(check-equal? (get-output-bytes output) #"\0\1\377next"))
|
||||||
|
(λ ()
|
||||||
|
(kill-thread worker)
|
||||||
|
(close-input-port input)
|
||||||
|
(close-output-port writer)
|
||||||
|
(close-output-port output)))))
|
||||||
|
|
||||||
(check-equal? (normal-state 'initialized) "stopped")
|
(check-equal? (normal-state 'initialized) "stopped")
|
||||||
(check-equal? (normal-state 'transitioning) "starting")
|
(check-equal? (normal-state 'transitioning) "starting")
|
||||||
@@ -620,6 +830,9 @@
|
|||||||
app-id)))
|
app-id)))
|
||||||
(check-false ((player-agent-runtime-running? runtime)))
|
(check-false ((player-agent-runtime-running? runtime)))
|
||||||
(check-false ((player-agent-runtime-current-track runtime)))
|
(check-false ((player-agent-runtime-current-track runtime)))
|
||||||
|
(check-equal?
|
||||||
|
(hash-ref ((player-agent-runtime-snapshot runtime)) 'download)
|
||||||
|
'null)
|
||||||
(check-equal?
|
(check-equal?
|
||||||
(hash-ref ((player-agent-runtime-snapshot runtime)) 'state)
|
(hash-ref ((player-agent-runtime-snapshot runtime)) 'state)
|
||||||
"stopped")
|
"stopped")
|
||||||
|
|||||||
@@ -95,6 +95,10 @@
|
|||||||
(playback-message #f)
|
(playback-message #f)
|
||||||
(playback-details #f)
|
(playback-details #f)
|
||||||
(playback-filename #f)
|
(playback-filename #f)
|
||||||
|
(download-panel #f)
|
||||||
|
(download-message #f)
|
||||||
|
(download-gauge #f)
|
||||||
|
(error-message #f)
|
||||||
(name-field #f)
|
(name-field #f)
|
||||||
(server-field #f)
|
(server-field #f)
|
||||||
(connect-button #f)
|
(connect-button #f)
|
||||||
@@ -163,6 +167,7 @@
|
|||||||
(when (and playback-message playback-details playback-filename)
|
(when (and playback-message playback-details playback-filename)
|
||||||
(let* ((snapshot ((player-agent-runtime-snapshot runtime)))
|
(let* ((snapshot ((player-agent-runtime-snapshot runtime)))
|
||||||
(track ((player-agent-runtime-current-track runtime)))
|
(track ((player-agent-runtime-current-track runtime)))
|
||||||
|
(download (hash-ref snapshot 'download 'null))
|
||||||
(state (hash-ref snapshot 'state "stopped"))
|
(state (hash-ref snapshot 'state "stopped"))
|
||||||
(title (and track (hash-ref track 'title #f)))
|
(title (and track (hash-ref track 'title #f)))
|
||||||
(artist (and track (hash-ref track 'artist #f)))
|
(artist (and track (hash-ref track 'artist #f)))
|
||||||
@@ -210,6 +215,36 @@
|
|||||||
(not (string=? format-name "")))
|
(not (string=? format-name "")))
|
||||||
format-name
|
format-name
|
||||||
"")))))
|
"")))))
|
||||||
|
(when error-message
|
||||||
|
(let ((message (hash-ref snapshot 'error 'null)))
|
||||||
|
(send error-message show (string? message))
|
||||||
|
(send error-message set-label
|
||||||
|
(if (string? message) (cap200 message) ""))))
|
||||||
|
(when download-panel
|
||||||
|
(send download-panel show (hash? download))
|
||||||
|
(when (hash? download)
|
||||||
|
(let* ((received (hash-ref download 'received))
|
||||||
|
(total (hash-ref download 'total))
|
||||||
|
(known-size? (and (number? total) (> total 0)))
|
||||||
|
(percent (if known-size?
|
||||||
|
(min 100 (quotient (* received 100) total))
|
||||||
|
0))
|
||||||
|
(megabytes
|
||||||
|
(~r (/ received 1000000.0) #:precision '(= 1))))
|
||||||
|
(send download-gauge show known-size?)
|
||||||
|
(send download-gauge set-value percent)
|
||||||
|
(send download-message set-label
|
||||||
|
(cap200
|
||||||
|
(format "~a: ~a~a — ~a"
|
||||||
|
(tr 'downloading)
|
||||||
|
megabytes
|
||||||
|
(if known-size?
|
||||||
|
(format " / ~a MB (~a%)"
|
||||||
|
(~r (/ total 1000000.0)
|
||||||
|
#:precision '(= 1))
|
||||||
|
percent)
|
||||||
|
" MB")
|
||||||
|
(hash-ref download 'filename)))))))
|
||||||
(send playback-message
|
(send playback-message
|
||||||
set-label
|
set-label
|
||||||
(cap200
|
(cap200
|
||||||
@@ -385,6 +420,29 @@
|
|||||||
(parent playback-panel)
|
(parent playback-panel)
|
||||||
(label "—")
|
(label "—")
|
||||||
[ellipsis 'right] [auto-resize #t] [stretchable-width #t]))
|
[ellipsis 'right] [auto-resize #t] [stretchable-width #t]))
|
||||||
|
(set! download-panel
|
||||||
|
(new vertical-panel%
|
||||||
|
(parent playback-panel)
|
||||||
|
(alignment '(left top))
|
||||||
|
(stretchable-height #f)))
|
||||||
|
(set! download-message
|
||||||
|
(new ellipsis-msg%
|
||||||
|
(parent download-panel)
|
||||||
|
(label "")
|
||||||
|
[ellipsis 'right] [auto-resize #t] [stretchable-width #t]))
|
||||||
|
(set! download-gauge
|
||||||
|
(new gauge%
|
||||||
|
(parent download-panel)
|
||||||
|
(label #f)
|
||||||
|
(range 100)
|
||||||
|
(stretchable-width #t)))
|
||||||
|
(send download-panel show #f)
|
||||||
|
(set! error-message
|
||||||
|
(new ellipsis-msg%
|
||||||
|
(parent playback-panel)
|
||||||
|
(label "")
|
||||||
|
[ellipsis 'right] [auto-resize #t] [stretchable-width #t]))
|
||||||
|
(send error-message show #f)
|
||||||
(set! quit-button
|
(set! quit-button
|
||||||
(new button%
|
(new button%
|
||||||
(parent controls)
|
(parent controls)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
'playing "Playing"
|
'playing "Playing"
|
||||||
'paused "Paused"
|
'paused "Paused"
|
||||||
'loading "Loading"
|
'loading "Loading"
|
||||||
|
'downloading "Downloading"
|
||||||
'stopped "Stopped"
|
'stopped "Stopped"
|
||||||
'channel "channel"
|
'channel "channel"
|
||||||
'channels "channels"
|
'channels "channels"
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
'playing "Speelt"
|
'playing "Speelt"
|
||||||
'paused "Gepauzeerd"
|
'paused "Gepauzeerd"
|
||||||
'loading "Laden"
|
'loading "Laden"
|
||||||
|
'downloading "Downloaden"
|
||||||
'stopped "Gestopt"
|
'stopped "Gestopt"
|
||||||
'channel "kanaal"
|
'channel "kanaal"
|
||||||
'channels "kanalen"
|
'channels "kanalen"
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
'paused "Pausiert"
|
'paused "Pausiert"
|
||||||
'loading "Laden"
|
'loading "Laden"
|
||||||
'stopped "Gestoppt"
|
'stopped "Gestoppt"
|
||||||
|
'downloading "Herunterladen"
|
||||||
'channel "Kanal"
|
'channel "Kanal"
|
||||||
'channels "Kanäle"
|
'channels "Kanäle"
|
||||||
'tray-open "RKT Web Player Agent öffnen"
|
'tray-open "RKT Web Player Agent öffnen"
|
||||||
@@ -106,6 +109,7 @@
|
|||||||
'playing "Lecture"
|
'playing "Lecture"
|
||||||
'paused "En pause"
|
'paused "En pause"
|
||||||
'loading "Chargement"
|
'loading "Chargement"
|
||||||
|
'downloading "Téléchargement"
|
||||||
'stopped "Arrêté"
|
'stopped "Arrêté"
|
||||||
'channel "canal"
|
'channel "canal"
|
||||||
'channels "canaux"
|
'channels "canaux"
|
||||||
@@ -131,6 +135,7 @@
|
|||||||
'playing "Reproduciendo"
|
'playing "Reproduciendo"
|
||||||
'paused "En pausa"
|
'paused "En pausa"
|
||||||
'loading "Cargando"
|
'loading "Cargando"
|
||||||
|
'downloading "Descargando"
|
||||||
'stopped "Detenido"
|
'stopped "Detenido"
|
||||||
'channel "canal"
|
'channel "canal"
|
||||||
'channels "canales"
|
'channels "canales"
|
||||||
@@ -156,6 +161,7 @@
|
|||||||
'playing "In riproduzione"
|
'playing "In riproduzione"
|
||||||
'paused "In pausa"
|
'paused "In pausa"
|
||||||
'loading "Caricamento"
|
'loading "Caricamento"
|
||||||
|
'downloading "Download"
|
||||||
'stopped "Arrestato"
|
'stopped "Arrestato"
|
||||||
'channel "canale"
|
'channel "canale"
|
||||||
'channels "canali"
|
'channels "canali"
|
||||||
@@ -181,6 +187,7 @@
|
|||||||
'playing "Spelar"
|
'playing "Spelar"
|
||||||
'paused "Pausad"
|
'paused "Pausad"
|
||||||
'loading "Läser in"
|
'loading "Läser in"
|
||||||
|
'downloading "Laddar ned"
|
||||||
'stopped "Stoppad"
|
'stopped "Stoppad"
|
||||||
'channel "kanal"
|
'channel "kanal"
|
||||||
'channels "kanaler"
|
'channels "kanaler"
|
||||||
@@ -206,6 +213,7 @@
|
|||||||
'playing "Spiller"
|
'playing "Spiller"
|
||||||
'paused "På pause"
|
'paused "På pause"
|
||||||
'loading "Laster"
|
'loading "Laster"
|
||||||
|
'downloading "Laster ned"
|
||||||
'stopped "Stoppet"
|
'stopped "Stoppet"
|
||||||
'channel "kanal"
|
'channel "kanal"
|
||||||
'channels "kanaler"
|
'channels "kanaler"
|
||||||
@@ -231,6 +239,7 @@
|
|||||||
'playing "Toistetaan"
|
'playing "Toistetaan"
|
||||||
'paused "Keskeytetty"
|
'paused "Keskeytetty"
|
||||||
'loading "Ladataan"
|
'loading "Ladataan"
|
||||||
|
'downloading "Ladataan tiedostoa"
|
||||||
'stopped "Pysäytetty"
|
'stopped "Pysäytetty"
|
||||||
'channel "kanava"
|
'channel "kanava"
|
||||||
'channels "kanavaa"
|
'channels "kanavaa"
|
||||||
@@ -256,6 +265,7 @@
|
|||||||
'playing "Spilar"
|
'playing "Spilar"
|
||||||
'paused "Í bið"
|
'paused "Í bið"
|
||||||
'loading "Hleður"
|
'loading "Hleður"
|
||||||
|
'downloading "Sæki"
|
||||||
'stopped "Stöðvað"
|
'stopped "Stöðvað"
|
||||||
'channel "rás"
|
'channel "rás"
|
||||||
'channels "rásir"
|
'channels "rásir"
|
||||||
|
|||||||
@@ -359,7 +359,10 @@
|
|||||||
(make-semaphore 1))))
|
(make-semaphore 1))))
|
||||||
(set-dlna-playback-monitor!
|
(set-dlna-playback-monitor!
|
||||||
playback
|
playback
|
||||||
(thread (λ () (monitor-loop playback poll-seconds))))
|
(thread (λ ()
|
||||||
|
(with-handlers ((exn:break? void))
|
||||||
|
(parameterize-break #t
|
||||||
|
(monitor-loop playback poll-seconds))))))
|
||||||
playback))
|
playback))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@@ -483,7 +486,9 @@
|
|||||||
(set-dlna-playback-running?! playback #f)
|
(set-dlna-playback-running?! playback #f)
|
||||||
(let ((monitor (dlna-playback-monitor playback)))
|
(let ((monitor (dlna-playback-monitor playback)))
|
||||||
(when (and monitor (not (thread-dead? monitor)))
|
(when (and monitor (not (thread-dead? monitor)))
|
||||||
(kill-thread monitor))
|
;; A break unwinds call-with-semaphore; kill-thread leaves its lock held.
|
||||||
|
(break-thread monitor)
|
||||||
|
(thread-wait monitor))
|
||||||
(set-dlna-playback-monitor! playback #f)
|
(set-dlna-playback-monitor! playback #f)
|
||||||
(with-lock
|
(with-lock
|
||||||
playback
|
playback
|
||||||
|
|||||||
+12
-1
@@ -1461,6 +1461,11 @@
|
|||||||
(player-renderers value))
|
(player-renderers value))
|
||||||
'rendererId (or (playback-session-selected-id session) 'null)
|
'rendererId (or (playback-session-selected-id session) 'null)
|
||||||
'currentIndex (or (playback-session-current-index session) 'null)
|
'currentIndex (or (playback-session-current-index session) 'null)
|
||||||
|
'download
|
||||||
|
(let ((backend (playback-session-backend session)))
|
||||||
|
(if (and backend (eq? (playback-session-backend-kind session) 'agent))
|
||||||
|
(hash-ref (playback-agent-reported-state backend) 'download 'null)
|
||||||
|
'null))
|
||||||
'state (symbol->string (playback-session-state session))
|
'state (symbol->string (playback-session-state session))
|
||||||
'position (playback-session-position session)
|
'position (playback-session-position session)
|
||||||
'duration (or (playback-session-duration session) 'null)
|
'duration (or (playback-session-duration session) 'null)
|
||||||
@@ -2154,7 +2159,13 @@
|
|||||||
'state
|
'state
|
||||||
(hasheq 'state "stopped"
|
(hasheq 'state "stopped"
|
||||||
'position 0
|
'position 0
|
||||||
'volume 25))))
|
'volume 25
|
||||||
|
'download (hasheq 'filename "Large.flac"
|
||||||
|
'received 65536 'total 100000000)))))
|
||||||
|
|
||||||
|
(check-equal?
|
||||||
|
(hash-ref (player-state->jsexpr example-player) 'download)
|
||||||
|
(hasheq 'filename "Large.flac" 'received 65536 'total 100000000))
|
||||||
|
|
||||||
(check-equal?
|
(check-equal?
|
||||||
(hash-ref (hash-ref first-poll 'command) 'action)
|
(hash-ref (hash-ref first-poll 'command) 'action)
|
||||||
|
|||||||
+45
-5
@@ -7,10 +7,13 @@
|
|||||||
racket/runtime-path
|
racket/runtime-path
|
||||||
racket-mimetypes
|
racket-mimetypes
|
||||||
racket/string
|
racket/string
|
||||||
|
racket/path
|
||||||
|
simple-log
|
||||||
net/url
|
net/url
|
||||||
web-server/dispatch
|
web-server/dispatch
|
||||||
web-server/http
|
web-server/http
|
||||||
web-server/http/json
|
web-server/http/json
|
||||||
|
web-server/safety-limits
|
||||||
web-server/servlet-env
|
web-server/servlet-env
|
||||||
"library.rkt"
|
"library.rkt"
|
||||||
"player.rkt"
|
"player.rkt"
|
||||||
@@ -18,6 +21,8 @@
|
|||||||
|
|
||||||
(provide serve-player)
|
(provide serve-player)
|
||||||
|
|
||||||
|
(sl-def-log web-player-http)
|
||||||
|
|
||||||
(define-runtime-path public-directory "../public")
|
(define-runtime-path public-directory "../public")
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@@ -175,16 +180,47 @@
|
|||||||
player
|
player
|
||||||
(request-jsexpr request)))))
|
(request-jsexpr request)))))
|
||||||
|
|
||||||
|
;;; Streams an agent download without holding player locks and reports its
|
||||||
|
;;; start, progress and outcome, including the client and actual bytes sent.
|
||||||
|
(define (stream-agent-media! file output client app-id)
|
||||||
|
(let ((received 0)
|
||||||
|
(total (file-size file))
|
||||||
|
(started (current-inexact-monotonic-milliseconds))
|
||||||
|
(reported (current-inexact-monotonic-milliseconds))
|
||||||
|
(completed? #f)
|
||||||
|
(name (file-name-from-path file)))
|
||||||
|
(dynamic-wind
|
||||||
|
(λ () (info-web-player-http "Agent download started: client=~a agent=~a file=~a bytes=~a"
|
||||||
|
client (substring app-id 0 8) name total))
|
||||||
|
(λ ()
|
||||||
|
(call-with-input-file
|
||||||
|
file
|
||||||
|
(λ (input)
|
||||||
|
(let ((buffer (make-bytes 65536)))
|
||||||
|
(let loop ()
|
||||||
|
(let ((count (read-bytes-avail! buffer input)))
|
||||||
|
(unless (eof-object? count)
|
||||||
|
(write-bytes buffer output 0 count)
|
||||||
|
(set! received (+ received count))
|
||||||
|
(when (>= (- (current-inexact-monotonic-milliseconds) reported) 5000)
|
||||||
|
(set! reported (current-inexact-monotonic-milliseconds))
|
||||||
|
(info-web-player-http "Agent download progress: client=~a file=~a bytes=~a/~a"
|
||||||
|
client name received total))
|
||||||
|
(loop)))))))
|
||||||
|
(flush-output output)
|
||||||
|
(set! completed? #t))
|
||||||
|
(λ ()
|
||||||
|
(info-web-player-http "Agent download ~a: client=~a file=~a bytes=~a/~a elapsed-ms=~a"
|
||||||
|
(if completed? "sent" "interrupted") client name received total
|
||||||
|
(round (- (current-inexact-monotonic-milliseconds) started)))))))
|
||||||
|
|
||||||
;;; Streams the media file identified by an agent's opaque token.
|
;;; Streams the media file identified by an agent's opaque token.
|
||||||
(define (agent-media-handler player _request app-id token)
|
(define (agent-media-handler player request app-id token)
|
||||||
(let ((file (player-agent-media player app-id token)))
|
(let ((file (player-agent-media player app-id token)))
|
||||||
(if (and file (file-exists? file))
|
(if (and file (file-exists? file))
|
||||||
(response/output
|
(response/output
|
||||||
(λ (output)
|
(λ (output)
|
||||||
(call-with-input-file
|
(stream-agent-media! file output (request-client-ip request) app-id))
|
||||||
file
|
|
||||||
(λ (input)
|
|
||||||
(copy-port input output))))
|
|
||||||
#:mime-type
|
#:mime-type
|
||||||
(let ((mime (mimetype-for-ext file)))
|
(let ((mime (mimetype-for-ext file)))
|
||||||
(if (string? mime)
|
(if (string? mime)
|
||||||
@@ -377,6 +413,10 @@
|
|||||||
#:listen-ip listen-ip
|
#:listen-ip listen-ip
|
||||||
#:port port
|
#:port port
|
||||||
#:connection-close? #t
|
#:connection-close? #t
|
||||||
|
;; Content-Length responses use one timeout for the entire copy-port.
|
||||||
|
;; Large lossless tracks can take several minutes over a healthy link.
|
||||||
|
#:safety-limits (make-safety-limits #:response-timeout 3600
|
||||||
|
#:response-send-timeout 3600)
|
||||||
#:launch-browser? launch-browser?
|
#:launch-browser? launch-browser?
|
||||||
#:quit? #f
|
#:quit? #f
|
||||||
#:banner? #t
|
#:banner? #t
|
||||||
|
|||||||
+19
-5
@@ -146,12 +146,21 @@ async function api(path, body) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(path, options);
|
const controller = new AbortController();
|
||||||
const data = await response.json();
|
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new ApiError(data.error || `HTTP ${response.status}`, response.status, data.code);
|
const response = await fetch(path, { ...options, signal: controller.signal });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(data.error || `HTTP ${response.status}`, response.status, data.code);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) throw new Error("request-timeout");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Displays the login overlay and optionally reports why authentication failed.
|
// Displays the login overlay and optionally reports why authentication failed.
|
||||||
@@ -671,6 +680,11 @@ function render(nextState) {
|
|||||||
renderPlayer(nextState);
|
renderPlayer(nextState);
|
||||||
if (nextState.error) {
|
if (nextState.error) {
|
||||||
setStatus(t(nextState.error));
|
setStatus(t(nextState.error));
|
||||||
|
} else if (nextState.download) {
|
||||||
|
const download = nextState.download;
|
||||||
|
const received = (download.received / 1000000).toFixed(1);
|
||||||
|
const total = Number.isFinite(download.total) ? (download.total / 1000000).toFixed(1) : "?";
|
||||||
|
setStatus(t("downloadingMedia", { name: download.filename, received, total }));
|
||||||
} else {
|
} else {
|
||||||
setStatus(nextState.discovering ? t("searchingPlayers") : "");
|
setStatus(nextState.discovering ? t("searchingPlayers") : "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
en: {
|
en: {
|
||||||
|
"request-timeout": "The server did not respond within 30 seconds. Status will be refreshed automatically.",
|
||||||
|
downloadingMedia: "Downloading {name}: {received} / {total} MB",
|
||||||
folders: "Folders",
|
folders: "Folders",
|
||||||
savePlaylist: "SAVE PLAYLIST",
|
savePlaylist: "SAVE PLAYLIST",
|
||||||
playlistSaved: "SAVED",
|
playlistSaved: "SAVED",
|
||||||
@@ -41,6 +43,8 @@
|
|||||||
"dlna-renderer-unreachable": "The DLNA renderer is unreachable.",
|
"dlna-renderer-unreachable": "The DLNA renderer is unreachable.",
|
||||||
},
|
},
|
||||||
nl: {
|
nl: {
|
||||||
|
"request-timeout": "De server antwoordde niet binnen 30 seconden. De status wordt automatisch opnieuw opgehaald.",
|
||||||
|
downloadingMedia: "Downloaden van {name}: {received} / {total} MB",
|
||||||
folders: "Mappen",
|
folders: "Mappen",
|
||||||
savePlaylist: "AFSPEELLIJST OPSLAAN",
|
savePlaylist: "AFSPEELLIJST OPSLAAN",
|
||||||
playlistSaved: "OPGESLAGEN",
|
playlistSaved: "OPGESLAGEN",
|
||||||
|
|||||||
@@ -141,6 +141,25 @@ in-progress download can temporarily exceed the budget. The cache lasts until
|
|||||||
the agent exits; it is not retained across restarts.
|
the agent exits; it is not retained across restarts.
|
||||||
|
|
||||||
A track that is not cached is still downloaded completely before playback.
|
A track that is not cached is still downloaded completely before playback.
|
||||||
|
The HTTP server allows up to one hour for a response, including media
|
||||||
|
transfers, so large lossless tracks can finish over slower connections.
|
||||||
|
The agent imposes no total download deadline: receiving another chunk restarts
|
||||||
|
its 30-second inactivity timer. A connection or transfer with no response bytes
|
||||||
|
for 30 seconds fails, closes its ports and removes the partial file. The private
|
||||||
|
@tt{call-with-http-timeout} procedure runs an HTTP operation with a
|
||||||
|
@tt{progress!} callback; each callback restarts this timer. Cancellation unwinds
|
||||||
|
the worker before its custodian closes the remaining HTTP resources.
|
||||||
|
|
||||||
|
The browser displays the selected agent's reported download filename and byte
|
||||||
|
counts, including prefetch downloads. Server logs record agent download start,
|
||||||
|
progress every five seconds while sending, and completion or interruption with
|
||||||
|
client address, filename and byte counts. DLNA media transfers are logged by
|
||||||
|
@tt{racket-upnp} with client address, method, range and elapsed time.
|
||||||
|
|
||||||
|
Browser API requests time out after 30 seconds, freeing the request queue and
|
||||||
|
allowing status polling to resume. Timing out a browser request does not undo a
|
||||||
|
command already accepted by the server.
|
||||||
|
|
||||||
The next track is fetched after the play command completes. Commands are
|
The next track is fetched after the play command completes. Commands are
|
||||||
processed in server order, so a pending prefetch can still delay a later manual
|
processed in server order, so a pending prefetch can still delay a later manual
|
||||||
track selection.
|
track selection.
|
||||||
@@ -345,6 +364,17 @@ configured RKT Web Player server, downloads assigned tracks over HTTP, and
|
|||||||
plays them with @tt{racket-audio}. Its configured display name is authoritative
|
plays them with @tt{racket-audio}. Its configured display name is authoritative
|
||||||
and is followed by the server. Importing the module does not start the GUI; the
|
and is followed by the server. Importing the module does not start the GUI; the
|
||||||
function must be called explicitly.
|
function must be called explicitly.
|
||||||
|
During downloads, including prefetching the next track, the GUI shows the
|
||||||
|
filename and received megabytes. When the server supplies the file size,
|
||||||
|
it also shows the total size, percentage and a progress bar. The display
|
||||||
|
updates every half second and disappears when the download finishes or fails.
|
||||||
|
Interrupted downloads are retried twice, starting again from the beginning.
|
||||||
|
After 60 seconds without incoming media data, the agent aborts the attempt
|
||||||
|
instead of waiting indefinitely. If all three attempts fail, a failed play
|
||||||
|
returns to the stopped state and the GUI displays the error, including byte
|
||||||
|
counts for an incomplete file. Background prefetching and audio status updates
|
||||||
|
preserve this message; a new playback command clears it. Polling continues,
|
||||||
|
so subsequent commands can still be processed.
|
||||||
The GUI and its @tt{racket-tray} system tray support the same ten languages
|
The GUI and its @tt{racket-tray} system tray support the same ten languages
|
||||||
based on the operating-system language, with English as fallback. Closing or
|
based on the operating-system language, with English as fallback. Closing or
|
||||||
minimizing the window hides it in the tray. The tray menu restores the window
|
minimizing the window hides it in the tray. The tray menu restores the window
|
||||||
|
|||||||
Reference in New Issue
Block a user