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:
@@ -160,6 +160,13 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
|
|||||||
a.AudioCache = ac
|
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 = 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.Config.Application.MaxImageCacheSizeMB = clamp(a.Config.Application.MaxImageCacheSizeMB, 1, 500)
|
||||||
a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576)
|
a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576)
|
||||||
a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) {
|
a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) {
|
||||||
|
|||||||
@@ -234,6 +234,12 @@ type MediaItemMetadata struct {
|
|||||||
AlbumID string
|
AlbumID string
|
||||||
CoverArtID string
|
CoverArtID string
|
||||||
Duration time.Duration
|
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 {
|
type MediaItem interface {
|
||||||
@@ -256,6 +262,12 @@ func (t *Track) Metadata() MediaItemMetadata {
|
|||||||
AlbumID: t.AlbumID,
|
AlbumID: t.AlbumID,
|
||||||
CoverArtID: t.CoverArtID,
|
CoverArtID: t.CoverArtID,
|
||||||
Duration: t.Duration,
|
Duration: t.Duration,
|
||||||
|
TrackNumber: t.TrackNumber,
|
||||||
|
Size: t.Size,
|
||||||
|
BitRate: t.BitRate,
|
||||||
|
SampleRate: t.SampleRate,
|
||||||
|
BitDepth: t.BitDepth,
|
||||||
|
ChannelCount: t.Channels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ type PlaybackManager struct {
|
|||||||
appCfg *AppConfig
|
appCfg *AppConfig
|
||||||
cfg *PlaybackConfig
|
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
|
localPlayer player.BasePlayer
|
||||||
remotePlayersLock sync.Mutex
|
remotePlayersLock sync.Mutex
|
||||||
remotePlayers []RemotePlaybackDevice
|
remotePlayers []RemotePlaybackDevice
|
||||||
@@ -247,17 +253,18 @@ func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool)
|
|||||||
func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) {
|
func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) {
|
||||||
devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl)
|
devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl)
|
||||||
|
|
||||||
|
coverArtPathFn := p.CoverArtPathFn
|
||||||
var discovered []RemotePlaybackDevice
|
var discovered []RemotePlaybackDevice
|
||||||
for _, d := range devices {
|
for _, d := range devices {
|
||||||
p := RemotePlaybackDevice{
|
rp := RemotePlaybackDevice{
|
||||||
Name: d.FriendlyName,
|
Name: d.FriendlyName,
|
||||||
URL: d.URL,
|
URL: d.URL,
|
||||||
Protocol: "DLNA",
|
Protocol: "DLNA",
|
||||||
new: func() (player.BasePlayer, error) {
|
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()
|
p.remotePlayersLock.Lock()
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ type DLNAPlayer struct {
|
|||||||
avTransport *avtransport.Client
|
avTransport *avtransport.Client
|
||||||
renderControl *renderingcontrol.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
|
state int // stopped, playing, paused
|
||||||
seeking bool
|
seeking bool
|
||||||
|
|
||||||
@@ -88,7 +95,7 @@ type DLNAPlayer struct {
|
|||||||
resetChan chan (time.Duration)
|
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 := retryablehttp.NewClient()
|
||||||
retry.RetryMax = 3
|
retry.RetryMax = 3
|
||||||
retry.RetryWaitMin = 100 * time.Millisecond
|
retry.RetryWaitMin = 100 * time.Millisecond
|
||||||
@@ -117,9 +124,44 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) {
|
|||||||
avTransport: avt,
|
avTransport: avt,
|
||||||
renderControl: rc,
|
renderControl: rc,
|
||||||
resetChan: make(chan time.Duration),
|
resetChan: make(chan time.Duration),
|
||||||
|
coverArtPathFn: coverArtPathFn,
|
||||||
}, nil
|
}, 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 {
|
func (d *DLNAPlayer) SetVolume(vol int) error {
|
||||||
if d.destroyed {
|
if d.destroyed {
|
||||||
return nil
|
return nil
|
||||||
@@ -153,12 +195,7 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat
|
|||||||
d.metaLock.Unlock()
|
d.metaLock.Unlock()
|
||||||
key := d.addURLToProxy(urlstr)
|
key := d.addURLToProxy(urlstr)
|
||||||
|
|
||||||
media := avtransport.MediaItem{
|
media := d.buildMediaItem(d.urlForItem(key), meta)
|
||||||
URL: d.urlForItem(key),
|
|
||||||
Title: meta.Name,
|
|
||||||
ContentType: meta.MIMEType,
|
|
||||||
Seekable: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := d.playAVTransportMedia(&media); err != nil {
|
if err := d.playAVTransportMedia(&media); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -223,12 +260,8 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat
|
|||||||
d.ensureSetupProxy()
|
d.ensureSetupProxy()
|
||||||
|
|
||||||
key := d.addURLToProxy(url)
|
key := d.addURLToProxy(url)
|
||||||
media = &avtransport.MediaItem{
|
item := d.buildMediaItem(d.urlForItem(key), meta)
|
||||||
URL: d.urlForItem(key),
|
media = &item
|
||||||
ContentType: meta.MIMEType,
|
|
||||||
Title: meta.Name,
|
|
||||||
Seekable: true,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// empty media item to signify erasing next track in device queue
|
// empty media item to signify erasing next track in device queue
|
||||||
media = &avtransport.MediaItem{}
|
media = &avtransport.MediaItem{}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ require (
|
|||||||
github.com/supersonic-app/fyne-lyrics v0.0.0-20250614151306-b1880a70a410
|
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-mpv v0.1.1-0.20250822102843-7a8cde5f5449
|
||||||
github.com/supersonic-app/go-subsonic v0.0.0-20260416152144-7a5f505a273c
|
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
|
github.com/zalando/go-keyring v0.2.8
|
||||||
golang.org/x/net v0.50.0
|
golang.org/x/net v0.50.0
|
||||||
golang.org/x/sys v0.41.0
|
golang.org/x/sys v0.41.0
|
||||||
|
|||||||
@@ -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-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 h1:wms3FvahSfcnacW2rs9L+vzJR8FmTE5xTrRgf3+k3eQ=
|
||||||
github.com/supersonic-app/go-subsonic v0.0.0-20260416152144-7a5f505a273c/go.mod h1:ClhAgC2qobwH19D6kdwULLYSHlOmldDGkdu2G2Z0Ijo=
|
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.1-0.20260517163705-d76cd97c192f h1:G2y9HyfAFoTZWrL6mdcH0MgR8EDqdurRcZq9Eu8Xdg8=
|
||||||
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/go.mod h1:o/QGDTK3qdaS3h9Bt8aH5ywgbWbrPVzPCHlZYFs0IHY=
|
||||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||||
|
|||||||
Reference in New Issue
Block a user