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:
@@ -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"})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user