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
This commit is contained in:
Deluan Quintão 2026-07-16 14:39:11 -04:00 committed by GitHub
parent c582ed31fa
commit 29f481cd7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 627 additions and 38 deletions

View File

@ -137,7 +137,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics)
return router
}

View File

@ -147,6 +147,12 @@ func (mf MediaFile) StructuredLyrics() (LyricList, error) {
return lyrics, nil
}
// HasEmbeddedLyrics reports whether the lyrics column holds any lyrics. It is never "" post-scan;
// no-lyrics is normalized to the "[]" sentinel, so string emptiness alone is meaningless.
func (mf MediaFile) HasEmbeddedLyrics() bool {
return mf.Lyrics != "" && mf.Lyrics != "[]"
}
// String is mainly used for debugging
func (mf MediaFile) String() string {
return mf.Path

View File

@ -604,6 +604,15 @@ var _ = Describe("MediaFile", func() {
})
var _ = DescribeTable("MediaFile.HasEmbeddedLyrics",
func(lyrics string, expected bool) {
Expect(MediaFile{Lyrics: lyrics}.HasEmbeddedLyrics()).To(Equal(expected))
},
Entry("empty string (never-scanned zero value)", "", false),
Entry(`the post-scan "[]" no-lyrics sentinel`, "[]", false),
Entry("a stored lyric list", `[{"lang":"eng","line":[{"value":"la"}]}]`, true),
)
var _ = Describe("MediaFile.Works", func() {
It("returns nil when there are no work tags", func() {
mf := MediaFile{}

View File

@ -338,11 +338,21 @@ make test PKG=./server/jellyfin/...
working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up
would broadcast real session/playstate and library-change events over it (via `server/events`),
mirroring Jellyfin's session messages.
- **No lyrics endpoint (follow-up).** `GET Audio/{id}/Lyrics` is unimplemented (404), but Finamp
and Jellify both request it. Navidrome already has line-synced lyrics, so a follow-up would serve
Jellyfin's `LyricsResponse` (`Lyrics: [{Text, Start}]`, `Start` in 100ns ticks) — enough for both
clients' synced view. (Finamp also renders word-level `Cues`, but Navidrome has only line-level
timing, so word-sync is out of scope.)
- **Lyrics.** `GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in
100ns ticks, word-level `Cues` when present), resolved through the full `core/lyrics` pipeline
(embedded, `.lrc` sidecars, plugins per `LyricsPriority`) behind a 5-minute TTL cache that also
caches misses — Jellify fetches for every played track, Feishin per song change, so lyric-less
tracks are the hot path. No lyrics → 404 (never an empty 200), which all three clients degrade
gracefully. Finamp gates its lyrics view on a `Lyric` `MediaStream` (not `HasLyrics`, which is
just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel
check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per
track so sidecar/plugin lyrics also light up. Feishin additionally requires server version
≥ 10.9 — the reason `jellyfinVersion` is 10.9.11.
Follow-ups: the lyrics cache loader is not singleflighted, so concurrent misses on the same
track can double-invoke the plugin pipeline (fix belongs in `utils/cache.SimpleCache` via
ttlcache's `SuppressedLoader`, affecting all callers — separate change); tracks whose only
lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in lists (request-time sources
can't be known at list time without per-row I/O).
- **No sonic similarity (follow-up).** `Items/{id}/InstantMix` and the `/Similar` endpoints are
backed only by external metadata agents (Last.fm), not sonic analysis: an instant mix is the seed
track followed by the provider's similar songs (with agents disabled it degrades to a seed-only

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
@ -13,6 +14,7 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
@ -21,6 +23,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/cache"
)
type Router struct {
@ -34,6 +37,8 @@ type Router struct {
playlists playlists.Playlists
provider external.Provider
sonic sonic.Engine
lyrics lyrics.Lyrics
lyricsCache cache.SimpleCache[string, model.LyricList]
similarFlight singleflight.Group
serverIDMu sync.Mutex
serverIDVal string
@ -42,11 +47,15 @@ type Router struct {
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer,
transcodeDecider stream.TranscodeDecider, players core.Players,
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider,
sonicSvc sonic.Engine) *Router {
sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics) *Router {
r := &Router{
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
players: players, scrobbler: scrobbler, playlists: playlists, provider: provider,
sonic: sonicSvc,
sonic: sonicSvc, lyrics: lyricsSvc,
lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{
SizeLimit: 1000,
DefaultTTL: 5 * time.Minute,
}),
}
r.Handler = r.routes()
return r
@ -155,6 +164,7 @@ func (api *Router) routes() http.Handler {
r.Get("/audio/{itemId}/main.m3u8", api.streamHls)
r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
r.Get("/audio/{itemId}/lyrics", api.getLyrics)
// Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of
// /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file.
r.Get("/items/{itemId}/file", api.streamFile)

View File

@ -18,7 +18,7 @@ import (
var _ = Describe("Router", func() {
It("serves the public handshake through the mounted handler", func() {
ds := &tests.MockDataStore{}
api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil)
api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
@ -26,7 +26,7 @@ var _ = Describe("Router", func() {
})
It("returns 404 JSON for unknown routes", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Nonexistent/Route", nil)
api.ServeHTTP(w, r)
@ -36,7 +36,7 @@ var _ = Describe("Router", func() {
})
It("returns 404 JSON for a known path with an unsupported method", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("PATCH", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
@ -53,7 +53,7 @@ var _ = Describe("Router", func() {
Expect(err).ToNot(HaveOccurred())
fp := &fakePlayers{}
api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil)
api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Users/Me", nil)
@ -70,7 +70,7 @@ var _ = Describe("Router", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 2
conf.Server.AuthWindowLength = time.Minute
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
login := func() int {
w := httptest.NewRecorder()

View File

@ -256,3 +256,32 @@ type PlaybackInfoResponse struct {
MediaSources []MediaSourceInfo `json:"MediaSources"`
PlaySessionId string `json:"PlaySessionId"`
}
// LyricDto mirrors Jellyfin's GET /Audio/{itemId}/Lyrics response. Feishin and Jellify read only
// Lyrics[].Text and Start (ticks); Metadata is for completeness/Finamp.
type LyricDto struct {
Metadata LyricMetadata `json:"Metadata"`
Lyrics []LyricLine `json:"Lyrics"`
}
type LyricMetadata struct {
Artist string `json:"Artist,omitempty"`
Album string `json:"Album,omitempty"`
Title string `json:"Title,omitempty"`
Length int64 `json:"Length,omitempty"`
Offset *int64 `json:"Offset,omitempty"`
IsSynced bool `json:"IsSynced"`
}
type LyricLine struct {
Text string `json:"Text"`
Start *int64 `json:"Start,omitempty"`
Cues []LyricLineCue `json:"Cues,omitempty"`
}
type LyricLineCue struct {
Position int `json:"Position"`
EndPosition int `json:"EndPosition"`
Start int64 `json:"Start"`
End *int64 `json:"End,omitempty"`
}

View File

@ -8,7 +8,16 @@ import (
"github.com/navidrome/navidrome/model"
)
func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) }
// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond.
const ticksPerMillis = 10_000
func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1000 * ticksPerMillis) }
// TicksFromMillis converts milliseconds (Navidrome lyric timestamps) to ticks.
func TicksFromMillis(ms int64) int64 { return ms * ticksPerMillis }
// MillisFromTicks converts ticks (client-reported playback positions) to milliseconds.
func MillisFromTicks(ticks int64) int64 { return ticks / ticksPerMillis }
// premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601
// PremiereDate clients parse, falling back to year; nil when neither exists.
@ -59,6 +68,20 @@ func channelLayout(n int) string {
// Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo
// responses (Finamp's download dialog reads MediaSources[0].Size from the browse response).
func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo {
streams := make([]MediaStream, 1, 2)
streams[0] = MediaStream{
Type: "Audio",
Index: 0,
Codec: mf.Codec,
BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps.
Channels: mf.Channels,
SampleRate: mf.SampleRate,
ChannelLayout: channelLayout(mf.Channels),
}
// Finamp gates its lyrics view on a Lyric stream in PlaybackInfo, not on HasLyrics.
if mf.HasEmbeddedLyrics() {
streams = append(streams, MediaStream{Type: "Lyric", Index: 1, IsExternal: true})
}
return MediaSourceInfo{
Id: EncodeID(mf.ID),
Protocol: "Http",
@ -73,17 +96,9 @@ func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo {
SupportsTranscoding: true,
IsRemote: false,
SupportsProbing: true,
MediaStreams: []MediaStream{{
Type: "Audio",
Index: 0,
Codec: mf.Codec,
BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps.
Channels: mf.Channels,
SampleRate: mf.SampleRate,
ChannelLayout: channelLayout(mf.Channels),
}},
MediaAttachments: []any{},
Formats: []string{},
MediaStreams: streams,
MediaAttachments: []any{},
Formats: []string{},
}
}
@ -119,7 +134,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
MediaType: "Audio",
IsFolder: false,
LocationType: "FileSystem",
HasLyrics: mf.Lyrics != "",
HasLyrics: mf.HasEmbeddedLyrics(),
ParentId: EncodeID(mf.AlbumID),
Album: mf.Album,
AlbumId: EncodeID(mf.AlbumID),
@ -254,3 +269,49 @@ func PlaylistToBaseItem(p model.Playlist) BaseItemDto {
UserData: UserData(p.Annotations, p.ID),
}
}
// LyricDtoFromLyrics maps one lyric track to Jellyfin's LyricDto. Clients infer synced-vs-plain
// from per-line Start presence, so synced drops start-less lines and unsynced never emits Start.
func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto {
d := LyricDto{
Metadata: LyricMetadata{
Artist: cmp.Or(lyrics.DisplayArtist, mf.Artist),
Album: mf.Album,
Title: cmp.Or(lyrics.DisplayTitle, mf.Title),
Length: TicksFromSeconds(mf.Duration),
IsSynced: lyrics.Synced,
},
Lyrics: make([]LyricLine, 0, len(lyrics.Line)),
}
if lyrics.Offset != nil {
offset := TicksFromMillis(*lyrics.Offset)
d.Metadata.Offset = &offset
}
for _, line := range lyrics.Line {
out := LyricLine{Text: line.Value}
if lyrics.Synced {
if line.Start == nil {
continue
}
start := TicksFromMillis(*line.Start)
out.Start = &start
for _, cue := range line.Cue {
if cue.Start == nil {
continue
}
c := LyricLineCue{
Position: cue.ByteStart,
EndPosition: cue.ByteEnd,
Start: TicksFromMillis(*cue.Start),
}
if cue.End != nil {
end := TicksFromMillis(*cue.End)
c.End = &end
}
out.Cues = append(out.Cues, c)
}
}
d.Lyrics = append(d.Lyrics, out)
}
return d
}

View File

@ -39,7 +39,7 @@ var _ = Describe("mappers", func() {
Describe("Fields gating (matches real Jellyfin)", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60,
SortTitle: "sort song", Lyrics: `[{"line":"la"}]`}
SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`}
It("omits MediaSources and SortName when Fields does not ask for them", func() {
item := SongToBaseItem(mf, nil)
@ -60,6 +60,8 @@ var _ = Describe("mappers", func() {
It("sets HasLyrics from the media file's lyrics", func() {
Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue())
Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse())
// "[]" is the no-lyrics sentinel, not a truthy value.
Expect(SongToBaseItem(model.MediaFile{ID: "s3", Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse())
})
})
@ -149,6 +151,29 @@ var _ = Describe("mappers", func() {
Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`))
})
Describe("Lyric media stream advertising", func() {
It("adds a Lyric media stream when the file has embedded lyrics", func() {
mf := model.MediaFile{ID: "s1", Lyrics: `[{"line":[{"value":"la"}]}]`}
src := MediaSourceFromMediaFile(mf)
Expect(src.MediaStreams).To(HaveLen(2))
Expect(src.MediaStreams[0].Type).To(Equal("Audio"))
Expect(src.MediaStreams[1].Type).To(Equal("Lyric"))
Expect(src.MediaStreams[1].Index).To(Equal(1))
Expect(src.MediaStreams[1].IsExternal).To(BeTrue())
})
It("emits only the Audio stream without lyrics", func() {
src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1"})
Expect(src.MediaStreams).To(HaveLen(1))
Expect(src.MediaStreams[0].Type).To(Equal("Audio"))
})
It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() {
src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1", Lyrics: "[]"})
Expect(src.MediaStreams).To(HaveLen(1))
})
})
It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() {
mf := model.MediaFile{
ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1",
@ -278,3 +303,90 @@ var _ = Describe("mappers", func() {
Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags))
})
})
var _ = Describe("LyricDtoFromLyrics", func() {
ms := func(v int64) *int64 { return &v }
mf := model.MediaFile{ID: "s1", Title: "Song", Artist: "Artist", Album: "Album", Duration: 100}
It("maps synced lyrics with tick conversion", func() {
l := model.Lyrics{
DisplayArtist: "Display Artist",
DisplayTitle: "Display Title",
Synced: true,
Offset: ms(-150),
Line: []model.Line{
{Start: ms(1000), Value: "line one"},
{Start: ms(2500), Value: "line two"},
},
}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Metadata.Artist).To(Equal("Display Artist"))
Expect(d.Metadata.Title).To(Equal("Display Title"))
Expect(d.Metadata.Album).To(Equal("Album"))
Expect(d.Metadata.IsSynced).To(BeTrue())
Expect(*d.Metadata.Offset).To(Equal(int64(-1_500_000)))
Expect(d.Metadata.Length).To(Equal(TicksFromSeconds(100)))
Expect(d.Lyrics).To(HaveLen(2))
Expect(d.Lyrics[0].Text).To(Equal("line one"))
Expect(*d.Lyrics[0].Start).To(Equal(int64(10_000_000)))
Expect(*d.Lyrics[1].Start).To(Equal(int64(25_000_000)))
})
It("falls back to the media file's artist and title", func() {
d := LyricDtoFromLyrics(mf, model.Lyrics{Line: []model.Line{{Value: "x"}}})
Expect(d.Metadata.Artist).To(Equal("Artist"))
Expect(d.Metadata.Title).To(Equal("Song"))
})
It("drops start-less lines from synced lyrics", func() {
l := model.Lyrics{Synced: true, Line: []model.Line{
{Start: ms(0), Value: "kept"},
{Value: "dropped"},
}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics).To(HaveLen(1))
Expect(d.Lyrics[0].Text).To(Equal("kept"))
})
It("emits no Start on unsynced lyrics even when lines have one", func() {
l := model.Lyrics{Synced: false, Line: []model.Line{{Start: ms(1000), Value: "plain"}}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics).To(HaveLen(1))
Expect(d.Lyrics[0].Start).To(BeNil())
Expect(d.Metadata.IsSynced).To(BeFalse())
})
It("maps word cues", func() {
end := int64(1500)
l := model.Lyrics{Synced: true, Line: []model.Line{{
Start: ms(1000),
Value: "word cue",
Cue: []model.Cue{{Start: ms(1000), End: &end, Value: "word", ByteStart: 0, ByteEnd: 4}},
}}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics[0].Cues).To(HaveLen(1))
c := d.Lyrics[0].Cues[0]
Expect(c.Position).To(Equal(0))
Expect(c.EndPosition).To(Equal(4))
Expect(c.Start).To(Equal(int64(10_000_000)))
Expect(*c.End).To(Equal(int64(15_000_000)))
})
It("skips a start-less cue while keeping its sibling", func() {
l := model.Lyrics{Synced: true, Line: []model.Line{{
Start: ms(1000),
Value: "word cue",
Cue: []model.Cue{
{Start: nil, Value: "dropped", ByteStart: 0, ByteEnd: 7},
{Start: ms(1000), Value: "kept", ByteStart: 8, ByteEnd: 12},
},
}}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics[0].Cues).To(HaveLen(1))
c := d.Lyrics[0].Cues[0]
Expect(c.Position).To(Equal(8))
Expect(c.EndPosition).To(Equal(12))
Expect(c.Start).To(Equal(int64(10_000_000)))
})
})

View File

@ -42,6 +42,7 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
@ -119,9 +120,11 @@ func buildTestFS() storagetest.FakeFS {
"Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")),
"Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")),
"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")),
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
"Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")),
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{
"lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold",
})),
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
"Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")),
// "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a
// performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists.
"Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})),
@ -325,6 +328,7 @@ func setupTestDB() {
playlists.NewPlaylists(ds, core.NewImageUploadService()),
providerFake,
sonicSvc,
lyrics.NewLyrics(ds, nil),
)
}

View File

@ -0,0 +1,71 @@
package e2e
import (
"net/http"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Lyrics", func() {
BeforeEach(func() { setupTestDB() })
Describe("PlaybackInfo", func() {
It("advertises a Lyric stream for a track with embedded lyrics", func() {
id := songID("Stairway To Heaven")
var info dto.PlaybackInfoResponse
parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info)
var found bool
for _, s := range info.MediaSources[0].MediaStreams {
if s.Type == "Lyric" {
found = true
}
}
Expect(found).To(BeTrue())
})
It("does not advertise a Lyric stream for a track without lyrics", func() {
id := songID("So What")
var info dto.PlaybackInfoResponse
parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info)
for _, s := range info.MediaSources[0].MediaStreams {
Expect(s.Type).ToNot(Equal("Lyric"))
}
})
})
Describe("GET /Audio/{id}/Lyrics", func() {
It("returns the LyricDto for a track with embedded synced lyrics", func() {
id := songID("Stairway To Heaven")
var lyrics dto.LyricDto
parseInto(get("/Audio/"+enc(id)+"/Lyrics"), &lyrics)
Expect(lyrics.Lyrics).To(HaveLen(2))
Expect(lyrics.Lyrics[0].Text).To(Equal("There's a lady who's sure"))
Expect(lyrics.Lyrics[0].Start).ToNot(BeNil())
Expect(*lyrics.Lyrics[0].Start).To(Equal(int64(10000000)))
Expect(lyrics.Metadata.IsSynced).To(BeTrue())
})
It("returns 404 for a track without lyrics", func() {
id := songID("So What")
Expect(get("/Audio/" + enc(id) + "/Lyrics").Code).To(Equal(http.StatusNotFound))
})
It("returns 404 for a fabricated id", func() {
Expect(get("/Audio/" + enc("nope") + "/Lyrics").Code).To(Equal(http.StatusNotFound))
})
})
Describe("HasLyrics badge", func() {
It("is true for a track with embedded lyrics and omitted/false otherwise", func() {
var stairway dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &stairway)
Expect(stairway.HasLyrics).To(BeTrue())
var soWhat dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("So What"))), &soWhat)
Expect(soWhat.HasLyrics).To(BeFalse())
})
})
})

47
server/jellyfin/lyrics.go Normal file
View File

@ -0,0 +1,47 @@
package jellyfin
import (
"context"
"net/http"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/jellyfin/dto"
)
// cachedLyrics resolves lyrics through the full source pipeline (embedded, sidecar, plugins),
// caching results — including empty: clients poll per played track, so misses are the hot path.
func (api *Router) cachedLyrics(ctx context.Context, mf *model.MediaFile) model.LyricList {
list, err := api.lyricsCache.GetWithLoader(mf.ID, func(string) (model.LyricList, time.Duration, error) {
l, err := api.lyrics.GetLyrics(ctx, mf)
return l, 0, err // 0 → cache DefaultTTL
})
if err != nil {
log.Error(ctx, "Error getting lyrics", "id", mf.ID, "title", mf.Title, err)
return nil
}
return list
}
// getLyrics serves GET /Audio/{itemId}/Lyrics. Jellyfin returns 404 when a track has no lyrics
// (never an empty 200); all surveyed clients treat that gracefully.
func (api *Router) getLyrics(w http.ResponseWriter, r *http.Request) {
mf, ok := api.mediaFileForRequest(w, r)
if !ok {
return
}
main, found := servableLyric(api.cachedLyrics(r.Context(), mf))
if !found {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
api.ok(w, r, dto.LyricDtoFromLyrics(*mf, main))
}
// servableLyric is the single predicate for both serving and advertising, so PlaybackInfo never
// advertises a Lyric stream that this endpoint would 404.
func servableLyric(list model.LyricList) (model.Lyrics, bool) {
main, found := list.Main()
return main, found && !main.IsEmpty()
}

View File

@ -0,0 +1,132 @@
package jellyfin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// fakeLyricsService returns canned lyrics per media-file ID and counts calls.
type fakeLyricsService struct {
lyrics map[string]model.LyricList
err error
calls int
}
func (f *fakeLyricsService) GetLyrics(_ context.Context, mf *model.MediaFile) (model.LyricList, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.lyrics[mf.ID], nil
}
func (f *fakeLyricsService) GetLyricsByArtistTitle(context.Context, string, string) (model.LyricList, error) {
return nil, nil
}
func p(ms int64) *int64 { return &ms }
func newTestLyricsCache() cache.SimpleCache[string, model.LyricList] {
return cache.NewSimpleCache[string, model.LyricList](cache.Options{SizeLimit: 1000})
}
var _ = Describe("getLyrics", func() {
var api *Router
var ds *tests.MockDataStore
var fake *fakeLyricsService
BeforeEach(func() {
ds = &tests.MockDataStore{}
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", LibraryID: 1},
{ID: "s2", Title: "Silent Song", LibraryID: 1},
})
fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}}
api = &Router{
ds: ds,
lyrics: fake,
lyricsCache: newTestLyricsCache(),
}
})
doRequest := func(id string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
ctx := request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})
// Clients send hex-encoded ids (matching real traffic and the other handler tests).
enc := dto.EncodeID(id)
r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx)
r = withChiURLParam(r, "itemId", enc)
invoke(api.getLyrics, w, r)
return w
}
It("returns 200 with a LyricDto for a track with synced lyrics", func() {
fake.lyrics["s1"] = model.LyricList{
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
w := doRequest("s1")
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.LyricDto
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Lyrics).To(HaveLen(1))
Expect(res.Lyrics[0].Text).To(Equal("hello"))
Expect(res.Lyrics[0].Start).ToNot(BeNil())
Expect(*res.Lyrics[0].Start).To(Equal(int64(10000000)))
})
It("serves the main-kind lyric when a translation is also present", func() {
fake.lyrics["s1"] = model.LyricList{
{Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}},
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
w := doRequest("s1")
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.LyricDto
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Lyrics).To(HaveLen(1))
Expect(res.Lyrics[0].Text).To(Equal("hello"))
})
It("returns 404 when the service returns no lyrics", func() {
w := doRequest("s2")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 404 when the main lyric has no lines", func() {
fake.lyrics["s1"] = model.LyricList{{Kind: "main", Lang: "eng"}}
w := doRequest("s1")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 404 for an unknown item id", func() {
w := doRequest("unknown")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("caches results so a second request doesn't re-invoke the service", func() {
fake.lyrics["s1"] = model.LyricList{
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
Expect(doRequest("s1").Code).To(Equal(http.StatusOK))
Expect(doRequest("s1").Code).To(Equal(http.StatusOK))
Expect(fake.calls).To(Equal(1))
})
It("caches empty results too", func() {
Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound))
Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound))
Expect(fake.calls).To(Equal(1))
})
})

View File

@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() {
var api *Router
BeforeEach(func() {
api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil)
api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("serves a fully lowercase path directly", func() {

View File

@ -49,7 +49,7 @@ func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) {
clientId, clientName := clientIdentity(ctx)
err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
MediaId: body.ItemId,
PositionMs: body.PositionTicks / 10_000,
PositionMs: dto.MillisFromTicks(body.PositionTicks),
State: scrobbler.StatePlaying,
PlaybackRate: 1.0,
ClientId: clientId,
@ -73,7 +73,7 @@ func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request
clientId, clientName := clientIdentity(ctx)
err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
MediaId: body.ItemId,
PositionMs: body.PositionTicks / 10_000,
PositionMs: dto.MillisFromTicks(body.PositionTicks),
State: state,
PlaybackRate: 1.0,
ClientId: clientId,
@ -97,7 +97,7 @@ func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request)
err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
MediaId: body.ItemId,
PositionMs: body.PositionTicks / 10_000,
PositionMs: dto.MillisFromTicks(body.PositionTicks),
State: scrobbler.StateStopped,
ClientId: clientId,
ClientName: clientName,

View File

@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() {
Expect(err).ToNot(HaveOccurred())
token = t
api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil)
api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("upgrades when authenticated via the api_key query parameter", func() {

View File

@ -5,6 +5,7 @@ import (
"math"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
@ -44,6 +45,15 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) {
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

View File

@ -33,7 +33,11 @@ var _ = Describe("Stream", func() {
ds = &tests.MockDataStore{}
streamer = &fakeMediaStreamer{}
decider = &fakeTranscodeDecider{}
api = &Router{ds: ds, streamer: streamer, transcodeDecider: decider}
api = &Router{
ds: ds, streamer: streamer, transcodeDecider: decider,
lyrics: &fakeLyricsService{lyrics: map[string]model.LyricList{}},
lyricsCache: newTestLyricsCache(),
}
})
Describe("getPlaybackInfo", func() {
@ -76,6 +80,88 @@ var _ = Describe("Stream", func() {
Expect(w.Code).To(Equal(http.StatusNotFound))
})
playbackInfo := func() dto.PlaybackInfoResponse {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("s1"))
api.getPlaybackInfo(w, r)
var res dto.PlaybackInfoResponse
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
return res
}
lyricStreams := func(res dto.PlaybackInfoResponse) []dto.MediaStream {
var out []dto.MediaStream
for _, s := range res.MediaSources[0].MediaStreams {
if s.Type == "Lyric" {
out = append(out, s)
}
}
return out
}
It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
})
api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{
"s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}},
}}
Expect(lyricStreams(playbackInfo())).To(HaveLen(1))
})
It("advertises no Lyric stream when the pipeline finds nothing", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
})
Expect(lyricStreams(playbackInfo())).To(BeEmpty())
})
It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
})
api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{
"s1": {{Kind: "main", Lang: "eng"}},
}}
Expect(lyricStreams(playbackInfo())).To(BeEmpty())
})
It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`},
})
api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{
"s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}},
}}
Expect(lyricStreams(playbackInfo())).To(HaveLen(1))
})
It("still returns 200 with a valid MediaSource and no Lyric stream when the lyrics pipeline errors", func() {
// Own ID: an erroring loader isn't cached, but a shared ID could still pick up
// another test's cached (non-error) result and mask this assertion.
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s-err", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1},
})
api.lyrics = &fakeLyricsService{err: errors.New("boom")}
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s-err")+"/PlaybackInfo", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("s-err"))
api.getPlaybackInfo(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.PlaybackInfoResponse
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.MediaSources).To(HaveLen(1))
Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s-err")))
Expect(lyricStreams(res)).To(BeEmpty())
})
})
Describe("streamAudio", func() {

View File

@ -17,8 +17,9 @@ import (
)
// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients feature-gate
// on it, so it must stay a real Jellyfin release, not Navidrome's own version.
const jellyfinVersion = "10.8.13"
// on it, so it must stay a real Jellyfin release, not Navidrome's own version. 10.9+ is required
// for Feishin to use the server lyrics endpoint.
const jellyfinVersion = "10.9.11"
func (api *Router) serverName() string {
if conf.Server.Jellyfin.ServerName != "" {