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.
This commit is contained in:
Andrew G. Dunn
2026-06-01 16:39:48 -07:00
committed by GitHub
parent 3205bb33f2
commit 6b6701e973
6 changed files with 101 additions and 42 deletions
+7
View File
@@ -160,6 +160,13 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
a.AudioCache = ac
}
a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.AudioCache, a.LocalPlayer, &a.Config.Playback, &a.Config.Scrobbling, &a.Config.Transcoding, &a.Config.Application)
a.PlaybackManager.CoverArtPathFn = func(coverArtID string) (string, error) {
// Ensure the thumbnail is cached on disk, then return its path so
// the DLNA player can expose it through the local proxy as
// upnp:albumArtURI.
a.ImageManager.GetCoverThumbnail(coverArtID)
return a.ImageManager.GetCoverArtPath(coverArtID)
}
a.Config.Application.MaxImageCacheSizeMB = clamp(a.Config.Application.MaxImageCacheSizeMB, 1, 500)
a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576)
a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) {
+12
View File
@@ -234,6 +234,12 @@ type MediaItemMetadata struct {
AlbumID string
CoverArtID string
Duration time.Duration
TrackNumber int
Size int64
BitRate int // kbps as reported by the server
SampleRate int // Hz
BitDepth int
ChannelCount int
}
type MediaItem interface {
@@ -256,6 +262,12 @@ func (t *Track) Metadata() MediaItemMetadata {
AlbumID: t.AlbumID,
CoverArtID: t.CoverArtID,
Duration: t.Duration,
TrackNumber: t.TrackNumber,
Size: t.Size,
BitRate: t.BitRate,
SampleRate: t.SampleRate,
BitDepth: t.BitDepth,
ChannelCount: t.Channels,
}
}
+10 -3
View File
@@ -31,6 +31,12 @@ type PlaybackManager struct {
appCfg *AppConfig
cfg *PlaybackConfig
// CoverArtPathFn returns a local filesystem path to the cached cover
// art image for the given CoverArtID. Used by DLNA cast so the
// renderer can fetch album art via the local proxy. Set externally
// (typically wired to ImageManager.GetCoverArtPath).
CoverArtPathFn func(coverArtID string) (string, error)
localPlayer player.BasePlayer
remotePlayersLock sync.Mutex
remotePlayers []RemotePlaybackDevice
@@ -247,17 +253,18 @@ func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool)
func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) {
devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl)
coverArtPathFn := p.CoverArtPathFn
var discovered []RemotePlaybackDevice
for _, d := range devices {
p := RemotePlaybackDevice{
rp := RemotePlaybackDevice{
Name: d.FriendlyName,
URL: d.URL,
Protocol: "DLNA",
new: func() (player.BasePlayer, error) {
return dlna.NewDLNAPlayer(d)
return dlna.NewDLNAPlayer(d, coverArtPathFn)
},
}
discovered = append(discovered, p)
discovered = append(discovered, rp)
}
p.remotePlayersLock.Lock()
+46 -13
View File
@@ -45,6 +45,13 @@ type DLNAPlayer struct {
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
@@ -88,7 +95,7 @@ type DLNAPlayer struct {
resetChan chan (time.Duration)
}
func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) {
func NewDLNAPlayer(device *device.MediaRenderer, coverArtPathFn func(coverArtID string) (string, error)) (*DLNAPlayer, error) {
retry := retryablehttp.NewClient()
retry.RetryMax = 3
retry.RetryWaitMin = 100 * time.Millisecond
@@ -117,9 +124,44 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) {
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
@@ -153,12 +195,7 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat
d.metaLock.Unlock()
key := d.addURLToProxy(urlstr)
media := avtransport.MediaItem{
URL: d.urlForItem(key),
Title: meta.Name,
ContentType: meta.MIMEType,
Seekable: true,
}
media := d.buildMediaItem(d.urlForItem(key), meta)
if err := d.playAVTransportMedia(&media); err != nil {
return err
@@ -223,12 +260,8 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat
d.ensureSetupProxy()
key := d.addURLToProxy(url)
media = &avtransport.MediaItem{
URL: d.urlForItem(key),
ContentType: meta.MIMEType,
Title: meta.Name,
Seekable: true,
}
item := d.buildMediaItem(d.urlForItem(key), meta)
media = &item
} else {
// empty media item to signify erasing next track in device queue
media = &avtransport.MediaItem{}
+1 -1
View File
@@ -23,7 +23,7 @@ require (
github.com/supersonic-app/fyne-lyrics v0.0.0-20250614151306-b1880a70a410
github.com/supersonic-app/go-mpv v0.1.1-0.20250822102843-7a8cde5f5449
github.com/supersonic-app/go-subsonic v0.0.0-20260416152144-7a5f505a273c
github.com/supersonic-app/go-upnpcast v0.1.0
github.com/supersonic-app/go-upnpcast v0.1.1-0.20260517163705-d76cd97c192f
github.com/zalando/go-keyring v0.2.8
golang.org/x/net v0.50.0
golang.org/x/sys v0.41.0
+2 -2
View File
@@ -125,8 +125,8 @@ github.com/supersonic-app/go-mpv v0.1.1-0.20250822102843-7a8cde5f5449 h1:UHIPI43
github.com/supersonic-app/go-mpv v0.1.1-0.20250822102843-7a8cde5f5449/go.mod h1:1bQz6kBQumJopXEbkiqoLxIXLy7F7yWFBvknvpAtIC0=
github.com/supersonic-app/go-subsonic v0.0.0-20260416152144-7a5f505a273c h1:wms3FvahSfcnacW2rs9L+vzJR8FmTE5xTrRgf3+k3eQ=
github.com/supersonic-app/go-subsonic v0.0.0-20260416152144-7a5f505a273c/go.mod h1:ClhAgC2qobwH19D6kdwULLYSHlOmldDGkdu2G2Z0Ijo=
github.com/supersonic-app/go-upnpcast v0.1.0 h1:SL0WVgnqvAVaLT9MSRhSeVwF9pcUw1cgBITKVoWXN3g=
github.com/supersonic-app/go-upnpcast v0.1.0/go.mod h1:o/QGDTK3qdaS3h9Bt8aH5ywgbWbrPVzPCHlZYFs0IHY=
github.com/supersonic-app/go-upnpcast v0.1.1-0.20260517163705-d76cd97c192f h1:G2y9HyfAFoTZWrL6mdcH0MgR8EDqdurRcZq9Eu8Xdg8=
github.com/supersonic-app/go-upnpcast v0.1.1-0.20260517163705-d76cd97c192f/go.mod h1:o/QGDTK3qdaS3h9Bt8aH5ywgbWbrPVzPCHlZYFs0IHY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=