From 16567f147b6a57f9cdfc4fe37c4577cf0a4b1d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 17 Sep 2026 23:48:39 -0400 Subject: [PATCH] fix(jellyfin): match Jellyfin on login SessionInfo, item types and universal streams (#6161) * fix(jellyfin): send SessionInfo on login so JellyBox gets past sign-in JellyBox parses AuthenticateByName's SessionInfo as a required object and fails silently when it is missing, leaving the user on the login screen. Real Jellyfin always sends it (SessionManager.AuthenticateNewSessionInternal, 10.10.7 and master), so the login response now carries a full SessionInfo built from the user and the MediaBrowser auth header. It includes every field JellyBox (Id, PlayState) and Finamp (UserId, LastActivityDate, the activity and control bools, PlayState's CanSeek/IsPaused/IsMuted) require once the object is present. The session Id is derived from client and device id, so repeated logins from one install share it. * fix(jellyfin): ignore IncludeItemTypes names that aren't Jellyfin kinds JellyBox opens an album with ParentId=&IncludeItemTypes=music. Music is not a BaseItemKind, and Jellyfin's comma-delimited binder drops values it cannot parse, so real Jellyfin treats the request as having no type filter and lists the album's tracks. Navidrome returned an empty list, so every album opened empty. Entries that aren't BaseItemKind names are now dropped before type resolution, so an all-unknown list behaves like an absent one. Real kinds Navidrome doesn't serve, such as Boxset, still return nothing. * fix(jellyfin): treat universal Container as the direct-play list On /Audio/{id}/universal, Container lists the "container|codec" entries the client can direct play, and TranscodingContainer/AudioCodec name the target when it can't (UniversalAudioController builds DirectPlayProfiles from it). Navidrome passed the whole list to the decider as one target format, which matched nothing and fell back to DefaultDownsamplingFormat, so JellyBox got every MP3 transcoded to Opus. /universal now has its own handler: a source matching an entry keeps its format (still downsampled under a bitrate cap), anything else is transcoded to TranscodingContainer, then AudioCodec. The /stream routes keep treating Container as the target format. * refactor(jellyfin): let the stream decider resolve universal requests streamUniversal matched the Container list itself with plain string equality and then asked the legacy resolver for the source format. That skipped the decider's container and codec aliases (mp4 vs m4a, ogg vs opus), and a direct-playable source over the bitrate cap was transcoded to its own format instead of the client's TranscodingContainer. The shared part of ResolveRequest (server-side player override, player MaxBitRate cap, decision to Request mapping) moves to a resolve helper, and a new ResolveClientRequest exposes it for callers that build their own ClientInfo. streamUniversal now turns Container into DirectPlayProfiles and TranscodingContainer/AudioCodec into a transcoding profile, so the decision uses the same rules as the Subsonic getTranscodeDecision path. streamFile and the /stream routes share a serveStream helper, NewSessionInfo reads the clock itself, and duplicate comments and tests are trimmed. --- core/stream/decider.go | 1 + core/stream/legacy_client.go | 77 ++++++++++++++++----------- core/stream/legacy_client_test.go | 69 ++++++++++++++++++++++++ server/jellyfin/api.go | 2 +- server/jellyfin/auth.go | 9 ++-- server/jellyfin/auth_test.go | 50 ++++++++++++++++-- server/jellyfin/dto/dto.go | 32 ++++++++++-- server/jellyfin/dto/mappers.go | 23 ++++++++ server/jellyfin/e2e/browsing_test.go | 7 ++- server/jellyfin/items.go | 25 ++++++++- server/jellyfin/items_test.go | 17 ++++++ server/jellyfin/stream.go | 78 ++++++++++++++++++++-------- server/jellyfin/stream_test.go | 47 +++++++++++++++++ server/subsonic/transcode_test.go | 4 ++ 14 files changed, 375 insertions(+), 66 deletions(-) diff --git a/core/stream/decider.go b/core/stream/decider.go index 3c6b01e05..886898206 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -23,6 +23,7 @@ type TranscodeDecider interface { CreateTranscodeParams(decision *TranscodeDecision) (string, error) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request + ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request } func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider { diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index 652e42eba..5b732c3e2 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -73,37 +73,8 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile } clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate) - - // Apply server-side player transcoding override before making the decision - if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } else if player, ok := request.PlayerFrom(ctx); ok { - modified := *clientInfo - if modified.CapBitrate(player.MaxBitRate) { - clientInfo = &modified - log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) - } - } - - decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) - if err != nil { - log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) - req.Format = "raw" - return req - } - - if decision.CanDirectPlay { - req.Format = "raw" - return req - } - - if decision.CanTranscode { - req.Format = decision.TargetFormat - req.BitRate = decision.TargetBitrate - req.SampleRate = decision.TargetSampleRate - req.BitDepth = decision.TargetBitDepth - req.Channels = decision.TargetChannels - return req + if resolved, ok := s.resolve(ctx, mf, clientInfo, offset); ok { + return resolved } // No compatible profile for the requested format — retry with DefaultDownsamplingFormat @@ -119,3 +90,47 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile req.Format = "raw" return req } + +// ResolveClientRequest resolves a stream request for a client that declared its own direct play +// and transcoding profiles, falling back to raw when none fits. +func (s *deciderService) ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request { + if req, ok := s.resolve(ctx, mf, clientInfo, offset); ok { + return req + } + return Request{Format: "raw", Offset: offset} +} + +// resolve applies the server-side player overrides to clientInfo and maps the decision to a +// Request. ok is false when no profile fits. +func (s *deciderService) resolve(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) (Request, bool) { + req := Request{Offset: offset} + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + clientInfo = applyServerOverride(ctx, clientInfo, &trc) + } else if player, ok := request.PlayerFrom(ctx); ok { + modified := *clientInfo + if modified.CapBitrate(player.MaxBitRate) { + clientInfo = &modified + log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } + } + + decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) + if err != nil { + log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) + req.Format = "raw" + return req, true + } + switch { + case decision.CanDirectPlay: + req.Format = "raw" + case decision.CanTranscode: + req.Format = decision.TargetFormat + req.BitRate = decision.TargetBitrate + req.SampleRate = decision.TargetSampleRate + req.BitDepth = decision.TargetBitDepth + req.Channels = decision.TargetChannels + default: + return req, false + } + return req, true +} diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index bc8405976..ad3417faf 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -414,3 +414,72 @@ var _ = Describe("ResolveRequest", func() { }) }) }) + +var _ = Describe("ResolveClientRequest", func() { + var ( + svc TranscodeDecider + ctx context.Context + ) + mp3Target := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}} + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds := &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + auth.Init(ds) + svc = NewTranscodeDecider(ds, tests.NewMockFFmpeg("")) + }) + + It("direct plays a source matching a profile through container aliases", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}}}, + TranscodingProfiles: mp3Target, + } + + req := svc.ResolveClientRequest(ctx, mf, ci, 5) + + Expect(req.Format).To(Equal("raw")) + Expect(req.Offset).To(Equal(5)) + }) + + It("transcodes to the client's transcoding profile when no direct play profile matches", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp3"}}}, + TranscodingProfiles: mp3Target, + } + + Expect(svc.ResolveClientRequest(ctx, mf, ci, 0).Format).To(Equal("mp3")) + }) + + It("transcodes a direct-playable source over the bitrate cap to the client's target", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := &ClientInfo{ + MaxAudioBitrate: 128, + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, + TranscodingProfiles: mp3Target, + } + + req := svc.ResolveClientRequest(ctx, mf, ci, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(128)) + }) + + It("applies the player's MaxBitRate cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ctx = request.WithPlayer(ctx, model.Player{MaxBitRate: 192}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, + TranscodingProfiles: mp3Target, + } + + req := svc.ResolveClientRequest(ctx, mf, ci, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(192)) + }) +}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index a4a1ee367..839c43ea5 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -165,7 +165,7 @@ func (api *Router) routes() http.Handler { r.Get("/audio/{itemId}/stream", api.streamAudio) r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) - r.Get("/audio/{itemId}/universal", api.streamAudio) + r.Get("/audio/{itemId}/universal", api.streamUniversal) r.Get("/audio/{itemId}/main.m3u8", api.streamHls) r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index e7070d341..bfcfab319 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -42,12 +42,13 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { return } - // SessionInfo is omitted, not partially filled: a stub {Id, UserId} could fail a strict client's - // parse, and Finamp's login doesn't need it (its AuthenticationResult.sessionInfo is nullable). + a := parseMediaBrowserAuth(r) + serverID := api.serverID(ctx) api.ok(w, r, dto.AuthenticationResult{ - User: userToDto(usr, api.serverName(), api.serverID(ctx)), + User: userToDto(usr, api.serverName(), serverID), + SessionInfo: dto.NewSessionInfo(usr, a.Client, a.DeviceId, a.Device, a.Version, serverID), AccessToken: token, - ServerId: api.serverID(ctx), + ServerId: serverID, }) } diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index 577677643..0821614d6 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -48,10 +48,54 @@ var _ = Describe("AuthenticateByName", func() { Expect(res.User.Policy.EnableAllFolders).To(BeTrue()) Expect(res.User.Policy.EnableMediaPlayback).To(BeTrue()) Expect(res.User.Configuration).ToNot(BeNil()) + }) - // Ours is a partial SessionInfo; a strict client may fail to parse it, and Finamp's - // login doesn't require it, so it should be omitted entirely rather than sent partial. - Expect(res.SessionInfo).To(BeNil()) + Describe("SessionInfo", func() { + login := func(authHeader string) map[string]any { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + r.Header.Set("Authorization", authHeader) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var raw map[string]any + Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) + Expect(raw).To(HaveKey("SessionInfo")) + return raw["SessionInfo"].(map[string]any) + } + const jellybox = `MediaBrowser Client="JellyBox", Device="Mac", DeviceId="dev-1", Version="2.1"` + + It("has the fields strict clients require", func() { + s := login(jellybox) + Expect(s["Id"]).To(And(BeAssignableToTypeOf(""), Not(BeEmpty()))) + Expect(s["UserId"]).To(Equal(dto.EncodeID(testID("u1")))) + Expect(s["LastActivityDate"]).To(And(BeAssignableToTypeOf(""), Not(BeEmpty()))) + for _, k := range []string{"SupportsRemoteControl", "SupportsMediaControl", "HasCustomDeviceName"} { + Expect(s[k]).To(BeAssignableToTypeOf(false), k) + } + ps, ok := s["PlayState"].(map[string]any) + Expect(ok).To(BeTrue()) + for _, k := range []string{"CanSeek", "IsPaused", "IsMuted"} { + Expect(ps[k]).To(BeAssignableToTypeOf(false), k) + } + }) + + It("describes the calling client", func() { + s := login(jellybox) + Expect(s).To(HaveKeyWithValue("UserName", "alice")) + Expect(s).To(HaveKeyWithValue("Client", "JellyBox")) + Expect(s).To(HaveKeyWithValue("DeviceName", "Mac")) + Expect(s).To(HaveKeyWithValue("DeviceId", "dev-1")) + Expect(s).To(HaveKeyWithValue("ApplicationVersion", "2.1")) + Expect(s).To(HaveKeyWithValue("IsActive", true)) + }) + + It("keeps the same Id for the same device, and a new one for another device", func() { + first := login(jellybox)["Id"] + Expect(login(jellybox)["Id"]).To(Equal(first)) + other := login(`MediaBrowser Client="JellyBox", Device="Mac", DeviceId="dev-2", Version="2.1"`) + Expect(other["Id"]).ToNot(Equal(first)) + }) }) It("records the login time, like the web UI login does", func() { diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 6d867ed21..21e3903f2 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -202,14 +202,40 @@ type UserConfiguration struct { CastReceiverId string `json:"CastReceiverId"` } +// SessionInfo mirrors real Jellyfin's SessionInfoDto. JellyBox requires Id and PlayState, and Finamp +// requires UserId, LastActivityDate and the bools, so none of those may be omitted. type SessionInfo struct { - Id string `json:"Id"` - UserId string `json:"UserId"` + Id string `json:"Id"` + UserId string `json:"UserId"` + UserName string `json:"UserName"` + Client string `json:"Client"` + DeviceId string `json:"DeviceId"` + DeviceName string `json:"DeviceName"` + ApplicationVersion string `json:"ApplicationVersion"` + ServerId string `json:"ServerId"` + LastActivityDate string `json:"LastActivityDate"` + IsActive bool `json:"IsActive"` + SupportsMediaControl bool `json:"SupportsMediaControl"` + SupportsRemoteControl bool `json:"SupportsRemoteControl"` + HasCustomDeviceName bool `json:"HasCustomDeviceName"` + PlayableMediaTypes []string `json:"PlayableMediaTypes"` + SupportedCommands []string `json:"SupportedCommands"` + AdditionalUsers []any `json:"AdditionalUsers"` + NowPlayingQueue []any `json:"NowPlayingQueue"` + PlayState PlayerStateInfo `json:"PlayState"` +} + +type PlayerStateInfo struct { + CanSeek bool `json:"CanSeek"` + IsPaused bool `json:"IsPaused"` + IsMuted bool `json:"IsMuted"` + RepeatMode string `json:"RepeatMode"` + PlaybackOrder string `json:"PlaybackOrder"` } type AuthenticationResult struct { User *UserDto `json:"User"` - SessionInfo *SessionInfo `json:"SessionInfo,omitempty"` + SessionInfo *SessionInfo `json:"SessionInfo"` AccessToken string `json:"AccessToken"` ServerId string `json:"ServerId"` } diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 0c9199066..bb17b40c7 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" ) @@ -446,3 +447,25 @@ func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto { } return d } + +// NewSessionInfo's Id is stable per client install, as Jellyfin reuses a device's session across logins. +func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, serverID string) *SessionInfo { + now := time.Now() + return &SessionInfo{ + Id: EncodeID(id.NewHash(client, deviceID)), + UserId: EncodeID(u.ID), + UserName: u.UserName, + Client: client, + DeviceId: deviceID, + DeviceName: deviceName, + ApplicationVersion: version, + ServerId: serverID, + LastActivityDate: jellyfinDate(&now), + IsActive: true, + PlayableMediaTypes: []string{"Audio"}, + SupportedCommands: []string{}, + AdditionalUsers: []any{}, + NowPlayingQueue: []any{}, + PlayState: PlayerStateInfo{RepeatMode: "RepeatNone", PlaybackOrder: "Default"}, + } +} diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 111e246fd..68d87cd25 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -119,9 +119,14 @@ var _ = Describe("Browsing", func() { Expect(q.Items).To(BeEmpty()) }, Entry("a Jellyfin kind Navidrome has none of", "Boxset"), - Entry("a name Jellyfin does not know either", "Nonsense"), ) + It("treats a name Jellyfin does not know as an absent IncludeItemTypes", func() { + q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(queryResult(get("/Items?Recursive=true")).TotalRecordCount)) + Expect(q.TotalRecordCount).To(BeNumerically(">", 0)) + }) + // A strict client (Manet) fails its whole sync on the first item missing any of these. DescribeTable("sends the keys Jellyfin puts on every item", func(itemType string, fields string, nonNull ...string) { diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 9ed3d2c8d..124d17920 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -349,7 +349,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQ } q := listParams(p) q.ids = ids - q.rawTypes = p.StringOr("includeitemtypes", "") + q.rawTypes = knownItemKinds(p.StringOr("includeitemtypes", "")) q.parentId = parentId q.genreIds = genreIds q.albumIds = albumIds @@ -593,6 +593,29 @@ var supportedTypes = map[string]string{ "audio": "Audio", "musicartist": "MusicArtist", "musicalbum": "MusicAlbum", "musicgenre": "MusicGenre", "playlist": "Playlist", } +// jellyfinItemKinds lists Jellyfin's BaseItemKind names, lowercased. +var jellyfinItemKinds = map[string]bool{ + "aggregatefolder": true, "audio": true, "audiobook": true, "basepluginfolder": true, "book": true, + "boxset": true, "channel": true, "channelfolderitem": true, "collectionfolder": true, "episode": true, + "folder": true, "genre": true, "manualplaylistsfolder": true, "movie": true, "livetvchannel": true, + "livetvprogram": true, "musicalbum": true, "musicartist": true, "musicgenre": true, "musicvideo": true, + "person": true, "photo": true, "photoalbum": true, "playlist": true, "playlistsfolder": true, + "program": true, "recording": true, "season": true, "series": true, "studio": true, "trailer": true, + "tvchannel": true, "tvprogram": true, "userrootfolder": true, "userview": true, "video": true, "year": true, +} + +// knownItemKinds drops IncludeItemTypes entries that aren't BaseItemKind names, as Jellyfin's binder +// does, so an all-unknown list (JellyBox sends "music") behaves like an absent one. +func knownItemKinds(types string) string { + var known []string + for t := range strings.SplitSeq(types, ",") { + if t = strings.TrimSpace(t); jellyfinItemKinds[strings.ToLower(t)] { + known = append(known, t) + } + } + return strings.Join(known, ",") +} + // parseTypes returns the supported entries in IncludeItemTypes in order. Only an absent param // defaults to albums, so ParentId= still browses that artist's albums. func parseTypes(types string) []string { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index c92c011fe..a8dfb7bb2 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -74,6 +74,18 @@ var _ = Describe("Items", func() { Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) + It("ignores IncludeItemTypes names that aren't Jellyfin item kinds", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=music", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) + }) + It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, @@ -1135,6 +1147,11 @@ var _ = Describe("Items", func() { Expect(parseTypes("BoxSet,Movie")).To(BeEmpty()) }) + It("drops names that aren't Jellyfin item kinds, case-insensitively", func() { + Expect(knownItemKinds("music")).To(BeEmpty()) + Expect(knownItemKinds("music, audio,MUSICVIDEO")).To(Equal("audio,MUSICVIDEO")) + }) + It("defaults to albums only when IncludeItemTypes is absent", func() { Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"})) Expect(parseTypes("Nonsense")).To(BeEmpty()) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 87660056c..c6dc0c80e 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -1,6 +1,7 @@ package jellyfin import ( + "cmp" "fmt" "math" "net/http" @@ -10,6 +11,7 @@ import ( "strings" "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -65,16 +67,14 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)}) } -// streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, -// reusing the same transcode-decision + streaming pipeline as the Subsonic /stream endpoint. +// streamAudio serves /Audio/{itemId}/stream[.container], reusing the same transcode-decision + +// streaming pipeline as the Subsonic /stream endpoint. func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { mf, ok := api.mediaFileForRequest(w, r) if !ok { return } - ctx := r.Context() p := req.Params(r) - format := p.StringOr("container", "") if format == "" { // The /stream.{container} route form carries the format as a path segment, not a query param. @@ -84,17 +84,61 @@ func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { // Jellyfin's audioCodec param names the target codec when no container is given. format = p.StringOr("audiocodec", "") } + api.serveAudio(w, r, mf, format) +} + +// streamUniversal serves /Audio/{itemId}/universal, where Container lists the "container|codec" +// entries the client direct-plays, and TranscodingContainer/AudioCodec name the fallback target. +func (api *Router) streamUniversal(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + p := req.Params(r) + var streamReq stream.Request if p.BoolOr("static", false) { + streamReq = api.transcodeDecider.ResolveRequest(r.Context(), mf, "raw", 0, 0) + } else { + streamReq = api.transcodeDecider.ResolveClientRequest(r.Context(), mf, universalClientInfo(p), 0) + } + api.serveStream(w, r, mf, streamReq) +} + +func universalClientInfo(p *req.Values) *stream.ClientInfo { + ci := &stream.ClientInfo{Name: "jellyfin-universal", MaxAudioBitrate: bitRateParam(p)} + for entry := range strings.SplitSeq(p.StringOr("container", ""), ",") { + container, codec, _ := strings.Cut(strings.TrimSpace(entry), "|") + if container == "" { + continue + } + profile := stream.DirectPlayProfile{Containers: []string{container}, Protocols: []string{stream.ProtocolHTTP}} + if codec != "" { + profile.AudioCodecs = []string{codec} + } + ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, profile) + } + codec := p.StringOr("audiocodec", "") + if container := cmp.Or(p.StringOr("transcodingcontainer", ""), codec); container != "" { + ci.TranscodingProfiles = []stream.Profile{{Container: container, AudioCodec: cmp.Or(codec, container), Protocol: stream.ProtocolHTTP}} + ci.MaxTranscodingAudioBitrate = ci.MaxAudioBitrate + } + return ci +} + +// bitRateParam reads Jellyfin's bits/sec bitrate params as the kbps the stream package expects. +func bitRateParam(p *req.Values) int { + return cmp.Or(p.IntOr("audiobitrate", 0), p.IntOr("maxstreamingbitrate", 0)) / 1000 +} + +func (api *Router) serveAudio(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, format string) { + if req.Params(r).BoolOr("static", false) { format = "raw" } + api.serveStream(w, r, mf, api.transcodeDecider.ResolveRequest(r.Context(), mf, format, bitRateParam(req.Params(r)), 0)) +} - // Bitrate params are bits/sec by Jellyfin convention; ResolveRequest expects kbps. - bitRate := p.IntOr("audiobitrate", 0) / 1000 - if bitRate == 0 { - bitRate = p.IntOr("maxstreamingbitrate", 0) / 1000 - } - - streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, format, bitRate, 0) +func (api *Router) serveStream(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, streamReq stream.Request) { + ctx := r.Context() s, err := api.streamer.NewStream(ctx, mf, streamReq) if err != nil { api.internalError(w, r, err) @@ -160,15 +204,5 @@ func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) { if !ok { return } - ctx := r.Context() - streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, "raw", 0, 0) - s, err := api.streamer.NewStream(ctx, mf, streamReq) - if err != nil { - api.internalError(w, r, err) - return - } - defer s.Close() - if _, err := s.Serve(ctx, w, r); err != nil { - log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) - } + api.serveStream(w, r, mf, api.transcodeDecider.ResolveRequest(r.Context(), mf, "raw", 0, 0)) } diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index d6220d33f..3cd692c1b 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -244,6 +244,45 @@ var _ = Describe("Stream", func() { }) }) + Describe("streamUniversal", func() { + universal := func(query string) { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: testID("s1"), Suffix: "mp3", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/universal?"+query, nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) + invoke(api.streamUniversal, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + } + + It("turns Container into direct play profiles and TranscodingContainer into the target", func() { + universal("Container=mp3,m4a|aac&TranscodingContainer=m4a&AudioCodec=aac&MaxStreamingBitrate=128000") + Expect(decider.client.DirectPlayProfiles).To(Equal([]stream.DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{stream.ProtocolHTTP}}, + {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{stream.ProtocolHTTP}}, + })) + Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ + {Container: "m4a", AudioCodec: "aac", Protocol: stream.ProtocolHTTP}, + })) + Expect(decider.client.MaxAudioBitrate).To(Equal(128)) + Expect(decider.client.MaxTranscodingAudioBitrate).To(Equal(128)) + }) + + It("uses AudioCodec as the target when no TranscodingContainer is given", func() { + universal("Container=mp3&AudioCodec=aac") + Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ + {Container: "aac", AudioCodec: "aac", Protocol: stream.ProtocolHTTP}, + })) + }) + + It("serves the file as is for static=true", func() { + universal("static=true&Container=ogg") + Expect(decider.req.Format).To(Equal("raw")) + Expect(decider.client).To(BeNil()) + }) + }) + Describe("streamHls", func() { BeforeEach(func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ @@ -364,6 +403,7 @@ var _ = Describe("Stream", func() { type fakeTranscodeDecider struct { invoked bool req stream.Request + client *stream.ClientInfo } func (f *fakeTranscodeDecider) MakeDecision(context.Context, *model.MediaFile, *stream.ClientInfo, stream.TranscodeOptions) (*stream.TranscodeDecision, error) { @@ -384,6 +424,13 @@ func (f *fakeTranscodeDecider) ResolveRequest(_ context.Context, _ *model.MediaF return f.req } +func (f *fakeTranscodeDecider) ResolveClientRequest(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, offset int) stream.Request { + f.invoked = true + f.client = ci + f.req = stream.Request{Offset: offset} + return f.req +} + // fakeMediaStreamer is a local test double for stream.MediaStreamer: it records whether // NewStream was invoked and, on success, returns a real (non-seekable) *stream.Stream backed // by an in-memory reader, so streamAudio's call to Stream.Serve exercises real code. diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 0f3c24832..1f2fdfcef 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -625,6 +625,10 @@ func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.Media return stream.Request{Format: "raw"} } +func (m *mockTranscodeDecision) ResolveClientRequest(context.Context, *model.MediaFile, *stream.ClientInfo, int) stream.Request { + return stream.Request{Format: "raw"} +} + func (m *mockTranscodeDecision) CreateTranscodeParams(_ *stream.TranscodeDecision) (string, error) { return m.token, m.tokenErr }