* 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 [].
160 lines
5.3 KiB
Go
160 lines
5.3 KiB
Go
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")
|
|
}
|
|
}
|