201 lines
6.6 KiB
Racket
201 lines
6.6 KiB
Racket
#lang racket/base
|
|
|
|
;; Resolve DLNA resource capabilities advertised by an HTTP media server.
|
|
|
|
(require net/http-client
|
|
net/url
|
|
racket/match
|
|
racket/port
|
|
racket/string
|
|
simple-log)
|
|
|
|
(provide resolve-dlna-resource-protocol-info
|
|
clear-dlna-resource-capability-cache!)
|
|
|
|
(sl-def-log upnp-resource)
|
|
|
|
(define capability-cache (make-hash))
|
|
(define capability-cache-lock (make-semaphore 1))
|
|
|
|
(define (clear-dlna-resource-capability-cache!)
|
|
(call-with-semaphore
|
|
capability-cache-lock
|
|
(lambda () (hash-clear! capability-cache))))
|
|
|
|
(define (url-request-target value)
|
|
(let* ([relative
|
|
(struct-copy url value
|
|
[scheme #f]
|
|
[user #f]
|
|
[host #f]
|
|
[port #f]
|
|
[fragment #f])]
|
|
[target (url->string relative)])
|
|
(if (string=? target "") "/" target)))
|
|
|
|
(define (header-value headers name)
|
|
(for/or ([header (in-list headers)])
|
|
(let* ([text
|
|
(if (bytes? header)
|
|
(bytes->string/latin-1 header)
|
|
header)]
|
|
[match
|
|
(and (string? text)
|
|
(regexp-match
|
|
#px"^([^:]+):[ \t]*(.*?)[ \t]*$"
|
|
text))])
|
|
(and match
|
|
(string-ci=? (cadr match) name)
|
|
(caddr match)))))
|
|
|
|
(define (protocol-info-parts protocol-info)
|
|
(and protocol-info
|
|
(regexp-match #px"^([^:]*):([^:]*):([^:]*):(.*)$"
|
|
protocol-info)))
|
|
|
|
(define (protocol-info-additional-info protocol-info)
|
|
(let ([parts (protocol-info-parts protocol-info)])
|
|
(and parts (list-ref parts 4))))
|
|
|
|
(define (replace-additional-info protocol-info additional-info)
|
|
(match (protocol-info-parts protocol-info)
|
|
[(list _ protocol network content-type _)
|
|
(string-join
|
|
(list protocol network content-type additional-info)
|
|
":")]
|
|
[_ protocol-info]))
|
|
|
|
(define (call-with-timeout seconds thunk)
|
|
(let ([custodian (make-custodian)]
|
|
[result-channel (make-channel)])
|
|
(parameterize ([current-custodian custodian])
|
|
(thread
|
|
(lambda ()
|
|
(with-handlers
|
|
([exn:fail?
|
|
(lambda (exception)
|
|
(channel-put result-channel
|
|
(cons #f exception)))])
|
|
(channel-put result-channel
|
|
(cons #t (thunk)))))))
|
|
(let ([result (sync/timeout seconds result-channel)])
|
|
(custodian-shutdown-all custodian)
|
|
(cond
|
|
[(not result)
|
|
(error 'resolve-dlna-resource-protocol-info
|
|
"HTTP capability request timed out")]
|
|
[(car result) (cdr result)]
|
|
[else (raise (cdr result))]))))
|
|
|
|
(define (request-content-features uri timeout)
|
|
(let* ([url-value (string->url uri)]
|
|
[scheme (or (url-scheme url-value) "http")]
|
|
[ssl? (string-ci=? scheme "https")]
|
|
[host (url-host url-value)]
|
|
[port (or (url-port url-value) (if ssl? 443 80))])
|
|
(unless (and host
|
|
(or (string-ci=? scheme "http")
|
|
(string-ci=? scheme "https")))
|
|
(raise-arguments-error
|
|
'resolve-dlna-resource-protocol-info
|
|
"resource is not an HTTP URI"
|
|
"uri" uri))
|
|
(call-with-timeout
|
|
timeout
|
|
(lambda ()
|
|
(let-values ([(status headers in)
|
|
(http-sendrecv
|
|
host
|
|
(url-request-target url-value)
|
|
#:ssl? ssl?
|
|
#:port port
|
|
#:method #"HEAD"
|
|
#:headers
|
|
(list "getcontentFeatures.dlna.org: 1"
|
|
"transferMode.dlna.org: Streaming"
|
|
"Connection: close"))])
|
|
(void status)
|
|
(close-input-port in)
|
|
(header-value headers "contentFeatures.dlna.org"))))))
|
|
|
|
(define (base-protocol-info protocol-info mime-type)
|
|
(cond
|
|
[(and (string? protocol-info)
|
|
(not (string=? (string-trim protocol-info) "")))
|
|
protocol-info]
|
|
[(and (string? mime-type)
|
|
(not (string=? (string-trim mime-type) "")))
|
|
(format "http-get:*:~a:*" mime-type)]
|
|
[else "http-get:*:application/octet-stream:*"]))
|
|
|
|
(define (resolve-dlna-resource-protocol-info
|
|
uri
|
|
#:protocol-info [protocol-info #f]
|
|
#:mime-type [mime-type #f]
|
|
#:timeout [timeout 5.0])
|
|
(unless (and (string? uri)
|
|
(not (string=? (string-trim uri) "")))
|
|
(raise-argument-error
|
|
'resolve-dlna-resource-protocol-info
|
|
"non-empty-string?"
|
|
uri))
|
|
(for ([value (in-list (list protocol-info mime-type))])
|
|
(unless (or (not value) (string? value))
|
|
(raise-argument-error
|
|
'resolve-dlna-resource-protocol-info
|
|
"(or/c #f string?)"
|
|
value)))
|
|
(unless (and (rational? timeout) (positive? timeout))
|
|
(raise-argument-error
|
|
'resolve-dlna-resource-protocol-info
|
|
"positive-real?"
|
|
timeout))
|
|
(let* ([base (base-protocol-info protocol-info mime-type)]
|
|
[additional-info (protocol-info-additional-info base)])
|
|
(cond
|
|
[(and additional-info
|
|
(not (string=? additional-info "*")))
|
|
base]
|
|
[else
|
|
(let* ([key (list uri base)]
|
|
[cached
|
|
(call-with-semaphore
|
|
capability-cache-lock
|
|
(lambda () (hash-ref capability-cache key #f)))])
|
|
(or cached
|
|
(with-handlers
|
|
([exn:fail?
|
|
(lambda (exception)
|
|
(warn-upnp-resource
|
|
"Could not resolve DLNA capabilities for ~a: ~a"
|
|
uri
|
|
(exn-message exception))
|
|
base)])
|
|
(let ([content-features
|
|
(request-content-features uri timeout)])
|
|
(if (and content-features
|
|
(not (string=? content-features "")))
|
|
(let ([resolved
|
|
(replace-additional-info
|
|
base
|
|
content-features)])
|
|
(dbg-upnp-resource
|
|
"Resolved DLNA protocolInfo for ~a: ~a"
|
|
uri
|
|
resolved)
|
|
(call-with-semaphore
|
|
capability-cache-lock
|
|
(lambda ()
|
|
(hash-set! capability-cache key resolved)))
|
|
resolved)
|
|
base)))))])))
|
|
|
|
(module+ test
|
|
(require rackunit)
|
|
|
|
(check-equal?
|
|
(replace-additional-info
|
|
"http-get:*:audio/flac:*"
|
|
"DLNA.ORG_OP=01;DLNA.ORG_CI=0")
|
|
"http-get:*:audio/flac:DLNA.ORG_OP=01;DLNA.ORG_CI=0"))
|