mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* 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
126 lines
4.1 KiB
Go
126 lines
4.1 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("handleSocket", func() {
|
|
var api *Router
|
|
|
|
BeforeEach(func() {
|
|
api = &Router{}
|
|
})
|
|
|
|
// Jellyfin's real-time clients (e.g. Finamp) open a WebSocket right after login; without
|
|
// a working handshake here they 404-loop-reconnect instead of settling into a session.
|
|
It("upgrades the connection and sends ForceKeepAlive", func() {
|
|
srv := httptest.NewServer(http.HandlerFunc(api.handleSocket))
|
|
defer srv.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer conn.Close()
|
|
|
|
Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed())
|
|
var msg map[string]any
|
|
Expect(conn.ReadJSON(&msg)).To(Succeed())
|
|
Expect(msg["MessageType"]).To(Equal("ForceKeepAlive"))
|
|
Expect(msg["Data"]).To(BeNumerically("==", 60))
|
|
})
|
|
|
|
It("replies to a KeepAlive message with a KeepAlive of its own", func() {
|
|
srv := httptest.NewServer(http.HandlerFunc(api.handleSocket))
|
|
defer srv.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer conn.Close()
|
|
|
|
Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed())
|
|
var handshake map[string]any
|
|
Expect(conn.ReadJSON(&handshake)).To(Succeed())
|
|
Expect(handshake["MessageType"]).To(Equal("ForceKeepAlive"))
|
|
|
|
Expect(conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"})).To(Succeed())
|
|
|
|
Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed())
|
|
var reply map[string]any
|
|
Expect(conn.ReadJSON(&reply)).To(Succeed())
|
|
Expect(reply["MessageType"]).To(Equal("KeepAlive"))
|
|
})
|
|
|
|
It("closes the connection when the client disconnects, without leaving the handler hanging", func() {
|
|
srv := httptest.NewServer(http.HandlerFunc(api.handleSocket))
|
|
defer srv.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed())
|
|
var handshake map[string]any
|
|
Expect(conn.ReadJSON(&handshake)).To(Succeed())
|
|
|
|
Expect(conn.Close()).To(Succeed())
|
|
})
|
|
|
|
// End-to-end: proves /socket is reachable through the full router (case-insensitive
|
|
// wrapper + chi mux + auth middleware) with a real network listener, exactly as Finamp
|
|
// hits it in production with ?api_key=<jwt>.
|
|
Context("mounted behind the full router and auth middleware", func() {
|
|
var ds *tests.MockDataStore
|
|
var token string
|
|
|
|
BeforeEach(func() {
|
|
ds = &tests.MockDataStore{}
|
|
auth.Init(ds)
|
|
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
|
|
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed())
|
|
|
|
t, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
token = t
|
|
|
|
api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
|
})
|
|
|
|
It("upgrades when authenticated via the api_key query parameter", func() {
|
|
srv := httptest.NewServer(api)
|
|
defer srv.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket?api_key=" + token
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer conn.Close()
|
|
|
|
Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed())
|
|
var msg map[string]any
|
|
Expect(conn.ReadJSON(&msg)).To(Succeed())
|
|
Expect(msg["MessageType"]).To(Equal("ForceKeepAlive"))
|
|
})
|
|
|
|
It("rejects the upgrade with no api_key", func() {
|
|
srv := httptest.NewServer(api)
|
|
defer srv.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket"
|
|
_, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized))
|
|
})
|
|
})
|
|
})
|