package jellyfin import ( "fmt" "math" "net/http" "net/url" "slices" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" ) // mediaFileForRequest resolves {itemId} to a MediaFile and verifies the user has access to its // library, writing 404 (never 403, to avoid an existence oracle) and returning ok=false otherwise. // Shared by getPlaybackInfo and streamAudio so a guessed id can't probe or stream another library. func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) mf, err := api.ds.MediaFile(ctx).Get(id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) return nil, false } u, _ := request.UserFrom(ctx) if !u.HasLibraryAccess(mf.LibraryID) { http.Error(w, "Not Found", http.StatusNotFound) return nil, false } return mf, true } // getPlaybackInfo answers /Items/{itemId}/PlaybackInfo with a single MediaSource for direct // playback. Format negotiation happens later in streamAudio (like Subsonic defers it to /stream). func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { mf, ok := api.mediaFileForRequest(w, r) if !ok { return } src := dto.MediaSourceFromMediaFile(*mf) // The mapper only sees embedded lyrics; per-track we can afford the full pipeline // (sidecars, plugins) so Finamp's Lyric-stream gate reflects every source. if !slices.ContainsFunc(src.MediaStreams, func(s dto.MediaStream) bool { return s.Type == "Lyric" }) { if _, found := servableLyric(api.cachedLyrics(r.Context(), mf)); found { src.MediaStreams = append(src.MediaStreams, dto.MediaStream{ Type: "Lyric", Index: len(src.MediaStreams), IsExternal: true, }) } } // Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl // verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients // (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so // a client resolving it as an absolute host path still hits the mounted router. if token := tokenFromRequest(r); token != "" { src.TranscodingSubProtocol = "http" src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) } api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: mf.ID}) } // streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, // 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. format = chi.URLParam(r, "container") } if format == "" { // Jellyfin's audioCodec param names the target codec when no container is given. format = p.StringOr("audiocodec", "") } if p.BoolOr("static", false) { format = "raw" } // 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) 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) } } // streamHls serves /Audio/{itemId}/main.m3u8 (Finamp's transcoding mode) as a single-segment VOD // playlist whose one segment is the progressive transcode endpoint, reusing that whole pipeline. // Trade-off: seeking re-reads from the start, like Subsonic transcoded streams. func (api *Router) streamHls(w http.ResponseWriter, r *http.Request) { mf, ok := api.mediaFileForRequest(w, r) if !ok { return } p := req.Params(r) // HLS packed audio can only carry ADTS/AAC or MP3; other codecs fall back to aac. A forced // transcoding wins verbatim — its override rewrites the segment anyway, and the playlist must match. codec := strings.ToLower(p.StringOr("audiocodec", "")) if codec != "mp3" { codec = "aac" } if trc, ok := request.TranscodingFrom(r.Context()); ok && trc.TargetFormat != "" { codec = strings.ToLower(trc.TargetFormat) } // Relative to the playlist URL. HLS fetches drop auth headers, so the token rides in the query. segment := "stream." + codec q := url.Values{} if token := tokenFromRequest(r); token != "" { q.Set("api_key", token) } if bitRate := p.IntOr("audiobitrate", 0); bitRate > 0 { q.Set("audioBitRate", strconv.Itoa(bitRate)) } if len(q) > 0 { segment += "?" + q.Encode() } w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") //nolint:gosec // not HTML; the only tainted value is query-escaped fmt.Fprintf(w, "#EXTM3U\n"+ "#EXT-X-VERSION:3\n"+ "#EXT-X-PLAYLIST-TYPE:VOD\n"+ "#EXT-X-TARGETDURATION:%d\n"+ "#EXT-X-MEDIA-SEQUENCE:0\n"+ "#EXTINF:%.3f,\n"+ "%s\n"+ "#EXT-X-ENDLIST\n", int(math.Ceil(float64(mf.Duration))), mf.Duration, segment) } // streamFile serves /Items/{itemId}/File and /Download, Jellyfin's direct-file endpoints. Some // clients (Finamp's just_audio engine) fetch playback audio here instead of /Audio/{id}/stream, so // it must always resolve to direct play ("raw"), never a forced transcode. func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) { mf, ok := api.mediaFileForRequest(w, r) 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) } }