* 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 [].
83 lines
3.4 KiB
Go
83 lines
3.4 KiB
Go
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)
|
|
}
|
|
}
|