Files
supersonic/backend/player/dlna/dlnaplayer.go
T
Andrew G. Dunn 6b6701e973 Populate richer DIDL-Lite metadata for DLNA cast (#934)
Extend MediaItemMetadata with TrackNumber, Size, BitRate, SampleRate,
BitDepth, and ChannelCount (all already present on Track) and populate
them in Track.Metadata(). Thread them through dlnaplayer.go into the
avtransport.MediaItem so the renderer's DIDL-Lite includes upnp:artist,
upnp:album, upnp:originalTrackNumber, plus <res> attributes for
duration, size, bitrate, sampleFrequency, bitsPerSample, and
nrAudioChannels. The previously dropped Duration field is now
populated too.

For cover art, add a CoverArtPathFn callback on PlaybackManager wired
to ImageManager.GetCoverArtPath in app.go. The DLNA player invokes it
when a track is queued, registers the cached cover-thumbnail path
with the local proxy, and emits the resulting proxy URL as
upnp:albumArtURI so the renderer can display album art alongside the
audio.

Bumps go-upnpcast to pull in the MediaItem fields and DIDL-Lite
emission added in supersonic-app/go-upnpcast#4.
2026-06-01 16:39:48 -07:00

715 lines
16 KiB
Go

package dlna
import (
"context"
"crypto/md5"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/util"
"github.com/hashicorp/go-retryablehttp"
"github.com/supersonic-app/go-upnpcast/device"
"github.com/supersonic-app/go-upnpcast/services/avtransport"
"github.com/supersonic-app/go-upnpcast/services/renderingcontrol"
)
const (
stopped = 0
playing = 1
paused = 2
)
type proxyMapEntry struct {
key string
url string
}
type DLNAPlayer struct {
player.BasePlayerCallbackImpl
destroyed bool
cancelRequest context.CancelFunc
avTransport *avtransport.Client
renderControl *renderingcontrol.Client
// coverArtPathFn returns a local filesystem path to the cached cover
// art image for the given CoverArtID, or an error if no path is
// available. When set and the resolver succeeds, the path is exposed
// through the local proxy and emitted as upnp:albumArtURI in DIDL-Lite
// so the renderer can fetch it.
coverArtPathFn func(coverArtID string) (string, error)
state int // stopped, playing, paused
seeking bool
metaLock sync.Mutex
curTrackMeta mediaprovider.MediaItemMetadata
nextTrackMeta mediaprovider.MediaItemMetadata
// if true, report playback time 00:00
// pending time sync with player after beginning playback
pendingPlayStart bool
// start playback position in seconds of the last seek/time sync
lastStartTime int
// how long the track has been playing since last time sync
stopwatch util.Stopwatch
proxyServer *http.Server
proxyActive atomic.Bool
localIP string
proxyPort int
pendingSeek bool
pendingSeekSecs float64
// keep in order of most recently accessed at the end
// that way the item in proxyURLs[0] can be kicked out
// when adding a new URL to the proxy, since
// only two will need to be active at any given time
proxyURLs [3]proxyMapEntry
proxyURLLock sync.Mutex
// If SetNextAVTransport fails (e.g. because the device
// does not support the API/gapless), this flag is set
// true, and the next firing of the track change timer
// should clear it to false and use SetAVTransport
// to begin playing the item in nextTrackMeta.
failedToSetNext bool
unsetNextMediaItem *avtransport.MediaItem
timerActive atomic.Bool
timer *time.Timer
resetChan chan (time.Duration)
}
func NewDLNAPlayer(device *device.MediaRenderer, coverArtPathFn func(coverArtID string) (string, error)) (*DLNAPlayer, error) {
retry := retryablehttp.NewClient()
retry.RetryMax = 3
retry.RetryWaitMin = 100 * time.Millisecond
retry.Logger = retryLogger{}
cli := retry.StandardClient()
avt, err := device.AVTransportClient()
if err != nil {
return nil, err
}
avt.RequestHandler = httpClientHandler{cli}
rc, err := device.RenderingControlClient()
if err != nil {
return nil, err
}
rc.Requesthandler = cli
// ping to test connectivity
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := avt.GetTransportInfo(ctx); err != nil {
return nil, fmt.Errorf("failed to connect to %s", device.FriendlyName)
}
return &DLNAPlayer{
avTransport: avt,
renderControl: rc,
resetChan: make(chan time.Duration),
coverArtPathFn: coverArtPathFn,
}, nil
}
// buildMediaItem assembles the avtransport.MediaItem for a track. It
// populates the audio metadata fields (artist, album, track number, sample
// rate, bit depth, channel count, size, bitrate, duration) so the
// resulting DIDL-Lite includes the information the renderer needs to
// display cover art, artist, album, and a stream-format readout.
//
// playbackURL is the proxy URL the renderer will fetch the stream from.
func (d *DLNAPlayer) buildMediaItem(playbackURL string, meta mediaprovider.MediaItemMetadata) avtransport.MediaItem {
item := avtransport.MediaItem{
URL: playbackURL,
Title: meta.Name,
ContentType: meta.MIMEType,
Seekable: true,
Duration: meta.Duration,
Artist: strings.Join(meta.Artists, ", "),
Album: meta.Album,
TrackNumber: meta.TrackNumber,
Size: meta.Size,
// DIDL-Lite res@bitrate is bytes/sec; meta.BitRate is kbps from
// the server. Convert: kbps * 1000 / 8 = bytes/sec.
Bitrate: meta.BitRate * 125,
SampleFrequency: meta.SampleRate,
BitsPerSample: meta.BitDepth,
NrAudioChannels: meta.ChannelCount,
}
if meta.CoverArtID != "" && d.coverArtPathFn != nil {
if path, err := d.coverArtPathFn(meta.CoverArtID); err == nil {
artKey := d.addURLToProxy(path)
item.AlbumArtURI = d.urlForItem(artKey)
}
}
return item
}
func (d *DLNAPlayer) SetVolume(vol int) error {
if d.destroyed {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
return d.renderControl.SetVolume(ctx, vol)
}
func (d *DLNAPlayer) GetVolume() int {
if d.destroyed {
return 0
}
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
vol, _ := d.renderControl.GetVolume(ctx)
return vol
}
func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata, startTime float64) error {
if d.destroyed {
return nil
}
d.ensureSetupProxy()
d.metaLock.Lock()
d.curTrackMeta = meta
d.metaLock.Unlock()
key := d.addURLToProxy(urlstr)
media := d.buildMediaItem(d.urlForItem(key), meta)
if err := d.playAVTransportMedia(&media); err != nil {
return err
}
d.pendingPlayStart = true
if startTime > 0 {
// TODO: do something better than this!!
time.Sleep(2 * time.Second)
if !d.destroyed {
d.sendSeekCmd(startTime)
}
d.pendingPlayStart = false
} else {
go func() {
time.Sleep(2 * time.Second)
if !d.destroyed {
d.syncPlaybackTime()
}
d.pendingPlayStart = false
}()
}
d.state = playing
remainingDur := meta.Duration - time.Duration(startTime)*time.Second
d.setTrackChangeTimer(remainingDur)
d.stopwatch.Reset()
d.stopwatch.Start()
d.lastStartTime = int(startTime)
d.InvokeOnPlaying()
d.InvokeOnTrackChange()
if startTime > 0 {
d.InvokeOnSeek()
}
return nil
}
func (d *DLNAPlayer) playAVTransportMedia(media *avtransport.MediaItem) error {
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
err := d.avTransport.SetAVTransportMedia(ctx, media)
if err != nil {
return err
}
if err := d.avTransport.Play(ctx); err != nil {
return err
}
return nil
}
func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadata) error {
if d.destroyed {
return nil
}
var media *avtransport.MediaItem
d.metaLock.Lock()
d.nextTrackMeta = meta
d.metaLock.Unlock()
if url != "" {
d.ensureSetupProxy()
key := d.addURLToProxy(url)
item := d.buildMediaItem(d.urlForItem(key), meta)
media = &item
} else {
// empty media item to signify erasing next track in device queue
media = &avtransport.MediaItem{}
}
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
err := d.avTransport.SetNextAVTransportMedia(ctx, media)
if err != nil {
d.metaLock.Lock()
d.failedToSetNext = true
d.unsetNextMediaItem = media
d.metaLock.Unlock()
}
return err
}
func (d *DLNAPlayer) Continue() error {
if d.destroyed || d.state == playing {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
if d.pendingSeek {
d.pendingSeek = false
err := d.avTransport.Seek(ctx, int(d.pendingSeekSecs))
if err != nil {
return err
}
}
if err := d.avTransport.Play(ctx); err != nil {
return err
}
d.metaLock.Lock()
nextTrackChange := d.curTrackMeta.Duration - d.curPlayPos()
d.metaLock.Unlock()
d.state = playing
d.setTrackChangeTimer(nextTrackChange)
d.stopwatch.Start()
d.InvokeOnPlaying()
return nil
}
func (d *DLNAPlayer) Pause() error {
if d.destroyed || d.state != playing {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
d.cancelRequest = cancel
defer cancel()
if err := d.avTransport.Pause(ctx); err != nil {
return err
}
d.setTrackChangeTimer(0)
d.stopwatch.Stop()
d.state = paused
d.InvokeOnPaused()
return nil
}
func (d *DLNAPlayer) Stop(force bool) error {
if d.destroyed {
return nil
}
if force && d.cancelRequest != nil {
d.cancelRequest()
}
switch d.state {
case stopped:
return nil
case playing:
var ctx context.Context
var cancel context.CancelFunc
if force {
ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
d.cancelRequest = cancel
defer cancel()
if err := d.avTransport.Pause(ctx); err != nil {
return err
}
fallthrough
case paused:
d.setTrackChangeTimer(0)
d.stopwatch.Reset()
d.lastStartTime = 0
d.state = stopped
d.InvokeOnStopped()
return nil
default:
return errors.New("invalid player state")
}
}
func (d *DLNAPlayer) SeekSeconds(secs float64) error {
if d.destroyed {
return nil
}
if d.state == paused {
d.pendingSeek = true
d.pendingSeekSecs = secs
} else {
if err := d.sendSeekCmd(secs); err != nil {
return err
}
}
d.lastStartTime = int(secs)
d.stopwatch.Reset()
if d.state == playing {
d.metaLock.Lock()
nextTrackChange := d.curTrackMeta.Duration - time.Duration(secs)*time.Second
d.metaLock.Unlock()
d.setTrackChangeTimer(nextTrackChange)
d.stopwatch.Start()
}
d.InvokeOnSeek()
go func() {
time.Sleep(4 * time.Second)
if !d.destroyed {
d.syncPlaybackTime()
}
}()
return nil
}
func (d *DLNAPlayer) sendSeekCmd(secs float64) error {
d.seeking = true
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := d.avTransport.Seek(ctx, int(secs)); err != nil {
d.seeking = false
return err
}
d.seeking = false
return nil
}
func (d *DLNAPlayer) IsSeeking() bool {
return d.seeking
}
func (d *DLNAPlayer) GetStatus() player.Status {
state := player.Stopped
if d.state == playing {
state = player.Playing
} else if d.state == paused {
state = player.Paused
}
var timePos float64
if !d.pendingPlayStart {
timePos = d.curPlayPos().Seconds()
}
return player.Status{
State: state,
TimePos: timePos,
Duration: d.curTrackMeta.Duration.Seconds(),
}
}
func (d *DLNAPlayer) curPlayPos() time.Duration {
return time.Duration(d.lastStartTime)*time.Second + d.stopwatch.Elapsed()
}
func (d *DLNAPlayer) Destroy() {
d.destroyed = true
d.setTrackChangeTimer(0)
if d.cancelRequest != nil {
d.cancelRequest()
}
if d.proxyServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go d.proxyServer.Shutdown(ctx)
d.proxyServer = nil
}
}
func (d *DLNAPlayer) syncPlaybackTime() {
start := time.Now()
if pos, err := d.avTransport.GetPositionInfo(context.Background()); err == nil {
d.lastStartTime = int(pos.RelTime.Seconds() + (time.Since(start) / 2).Seconds())
d.stopwatch.Reset()
if d.state == playing {
d.stopwatch.Start()
}
d.setTrackChangeTimer(d.curTrackMeta.Duration - time.Duration(d.lastStartTime)*time.Second)
d.InvokeOnSeek()
}
}
func (d *DLNAPlayer) ensureSetupProxy() error {
if d.proxyActive.Swap(true) {
return nil // already active
}
var err error
d.localIP, err = util.GetLocalIP()
if err != nil {
return err
}
listener, err := net.Listen("tcp", ":0")
if err != nil {
return err
}
d.proxyPort = listener.Addr().(*net.TCPAddr).Port
d.proxyServer = &http.Server{
Handler: http.HandlerFunc(d.handleRequest),
}
go d.proxyServer.Serve(listener)
return nil
}
func (d *DLNAPlayer) setTrackChangeTimer(dur time.Duration) {
if d.timerActive.Swap(true) {
// was active
d.resetChan <- dur
return
}
if dur == 0 {
d.timerActive.Store(false)
return
}
d.timer = time.NewTimer(dur)
go func() {
for {
select {
case dur := <-d.resetChan:
if dur == 0 {
d.timerActive.Store(false)
if !d.timer.Stop() {
select {
case <-d.timer.C:
default:
}
}
d.timer = nil
return
}
// reset the timer
if !d.timer.Stop() {
select {
case <-d.timer.C:
default:
}
}
d.timer.Reset(dur)
case <-d.timer.C:
d.timerActive.Store(false)
d.timer = nil
d.handleOnTrackChange()
return
}
}
}()
}
func (d *DLNAPlayer) handleOnTrackChange() {
stopping := false
d.metaLock.Lock()
if d.nextTrackMeta.ID == "" {
stopping = true
}
d.curTrackMeta = d.nextTrackMeta
d.nextTrackMeta = mediaprovider.MediaItemMetadata{}
nextTrackChange := d.curTrackMeta.Duration
d.metaLock.Unlock()
if stopping {
d.lastStartTime = 0
d.stopwatch.Reset()
d.InvokeOnStopped()
} else {
d.metaLock.Lock()
if d.failedToSetNext {
d.failedToSetNext = false
media := d.unsetNextMediaItem
d.unsetNextMediaItem = nil
d.metaLock.Unlock()
d.playAVTransportMedia(media)
} else {
d.metaLock.Unlock()
}
d.lastStartTime = 0
d.stopwatch.Reset()
d.stopwatch.Start()
d.setTrackChangeTimer(nextTrackChange)
d.InvokeOnTrackChange()
go func() {
time.Sleep(5 * time.Second)
if !d.destroyed {
d.syncPlaybackTime()
}
}()
}
}
func (d *DLNAPlayer) urlForItem(key string) string {
return fmt.Sprintf("http://%s:%d/%s", d.localIP, d.proxyPort, key)
}
func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/")
url, _ := d.lookupProxyURL(key)
if url == "" {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404"))
return
}
// if the url is a filepath for a local cached file, serve it
if info, err := os.Stat(url); err == nil && info.Size() > 0 {
http.ServeFile(w, r, url)
return
}
// Otherwise, proxy request to the music server
proxyReq, err := http.NewRequest(r.Method, url, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Copy headers from the original request to the new request
proxyReq.Header = r.Header
// Create an HTTP client and send the request
client := &http.Client{}
resp, err := client.Do(proxyReq)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Copy headers from the response to the writer
for name, values := range resp.Header {
for _, value := range values {
w.Header().Add(name, value)
}
}
// Set the status code
w.WriteHeader(resp.StatusCode)
// Copy the response body to the writer
_, err = io.Copy(w, resp.Body)
if err != nil {
fmt.Fprintln(os.Stderr, "Error copying response body:", err)
}
}
func (d *DLNAPlayer) addURLToProxy(url string) string {
hash := md5.Sum([]byte(url))
key := base64.StdEncoding.EncodeToString(hash[:])
d.proxyURLLock.Lock()
defer d.proxyURLLock.Unlock()
d._updateProxyURL(key, url)
return key
}
// lookupProxyURL finds a URL by key and updates its position to most recently used
func (d *DLNAPlayer) lookupProxyURL(key string) (string, bool) {
d.proxyURLLock.Lock()
defer d.proxyURLLock.Unlock()
for i := range len(d.proxyURLs) {
if d.proxyURLs[i].key == key {
url := d.proxyURLs[i].url
// Move accessed entry to the most recent position
d._updateProxyURL(key, url)
return url, true
}
}
return "", false
}
func (d *DLNAPlayer) _updateProxyURL(key, url string) {
// Check if the key already exists, and if so, move it to the most recently used position
for i := range len(d.proxyURLs) {
if d.proxyURLs[i].key == key {
if i < len(d.proxyURLs)-1 {
// Shift elements to the left from found position to the end
copy(d.proxyURLs[i:], d.proxyURLs[i+1:])
}
// Place updated entry at the last position
d.proxyURLs[len(d.proxyURLs)-1] = proxyMapEntry{key: key, url: url}
return
}
}
// Shift all elements left to make room for the new entry at the end
copy(d.proxyURLs[:], d.proxyURLs[1:])
// Insert new element at the most recent position
d.proxyURLs[len(d.proxyURLs)-1] = proxyMapEntry{key: key, url: url}
}
// httpClientHandler wraps an http.Client to implement services.RequestHandler
type httpClientHandler struct {
client *http.Client
}
func (h httpClientHandler) Do(req *http.Request) (*http.Response, error) {
return h.client.Do(req)
}
type retryLogger struct{}
func (retryLogger) Error(msg string, keysAndValues ...any) {
log.Println(msg, keysAndValues)
}
func (retryLogger) Info(msg string, keysAndValues ...any) {
log.Println(msg, keysAndValues)
}
func (retryLogger) Warn(msg string, keysAndValues ...any) {
log.Println(msg, keysAndValues)
}
func (retryLogger) Debug(msg string, keysAndValues ...any) {
// log only retries, not every request
if strings.Contains(msg, "retrying request") {
log.Println(msg, keysAndValues)
}
}