Deluan Quintão 29f481cd7b
feat(jellyfin): lyrics endpoint and Lyric stream advertising (#5791)
* feat(jellyfin): add LyricDto and lyrics mapper

* feat(jellyfin): advertise Lyric media stream for embedded lyrics

* feat(jellyfin): implement GET /Audio/{itemId}/Lyrics

* feat(jellyfin): advertise pipeline-resolved lyrics in PlaybackInfo

* feat(jellyfin): advertise server version 10.9.11 for client lyrics gates

* test(jellyfin): e2e coverage for lyrics endpoint and advertising

Seeds "Stairway To Heaven" with an embedded LRC lyric tag (lyrics:eng)
and covers PlaybackInfo's Lyric MediaStream, GET /Audio/{id}/Lyrics,
and the HasLyrics badge end to end.

Fixes a bug the new seed exposed: HasLyrics and the Lyric MediaStream
gate compared mf.Lyrics against "", but the persistence layer never
stores an empty string post-scan (it normalizes to the JSON sentinel
"[]"), so every track was reporting HasLyrics=true. Both call sites
now parse the column via StructuredLyrics()/LyricList.Main() instead.

* fix(jellyfin): cheap sentinel check for embedded lyrics advertising

* chore(jellyfin): trim over-budget comments in lyrics code

* test(jellyfin): cover lyrics pipeline error and nil-start cue skip

* docs(jellyfin): document lyrics support and follow-ups in README

* refactor(jellyfin): promote embedded-lyrics sentinel check to MediaFile

The "[]" no-lyrics sentinel is persistence-layer knowledge; expose it as
model.MediaFile.HasEmbeddedLyrics() instead of a dto-local helper. Also
dedupe the test lyrics-cache construction and pre-size the media stream
slice.

* refactor(jellyfin): consolidate tick conversions around one constant

ticksPerMillis is now the single source of the 100ns-tick unit; the
scrobble handlers' three inline /10_000 divisions become
dto.MillisFromTicks.

* fix(jellyfin): align lyric advertising with the serving predicate

PlaybackInfo advertised on any non-empty LyricList while the endpoint
404s when the main lyric has no lines; both now share servableLyric.
Handler tests also send hex-encoded ids to match real traffic.

* chore(jellyfin): drop unneeded lyrics package alias in e2e suite
2026-07-16 14:39:11 -04:00

175 lines
6.3 KiB
Go

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