feat(jellycompat): add Filters2, LocalTrailers, UserImage, ClientLog and Sessions endpoints (#164)

* feat(jellycompat): add Filters2, LocalTrailers, UserImage and ClientLog endpoints

Four endpoints that real Jellyfin clients call were unregistered and fell
through to chi's default 404 (or, for Filters2, were swallowed by /Items/{id}).
All are additive and contract-faithful to the Jellyfin C# server:

- GET /Items/Filters2 -> 200 QueryFilters v2 shape (Genres NameGuidPair[],
  Tags, Audio/SubtitleLanguages), empty arrays. Fladder's filter UI 404'd before.
- GET /Items/{id}/LocalTrailers (+ /Users/{userId}/... alias) -> 200 bare
  BaseItemDto[] ([]); Silo indexes no local trailers. Infuse/Moonfin hit this
  on every item-detail load.
- GET|HEAD /UserImage?userId= -> the same anonymous palette avatar as the
  legacy /Users/{id}/Images/Primary route; HandleUserImage now reads the id
  from the query param when the path segment is absent (modern Jellyfin route).
- POST /ClientLog/Document -> 200 {FileName} after draining/discarding the
  body (Silo has no client-log store); 413 over 1 MiB, matching MaxDocumentSize.

Stops recurring 404 noise and lets clients that depend on these (filter sheets,
avatars, crash-log upload) work. Adds handler unit tests for each.

* feat(jellycompat): add GET /Sessions returning a contract-shaped session list

Wholphin and other jellyfin-sdk clients poll GET /Sessions (optionally
?deviceId=) every few seconds during playback; the route was unregistered, so
each poll hit a chi 404 the SDK could not deserialize — a ~289-per-4h 404 storm
in production. Register it under the same [Authorize] group Jellyfin uses and
return a correctly-typed SessionInfoDto[] (currently empty, consistent with the
existing compat stub handlers). This stops the storm and lets clients degrade
cleanly; populating live session/now-playing state from the playback store is a
follow-up.

* refactor(jellycompat): match Jellyfin client-log size limit exactly

Use 1,000,000 bytes (Jellyfin's ClientLogController.MaxDocumentSize, decimal)
instead of 1<<20, and fix the comment that wrongly called it 1 MiB. Behavior is
functionally identical (the body is discarded); this is contract-fidelity only.
Review follow-up.

* test(jellycompat): add router-level coverage for the new endpoints

The per-handler tests call handlers directly and never exercise NewRouter, so
route registration, chi static-vs-{id} ordering, and auth-group placement were
untested — the one thing this change is actually about. Add a full
NewRouter/ServeHTTP test asserting the session-auth-group routes (Filters2,
LocalTrailers x2, Sessions, ClientLog/Document) return 401 unauthenticated
(registered + behind auth, not 404 or accidentally anonymous), /UserImage serves
its anonymous palette avatar, and an authenticated Filters2 reaches the v2
filters handler (not shadowed by /Items/{id}) with /Sessions returning [].
This commit is contained in:
d3v1l1989
2026-06-16 17:51:00 -04:00
committed by GitHub
parent 3f3cf55002
commit c81de3459e
8 changed files with 375 additions and 0 deletions
+24
View File
@@ -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"`
@@ -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"})
}
+7
View File
@@ -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")
+30
View File
@@ -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())
@@ -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)
}
}
@@ -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")
}
}
+29
View File
@@ -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{})
}
+9
View File
@@ -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)
})
}