fix(jellyfin): populate MediaStreams with the audio stream so clients can size/transcode

This commit is contained in:
Deluan 2026-07-05 22:14:56 -04:00
commit c70d3c17e6
3 changed files with 104 additions and 31 deletions

View file

@ -179,38 +179,58 @@ type AuthenticationResult struct {
ServerId string `json:"ServerId"`
}
// MediaStream mirrors real Jellyfin's MediaStream object. Finamp's model declares several bools
// as non-nullable, so they must always be emitted (no omitempty) even when false. Finamp's
// MediaSourceInfo.transcodedSize also does MediaStreams.firstWhere((s) => s.type == 'Audio'), so
// MediaSourceInfo must always include at least one Audio stream or that lookup throws.
type MediaStream struct {
Codec string `json:"Codec,omitempty"`
Type string `json:"Type"`
Index int `json:"Index"`
BitRate int `json:"BitRate,omitempty"`
Channels int `json:"Channels,omitempty"`
SampleRate int `json:"SampleRate,omitempty"`
ChannelLayout string `json:"ChannelLayout,omitempty"`
IsInterlaced bool `json:"IsInterlaced"`
IsDefault bool `json:"IsDefault"`
IsForced bool `json:"IsForced"`
IsExternal bool `json:"IsExternal"`
IsTextSubtitleStream bool `json:"IsTextSubtitleStream"`
SupportsExternalStream bool `json:"SupportsExternalStream"`
}
// MediaSourceInfo mirrors real Jellyfin's MediaSourceInfo object. Finamp's model declares several
// of these bools and arrays as non-nullable, so a missing field deserializes as null and throws a
// "type 'Null' is not a subtype of type 'bool'" cast error that aborts parsing of the whole item
// list. They must always be emitted (no omitempty on bools) even when false/empty.
type MediaSourceInfo struct {
Id string `json:"Id"`
Path string `json:"Path,omitempty"`
Protocol string `json:"Protocol"`
Container string `json:"Container,omitempty"`
Size int64 `json:"Size,omitempty"`
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"`
Type string `json:"Type"`
ReadAtNativeFramerate bool `json:"ReadAtNativeFramerate"`
IgnoreDts bool `json:"IgnoreDts"`
IgnoreIndex bool `json:"IgnoreIndex"`
GenPtsInput bool `json:"GenPtsInput"`
IsInfiniteStream bool `json:"IsInfiniteStream"`
UseMostCompatibleTranscodingProfile bool `json:"UseMostCompatibleTranscodingProfile"`
RequiresOpening bool `json:"RequiresOpening"`
RequiresClosing bool `json:"RequiresClosing"`
RequiresLooping bool `json:"RequiresLooping"`
SupportsProbing bool `json:"SupportsProbing"`
HasSegments bool `json:"HasSegments"`
MediaStreams []any `json:"MediaStreams"`
MediaAttachments []any `json:"MediaAttachments"`
Formats []string `json:"Formats"`
Id string `json:"Id"`
Path string `json:"Path,omitempty"`
Protocol string `json:"Protocol"`
Container string `json:"Container,omitempty"`
Size int64 `json:"Size,omitempty"`
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"`
Type string `json:"Type"`
ReadAtNativeFramerate bool `json:"ReadAtNativeFramerate"`
IgnoreDts bool `json:"IgnoreDts"`
IgnoreIndex bool `json:"IgnoreIndex"`
GenPtsInput bool `json:"GenPtsInput"`
IsInfiniteStream bool `json:"IsInfiniteStream"`
UseMostCompatibleTranscodingProfile bool `json:"UseMostCompatibleTranscodingProfile"`
RequiresOpening bool `json:"RequiresOpening"`
RequiresClosing bool `json:"RequiresClosing"`
RequiresLooping bool `json:"RequiresLooping"`
SupportsProbing bool `json:"SupportsProbing"`
HasSegments bool `json:"HasSegments"`
MediaStreams []MediaStream `json:"MediaStreams"`
MediaAttachments []any `json:"MediaAttachments"`
Formats []string `json:"Formats"`
}
type PlaybackInfoResponse struct {

View file

@ -8,6 +8,22 @@ import (
func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) }
// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream.
func channelLayout(n int) string {
switch n {
case 1:
return "mono"
case 2:
return "stereo"
case 6:
return "5.1"
case 8:
return "7.1"
default:
return ""
}
}
// 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
@ -27,9 +43,17 @@ func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo {
SupportsTranscoding: true,
IsRemote: false,
SupportsProbing: true,
MediaStreams: []any{},
MediaAttachments: []any{},
Formats: []string{},
MediaStreams: []MediaStream{{
Type: "Audio",
Index: 0,
Codec: mf.Codec,
BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps.
Channels: mf.Channels,
SampleRate: mf.SampleRate,
ChannelLayout: channelLayout(mf.Channels),
}},
MediaAttachments: []any{},
Formats: []string{},
}
}

View file

@ -47,6 +47,22 @@ var _ = Describe("mappers", func() {
Expect(src.SupportsDirectPlay).To(BeTrue())
})
It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() {
mf := model.MediaFile{
ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100,
Channels: 2, SampleRate: 44100, Codec: "mp3",
}
src := MediaSourceFromMediaFile(mf)
Expect(src.MediaStreams).To(HaveLen(1))
stream := src.MediaStreams[0]
Expect(stream.Type).To(Equal("Audio"))
Expect(stream.Channels).To(Equal(2))
Expect(stream.SampleRate).To(Equal(44100))
Expect(stream.BitRate).To(Equal(320_000))
Expect(stream.Codec).To(Equal("mp3"))
Expect(stream.ChannelLayout).To(Equal("stereo"))
})
It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() {
mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)
@ -56,11 +72,24 @@ var _ = Describe("mappers", func() {
Expect(j).To(ContainSubstring(`"SupportsProbing":true`))
Expect(j).To(ContainSubstring(`"IsInfiniteStream":false`))
Expect(j).To(ContainSubstring(`"RequiresOpening":false`))
Expect(j).To(ContainSubstring(`"MediaStreams":[]`))
Expect(j).To(ContainSubstring(`"MediaAttachments":[]`))
Expect(j).To(ContainSubstring(`"Formats":[]`))
})
It("serializes MediaStream's required non-nullable bools, never omitted", func() {
stream := MediaStream{Type: "Audio", Index: 0}
b, err := json.Marshal(stream)
Expect(err).ToNot(HaveOccurred())
j := string(b)
Expect(j).To(ContainSubstring(`"Type":"Audio"`))
Expect(j).To(ContainSubstring(`"IsDefault":false`))
Expect(j).To(ContainSubstring(`"IsInterlaced":false`))
Expect(j).To(ContainSubstring(`"IsForced":false`))
Expect(j).To(ContainSubstring(`"IsExternal":false`))
Expect(j).To(ContainSubstring(`"IsTextSubtitleStream":false`))
Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`))
})
It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() {
mf := model.MediaFile{
ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1",