navidrome/server/jellyfin/stream.go
Deluan Quintão 546302576a
fix(jellyfin): emit TranscodingUrl without the /jellyfin base path (#6089)
PlaybackInfo returned a TranscodingUrl prefixed with the /jellyfin mount path.
Clients concatenate that value onto a server base URL that already carries the
prefix, producing /jellyfin/jellyfin/Audio/{id}/universal and a 404, so playback
never started. Jellify, jellyfin-web, jellyfin-vue and Streamyfin all consume the
field this way; real Jellyfin emits it server-relative (StreamInfo.ToUrl is called
with a nil baseUrl).

Emit the path server-relative to match. Finamp is unaffected: it builds its own
stream URLs and never reads the field.
2026-09-05 21:44:03 -04:00

174 lines
6 KiB
Go

package jellyfin
import (
"fmt"
"math"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"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, ok := itemIDParam(w, r, "itemId")
if !ok {
return nil, false
}
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,
})
}
}
// Self-authenticating: native players fetch this without an auth header. Server-relative:
// clients append it to a base URL already carrying /jellyfin.
if token := tokenFromRequest(r); token != "" {
src.TranscodingSubProtocol = "http"
src.TranscodingUrl = "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token)
}
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.
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)
}
}