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=