From 200ae482c35e9bd5a5180f8059e2e421d184997f Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 5 Jul 2026 21:31:06 -0400 Subject: [PATCH] fix(jellyfin): include MediaSources with Size/Bitrate on tracks so clients show download size --- server/jellyfin/dto/dto.go | 1 + server/jellyfin/dto/mappers.go | 22 ++++++++++++++++++++++ server/jellyfin/dto/mappers_test.go | 16 +++++++++++++++- server/jellyfin/stream.go | 13 +------------ server/jellyfin/stream_test.go | 1 + 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 67b1710d4..97fdba4b9 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -188,6 +188,7 @@ type MediaSourceInfo struct { Name string `json:"Name,omitempty"` IsRemote bool `json:"IsRemote"` RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + Bitrate int `json:"Bitrate,omitempty"` SupportsTranscoding bool `json:"SupportsTranscoding"` SupportsDirectStream bool `json:"SupportsDirectStream"` SupportsDirectPlay bool `json:"SupportsDirectPlay"` diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index ab7e0ad1b..4f62d3acd 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -8,6 +8,27 @@ import ( func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) } +// MediaSourceFromMediaFile builds the MediaSourceInfo describing direct playback of mf's +// source file. Shared by SongToBaseItem and getPlaybackInfo so clients see the same Size and +// Bitrate whether they read it from a browse response or from /PlaybackInfo -- Finamp's +// download dialog reads MediaSources[0].Size from the former. +func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { + return MediaSourceInfo{ + Id: mf.ID, + Protocol: "Http", + Container: mf.Suffix, + Size: mf.Size, + Name: mf.Title, + Type: "Default", + RunTimeTicks: TicksFromSeconds(mf.Duration), + Bitrate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's Bitrate is bps. + SupportsDirectPlay: true, + SupportsDirectStream: true, + SupportsTranscoding: true, + IsRemote: false, + } +} + func UserData(a model.Annotations, itemID string) *UserItemDataDto { d := &UserItemDataDto{ PlayCount: int(a.PlayCount), @@ -44,6 +65,7 @@ func SongToBaseItem(mf model.MediaFile) BaseItemDto { CanDownload: true, BackdropImageTags: []string{}, UserData: UserData(mf.Annotations, mf.ID), + MediaSources: []MediaSourceInfo{MediaSourceFromMediaFile(mf)}, } if mf.Year > 0 { item.ProductionYear = new(mf.Year) diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 159c86aec..1a63ed47c 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -13,7 +13,7 @@ var _ = Describe("mappers", func() { mf := model.MediaFile{ ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1", Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, - Year: 1999, Duration: 60, + Year: 1999, Duration: 60, Size: 2_500_000, } mf.PlayCount = 2 mf.Starred = true @@ -28,6 +28,20 @@ var _ = Describe("mappers", func() { Expect(item.UserData.IsFavorite).To(BeTrue()) Expect(item.UserData.PlayCount).To(Equal(2)) Expect(item.UserData.Played).To(BeTrue()) + Expect(item.MediaSources).To(HaveLen(1)) + Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000))) + }) + + It("builds a MediaSourceInfo from a media file", func() { + mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + src := MediaSourceFromMediaFile(mf) + Expect(src.Id).To(Equal("s1")) + Expect(src.Size).To(Equal(int64(5242880))) + Expect(src.Container).To(Equal("mp3")) + Expect(src.Bitrate).To(Equal(320_000)) + Expect(src.RunTimeTicks).To(Equal(int64(1_000_000_000))) + Expect(src.Protocol).To(Equal("Http")) + Expect(src.SupportsDirectPlay).To(BeTrue()) }) It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() { diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index e5c3cb2df..3cc3cbed9 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -40,18 +40,7 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { if !ok { return } - src := dto.MediaSourceInfo{ - Id: mf.ID, - Protocol: "Http", - Container: mf.Suffix, - Size: mf.Size, - Name: mf.Title, - Type: "Default", - RunTimeTicks: dto.TicksFromSeconds(mf.Duration), - SupportsDirectPlay: true, - SupportsDirectStream: true, - SupportsTranscoding: true, - } + src := dto.MediaSourceFromMediaFile(*mf) api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: mf.ID}) } diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index afd04200c..d5ddb3842 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -52,6 +52,7 @@ var _ = Describe("Stream", func() { Expect(res.MediaSources).To(HaveLen(1)) Expect(res.MediaSources[0].Id).To(Equal("s1")) Expect(res.MediaSources[0].Container).To(Equal("mp3")) + Expect(res.MediaSources[0].Size).To(Equal(int64(1000))) Expect(res.PlaySessionId).ToNot(BeEmpty()) })