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=<album>&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.
This commit is contained in:
Deluan Quintão 2026-09-17 23:48:39 -04:00 • committed by GitHub
commit 16567f147b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 365 additions and 56 deletions

View file

@ -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 {

View file

@ -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
}

View file

@ -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))
})
})

View file

@ -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)

View file

@ -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,
})
}

View file

@ -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() {

View file

@ -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"`
}

View file

@ -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"},
}
}

View file

@ -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) {

View file

@ -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=<artistId> still browses that artist's albums.
func parseTypes(types string) []string {

View file

@ -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())

View file

@ -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))
}

View file

@ -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.

View file

@ -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
}