Download timeouts

This commit is contained in:
2026-10-21 23:21:37 +02:00
parent 106800b257
commit e751e1e17c
5 changed files with 141 additions and 10 deletions
+74 -10
View File
@@ -84,6 +84,17 @@
(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!)
(let ((buffer (make-bytes 65536)))
(let loop ((received 0))
(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 +162,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 +185,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)
@@ -243,7 +257,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! 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)) #""))
@@ -257,14 +271,35 @@
(λ (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)
(with-agent-state
(λ ()
(set! download-progress
(hasheq 'filename filename 'received 0
'total (if (exact-nonnegative-integer? size)
size 'null)))))
(set! input (get-pure-port (endpoint-url server-url path))) (set! input (get-pure-port (endpoint-url server-url path)))
(call-with-output-file (call-with-output-file
target target
(λ (output) (copy-port input output)) (λ (output)
(copy-media-port
input output
(λ (received)
(with-agent-state
(λ ()
(set! download-progress
(hash-set download-progress 'received received)))))))
#:exists 'truncate/replace) #:exists 'truncate/replace)
(close-input-port input) (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)
@@ -299,12 +334,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)))
@@ -541,6 +572,7 @@
(kill-thread command-worker)) (kill-thread command-worker))
(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.
@@ -579,6 +611,35 @@
(module+ test (module+ test
(require rackunit) (require rackunit)
;; 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")
(check-equal? (normal-state 'playing) "playing") (check-equal? (normal-state 'playing) "playing")
@@ -620,6 +681,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")
+46
View File
@@ -95,6 +95,9 @@
(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)
(name-field #f) (name-field #f)
(server-field #f) (server-field #f)
(connect-button #f) (connect-button #f)
@@ -163,6 +166,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 +214,31 @@
(not (string=? format-name ""))) (not (string=? format-name "")))
format-name format-name
""))))) "")))))
(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 +414,23 @@
(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! 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"
+5
View File
@@ -11,6 +11,7 @@
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"
@@ -377,6 +378,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
+6
View File
@@ -141,6 +141,8 @@ 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 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 +347,10 @@ 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.
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