diff --git a/internal/jellycompat/dto.go b/internal/jellycompat/dto.go index c06a725c..aa8bc35f 100644 --- a/internal/jellycompat/dto.go +++ b/internal/jellycompat/dto.go @@ -17,6 +17,30 @@ type themeMediaResultDTO struct { OwnerID string `json:"OwnerId"` } +// nameGuidPair mirrors Jellyfin's MediaBrowser.Model.Dto.NameGuidPair. +type nameGuidPair struct { + Name string `json:"Name"` + ID string `json:"Id"` +} + +// nameValuePair mirrors Jellyfin's MediaBrowser.Model.Dto.NameValuePair. +type nameValuePair struct { + Name string `json:"Name"` + Value string `json:"Value"` +} + +// queryFiltersDTO mirrors Jellyfin's MediaBrowser.Model.Querying.QueryFilters, +// the v2 (/Items/Filters2) shape. It differs from the legacy /Items/Filters +// result (QueryFiltersLegacy): Genres are NameGuidPair, and AudioLanguages / +// SubtitleLanguages replace OfficialRatings / Years. Every field defaults to an +// empty (non-nil) slice so the JSON is always arrays, never null. +type queryFiltersDTO struct { + Genres []nameGuidPair `json:"Genres"` + Tags []string `json:"Tags"` + AudioLanguages []nameValuePair `json:"AudioLanguages"` + SubtitleLanguages []nameValuePair `json:"SubtitleLanguages"` +} + type baseItemDTO struct { ServerID string `json:"ServerId,omitempty"` ID string `json:"Id"` diff --git a/internal/jellycompat/handlers_clientlog.go b/internal/jellycompat/handlers_clientlog.go new file mode 100644 index 00000000..c6cf8ca5 --- /dev/null +++ b/internal/jellycompat/handlers_clientlog.go @@ -0,0 +1,35 @@ +package jellycompat + +import ( + "io" + "net/http" +) + +// maxClientLogBytes bounds how much of a client log upload we read before +// discarding it, matching Jellyfin's ClientLogController.MaxDocumentSize +// (1,000,000 bytes). Anything larger is rejected with 413. +const maxClientLogBytes = 1_000_000 + +// clientLogDocumentResponse mirrors Jellyfin's ClientLogDocumentResponseDto. +// Some clients parse FileName from the 200 response, so it must be present. +type clientLogDocumentResponse struct { + FileName string `json:"FileName"` +} + +// HandleClientLogDocument accepts POST /ClientLog/Document. Clients (Jellyfin +// Android TV, Wholphin, Fire TV apps) upload crash/diagnostic bundles here; with +// no route they hit a chi 404 and "upload logs" silently fails. Silo has no +// client-log store, so the body is drained and discarded, but we answer 200 with +// a generated FileName to match Jellyfin's contract (it returns the stored file +// name, never 204). Oversized uploads get 413 like Jellyfin's MaxDocumentSize. +func HandleClientLogDocument(w http.ResponseWriter, r *http.Request) { + if r.ContentLength > maxClientLogBytes { + writeError(w, http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Client log document is too large") + return + } + // Drain (bounded) and discard: Silo does not persist client logs, but the + // body must be consumed so the client's upload completes cleanly. + _, _ = io.Copy(io.Discard, io.LimitReader(r.Body, maxClientLogBytes)) + + writeJSON(w, http.StatusOK, clientLogDocumentResponse{FileName: uuidNewString() + ".log"}) +} diff --git a/internal/jellycompat/handlers_images.go b/internal/jellycompat/handlers_images.go index 76658793..5a82002f 100644 --- a/internal/jellycompat/handlers_images.go +++ b/internal/jellycompat/handlers_images.go @@ -585,6 +585,13 @@ func parseRemoteImageURL(imageURL string) (*url.URL, error) { // varying-{id} flood would otherwise turn into a CPU DoS amplifier). func (h *ImagesHandler) HandleUserImage(w http.ResponseWriter, r *http.Request) { id := chiURLParam(r, "id") + if id == "" { + // The modern /UserImage route carries the id as a ?userId= query param + // rather than a path segment. Fall back to it so each user still hashes + // to a stable palette entry instead of every caller sharing the empty-id + // avatar. + id = firstNonEmpty(r.URL.Query().Get("userId"), r.URL.Query().Get("UserId")) + } w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "public, max-age=3600") diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index 48eafcdd..acabddf5 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -794,6 +794,36 @@ func (h *ItemsHandler) HandleFiltersStub(w http.ResponseWriter, r *http.Request) }) } +// HandleFilters2Stub serves GET /Items/Filters2, Jellyfin's v2 query-filters +// endpoint. Clients like Fladder call it to populate their filter UI; without a +// route it fell through to GET /Items/{id} and 404'd. Jellyfin returns a +// QueryFilters object whose fields default to empty arrays, so an empty (but +// correctly-shaped) result is contract-faithful and never blocks the UI. +func (h *ItemsHandler) HandleFilters2Stub(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, queryFiltersDTO{ + Genres: []nameGuidPair{}, + Tags: []string{}, + AudioLanguages: []nameValuePair{}, + SubtitleLanguages: []nameValuePair{}, + }) +} + +// HandleLocalTrailers serves GET /Items/{id}/LocalTrailers (and the legacy +// /Users/{userId}/Items/{id}/LocalTrailers alias). Jellyfin returns a bare +// BaseItemDto array — not the {Items,TotalRecordCount,StartIndex} envelope, so +// this cannot reuse HandleItemStub. Silo does not index local trailer files, so +// the result is always empty; returning [] matches Jellyfin's contract for an +// item with no local trailers and stops the chi 404 that clients (Infuse, +// Moonfin) otherwise hit on every item-detail load. +func (h *ItemsHandler) HandleLocalTrailers(w http.ResponseWriter, r *http.Request) { + session := SessionFromContext(r.Context()) + if session == nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") + return + } + writeJSON(w, http.StatusOK, []baseItemDTO{}) +} + // HandleLatest serves GET /Items/Latest. func (h *ItemsHandler) HandleLatest(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) diff --git a/internal/jellycompat/handlers_missing_endpoints_router_test.go b/internal/jellycompat/handlers_missing_endpoints_router_test.go new file mode 100644 index 00000000..4f93a3a0 --- /dev/null +++ b/internal/jellycompat/handlers_missing_endpoints_router_test.go @@ -0,0 +1,82 @@ +package jellycompat + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/config" +) + +// TestRouter_MissingEndpointsRegistered exercises the newly added routes through +// the full NewRouter/ServeHTTP stack — registration, chi static-vs-{id} ordering, +// and auth-group placement that the per-handler tests cannot see. Auth-group +// routes must answer 401 (registered + behind auth) rather than 404 (route +// dropped) or 2xx (accidentally anonymous); /UserImage must serve its anonymous +// avatar; and an authenticated Filters2/Sessions request must reach the right +// handler with the right shape. +func TestRouter_MissingEndpointsRegistered(t *testing.T) { + cfg, err := config.LoadFromDB(map[string]string{}) + if err != nil { + t.Fatalf("LoadFromDB: %v", err) + } + store := NewSessionStore(time.Hour, time.Now) + const token = "router-test-token" + if err := store.Put(Session{Token: token, StreamAppUserID: 1, ProfileID: "p1", PseudoUserID: PseudoUserID(1, "p1")}); err != nil { + t.Fatalf("seed session: %v", err) + } + router := NewRouter(Dependencies{Config: cfg, SessionStore: store}) + + // (1) Registration + auth placement: unauthenticated requests to the + // session-auth-group routes must be 401 (registered + behind auth), never 404 + // (route dropped/misordered) or 2xx (accidentally registered anonymous). + authGroup := []struct{ method, path string }{ + {http.MethodGet, "/Items/Filters2"}, + {http.MethodGet, "/Items/abc/LocalTrailers"}, + {http.MethodGet, "/Users/u1/Items/abc/LocalTrailers"}, + {http.MethodGet, "/Sessions"}, + {http.MethodPost, "/ClientLog/Document"}, + } + for _, tc := range authGroup { + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("unauth %s %s = %d, want 401 (registered + behind auth)", tc.method, tc.path, rec.Code) + } + } + + // (2) /UserImage is anonymous-by-design (PR #158 palette): serves with no auth. + { + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/UserImage?userId=router-test", nil)) + if rec.Code != http.StatusOK { + t.Errorf("GET /UserImage = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "image/png" { + t.Errorf("GET /UserImage Content-Type = %q, want image/png", ct) + } + } + + // (3) Authenticated routing/shape: prove /Items/Filters2 reaches the v2 + // filters handler (not shadowed by /Items/{id}, which would 404 "Item not + // found") and /Sessions returns the empty list. + authed := func(method, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + req.Header.Set("X-Emby-Token", token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + if rec := authed(http.MethodGet, "/Items/Filters2"); rec.Code != http.StatusOK { + t.Errorf("authed GET /Items/Filters2 = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } else if !strings.Contains(rec.Body.String(), "AudioLanguages") { + t.Errorf("Filters2 not the v2 QueryFilters shape (no AudioLanguages); shadowed by /Items/{id}? body=%s", rec.Body.String()) + } + if rec := authed(http.MethodGet, "/Sessions"); rec.Code != http.StatusOK { + t.Errorf("authed GET /Sessions = %d, want 200", rec.Code) + } else if got := strings.TrimSpace(rec.Body.String()); got != "[]" { + t.Errorf("authed GET /Sessions body = %q, want []", got) + } +} diff --git a/internal/jellycompat/handlers_missing_endpoints_test.go b/internal/jellycompat/handlers_missing_endpoints_test.go new file mode 100644 index 00000000..68c0271f --- /dev/null +++ b/internal/jellycompat/handlers_missing_endpoints_test.go @@ -0,0 +1,159 @@ +package jellycompat + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestHandleFilters2Stub verifies /Items/Filters2 returns 200 with Jellyfin's +// v2 QueryFilters shape (every field an array, never null). +func TestHandleFilters2Stub(t *testing.T) { + h := &ItemsHandler{} + req := httptest.NewRequest(http.MethodGet, "/Items/Filters2?IncludeItemTypes=Movie&Recursive=true", nil) + rec := httptest.NewRecorder() + + h.HandleFilters2Stub(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, field := range []string{`"Genres":[]`, `"Tags":[]`, `"AudioLanguages":[]`, `"SubtitleLanguages":[]`} { + if !strings.Contains(body, field) { + t.Errorf("body missing %s: %s", field, body) + } + } + // Must not be the legacy QueryFiltersLegacy shape. + if strings.Contains(body, "OfficialRatings") || strings.Contains(body, "Years") { + t.Errorf("Filters2 returned legacy v1 fields: %s", body) + } + var dto queryFiltersDTO + if err := json.Unmarshal(rec.Body.Bytes(), &dto); err != nil { + t.Fatalf("response is not valid QueryFilters JSON: %v", err) + } +} + +// TestHandleLocalTrailers verifies the endpoint returns a bare empty array (not +// the {Items,...} envelope) for an authenticated request, and 401 without a +// session. +func TestHandleLocalTrailers(t *testing.T) { + h := &ItemsHandler{} + + t.Run("authenticated returns empty array", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Items/abc/LocalTrailers", nil) + req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{})) + rec := httptest.NewRecorder() + + h.HandleLocalTrailers(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := strings.TrimSpace(rec.Body.String()); got != "[]" { + t.Fatalf("body = %q, want bare empty array %q", got, "[]") + } + }) + + t.Run("missing session returns 401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Items/abc/LocalTrailers", nil) + rec := httptest.NewRecorder() + + h.HandleLocalTrailers(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + }) +} + +// TestHandleClientLogDocument verifies the upload endpoint drains the body and +// answers 200 with a FileName (Jellyfin's contract), and rejects oversized +// uploads with 413. +func TestHandleClientLogDocument(t *testing.T) { + t.Run("accepts and returns FileName", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/ClientLog/Document", + bytes.NewBufferString("type: crash_report\nclient: test\n")) + rec := httptest.NewRecorder() + + HandleClientLogDocument(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var resp clientLogDocumentResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid response JSON: %v", err) + } + if resp.FileName == "" { + t.Fatalf("FileName empty; clients parse it from the 200 body") + } + }) + + t.Run("rejects oversized upload with 413", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/ClientLog/Document", strings.NewReader("x")) + req.ContentLength = maxClientLogBytes + 1 + rec := httptest.NewRecorder() + + HandleClientLogDocument(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413", rec.Code) + } + }) +} + +// TestHandleSessions verifies GET /Sessions returns a 200 JSON array (not the +// chi 404 that broke client session polling). +func TestHandleSessions(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Sessions?deviceId=abc123", nil) + rec := httptest.NewRecorder() + + HandleSessions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := strings.TrimSpace(rec.Body.String()); got != "[]" { + t.Fatalf("body = %q, want JSON array %q", got, "[]") + } + var sessions []sessionInfoDTO + if err := json.Unmarshal(rec.Body.Bytes(), &sessions); err != nil { + t.Fatalf("response is not a SessionInfoDto array: %v", err) + } +} + +// TestHandleUserImageQueryFallback verifies /UserImage?userId= resolves the id +// from the query string (the path param is empty on this route) and serves the +// same deterministic palette avatar as the legacy path form — not the empty-id +// avatar every caller would otherwise share. +func TestHandleUserImageQueryFallback(t *testing.T) { + h := &ImagesHandler{} + const userID = "45def085-5dd8-5ad7-b972-9c7a499fa846" + + req := httptest.NewRequest(http.MethodGet, "/UserImage?userId="+userID, nil) + rec := httptest.NewRecorder() + + h.HandleUserImage(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "image/png" { + t.Fatalf("Content-Type = %q, want image/png", ct) + } + want := avatarPalette[avatarPaletteIndex(userID)] + if !bytes.Equal(rec.Body.Bytes(), want) { + t.Fatalf("served avatar does not match the userId-derived palette entry; query fallback not applied") + } + // Guard against regression to the empty-id avatar. + if empty := avatarPalette[avatarPaletteIndex("")]; bytes.Equal(want, empty) { + t.Skip("palette collision: userID and empty id hash to the same entry; pick another userID") + } else if bytes.Equal(rec.Body.Bytes(), empty) { + t.Fatalf("served the empty-id avatar; query fallback not applied") + } +} diff --git a/internal/jellycompat/handlers_sessions.go b/internal/jellycompat/handlers_sessions.go new file mode 100644 index 00000000..207e570f --- /dev/null +++ b/internal/jellycompat/handlers_sessions.go @@ -0,0 +1,29 @@ +package jellycompat + +import "net/http" + +// sessionInfoDTO is a minimal subset of Jellyfin's SessionInfoDto. It exists to +// give GET /Sessions a correctly-typed array element; the list is currently +// always empty (see HandleSessions), so only the contract shape matters. +type sessionInfoDTO struct { + ID string `json:"Id"` + UserID string `json:"UserId"` + UserName string `json:"UserName,omitempty"` + Client string `json:"Client,omitempty"` + DeviceID string `json:"DeviceId,omitempty"` + DeviceName string `json:"DeviceName,omitempty"` +} + +// HandleSessions serves GET /Sessions. Jellyfin returns the caller's visible +// sessions (SessionInfoDto[]); clients such as Wholphin poll it (optionally +// filtered by ?deviceId=) every few seconds while playing to read their own +// session state. The route was unregistered, so those polls hit a chi 404 that +// the jellyfin-sdk could not deserialize — observed as a ~289-per-4h 404 storm. +// +// Silo does not yet expose live session/now-playing state through the compat +// surface, so this returns a contract-shaped empty list: it stops the 404 storm +// and lets clients degrade cleanly. Populating it from the playback session +// store (so the now-playing/transcoding overlay works) is a follow-up. +func HandleSessions(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, []sessionInfoDTO{}) +} diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index 9095570a..86ef4998 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -121,6 +121,10 @@ func NewRouter(deps Dependencies) chi.Router { // than inside the session-auth group. r.Get("/Users/{id}/Images/Primary", imagesHandler.HandleUserImage) r.Method(http.MethodHead, "/Users/{id}/Images/Primary", http.HandlerFunc(imagesHandler.HandleUserImage)) + // Modern Jellyfin clients fetch the current user's avatar via /UserImage?userId= + // (the path form above is [Obsolete] upstream). Same anonymous palette handler. + r.Get("/UserImage", imagesHandler.HandleUserImage) + r.Method(http.MethodHead, "/UserImage", http.HandlerFunc(imagesHandler.HandleUserImage)) webHandler := http.StripPrefix("/web", newDynamicCompatWebHandler(deps)) r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/web/", http.StatusFound) @@ -146,6 +150,7 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/Users/{id}/Items", itemsHandler.HandleItems) r.Get("/Items/Latest", itemsHandler.HandleLatest) r.Get("/Items/Filters", itemsHandler.HandleFiltersStub) + r.Get("/Items/Filters2", itemsHandler.HandleFilters2Stub) r.Get("/Items/Suggestions", itemsHandler.HandleSuggestions) r.Get("/Users/{id}/Items/Latest", itemsHandler.HandleLatest) r.Get("/Items/{id}/Similar", itemsHandler.HandleSimilar) @@ -155,10 +160,12 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/Items/{id}/ThemeSongs", itemsHandler.HandleThemeSongsStub) r.Get("/Items/{id}/SpecialFeatures", itemsHandler.HandleItemStub) r.Get("/Items/{id}/Intros", itemsHandler.HandleItemStub) + r.Get("/Items/{id}/LocalTrailers", itemsHandler.HandleLocalTrailers) r.Get("/Users/{userId}/Items/{id}/ThemeMedia", itemsHandler.HandleItemStub) r.Get("/Users/{userId}/Items/{id}/ThemeSongs", itemsHandler.HandleThemeSongsStub) r.Get("/Users/{userId}/Items/{id}/SpecialFeatures", itemsHandler.HandleItemStub) r.Get("/Users/{userId}/Items/{id}/Intros", itemsHandler.HandleItemStub) + r.Get("/Users/{userId}/Items/{id}/LocalTrailers", itemsHandler.HandleLocalTrailers) r.Get("/Items/{id}", itemsHandler.HandleItem) r.Get("/Users/{userId}/Items/Resume", itemsHandler.HandleResume) r.Get("/Users/{userId}/Items/{id}", itemsHandler.HandleItem) @@ -196,6 +203,7 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/Studios", itemsHandler.HandleItemStub) r.Get("/Artists", itemsHandler.HandleItemStub) r.Get("/Movies/Recommendations", recsHandler.HandleRecommendations) + r.Get("/Sessions", HandleSessions) r.Post("/Sessions/Capabilities", playbackHandler.HandleCapabilitiesFull) r.Post("/Sessions/Capabilities/Full", playbackHandler.HandleCapabilitiesFull) r.Get("/Playback/BitrateTest", playbackHandler.HandleBitrateTest) @@ -208,6 +216,7 @@ func NewRouter(deps Dependencies) chi.Router { r.Post("/Sessions/Playing/Stopped", playbackHandler.HandleSessionPlayingStopped) r.Delete("/Videos/ActiveEncodings", playbackHandler.HandleDeleteActiveEncodings) r.Post("/Sessions/Logout", authHandler.HandleLogout) + r.Post("/ClientLog/Document", HandleClientLogDocument) r.Get("/socket", HandleSocket) }) }