mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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:
parent
c582ed31fa
commit
29f481cd7b
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
71
server/jellyfin/e2e/lyrics_test.go
Normal file
71
server/jellyfin/e2e/lyrics_test.go
Normal 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
47
server/jellyfin/lyrics.go
Normal 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()
|
||||
}
|
||||
132
server/jellyfin/lyrics_test.go
Normal file
132
server/jellyfin/lyrics_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user