mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-30 15:07:56 +01:00
feat(jellyfin): implement /socket WebSocket for real-time client sessions
This commit is contained in:
parent
3a155e23e8
commit
da51d3f1c6
3 changed files with 185 additions and 0 deletions
|
|
@ -100,6 +100,10 @@ func (api *Router) routes() http.Handler {
|
|||
})
|
||||
r.Post("/Sessions/Capabilities", api.postCapabilities)
|
||||
r.Post("/Sessions/Capabilities/Full", api.postCapabilities)
|
||||
|
||||
// Real-time clients (e.g. Finamp) open this right after login; without it they
|
||||
// 404-loop-reconnect instead of settling into a working session.
|
||||
r.Get("/socket", api.handleSocket)
|
||||
})
|
||||
|
||||
// Logged at Debug (not Warn/Error) because a real client probing for optional/legacy
|
||||
|
|
|
|||
56
server/jellyfin/socket.go
Normal file
56
server/jellyfin/socket.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package jellyfin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// socketKeepAliveInterval is sent to the client in the initial ForceKeepAlive message, telling it
|
||||
// how often (in seconds) it must send a KeepAlive, and used locally to bound the read deadline.
|
||||
const socketKeepAliveInterval = 60
|
||||
|
||||
// socketReadTimeout is generous relative to socketKeepAliveInterval so a single delayed
|
||||
// KeepAlive doesn't drop the connection.
|
||||
const socketReadTimeout = 90 * time.Second
|
||||
|
||||
var socketUpgrader = websocket.Upgrader{
|
||||
// Finamp (and other Jellyfin clients) are not browsers, so there's no cross-origin risk
|
||||
// to guard against here; the connection is already authenticated via api_key.
|
||||
CheckOrigin: func(*http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// handleSocket implements Jellyfin's /socket WebSocket endpoint. Real-time clients like Finamp
|
||||
// open this right after login; without it they 404-loop-reconnect instead of ever settling into
|
||||
// a working session. This is a minimal implementation: it just keeps the connection alive and
|
||||
// answers KeepAlive pings, with no session/playstate push, which is enough to stop the loop.
|
||||
func (api *Router) handleSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := socketUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Warn(r.Context(), "Jellyfin API: WebSocket upgrade failed", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.WriteJSON(map[string]any{"MessageType": "ForceKeepAlive", "Data": socketKeepAliveInterval}); err != nil {
|
||||
log.Warn(r.Context(), "Jellyfin API: WebSocket failed to send ForceKeepAlive", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(socketReadTimeout))
|
||||
var msg struct {
|
||||
MessageType string `json:"MessageType"`
|
||||
}
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
if msg.MessageType == "KeepAlive" {
|
||||
if err := conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
125
server/jellyfin/socket_test.go
Normal file
125
server/jellyfin/socket_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
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)
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue