From 6b6701e973efa91af5dc415189349aaedc29f6fe Mon Sep 17 00:00:00 2001 From: "Andrew G. Dunn" Date: Mon, 1 Jun 2026 19:39:48 -0400 Subject: [PATCH] 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 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. --- backend/app.go | 7 ++++ backend/mediaprovider/model.go | 52 +++++++++++++++---------- backend/playbackmanager.go | 13 +++++-- backend/player/dlna/dlnaplayer.go | 65 +++++++++++++++++++++++-------- go.mod | 2 +- go.sum | 4 +- 6 files changed, 101 insertions(+), 42 deletions(-) diff --git a/backend/app.go b/backend/app.go index e9593fa..9898beb 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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) { diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 4194ae9..a6aaeb6 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -224,16 +224,22 @@ const ( ) type MediaItemMetadata struct { - Type MediaItemType - MIMEType string - ID string - Name string - Artists []string - ArtistIDs []string - Album string - AlbumID string - CoverArtID string - Duration time.Duration + Type MediaItemType + MIMEType string + ID string + Name string + Artists []string + ArtistIDs []string + Album string + 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 { @@ -246,16 +252,22 @@ func (t *Track) Metadata() MediaItemMetadata { return MediaItemMetadata{} } return MediaItemMetadata{ - Type: MediaItemTypeTrack, - MIMEType: t.ContentType, - ID: t.ID, - Name: t.Title, - Artists: t.ArtistNames, - ArtistIDs: t.ArtistIDs, - Album: t.Album, - AlbumID: t.AlbumID, - CoverArtID: t.CoverArtID, - Duration: t.Duration, + Type: MediaItemTypeTrack, + MIMEType: t.ContentType, + ID: t.ID, + Name: t.Title, + Artists: t.ArtistNames, + ArtistIDs: t.ArtistIDs, + Album: t.Album, + 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, } } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index b13e599..04ac635 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -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() diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 66463d7..0180462 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -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 @@ -114,12 +121,47 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { } return &DLNAPlayer{ - avTransport: avt, - renderControl: rc, - resetChan: make(chan time.Duration), + 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{} diff --git a/go.mod b/go.mod index 307d354..aa6bd57 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index fd13d4d..98f42a2 100644 --- a/go.sum +++ b/go.sum @@ -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=