From 8d9250707b8cc5d8d94634dade67dac4529fe37f Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:52:16 -0400 Subject: [PATCH 01/53] docs: design jellyfin autoscan scan compatibility --- ...24-jellyfin-autoscan-scan-compat-design.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-24-jellyfin-autoscan-scan-compat-design.md diff --git a/docs/superpowers/specs/2026-05-24-jellyfin-autoscan-scan-compat-design.md b/docs/superpowers/specs/2026-05-24-jellyfin-autoscan-scan-compat-design.md new file mode 100644 index 00000000..35117085 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-jellyfin-autoscan-scan-compat-design.md @@ -0,0 +1,166 @@ +# Jellyfin Autoscan Scan Compatibility Design + +## Goal + +Make Silo work with Autoscan's stock Jellyfin target. Autoscan should be able to +point at Silo's Jellyfin compatibility URL, use a Silo admin API key as the +Jellyfin token, discover Silo library roots, and notify Silo about changed media +paths without a custom script. + +This design is intentionally scoped to Jellyfin compatibility only. Emby routes +and aliases are out of scope. + +## Current State + +Silo already has a native admin scan API at `POST /api/v1/scan`. It accepts +either `library_id`, `path`, or both, resolves the target to a full-library, +subtree, or single-file scan, and dispatches through the existing scan queue or +scanner path. + +Silo's Jellyfin compatibility server currently supports enough read/playback +routes for Jellyfin clients, including `GET /System/Info` and +`GET /Library/VirtualFolders`, but it does not expose Jellyfin's scan notification +endpoint. `GET /Library/VirtualFolders` also returns empty `Locations`, which +prevents Autoscan from matching incoming paths to Jellyfin libraries. + +Autoscan's Jellyfin target uses this flow: + +1. `GET /System/Info` with `X-Emby-Token`. +2. `GET /Library/VirtualFolders` with `X-Emby-Token`. +3. `POST /Library/Media/Updated` with `X-Emby-Token` and a body shaped like: + + ```json + { + "Updates": [ + { + "path": "/media/tv/Show/Season 01/Episode.mkv", + "updateType": "Modified" + } + ] + } + ``` + +## Compatibility Surface + +Add a small Jellyfin scan compatibility adapter under `internal/jellycompat`. +The adapter should own Jellyfin scan/discovery semantics and translate them into +Silo's existing scan behavior. + +The first supported route set is: + +- `GET /System/Info`: already present, but should accept Silo admin API keys on + the Autoscan path. +- `GET /Library/VirtualFolders`: return enabled Silo libraries with real + configured root paths in `Locations`. +- `POST /Library/Media/Updated`: accept Autoscan update payloads and enqueue + equivalent Silo scans. + +Do not add Emby-specific routes such as `/emby/Library/SelectableMediaFolders` +or `/emby/Library/Media/Updated` in this pass. + +## Authentication + +For the Jellyfin scan/discovery routes needed by Autoscan, allow a Silo admin API +key (`sa_...`) in the token locations Autoscan uses: + +- `X-Emby-Token` +- `X-Mediabrowser-Token` +- `Authorization: Bearer` +- `api_key` query parameter + +The API key must resolve to an enabled Silo admin user. Non-admin API keys must +receive a non-2xx authorization error. Existing Jellyfin compatibility session +tokens should continue to work for normal Jellyfin client routes; this change +should not broadly weaken playback or browse authorization. + +## Library Discovery + +`GET /Library/VirtualFolders` should include `Locations` using the exact +server-side paths configured on each enabled Silo library. Autoscan appends a +trailing slash internally and compares incoming paths against these roots, so the +paths must be real filesystem paths as Silo sees them. + +Disabled libraries should be omitted from the Autoscan discovery response because +they are not valid scan targets. + +## Scan Notification Behavior + +`POST /Library/Media/Updated` should parse every `Updates[]` entry with a +non-empty `path`. The first pass ignores `updateType`; Autoscan sends +`Modified`, and Silo's existing path resolver determines the correct scan mode. + +Each update path should use the same effective target resolution as +`POST /api/v1/scan`: + +- A path equal to a configured library root becomes a full-library scan. +- A directory under a configured root becomes a subtree scan. +- A supported media file under a configured root becomes a file scan. +- Paths outside all libraries, missing paths, permission failures, special files, + disabled libraries, and unsupported file extensions are rejected. + +For requests containing multiple updates, resolution should be all-or-fail: +validate every update first, enqueue nothing if any update is invalid, and return +a non-2xx error. This avoids Autoscan seeing success while Silo silently drops +part of the request. + +When all updates are valid, enqueue each resolved scan independently and let the +existing scan queue deduplicate or serialize overlapping work. The compatibility +adapter should not implement a separate deduplication policy. + +The successful response can be `204 No Content`; Autoscan only requires a 2xx. + +## Component Boundaries + +Keep the compatibility layer small and explicit: + +- Add a Jellyfin scan handler in `internal/jellycompat` for + `Library/Media/Updated` and Autoscan-facing `VirtualFolders`. +- Share scan target resolution with the native scan API by extracting the + resolver/enqueue logic behind a small interface or helper. Avoid duplicating + path classification rules in two packages. +- Reuse the existing API key repository and user lookup logic for admin API key + validation rather than creating a Jellyfin-specific API key store. +- Continue routing normal playback, browse, and user-data Jellyfin endpoints + through the existing compat session authenticator. + +## Error Handling + +Return non-2xx responses for invalid scan notifications so Autoscan can treat the +target as failed: + +- `401 Unauthorized` for missing or invalid tokens. +- `403 Forbidden` for valid non-admin keys. +- `400 Bad Request` for malformed JSON, empty update lists, empty paths, paths + outside libraries, missing paths, unsupported files, and other validation + failures. +- `409 Conflict` for paths that map only to a disabled library. +- `503 Service Unavailable` if the scanner or scan queue is unavailable. +- `500 Internal Server Error` for unexpected repository or enqueue failures. + +The response body may use Silo's existing JSON error shape where practical. + +## Testing + +Add focused backend tests for this compatibility surface: + +- Admin API key auth is accepted by Autoscan routes. +- Non-admin or invalid keys are rejected. +- `GET /Library/VirtualFolders` includes enabled library `Locations`. +- `POST /Library/Media/Updated` maps a valid file or directory path into an + enqueued Silo scan. +- Multi-update requests are all-or-fail and do not enqueue partial scans when + one path is invalid. + +No frontend tests are needed. + +## Documentation + +Update `docs/scan-api.md` to explain that Autoscan can use its stock Jellyfin +target: + +- URL: Silo's Jellyfin compatibility URL, usually `http://host:8096`. +- Token: a Silo admin API key beginning with `sa_`. +- Paths: server-side paths as seen by Silo. + +Keep the custom script/webhook example as an alternative for users who do not +want to expose the Jellyfin compatibility endpoint. From e55a9b35e99767709df3a27f18d7bc67f616739d Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 19:04:56 -0400 Subject: [PATCH 02/53] refactor(scan): extract scan trigger resolver --- internal/scantrigger/scantrigger.go | 256 +++++++++++++++++++++++ internal/scantrigger/scantrigger_test.go | 137 ++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 internal/scantrigger/scantrigger.go create mode 100644 internal/scantrigger/scantrigger_test.go diff --git a/internal/scantrigger/scantrigger.go b/internal/scantrigger/scantrigger.go new file mode 100644 index 00000000..d6587de1 --- /dev/null +++ b/internal/scantrigger/scantrigger.go @@ -0,0 +1,256 @@ +package scantrigger + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scanner" +) + +const ( + ModeLibrary = "library" + ModeSubtree = "subtree" + ModeFile = "file" +) + +type FolderRepository interface { + GetByID(ctx context.Context, id int) (*models.MediaFolder, error) + List(ctx context.Context) ([]*models.MediaFolder, error) +} + +type Queuer interface { + EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error) +} + +type Request struct { + LibraryID *int + Path string + Trigger string +} + +type Target struct { + Folder *models.MediaFolder + LibraryID int + Mode string + Path string + Trigger string +} + +type RequestError struct { + Status int + Code string + Message string +} + +func (e *RequestError) Error() string { + return e.Message +} + +type Resolver struct { + folders FolderRepository +} + +func NewResolver(folders FolderRepository) *Resolver { + return &Resolver{folders: folders} +} + +func (r *Resolver) ResolveAll(ctx context.Context, requests []Request) ([]Target, error) { + targets := make([]Target, 0, len(requests)) + for _, req := range requests { + target, err := r.Resolve(ctx, req) + if err != nil { + return nil, err + } + targets = append(targets, *target) + } + return targets, nil +} + +func (r *Resolver) Resolve(ctx context.Context, req Request) (*Target, error) { + if r == nil || r.folders == nil { + return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"} + } + if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" { + return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Either library_id or path is required"} + } + + var folder *models.MediaFolder + var err error + if req.LibraryID != nil { + folder, err = r.folders.GetByID(ctx, *req.LibraryID) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + return nil, &RequestError{Status: http.StatusNotFound, Code: "not_found", Message: "Library not found"} + } + return nil, fmt.Errorf("fetching library for scan: %w", err) + } + } + + trigger := strings.TrimSpace(req.Trigger) + if trigger == "" { + trigger = "manual" + } + if strings.TrimSpace(req.Path) == "" { + if folder != nil && !folder.Enabled { + return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"} + } + return &Target{Folder: folder, LibraryID: folder.ID, Mode: ModeLibrary, Trigger: trigger}, nil + } + + cleanPath := filepath.Clean(req.Path) + var matchedRoot string + if folder != nil { + matchedRoot, err = LongestMatchingRoot(cleanPath, folder.Paths) + if err != nil { + return nil, err + } + if matchedRoot == "" { + return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not belong to the specified library"} + } + } else { + folders, listErr := r.folders.List(ctx) + if listErr != nil { + return nil, fmt.Errorf("listing libraries for scan: %w", listErr) + } + folder, matchedRoot, err = MatchFolderForPath(cleanPath, folders) + if err != nil { + return nil, err + } + } + if folder != nil && !folder.Enabled { + return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"} + } + + mode, err := ClassifyPath(cleanPath, matchedRoot) + if err != nil { + return nil, err + } + if trigger == "manual" { + trigger = "path" + if req.LibraryID != nil { + trigger = "library_id_path" + } + } + + targetPath := cleanPath + if mode == ModeLibrary { + targetPath = "" + } + return &Target{Folder: folder, LibraryID: folder.ID, Mode: mode, Path: targetPath, Trigger: trigger}, nil +} + +func EnqueueAll(ctx context.Context, queue Queuer, targets []Target) error { + if queue == nil { + return &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"} + } + for _, target := range targets { + if _, err := queue.EnqueueScan(ctx, target.LibraryID, target.Mode, target.Path, target.Trigger); err != nil { + return fmt.Errorf("queueing library scan: %w", err) + } + } + return nil +} + +func LongestMatchingRoot(targetPath string, roots []string) (string, error) { + bestRoot := "" + bestLen := -1 + for _, root := range roots { + if !PathWithinRoot(targetPath, root) { + continue + } + cleanRoot := filepath.Clean(root) + rootLen := len(cleanRoot) + if rootLen > bestLen { + bestRoot = cleanRoot + bestLen = rootLen + } + } + return bestRoot, nil +} + +func MatchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) { + var bestFolder *models.MediaFolder + bestRoot := "" + bestLen := -1 + ambiguous := false + + for _, folder := range folders { + if folder == nil { + continue + } + root, err := LongestMatchingRoot(targetPath, folder.Paths) + if err != nil { + return nil, "", err + } + if root == "" { + continue + } + rootLen := len(root) + if rootLen > bestLen { + bestFolder = folder + bestRoot = root + bestLen = rootLen + ambiguous = false + continue + } + if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID { + ambiguous = true + } + } + + if ambiguous { + return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path matches multiple libraries"} + } + if bestFolder == nil { + return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "No library matches the given path"} + } + return bestFolder, bestRoot, nil +} + +func ClassifyPath(targetPath, matchedRoot string) (string, error) { + if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) { + return ModeLibrary, nil + } + + info, err := os.Stat(targetPath) + if err != nil { + switch { + case errors.Is(err, os.ErrNotExist): + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not exist"} + case errors.Is(err, os.ErrPermission): + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Permission denied for path"} + default: + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path could not be inspected"} + } + } + if info.IsDir() { + return ModeSubtree, nil + } + if !info.Mode().IsRegular() { + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path must be a file or directory"} + } + if !scanner.SupportsVideoFile(targetPath) { + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Unsupported media file extension"} + } + return ModeFile, nil +} + +func PathWithinRoot(targetPath, rootPath string) bool { + cleanTarget := filepath.Clean(targetPath) + cleanRoot := filepath.Clean(rootPath) + rel, err := filepath.Rel(cleanRoot, cleanTarget) + if err != nil { + return false + } + if rel == "." || rel == "" { + return true + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/internal/scantrigger/scantrigger_test.go b/internal/scantrigger/scantrigger_test.go new file mode 100644 index 00000000..dfb1b2e3 --- /dev/null +++ b/internal/scantrigger/scantrigger_test.go @@ -0,0 +1,137 @@ +package scantrigger + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakeFolderRepo struct { + folders []*models.MediaFolder +} + +func (r *fakeFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) { + for _, folder := range r.folders { + if folder.ID == id { + return folder, nil + } + } + return nil, catalog.ErrFolderNotFound +} + +func (r *fakeFolderRepo) List(context.Context) ([]*models.MediaFolder, error) { + return r.folders, nil +} + +func TestResolverClassifiesLibraryRoot(t *testing.T) { + root := t.TempDir() + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 7, + Name: "Movies", + Enabled: true, + Paths: []string{root}, + }}} + + target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: root}) + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if target.LibraryID != 7 || target.Mode != ModeLibrary || target.Path != "" { + t.Fatalf("unexpected target: %#v", target) + } +} + +func TestResolverClassifiesSubtree(t *testing.T) { + root := t.TempDir() + subtree := filepath.Join(root, "Show") + if err := os.Mkdir(subtree, 0o755); err != nil { + t.Fatal(err) + } + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 8, + Name: "TV", + Enabled: true, + Paths: []string{root}, + }}} + + target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: subtree}) + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if target.LibraryID != 8 || target.Mode != ModeSubtree || target.Path != filepath.Clean(subtree) { + t.Fatalf("unexpected target: %#v", target) + } +} + +func TestResolverClassifiesVideoFile(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "Movie (2024).mkv") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 9, + Name: "Movies", + Enabled: true, + Paths: []string{root}, + }}} + + target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: filePath}) + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if target.LibraryID != 9 || target.Mode != ModeFile || target.Path != filepath.Clean(filePath) { + t.Fatalf("unexpected target: %#v", target) + } +} + +func TestResolverRejectsDisabledLibrary(t *testing.T) { + root := t.TempDir() + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 10, + Name: "Disabled", + Enabled: false, + Paths: []string{root}, + }}} + + _, err := NewResolver(repo).Resolve(context.Background(), Request{Path: root}) + var reqErr *RequestError + if !errors.As(err, &reqErr) { + t.Fatalf("expected RequestError, got %T: %v", err, err) + } + if reqErr.Status != http.StatusConflict || reqErr.Code != "conflict" { + t.Fatalf("unexpected error: %#v", reqErr) + } +} + +func TestResolveAllIsAllOrFail(t *testing.T) { + root := t.TempDir() + valid := filepath.Join(root, "Movie.mkv") + if err := os.WriteFile(valid, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 11, + Name: "Movies", + Enabled: true, + Paths: []string{root}, + }}} + + _, err := NewResolver(repo).ResolveAll(context.Background(), []Request{ + {Path: valid}, + {Path: filepath.Join(root, "missing.mkv")}, + }) + var reqErr *RequestError + if !errors.As(err, &reqErr) { + t.Fatalf("expected RequestError, got %T: %v", err, err) + } + if reqErr.Message != "Path does not exist" { + t.Fatalf("unexpected error message: %q", reqErr.Message) + } +} From 97e9e9c10613587bff748aeeb5d403f2c831b43a Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 19:06:51 -0400 Subject: [PATCH 03/53] refactor(api): share scan target resolution --- internal/api/handlers/libraries.go | 302 ++++------------------------- 1 file changed, 33 insertions(+), 269 deletions(-) diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index 2bf90584..a0b0d080 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -32,6 +32,7 @@ import ( "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/plugins" "github.com/Silo-Server/silo-server/internal/scanner" + "github.com/Silo-Server/silo-server/internal/scantrigger" "github.com/Silo-Server/silo-server/internal/sections" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -570,10 +571,11 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ } } else { initialScanID := ulid.Make().String() - h.recordAcceptedScan(initialScanID, &resolvedScanTarget{ - folder: folder, - mode: scanModeLibrary, - trigger: "library_created", + h.recordAcceptedScan(initialScanID, &scantrigger.Target{ + Folder: folder, + LibraryID: folder.ID, + Mode: scantrigger.ModeLibrary, + Trigger: "library_created", }) h.runFolderScanAsync(initialScanID, folder, "library_created") } @@ -660,10 +662,11 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ } } else { updateScanID := ulid.Make().String() - h.recordAcceptedScan(updateScanID, &resolvedScanTarget{ - folder: folder, - mode: scanModeLibrary, - trigger: "library_paths_changed", + h.recordAcceptedScan(updateScanID, &scantrigger.Target{ + Folder: folder, + LibraryID: folder.ID, + Mode: scantrigger.ModeLibrary, + Trigger: "library_paths_changed", }) h.runFolderScanAsync(updateScanID, folder, "library_paths_changed") } @@ -791,29 +794,6 @@ func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http. writeJSON(w, http.StatusOK, resp) } -type scanMode string - -const ( - scanModeLibrary scanMode = "library" - scanModeSubtree scanMode = "subtree" - scanModeFile scanMode = "file" -) - -type resolvedScanTarget struct { - folder *models.MediaFolder - mode scanMode - path string - trigger string -} - -type scanRequestError struct { - status int - code string - message string -} - -func (e *scanRequestError) Error() string { return e.message } - // HandleScan handles POST /scan. It accepts either a library_id, a path, or both // and dispatches to full-library, subtree, or single-file scanning. func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) { @@ -823,11 +803,14 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) { return } - target, err := h.resolveScanTarget(r.Context(), req) + target, err := scantrigger.NewResolver(h.folderRepo).Resolve(r.Context(), scantrigger.Request{ + LibraryID: req.LibraryID, + Path: req.Path, + }) if err != nil { - var reqErr *scanRequestError + var reqErr *scantrigger.RequestError if errors.As(err, &reqErr) { - writeError(w, reqErr.status, reqErr.code, reqErr.message) + writeError(w, reqErr.Status, reqErr.Code, reqErr.Message) return } slog.Error("resolving scan target", "error", err) @@ -836,21 +819,21 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) { } if h.ScanQueue != nil { - if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.folder.ID, string(target.mode), target.path, target.trigger); err != nil { - slog.Error("queueing library scan", "library_id", target.folder.ID, "error", err) + if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.LibraryID, target.Mode, target.Path, target.Trigger); err != nil { + slog.Error("queueing library scan", "library_id", target.LibraryID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan") return } } else if h.ingester != nil { scanID := ulid.Make().String() h.recordAcceptedScan(scanID, target) - switch target.mode { - case scanModeFile: - h.runFileScanAsync(scanID, target.folder, target.path, target.trigger) - case scanModeSubtree: - h.runSubtreeScanAsync(scanID, target.folder, target.path, target.trigger) + switch target.Mode { + case scantrigger.ModeFile: + h.runFileScanAsync(scanID, target.Folder, target.Path, target.Trigger) + case scantrigger.ModeSubtree: + h.runSubtreeScanAsync(scanID, target.Folder, target.Path, target.Trigger) default: - h.runFolderScanAsync(scanID, target.folder, target.trigger) + h.runFolderScanAsync(scanID, target.Folder, target.Trigger) } } else { writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available") @@ -859,8 +842,8 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusAccepted, scanResponse{ Status: "accepted", - Mode: string(target.mode), - LibraryID: target.folder.ID, + Mode: target.Mode, + LibraryID: target.LibraryID, }) } @@ -908,225 +891,6 @@ func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request }) } -func (h *LibraryHandler) resolveScanTarget(ctx context.Context, req scanRequest) (*resolvedScanTarget, error) { - if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" { - return nil, &scanRequestError{ - status: http.StatusBadRequest, - code: "bad_request", - message: "Either library_id or path is required", - } - } - - var ( - folder *models.MediaFolder - err error - ) - if req.LibraryID != nil { - folder, err = h.folderRepo.GetByID(ctx, *req.LibraryID) - if err != nil { - if errors.Is(err, catalog.ErrFolderNotFound) { - return nil, &scanRequestError{ - status: http.StatusNotFound, - code: "not_found", - message: "Library not found", - } - } - return nil, fmt.Errorf("fetching library for scan: %w", err) - } - } - - if strings.TrimSpace(req.Path) == "" { - if folder != nil && !folder.Enabled { - return nil, &scanRequestError{ - status: http.StatusConflict, - code: "conflict", - message: "Library is disabled", - } - } - return &resolvedScanTarget{ - folder: folder, - mode: scanModeLibrary, - trigger: "manual", - }, nil - } - - cleanPath := filepath.Clean(req.Path) - var matchedRoot string - if folder != nil { - matchedRoot, err = longestMatchingRoot(cleanPath, folder.Paths) - if err != nil { - return nil, err - } - if matchedRoot == "" { - return nil, &scanRequestError{ - status: http.StatusBadRequest, - code: "bad_request", - message: "Path does not belong to the specified library", - } - } - } else { - folders, err := h.folderRepo.List(ctx) - if err != nil { - return nil, fmt.Errorf("listing libraries for scan: %w", err) - } - folder, matchedRoot, err = matchFolderForPath(cleanPath, folders) - if err != nil { - return nil, err - } - } - if folder != nil && !folder.Enabled { - return nil, &scanRequestError{ - status: http.StatusConflict, - code: "conflict", - message: "Library is disabled", - } - } - - mode, err := classifyScanPath(cleanPath, matchedRoot) - if err != nil { - return nil, err - } - - trigger := "path" - if req.LibraryID != nil { - trigger = "library_id_path" - } - - return &resolvedScanTarget{ - folder: folder, - mode: mode, - path: cleanPath, - trigger: trigger, - }, nil -} - -func longestMatchingRoot(targetPath string, roots []string) (string, error) { - bestRoot := "" - bestLen := -1 - for _, root := range roots { - if !pathWithinRoot(targetPath, root) { - continue - } - cleanRoot := filepath.Clean(root) - rootLen := len(cleanRoot) - if rootLen > bestLen { - bestRoot = cleanRoot - bestLen = rootLen - } - } - return bestRoot, nil -} - -func matchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) { - var ( - bestFolder *models.MediaFolder - bestRoot string - bestLen = -1 - ambiguous bool - ) - - for _, folder := range folders { - if folder == nil { - continue - } - root, err := longestMatchingRoot(targetPath, folder.Paths) - if err != nil { - return nil, "", err - } - if root == "" { - continue - } - rootLen := len(root) - if rootLen > bestLen { - bestFolder = folder - bestRoot = root - bestLen = rootLen - ambiguous = false - continue - } - if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID { - ambiguous = true - } - } - - if ambiguous { - return nil, "", &scanRequestError{ - status: http.StatusBadRequest, - code: "bad_request", - message: "Path matches multiple libraries", - } - } - if bestFolder == nil { - return nil, "", &scanRequestError{ - status: http.StatusBadRequest, - code: "bad_request", - message: "No library matches the given path", - } - } - return bestFolder, bestRoot, nil -} - -func classifyScanPath(targetPath, matchedRoot string) (scanMode, error) { - if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) { - return scanModeLibrary, nil - } - - info, err := os.Stat(targetPath) - if err != nil { - switch { - case errors.Is(err, os.ErrNotExist): - return "", &scanRequestError{ - status: http.StatusBadRequest, - code: "bad_request", - message: "Path does not exist", - } - case errors.Is(err, os.ErrPermission): - return "", &scanRequestError{ - status: http.StatusBadRequest, - code: "bad_request", - message: "Permission denied for path", - } - default: - return "", &scanRequestError{ - status: http.StatusBadRequest, - code: "bad_request", - message: "Path could not be inspected", - } - } - } - if info.IsDir() { - return scanModeSubtree, nil - } - if !info.Mode().IsRegular() { - return "", &scanRequestError{ - status: http.StatusBadRequest, - code: "bad_request", - message: "Path must be a file or directory", - } - } - if !scanner.SupportsVideoFile(targetPath) { - return "", &scanRequestError{ - status: http.StatusBadRequest, - code: "bad_request", - message: "Unsupported media file extension", - } - } - return scanModeFile, nil -} - -func pathWithinRoot(targetPath, rootPath string) bool { - cleanTarget := filepath.Clean(targetPath) - cleanRoot := filepath.Clean(rootPath) - rel, err := filepath.Rel(cleanRoot, cleanTarget) - if err != nil { - return false - } - if rel == "." || rel == "" { - return true - } - return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) -} - func (h *LibraryHandler) runFolderScanAsync(scanID string, folder *models.MediaFolder, trigger string) { go func() { h.markScanRunning(scanID) @@ -1287,16 +1051,16 @@ func (h *LibraryHandler) runFileScanAsync(scanID string, folder *models.MediaFol }() } -func (h *LibraryHandler) recordAcceptedScan(scanID string, target *resolvedScanTarget) { - if h == nil || h.ScanRegistry == nil || target == nil || target.folder == nil { +func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.Target) { + if h == nil || h.ScanRegistry == nil || target == nil || target.Folder == nil { return } h.ScanRegistry.Upsert(evt.ScanRun{ ID: scanID, - LibraryID: target.folder.ID, - Mode: string(target.mode), - Path: target.path, - Trigger: target.trigger, + LibraryID: target.LibraryID, + Mode: target.Mode, + Path: target.Path, + Trigger: target.Trigger, Status: "accepted", }) } From e49d164b9c1a676c1dabd5879f60e8a3ce45b555 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 19:08:34 -0400 Subject: [PATCH 04/53] feat(jellycompat): accept admin api keys for autoscan --- cmd/silo/main.go | 3 + internal/jellycompat/auth_api_key.go | 120 +++++++++++++++++++++++++++ internal/jellycompat/auth_test.go | 69 +++++++++++++++ internal/jellycompat/server.go | 6 ++ 4 files changed, 198 insertions(+) create mode 100644 internal/jellycompat/auth_api_key.go diff --git a/cmd/silo/main.go b/cmd/silo/main.go index e470ed37..c36dcddf 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -1577,6 +1577,9 @@ func main() { // Construct auth service for jellycompat login. userRepo := auth.NewUserRepository(deps.DB) + compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB) + compatDeps.APIKeyUserLoader = userRepo + compatDeps.ScanQueue = deps.LibraryScanQueue sessionRepo := auth.NewSessionRepository(deps.DB) jwtService := auth.NewJWTService( cfg.Auth.JWTSecret, diff --git a/internal/jellycompat/auth_api_key.go b/internal/jellycompat/auth_api_key.go new file mode 100644 index 00000000..3220e8d7 --- /dev/null +++ b/internal/jellycompat/auth_api_key.go @@ -0,0 +1,120 @@ +package jellycompat + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type adminAPIKeyContextKey string + +const adminAPIKeyKey adminAPIKeyContextKey = "jellycompat_admin_api_key" + +type apiKeyValidator interface { + GetByKey(ctx context.Context, key string) (*models.APIKey, error) + UpdateLastUsed(ctx context.Context, id int64) error +} + +type apiKeyUserLoader interface { + GetByID(ctx context.Context, id int) (*models.User, error) +} + +type AdminAPIKeyAuthenticator struct { + keys apiKeyValidator + users apiKeyUserLoader +} + +type adminAPIKeyAuthResult struct { + ctx context.Context + status int + ok bool +} + +func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator { + if keys == nil || users == nil { + return nil + } + return &AdminAPIKeyAuthenticator{keys: keys, users: users} +} + +func AdminAPIKeyFromContext(ctx context.Context) bool { + ok, _ := ctx.Value(adminAPIKeyKey).(bool) + return ok +} + +func (a *AdminAPIKeyAuthenticator) RequireAdminAPIKey(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + result := a.authenticate(r) + if !result.ok { + writeError(w, result.status, authErrorCode(result.status), authErrorMessage(result.status)) + return + } + next.ServeHTTP(w, r.WithContext(result.ctx)) + }) +} + +func RequireSessionOrAdminAPIKey(sessionAuth *Authenticator, keyAuth *AdminAPIKeyAuthenticator) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := ExtractToken(r) + if ok && strings.HasPrefix(token, "sa_") { + result := keyAuth.authenticate(r) + if !result.ok { + writeError(w, result.status, authErrorCode(result.status), authErrorMessage(result.status)) + return + } + next.ServeHTTP(w, r.WithContext(result.ctx)) + return + } + sessionAuth.RequireSession(next).ServeHTTP(w, r) + }) + } +} + +func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuthResult { + if a == nil || a.keys == nil || a.users == nil { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + token, ok := ExtractToken(r) + if !ok || !strings.HasPrefix(token, "sa_") { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + apiKey, err := a.keys.GetByKey(r.Context(), token) + if err != nil { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + user, err := a.users.GetByID(r.Context(), apiKey.UserID) + if err != nil || user == nil || !user.Enabled { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + if user.Role != "admin" { + return adminAPIKeyAuthResult{ctx: context.WithValue(r.Context(), adminAPIKeyKey, false), status: http.StatusForbidden} + } + go func(id int64) { + if err := a.keys.UpdateLastUsed(context.Background(), id); err != nil { + slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err) + } + }(apiKey.ID) + return adminAPIKeyAuthResult{ + ctx: context.WithValue(r.Context(), adminAPIKeyKey, true), + status: http.StatusOK, + ok: true, + } +} + +func authErrorCode(status int) string { + if status == http.StatusForbidden { + return "Forbidden" + } + return "Unauthorized" +} + +func authErrorMessage(status int) string { + if status == http.StatusForbidden { + return "Admin access required" + } + return "Invalid API key" +} diff --git a/internal/jellycompat/auth_test.go b/internal/jellycompat/auth_test.go index 4827ce48..89e8ae7a 100644 --- a/internal/jellycompat/auth_test.go +++ b/internal/jellycompat/auth_test.go @@ -1,10 +1,14 @@ package jellycompat import ( + "context" "net/http" "net/http/httptest" "testing" "time" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" ) func TestRequireSession_SkipsRefreshWhenNoAuthService(t *testing.T) { @@ -114,3 +118,68 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test t.Errorf("expected 200 (no authService = skip refresh), got %d", rec.Code) } } + +func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) { + authn := NewAdminAPIKeyAuthenticator( + &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}, + &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}}, + ) + req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil) + req.Header.Set("X-Emby-Token", "sa_test") + rec := httptest.NewRecorder() + + authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !AdminAPIKeyFromContext(r.Context()) { + t.Fatal("expected admin API key marker in context") + } + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) { + authn := NewAdminAPIKeyAuthenticator( + &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}, + &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}}, + ) + req := httptest.NewRequest("POST", "/Library/Media/Updated", nil) + req.Header.Set("X-Emby-Token", "sa_test") + rec := httptest.NewRecorder() + + authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not run") + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", rec.Code, rec.Body.String()) + } +} + +type fakeAPIKeyValidator struct { + key *models.APIKey +} + +func (f *fakeAPIKeyValidator) GetByKey(_ context.Context, key string) (*models.APIKey, error) { + if f.key != nil && f.key.Key == key { + return f.key, nil + } + return nil, auth.ErrAPIKeyNotFound +} + +func (f *fakeAPIKeyValidator) UpdateLastUsed(context.Context, int64) error { + return nil +} + +type fakeAPIKeyUserLoader struct { + user *models.User +} + +func (f *fakeAPIKeyUserLoader) GetByID(_ context.Context, id int) (*models.User, error) { + if f.user != nil && f.user.ID == id { + return f.user, nil + } + return nil, auth.ErrNotFound +} diff --git a/internal/jellycompat/server.go b/internal/jellycompat/server.go index af4f6230..02d683e8 100644 --- a/internal/jellycompat/server.go +++ b/internal/jellycompat/server.go @@ -15,6 +15,7 @@ import ( "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/recommendations" + "github.com/Silo-Server/silo-server/internal/scantrigger" "github.com/Silo-Server/silo-server/internal/subtitles" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -41,6 +42,11 @@ type Dependencies struct { UserDataService UserDataService AuthService *auth.Service + // Autoscan / admin compatibility support. + APIKeyValidator apiKeyValidator + APIKeyUserLoader apiKeyUserLoader + ScanQueue scantrigger.Queuer + // Catalog repos (for ContentService construction) BrowseRepo *catalog.BrowseRepository ItemRepo *catalog.ItemRepository From c3095a5d17117472d011b1544b4587a07aa923d5 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 19:10:37 -0400 Subject: [PATCH 05/53] feat(jellycompat): add autoscan media update route --- internal/jellycompat/handlers_autoscan.go | 146 ++++++++++++++++++ .../jellycompat/handlers_autoscan_test.go | 146 ++++++++++++++++++ internal/jellycompat/router.go | 14 +- 3 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 internal/jellycompat/handlers_autoscan.go create mode 100644 internal/jellycompat/handlers_autoscan_test.go diff --git a/internal/jellycompat/handlers_autoscan.go b/internal/jellycompat/handlers_autoscan.go new file mode 100644 index 00000000..52dc5cb9 --- /dev/null +++ b/internal/jellycompat/handlers_autoscan.go @@ -0,0 +1,146 @@ +package jellycompat + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scantrigger" +) + +const autoscanTrigger = "jellyfin_autoscan" + +type autoscanFolderRepository interface { + GetByID(ctx context.Context, id int) (*models.MediaFolder, error) + List(ctx context.Context) ([]*models.MediaFolder, error) +} + +type autoscanVirtualFolderFallback interface { + HandleVirtualFolders(w http.ResponseWriter, r *http.Request) +} + +type AutoscanHandler struct { + folders autoscanFolderRepository + queue scantrigger.Queuer + codec *ResourceIDCodec + fallback autoscanVirtualFolderFallback +} + +func NewAutoscanHandler( + folders autoscanFolderRepository, + queue scantrigger.Queuer, + codec *ResourceIDCodec, + fallback autoscanVirtualFolderFallback, +) *AutoscanHandler { + if codec == nil { + codec = NewResourceIDCodec() + } + return &AutoscanHandler{folders: folders, queue: queue, codec: codec, fallback: fallback} +} + +func (h *AutoscanHandler) HandleVirtualFolders(w http.ResponseWriter, r *http.Request) { + if h == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Library discovery not available") + return + } + if !AdminAPIKeyFromContext(r.Context()) { + if h.fallback != nil { + h.fallback.HandleVirtualFolders(w, r) + return + } + writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") + return + } + if h.folders == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Library discovery not available") + return + } + folders, err := h.folders.List(r.Context()) + if err != nil { + slog.Error("jellycompat autoscan: listing libraries", "error", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to list libraries") + return + } + resp := make([]virtualFolderDTO, 0, len(folders)) + for _, folder := range folders { + if folder == nil || !folder.Enabled { + continue + } + resp = append(resp, virtualFolderDTO{ + Name: folder.Name, + Locations: folder.Paths, + CollectionType: libraryCollectionType(folder.Type), + ItemID: h.codec.EncodeIntID(EncodedIDLibrary, int64(folder.ID)), + LibraryOptions: virtualLibraryOptDTO{ + Enabled: true, + EnableRealtimeMonitor: true, + EnableInternetProviders: true, + SeasonZeroDisplayName: "Specials", + TypeOptions: []string{}, + }, + }) + } + writeJSON(w, http.StatusOK, resp) +} + +type mediaUpdatedRequest struct { + Updates []mediaUpdatedEntry `json:"Updates"` +} + +type mediaUpdatedEntry struct { + Path string `json:"path"` + UpdateType string `json:"updateType"` +} + +func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Request) { + if h == nil || h.folders == nil || h.queue == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available") + return + } + var req mediaUpdatedRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "BadRequest", "Invalid request body") + return + } + if len(req.Updates) == 0 { + writeError(w, http.StatusBadRequest, "BadRequest", "Updates is required") + return + } + scanRequests := make([]scantrigger.Request, 0, len(req.Updates)) + for _, update := range req.Updates { + path := strings.TrimSpace(update.Path) + if path == "" { + writeError(w, http.StatusBadRequest, "BadRequest", "Update path is required") + return + } + scanRequests = append(scanRequests, scantrigger.Request{ + Path: path, + Trigger: autoscanTrigger, + }) + } + targets, err := scantrigger.NewResolver(h.folders).ResolveAll(r.Context(), scanRequests) + if err != nil { + writeScanTriggerError(w, err) + return + } + if err := scantrigger.EnqueueAll(r.Context(), h.queue, targets); err != nil { + writeScanTriggerError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func writeScanTriggerError(w http.ResponseWriter, err error) { + var reqErr *scantrigger.RequestError + if errors.As(err, &reqErr) { + writeError(w, reqErr.Status, reqErr.Code, reqErr.Message) + return + } + slog.Error("jellycompat autoscan: scan update failed", "error", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", fmt.Sprintf("Failed to process scan update: %v", err)) +} diff --git a/internal/jellycompat/handlers_autoscan_test.go b/internal/jellycompat/handlers_autoscan_test.go new file mode 100644 index 00000000..6dd98112 --- /dev/null +++ b/internal/jellycompat/handlers_autoscan_test.go @@ -0,0 +1,146 @@ +package jellycompat + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakeAutoscanFolders struct { + folders []*models.MediaFolder +} + +func (f *fakeAutoscanFolders) GetByID(_ context.Context, id int) (*models.MediaFolder, error) { + for _, folder := range f.folders { + if folder.ID == id { + return folder, nil + } + } + return nil, catalog.ErrFolderNotFound +} + +func (f *fakeAutoscanFolders) List(context.Context) ([]*models.MediaFolder, error) { + return f.folders, nil +} + +type fakeAutoscanQueue struct { + calls []queuedScan +} + +type queuedScan struct { + libraryID int + mode string + path string + trigger string +} + +func (q *fakeAutoscanQueue) EnqueueScan(_ context.Context, folderID int, mode, path, trigger string) (bool, error) { + q.calls = append(q.calls, queuedScan{libraryID: folderID, mode: mode, path: path, trigger: trigger}) + return true, nil +} + +func TestAutoscanVirtualFoldersIncludesEnabledLocationsForAdminKey(t *testing.T) { + enabledRoot := t.TempDir() + disabledRoot := t.TempDir() + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{ + {ID: 1, Name: "Movies", Type: "movie", Enabled: true, Paths: []string{enabledRoot}}, + {ID: 2, Name: "Disabled", Type: "movie", Enabled: false, Paths: []string{disabledRoot}}, + }}, nil, NewResourceIDCodec(), nil) + + req := httptest.NewRequest(http.MethodGet, "/Library/VirtualFolders", nil) + req = req.WithContext(context.WithValue(req.Context(), adminAPIKeyKey, true)) + rec := httptest.NewRecorder() + + handler.HandleVirtualFolders(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var got []virtualFolderDTO + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected one enabled library, got %d", len(got)) + } + if got[0].Name != "Movies" || len(got[0].Locations) != 1 || got[0].Locations[0] != enabledRoot { + t.Fatalf("unexpected folder response: %#v", got[0]) + } +} + +func TestAutoscanMediaUpdatedEnqueuesResolvedPath(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "Movie.mkv") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + queue := &fakeAutoscanQueue{} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 3, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + body := []byte(`{"Updates":[{"path":` + strconv.Quote(filePath) + `,"updateType":"Modified"}]}`) + req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + handler.HandleMediaUpdated(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + if len(queue.calls) != 1 { + t.Fatalf("expected one queued scan, got %d", len(queue.calls)) + } + if queue.calls[0].libraryID != 3 || queue.calls[0].mode != "file" || queue.calls[0].path != filePath || queue.calls[0].trigger != "jellyfin_autoscan" { + t.Fatalf("unexpected queued scan: %#v", queue.calls[0]) + } +} + +func TestAutoscanMediaUpdatedAllOrFail(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "Movie.mkv") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + queue := &fakeAutoscanQueue{} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 4, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + payload := map[string]any{"Updates": []map[string]string{ + {"path": filePath, "updateType": "Modified"}, + {"path": filepath.Join(root, "missing.mkv"), "updateType": "Modified"}, + }} + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data)) + rec := httptest.NewRecorder() + + handler.HandleMediaUpdated(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + if len(queue.calls) != 0 { + t.Fatalf("expected no partial enqueue, got %#v", queue.calls) + } +} diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index 0cb16a92..f7c2a03d 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -73,6 +73,16 @@ func NewRouter(deps Dependencies) chi.Router { } itemsHandler := NewItemsHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.ImageCache, nextUpRepo, deps.BrowseRepo, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.EpisodeRepo, deps.AccessFilterFn, subtitleRepo) itemsHandler.recommender = deps.Recommender + autoscanHandler := NewAutoscanHandler(deps.FolderRepo, deps.ScanQueue, deps.IDCodec, itemsHandler) + adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader) + autoscanVirtualFoldersRegistered := false + if deps.Authenticator != nil && adminAPIKeyAuth != nil && autoscanHandler != nil { + r.With(RequireSessionOrAdminAPIKey(deps.Authenticator, adminAPIKeyAuth)). + Get("/Library/VirtualFolders", autoscanHandler.HandleVirtualFolders) + r.With(adminAPIKeyAuth.RequireAdminAPIKey). + Post("/Library/Media/Updated", autoscanHandler.HandleMediaUpdated) + autoscanVirtualFoldersRegistered = true + } userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config) playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider) if deps.DB != nil { @@ -120,7 +130,9 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/Users/{id}", authHandler.HandleUserByID) r.Get("/UserViews", itemsHandler.HandleViews) r.Get("/UserViews/GroupingOptions", itemsHandler.HandleGroupingOptionsStub) - r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders) + if !autoscanVirtualFoldersRegistered { + r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders) + } r.Get("/Users/{userId}/Views", itemsHandler.HandleViews) r.Get("/Items", itemsHandler.HandleItems) r.Get("/Users/{id}/Items", itemsHandler.HandleItems) From 89f74f66e9cf88453200ecde5412dd7d83ec9899 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 19:11:32 -0400 Subject: [PATCH 06/53] docs: document jellyfin autoscan setup --- docs/scan-api.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/scan-api.md b/docs/scan-api.md index 70ebb0d2..0df2f89c 100644 --- a/docs/scan-api.md +++ b/docs/scan-api.md @@ -185,9 +185,22 @@ curl -X POST http://your-server:8090/api/v1/scan \ ## Integration with Autoscan -[Autoscan](https://github.com/Cloudbox/autoscan) monitors Sonarr, Radarr, and other sources for new downloads, then relays scan requests to media servers. To use Autoscan with Silo, configure a **manual/generic target** using a custom script or webhook that calls the Silo scan API. +[Autoscan](https://github.com/Cloudbox/autoscan) monitors Sonarr, Radarr, and +other sources for new downloads, then relays scan requests to media servers. +Silo supports Autoscan's stock Jellyfin target through the Jellyfin compatibility +server. -### Autoscan Custom Script Target +Use: + +- URL: Silo's Jellyfin compatibility URL, usually `http://your-server:8096` +- Token: a Silo admin API key beginning with `sa_` +- Target type: Autoscan `jellyfin` + +Autoscan discovers library roots from `GET /Library/VirtualFolders` and sends +changed paths to `POST /Library/Media/Updated`. The paths must be server-side +paths as Silo sees them. + +### Alternative: Autoscan Custom Script Target Create a script (e.g., `silo-scan.sh`) that Autoscan calls with the changed path: From 6a1189f2d8c4063f24ce0c6164d6eb266500c359 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 11:39:35 -0400 Subject: [PATCH 07/53] fix(jellycompat): harden autoscan auth and batch scan enqueue - Reject nil API keys and bound last-used update with a 5s timeout - Stop leaking internal queue errors in autoscan responses - Batch scan enqueues via new CreateBatch and reuse folder list across path resolves --- internal/jellycompat/auth_api_key.go | 7 +- internal/jellycompat/auth_test.go | 64 +++++++++++++++- internal/jellycompat/handlers_autoscan.go | 3 +- .../jellycompat/handlers_autoscan_test.go | 55 +++++++++++++- internal/scanqueue/repository.go | 62 ++++++++++++++++ internal/scanqueue/service.go | 26 +++++++ internal/scantrigger/scantrigger.go | 38 ++++++++-- internal/scantrigger/scantrigger_test.go | 74 ++++++++++++++++++- 8 files changed, 313 insertions(+), 16 deletions(-) diff --git a/internal/jellycompat/auth_api_key.go b/internal/jellycompat/auth_api_key.go index 3220e8d7..6a4a3b42 100644 --- a/internal/jellycompat/auth_api_key.go +++ b/internal/jellycompat/auth_api_key.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http" "strings" + "time" "github.com/Silo-Server/silo-server/internal/models" ) @@ -83,7 +84,7 @@ func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuth return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} } apiKey, err := a.keys.GetByKey(r.Context(), token) - if err != nil { + if err != nil || apiKey == nil { return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} } user, err := a.users.GetByID(r.Context(), apiKey.UserID) @@ -94,7 +95,9 @@ func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuth return adminAPIKeyAuthResult{ctx: context.WithValue(r.Context(), adminAPIKeyKey, false), status: http.StatusForbidden} } go func(id int64) { - if err := a.keys.UpdateLastUsed(context.Background(), id); err != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := a.keys.UpdateLastUsed(ctx, id); err != nil { slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err) } }(apiKey.ID) diff --git a/internal/jellycompat/auth_test.go b/internal/jellycompat/auth_test.go index 89e8ae7a..a5e7cb95 100644 --- a/internal/jellycompat/auth_test.go +++ b/internal/jellycompat/auth_test.go @@ -158,18 +158,78 @@ func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) { } } +func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) { + authn := NewAdminAPIKeyAuthenticator( + &fakeAPIKeyValidator{returnNilWithoutError: true}, + &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}}, + ) + req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil) + req.Header.Set("X-Emby-Token", "sa_test") + rec := httptest.NewRecorder() + + authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not run") + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) { + called := make(chan bool, 1) + authn := NewAdminAPIKeyAuthenticator( + &fakeAPIKeyValidator{ + key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}, + update: func(ctx context.Context, _ int64) error { + _, ok := ctx.Deadline() + called <- ok + return nil + }, + }, + &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}}, + ) + req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil) + req.Header.Set("X-Emby-Token", "sa_test") + rec := httptest.NewRecorder() + + authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + select { + case ok := <-called: + if !ok { + t.Fatal("expected last-used update context to have a deadline") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for last-used update") + } +} + type fakeAPIKeyValidator struct { - key *models.APIKey + key *models.APIKey + returnNilWithoutError bool + update func(context.Context, int64) error } func (f *fakeAPIKeyValidator) GetByKey(_ context.Context, key string) (*models.APIKey, error) { + if f.returnNilWithoutError { + return nil, nil + } if f.key != nil && f.key.Key == key { return f.key, nil } return nil, auth.ErrAPIKeyNotFound } -func (f *fakeAPIKeyValidator) UpdateLastUsed(context.Context, int64) error { +func (f *fakeAPIKeyValidator) UpdateLastUsed(ctx context.Context, id int64) error { + if f.update != nil { + return f.update(ctx, id) + } return nil } diff --git a/internal/jellycompat/handlers_autoscan.go b/internal/jellycompat/handlers_autoscan.go index 52dc5cb9..8fcf9627 100644 --- a/internal/jellycompat/handlers_autoscan.go +++ b/internal/jellycompat/handlers_autoscan.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "log/slog" "net/http" "strings" @@ -142,5 +141,5 @@ func writeScanTriggerError(w http.ResponseWriter, err error) { return } slog.Error("jellycompat autoscan: scan update failed", "error", err) - writeError(w, http.StatusInternalServerError, "InternalServerError", fmt.Sprintf("Failed to process scan update: %v", err)) + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to process scan update") } diff --git a/internal/jellycompat/handlers_autoscan_test.go b/internal/jellycompat/handlers_autoscan_test.go index 6dd98112..d8a74e93 100644 --- a/internal/jellycompat/handlers_autoscan_test.go +++ b/internal/jellycompat/handlers_autoscan_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -13,6 +14,7 @@ import ( "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scantrigger" ) type fakeAutoscanFolders struct { @@ -33,7 +35,9 @@ func (f *fakeAutoscanFolders) List(context.Context) ([]*models.MediaFolder, erro } type fakeAutoscanQueue struct { - calls []queuedScan + calls []queuedScan + batches [][]scantrigger.Target + batchErr error } type queuedScan struct { @@ -48,6 +52,23 @@ func (q *fakeAutoscanQueue) EnqueueScan(_ context.Context, folderID int, mode, p return true, nil } +func (q *fakeAutoscanQueue) EnqueueScans(_ context.Context, targets []scantrigger.Target) error { + copied := append([]scantrigger.Target(nil), targets...) + q.batches = append(q.batches, copied) + if q.batchErr != nil { + return q.batchErr + } + for _, target := range targets { + q.calls = append(q.calls, queuedScan{ + libraryID: target.LibraryID, + mode: target.Mode, + path: target.Path, + trigger: target.Trigger, + }) + } + return nil +} + func TestAutoscanVirtualFoldersIncludesEnabledLocationsForAdminKey(t *testing.T) { enabledRoot := t.TempDir() disabledRoot := t.TempDir() @@ -104,6 +125,9 @@ func TestAutoscanMediaUpdatedEnqueuesResolvedPath(t *testing.T) { if len(queue.calls) != 1 { t.Fatalf("expected one queued scan, got %d", len(queue.calls)) } + if len(queue.batches) != 1 { + t.Fatalf("expected one batch enqueue, got %d", len(queue.batches)) + } if queue.calls[0].libraryID != 3 || queue.calls[0].mode != "file" || queue.calls[0].path != filePath || queue.calls[0].trigger != "jellyfin_autoscan" { t.Fatalf("unexpected queued scan: %#v", queue.calls[0]) } @@ -144,3 +168,32 @@ func TestAutoscanMediaUpdatedAllOrFail(t *testing.T) { t.Fatalf("expected no partial enqueue, got %#v", queue.calls) } } + +func TestAutoscanMediaUpdatedHidesInternalQueueError(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "Movie.mkv") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + queue := &fakeAutoscanQueue{batchErr: errors.New("database password leaked")} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 5, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + body := []byte(`{"Updates":[{"path":` + strconv.Quote(filePath) + `,"updateType":"Modified"}]}`) + req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + handler.HandleMediaUpdated(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", rec.Code, rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte("database password leaked")) { + t.Fatalf("response leaked internal error: %s", rec.Body.String()) + } +} diff --git a/internal/scanqueue/repository.go b/internal/scanqueue/repository.go index d2076a5a..fceecd32 100644 --- a/internal/scanqueue/repository.go +++ b/internal/scanqueue/repository.go @@ -121,6 +121,68 @@ func (r *Repository) Create(ctx context.Context, input CreateInput) (*models.Sca return nil, false, fmt.Errorf("create scan run: %w", err) } +func (r *Repository) CreateBatch(ctx context.Context, inputs []CreateInput) ([]*models.ScanRun, []bool, error) { + if len(inputs) == 0 { + return []*models.ScanRun{}, []bool{}, nil + } + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return nil, nil, fmt.Errorf("begin scan run batch: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + runs := make([]*models.ScanRun, 0, len(inputs)) + created := make([]bool, 0, len(inputs)) + for _, input := range inputs { + run, err := scanRunRow(tx.QueryRow(ctx, ` + INSERT INTO scan_runs ( + id, media_folder_id, mode, path, trigger, status + ) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT DO NOTHING + RETURNING `+scanRunColumns, + ulid.Make().String(), + input.LibraryID, + input.Mode, + input.Path, + input.Trigger, + StatusAccepted, + )) + if err == nil { + runs = append(runs, run) + created = append(created, true) + continue + } + if !errors.Is(err, ErrScanRunNotFound) { + return nil, nil, fmt.Errorf("create scan run: %w", err) + } + + existing, lookupErr := scanRunRow(tx.QueryRow(ctx, ` + SELECT `+scanRunColumns+` + FROM scan_runs + WHERE media_folder_id = $1 + AND mode = $2 + AND path = $3 + AND status = ANY($4) + ORDER BY requested_at ASC + LIMIT 1`, + input.LibraryID, + input.Mode, + input.Path, + []string{StatusAccepted, StatusRunning}, + )) + if lookupErr != nil { + return nil, nil, lookupErr + } + runs = append(runs, existing) + created = append(created, false) + } + + if err := tx.Commit(ctx); err != nil { + return nil, nil, fmt.Errorf("commit scan run batch: %w", err) + } + return runs, created, nil +} + func (r *Repository) GetActiveByScope(ctx context.Context, libraryID int, mode, path string) (*models.ScanRun, error) { return scanRunRow(r.pool.QueryRow(ctx, ` SELECT `+scanRunColumns+` diff --git a/internal/scanqueue/service.go b/internal/scanqueue/service.go index 0dfa1a37..3fb92732 100644 --- a/internal/scanqueue/service.go +++ b/internal/scanqueue/service.go @@ -13,6 +13,7 @@ import ( evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/libraryingest" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scantrigger" ) const ( @@ -131,6 +132,31 @@ func (s *Service) EnqueueScan(ctx context.Context, folderID int, mode, path, tri return created, nil } +func (s *Service) EnqueueScans(ctx context.Context, targets []scantrigger.Target) error { + if s == nil || s.repo == nil { + return fmt.Errorf("scan queue is not configured") + } + inputs := make([]CreateInput, 0, len(targets)) + for _, target := range targets { + inputs = append(inputs, CreateInput{ + LibraryID: target.LibraryID, + Mode: target.Mode, + Path: target.Path, + Trigger: target.Trigger, + }) + } + runs, created, err := s.repo.CreateBatch(ctx, inputs) + if err != nil { + return err + } + for i, run := range runs { + if i < len(created) && created[i] { + s.publish(ctx, "scan.accepted", run) + } + } + return nil +} + func (s *Service) CancelAcceptedByLibrary(ctx context.Context, libraryID int) (int, error) { if s == nil || s.repo == nil { return 0, nil diff --git a/internal/scantrigger/scantrigger.go b/internal/scantrigger/scantrigger.go index d6587de1..c082a585 100644 --- a/internal/scantrigger/scantrigger.go +++ b/internal/scantrigger/scantrigger.go @@ -27,6 +27,7 @@ type FolderRepository interface { type Queuer interface { EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error) + EnqueueScans(ctx context.Context, targets []Target) error } type Request struct { @@ -63,8 +64,23 @@ func NewResolver(folders FolderRepository) *Resolver { func (r *Resolver) ResolveAll(ctx context.Context, requests []Request) ([]Target, error) { targets := make([]Target, 0, len(requests)) + var pathFolders []*models.MediaFolder + pathFoldersLoaded := false for _, req := range requests { - target, err := r.Resolve(ctx, req) + usePathFolders := req.LibraryID == nil && strings.TrimSpace(req.Path) != "" + if usePathFolders && !pathFoldersLoaded { + if r == nil || r.folders == nil { + return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"} + } + folders, listErr := r.folders.List(ctx) + if listErr != nil { + return nil, fmt.Errorf("listing libraries for scan: %w", listErr) + } + pathFolders = folders + pathFoldersLoaded = true + } + + target, err := r.resolve(ctx, req, pathFolders, usePathFolders) if err != nil { return nil, err } @@ -74,6 +90,10 @@ func (r *Resolver) ResolveAll(ctx context.Context, requests []Request) ([]Target } func (r *Resolver) Resolve(ctx context.Context, req Request) (*Target, error) { + return r.resolve(ctx, req, nil, false) +} + +func (r *Resolver) resolve(ctx context.Context, req Request, pathFolders []*models.MediaFolder, usePathFolders bool) (*Target, error) { if r == nil || r.folders == nil { return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"} } @@ -115,9 +135,13 @@ func (r *Resolver) Resolve(ctx context.Context, req Request) (*Target, error) { return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not belong to the specified library"} } } else { - folders, listErr := r.folders.List(ctx) - if listErr != nil { - return nil, fmt.Errorf("listing libraries for scan: %w", listErr) + folders := pathFolders + if !usePathFolders { + var listErr error + folders, listErr = r.folders.List(ctx) + if listErr != nil { + return nil, fmt.Errorf("listing libraries for scan: %w", listErr) + } } folder, matchedRoot, err = MatchFolderForPath(cleanPath, folders) if err != nil { @@ -150,10 +174,8 @@ func EnqueueAll(ctx context.Context, queue Queuer, targets []Target) error { if queue == nil { return &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"} } - for _, target := range targets { - if _, err := queue.EnqueueScan(ctx, target.LibraryID, target.Mode, target.Path, target.Trigger); err != nil { - return fmt.Errorf("queueing library scan: %w", err) - } + if err := queue.EnqueueScans(ctx, targets); err != nil { + return fmt.Errorf("queueing library scans: %w", err) } return nil } diff --git a/internal/scantrigger/scantrigger_test.go b/internal/scantrigger/scantrigger_test.go index dfb1b2e3..699cd3e7 100644 --- a/internal/scantrigger/scantrigger_test.go +++ b/internal/scantrigger/scantrigger_test.go @@ -13,7 +13,8 @@ import ( ) type fakeFolderRepo struct { - folders []*models.MediaFolder + folders []*models.MediaFolder + listCalls int } func (r *fakeFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) { @@ -26,6 +27,7 @@ func (r *fakeFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder } func (r *fakeFolderRepo) List(context.Context) ([]*models.MediaFolder, error) { + r.listCalls++ return r.folders, nil } @@ -135,3 +137,73 @@ func TestResolveAllIsAllOrFail(t *testing.T) { t.Fatalf("unexpected error message: %q", reqErr.Message) } } + +func TestResolveAllReusesPathOnlyLibraryList(t *testing.T) { + root := t.TempDir() + first := filepath.Join(root, "First.mkv") + second := filepath.Join(root, "Second.mkv") + for _, path := range []string{first, second} { + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + } + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 12, + Name: "Movies", + Enabled: true, + Paths: []string{root}, + }}} + + targets, err := NewResolver(repo).ResolveAll(context.Background(), []Request{ + {Path: first}, + {Path: second}, + }) + if err != nil { + t.Fatalf("ResolveAll returned error: %v", err) + } + if len(targets) != 2 { + t.Fatalf("expected two targets, got %d", len(targets)) + } + if repo.listCalls != 1 { + t.Fatalf("expected one folder list lookup, got %d", repo.listCalls) + } +} + +type fakeQueue struct { + calls []Target + batches [][]Target + batchErr error +} + +func (q *fakeQueue) EnqueueScan(_ context.Context, folderID int, mode, path, trigger string) (bool, error) { + q.calls = append(q.calls, Target{LibraryID: folderID, Mode: mode, Path: path, Trigger: trigger}) + return true, nil +} + +func (q *fakeQueue) EnqueueScans(_ context.Context, targets []Target) error { + copied := append([]Target(nil), targets...) + q.batches = append(q.batches, copied) + if q.batchErr != nil { + return q.batchErr + } + q.calls = append(q.calls, targets...) + return nil +} + +func TestEnqueueAllUsesBatchQueue(t *testing.T) { + queue := &fakeQueue{} + targets := []Target{ + {LibraryID: 1, Mode: ModeFile, Path: "/media/one.mkv", Trigger: "autoscan"}, + {LibraryID: 1, Mode: ModeFile, Path: "/media/two.mkv", Trigger: "autoscan"}, + } + + if err := EnqueueAll(context.Background(), queue, targets); err != nil { + t.Fatalf("EnqueueAll returned error: %v", err) + } + if len(queue.batches) != 1 { + t.Fatalf("expected one batch enqueue, got %d", len(queue.batches)) + } + if len(queue.calls) != 2 { + t.Fatalf("expected two queued calls from batch, got %d", len(queue.calls)) + } +} From a05a0d26a2d600cd897fabc28c97bf58a8e242fe Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 12:11:56 -0400 Subject: [PATCH 08/53] refactor(scantrigger): drop redundant Target.LibraryID field - Read library ID from Target.Folder.ID everywhere - Guard scan queue enqueue against nil Folder - Simplify admin API key auth error plumbing --- internal/api/handlers/libraries.go | 22 ++++----- internal/jellycompat/auth_api_key.go | 47 +++++++++---------- .../jellycompat/handlers_autoscan_test.go | 6 ++- internal/scanqueue/service.go | 5 +- internal/scantrigger/scantrigger.go | 16 ++++--- internal/scantrigger/scantrigger_test.go | 13 ++--- 6 files changed, 58 insertions(+), 51 deletions(-) diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index a0b0d080..7d5c7acb 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -572,10 +572,9 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ } else { initialScanID := ulid.Make().String() h.recordAcceptedScan(initialScanID, &scantrigger.Target{ - Folder: folder, - LibraryID: folder.ID, - Mode: scantrigger.ModeLibrary, - Trigger: "library_created", + Folder: folder, + Mode: scantrigger.ModeLibrary, + Trigger: "library_created", }) h.runFolderScanAsync(initialScanID, folder, "library_created") } @@ -663,10 +662,9 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ } else { updateScanID := ulid.Make().String() h.recordAcceptedScan(updateScanID, &scantrigger.Target{ - Folder: folder, - LibraryID: folder.ID, - Mode: scantrigger.ModeLibrary, - Trigger: "library_paths_changed", + Folder: folder, + Mode: scantrigger.ModeLibrary, + Trigger: "library_paths_changed", }) h.runFolderScanAsync(updateScanID, folder, "library_paths_changed") } @@ -819,8 +817,8 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) { } if h.ScanQueue != nil { - if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.LibraryID, target.Mode, target.Path, target.Trigger); err != nil { - slog.Error("queueing library scan", "library_id", target.LibraryID, "error", err) + if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.Folder.ID, target.Mode, target.Path, target.Trigger); err != nil { + slog.Error("queueing library scan", "library_id", target.Folder.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan") return } @@ -843,7 +841,7 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusAccepted, scanResponse{ Status: "accepted", Mode: target.Mode, - LibraryID: target.LibraryID, + LibraryID: target.Folder.ID, }) } @@ -1057,7 +1055,7 @@ func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.T } h.ScanRegistry.Upsert(evt.ScanRun{ ID: scanID, - LibraryID: target.LibraryID, + LibraryID: target.Folder.ID, Mode: target.Mode, Path: target.Path, Trigger: target.Trigger, diff --git a/internal/jellycompat/auth_api_key.go b/internal/jellycompat/auth_api_key.go index 6a4a3b42..8841b00d 100644 --- a/internal/jellycompat/auth_api_key.go +++ b/internal/jellycompat/auth_api_key.go @@ -29,9 +29,11 @@ type AdminAPIKeyAuthenticator struct { } type adminAPIKeyAuthResult struct { - ctx context.Context - status int - ok bool + ctx context.Context + status int + code string + message string + ok bool } func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator { @@ -50,7 +52,7 @@ func (a *AdminAPIKeyAuthenticator) RequireAdminAPIKey(next http.Handler) http.Ha return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { result := a.authenticate(r) if !result.ok { - writeError(w, result.status, authErrorCode(result.status), authErrorMessage(result.status)) + writeError(w, result.status, result.code, result.message) return } next.ServeHTTP(w, r.WithContext(result.ctx)) @@ -64,7 +66,7 @@ func RequireSessionOrAdminAPIKey(sessionAuth *Authenticator, keyAuth *AdminAPIKe if ok && strings.HasPrefix(token, "sa_") { result := keyAuth.authenticate(r) if !result.ok { - writeError(w, result.status, authErrorCode(result.status), authErrorMessage(result.status)) + writeError(w, result.status, result.code, result.message) return } next.ServeHTTP(w, r.WithContext(result.ctx)) @@ -76,23 +78,34 @@ func RequireSessionOrAdminAPIKey(sessionAuth *Authenticator, keyAuth *AdminAPIKe } func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuthResult { + unauthorized := adminAPIKeyAuthResult{ + ctx: r.Context(), + status: http.StatusUnauthorized, + code: "Unauthorized", + message: "Invalid API key", + } if a == nil || a.keys == nil || a.users == nil { - return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + return unauthorized } token, ok := ExtractToken(r) if !ok || !strings.HasPrefix(token, "sa_") { - return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + return unauthorized } apiKey, err := a.keys.GetByKey(r.Context(), token) if err != nil || apiKey == nil { - return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + return unauthorized } user, err := a.users.GetByID(r.Context(), apiKey.UserID) if err != nil || user == nil || !user.Enabled { - return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + return unauthorized } if user.Role != "admin" { - return adminAPIKeyAuthResult{ctx: context.WithValue(r.Context(), adminAPIKeyKey, false), status: http.StatusForbidden} + return adminAPIKeyAuthResult{ + ctx: r.Context(), + status: http.StatusForbidden, + code: "Forbidden", + message: "Admin access required", + } } go func(id int64) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -107,17 +120,3 @@ func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuth ok: true, } } - -func authErrorCode(status int) string { - if status == http.StatusForbidden { - return "Forbidden" - } - return "Unauthorized" -} - -func authErrorMessage(status int) string { - if status == http.StatusForbidden { - return "Admin access required" - } - return "Invalid API key" -} diff --git a/internal/jellycompat/handlers_autoscan_test.go b/internal/jellycompat/handlers_autoscan_test.go index d8a74e93..e5a8b707 100644 --- a/internal/jellycompat/handlers_autoscan_test.go +++ b/internal/jellycompat/handlers_autoscan_test.go @@ -59,8 +59,12 @@ func (q *fakeAutoscanQueue) EnqueueScans(_ context.Context, targets []scantrigge return q.batchErr } for _, target := range targets { + folderID := 0 + if target.Folder != nil { + folderID = target.Folder.ID + } q.calls = append(q.calls, queuedScan{ - libraryID: target.LibraryID, + libraryID: folderID, mode: target.Mode, path: target.Path, trigger: target.Trigger, diff --git a/internal/scanqueue/service.go b/internal/scanqueue/service.go index 3fb92732..b38c8953 100644 --- a/internal/scanqueue/service.go +++ b/internal/scanqueue/service.go @@ -138,8 +138,11 @@ func (s *Service) EnqueueScans(ctx context.Context, targets []scantrigger.Target } inputs := make([]CreateInput, 0, len(targets)) for _, target := range targets { + if target.Folder == nil { + return fmt.Errorf("scan queue: target is missing folder") + } inputs = append(inputs, CreateInput{ - LibraryID: target.LibraryID, + LibraryID: target.Folder.ID, Mode: target.Mode, Path: target.Path, Trigger: target.Trigger, diff --git a/internal/scantrigger/scantrigger.go b/internal/scantrigger/scantrigger.go index c082a585..d6c0ffca 100644 --- a/internal/scantrigger/scantrigger.go +++ b/internal/scantrigger/scantrigger.go @@ -36,12 +36,14 @@ type Request struct { Trigger string } +// Target is a fully-resolved scan request. Folder is always non-nil for +// targets returned by Resolver; callers should read the library ID via +// target.Folder.ID rather than tracking it separately. type Target struct { - Folder *models.MediaFolder - LibraryID int - Mode string - Path string - Trigger string + Folder *models.MediaFolder + Mode string + Path string + Trigger string } type RequestError struct { @@ -121,7 +123,7 @@ func (r *Resolver) resolve(ctx context.Context, req Request, pathFolders []*mode if folder != nil && !folder.Enabled { return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"} } - return &Target{Folder: folder, LibraryID: folder.ID, Mode: ModeLibrary, Trigger: trigger}, nil + return &Target{Folder: folder, Mode: ModeLibrary, Trigger: trigger}, nil } cleanPath := filepath.Clean(req.Path) @@ -167,7 +169,7 @@ func (r *Resolver) resolve(ctx context.Context, req Request, pathFolders []*mode if mode == ModeLibrary { targetPath = "" } - return &Target{Folder: folder, LibraryID: folder.ID, Mode: mode, Path: targetPath, Trigger: trigger}, nil + return &Target{Folder: folder, Mode: mode, Path: targetPath, Trigger: trigger}, nil } func EnqueueAll(ctx context.Context, queue Queuer, targets []Target) error { diff --git a/internal/scantrigger/scantrigger_test.go b/internal/scantrigger/scantrigger_test.go index 699cd3e7..a5095580 100644 --- a/internal/scantrigger/scantrigger_test.go +++ b/internal/scantrigger/scantrigger_test.go @@ -44,7 +44,7 @@ func TestResolverClassifiesLibraryRoot(t *testing.T) { if err != nil { t.Fatalf("Resolve returned error: %v", err) } - if target.LibraryID != 7 || target.Mode != ModeLibrary || target.Path != "" { + if target.Folder == nil || target.Folder.ID != 7 || target.Mode != ModeLibrary || target.Path != "" { t.Fatalf("unexpected target: %#v", target) } } @@ -66,7 +66,7 @@ func TestResolverClassifiesSubtree(t *testing.T) { if err != nil { t.Fatalf("Resolve returned error: %v", err) } - if target.LibraryID != 8 || target.Mode != ModeSubtree || target.Path != filepath.Clean(subtree) { + if target.Folder == nil || target.Folder.ID != 8 || target.Mode != ModeSubtree || target.Path != filepath.Clean(subtree) { t.Fatalf("unexpected target: %#v", target) } } @@ -88,7 +88,7 @@ func TestResolverClassifiesVideoFile(t *testing.T) { if err != nil { t.Fatalf("Resolve returned error: %v", err) } - if target.LibraryID != 9 || target.Mode != ModeFile || target.Path != filepath.Clean(filePath) { + if target.Folder == nil || target.Folder.ID != 9 || target.Mode != ModeFile || target.Path != filepath.Clean(filePath) { t.Fatalf("unexpected target: %#v", target) } } @@ -176,7 +176,7 @@ type fakeQueue struct { } func (q *fakeQueue) EnqueueScan(_ context.Context, folderID int, mode, path, trigger string) (bool, error) { - q.calls = append(q.calls, Target{LibraryID: folderID, Mode: mode, Path: path, Trigger: trigger}) + q.calls = append(q.calls, Target{Folder: &models.MediaFolder{ID: folderID}, Mode: mode, Path: path, Trigger: trigger}) return true, nil } @@ -192,9 +192,10 @@ func (q *fakeQueue) EnqueueScans(_ context.Context, targets []Target) error { func TestEnqueueAllUsesBatchQueue(t *testing.T) { queue := &fakeQueue{} + folder := &models.MediaFolder{ID: 1} targets := []Target{ - {LibraryID: 1, Mode: ModeFile, Path: "/media/one.mkv", Trigger: "autoscan"}, - {LibraryID: 1, Mode: ModeFile, Path: "/media/two.mkv", Trigger: "autoscan"}, + {Folder: folder, Mode: ModeFile, Path: "/media/one.mkv", Trigger: "autoscan"}, + {Folder: folder, Mode: ModeFile, Path: "/media/two.mkv", Trigger: "autoscan"}, } if err := EnqueueAll(context.Background(), queue, targets); err != nil { From 2ed2e013b45c6a4f074ea2d92751115bffb05eb0 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 13:53:03 -0400 Subject: [PATCH 09/53] docs(specs): add design for TMDB-backed request section in search Adds the design for surfacing requestable TMDB results inside the main catalog search (Cmd+K dialog and full results page) as a clearly delimited "Request to Add" section that never blocks or displaces library results. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-25-search-request-section-design.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-25-search-request-section-design.md diff --git a/docs/superpowers/specs/2026-05-25-search-request-section-design.md b/docs/superpowers/specs/2026-05-25-search-request-section-design.md new file mode 100644 index 00000000..89ab119d --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-search-request-section-design.md @@ -0,0 +1,79 @@ +# Search Request Section Design + +## Goal + +Surface TMDB-backed "requestable" results inside the main catalog search so users can discover and request items that aren't in their library without leaving the search flow. The library remains the primary surface; requestable results are an additive, clearly delimited section that never blocks or displaces library results. + +## Behavior + +### Layout (both surfaces) + +- The library section renders first using existing FTS results. No changes to library ranking, pagination, or row layout. +- A "Request to Add" section renders below the library results when: + - admin `RequestsEnabled = true`, AND + - the viewer has a profile, AND + - the TMDB query returns at least one result that is not already in the library. +- The Cmd+K search dialog (`GlobalSearch`) shows up to 4 TMDB rows beneath a single "Not in your library?" CTA strip. +- The full search results page (`Catalog`) shows a section divider, a section header, then a grid of up to 20 TMDB cards on initial render. +- Clicking any TMDB row or card navigates to the existing `/requests/{media_type}/{tmdb_id}` detail page. The detail page is responsible for the actual request action and confirmation. + +### Section header copy + +- When library has ≥1 hit: header reads "Request to Add". +- When library has 0 hits and TMDB has ≥1 hit: header is replaced by a soft framing — "Not in your library, but you can request" — and there is no separate empty state for library. +- When both sources return 0 results: the existing "No matches" / "No items found" empty state is unchanged; no requestable section renders. + +### Quota / blocked viewers + +- If the viewer is quota-exhausted or individually blocked, the section still renders, but each row's request affordance is disabled with a tooltip explaining the reason. Rows remain clickable and still navigate to the detail page. + +### Performance + +- Library results never wait on TMDB. The two queries fire concurrently from the client; the library section paints as soon as FTS returns. +- TMDB query is debounced at 400ms; library query stays at the current 200ms. +- TMDB query is cancelled in-flight when the query string changes, using the existing react-query `{ signal }` pattern. +- TMDB error or timeout silently omits the section; no error banner. +- React-query `staleTime`: 5 minutes for TMDB results (reduces external calls and respects TMDB rate limits), 60 seconds for library results (matches the existing `GlobalSearch` preview). + +## Architecture + +- No backend changes to existing endpoints. The frontend coordinates two parallel queries. +- Library on the results page: existing `useCatalogWindow` against `/api/v1/catalog?source=query`. +- Library in the Cmd+K dialog: existing `previewQuery` pattern using `fetchCatalogPage` against the same endpoint. +- TMDB: existing `useRequestSearch` hook against `/api/v1/requests/search`, used by both surfaces. +- Deduplication is handled server-side by the existing `enrichPage()` → `presence.Lookup()` flow on `/requests/search`. Client filters TMDB results where `availability == "available"` so they don't shadow library rows. +- A new `useCanRequest()` hook centralizes the gating logic. It reads admin settings (`RequestsEnabled`) and viewer policy (`EffectivePolicy.LimitMode`, quota state) and returns `{ enabled, disabledReason }`. When `enabled === false`, the TMDB query is not fired. + +## Components + +- `web/src/hooks/useCanRequest.ts` (new): exposes `{ enabled, disabledReason }` derived from settings + viewer policy. +- `web/src/components/RequestToAddSection.tsx` (new): renders the section in two variants: + - `variant="dialog"` — compact row layout for `GlobalSearch`. + - `variant="grid"` — poster grid using existing `RequestPosterCard` for `Catalog`. +- `web/src/components/GlobalSearch.tsx` (modified): wires the second query, passes results into `RequestToAddSection` with `variant="dialog"`. +- `web/src/pages/Catalog.tsx` (modified): renders `RequestToAddSection` with `variant="grid"` below the existing `ItemGrid` when the source is `query`. + +## Edge cases + +- TMDB returns only items already available in the library: section is omitted (after client filter). +- Library has hits but TMDB is still loading: library renders immediately; section shows a compact skeleton in its slot, then either renders or vanishes. +- Library has 0 hits and TMDB is still pending: the page suppresses the "No matches" empty state and shows a single loading indicator until TMDB resolves. Only after TMDB returns 0 (or errors) does the empty state render. +- TMDB query never fires (gated off): library follows its existing behavior including the standard empty state. +- Viewer logs out / profile changes mid-query: `useCanRequest()` re-evaluates and cancels the TMDB query if it becomes ineligible. +- Source is not `query` (e.g., `favorites`, `watchlist`, `history`, `section`): section never renders. + +## Out of scope + +- Backend changes to `/api/v1/catalog` or any merged endpoint. +- Inline request submission from search results (the detail page continues to own request creation). +- Surfacing requestable results in any non-search context (home, library browse, etc.). +- Person / cast results from TMDB. Only movie and series results are shown. + +## Verification + +Commands assume the repository root is the cwd. + +- `cd web && pnpm run lint` +- `cd web && pnpm run format:check` +- Frontend component tests for `GlobalSearch`, `Catalog`, and `RequestToAddSection` covering: library-only results, library + TMDB, TMDB-only (library empty), both-empty, TMDB error, quota-disabled viewer, requests-globally-off. +- Manual smoke in the dev frontend: confirm library results are not delayed when TMDB is slow or errors; confirm the dialog and full-page surfaces both show the section under matching conditions. From 94b47fdf42de49b50f9a74754ad42cb653b1de81 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 14:19:37 -0400 Subject: [PATCH 10/53] docs(specs): address Codex adversarial review for search request section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits discovery eligibility from submission eligibility so blocked and quota-exhausted viewers still see the requestable section with disabled per-row CTAs, matching the documented behavior. Documents the required extensions to useRequestSearch — signal forwarding, viewer-identity-keyed cache, and invalidation on auth/profile/ settings/limit changes — so the planned 5-minute staleTime is safe and cancellation works as described. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-25-search-request-section-design.md | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-05-25-search-request-section-design.md b/docs/superpowers/specs/2026-05-25-search-request-section-design.md index 89ab119d..bc15b977 100644 --- a/docs/superpowers/specs/2026-05-25-search-request-section-design.md +++ b/docs/superpowers/specs/2026-05-25-search-request-section-design.md @@ -25,31 +25,54 @@ Surface TMDB-backed "requestable" results inside the main catalog search so user ### Quota / blocked viewers -- If the viewer is quota-exhausted or individually blocked, the section still renders, but each row's request affordance is disabled with a tooltip explaining the reason. Rows remain clickable and still navigate to the detail page. +Discovery eligibility (whether the TMDB query fires) is separate from submission eligibility (whether the row's request CTA is active): + +- **Discovery eligibility** is gated only by global/identity preconditions: admin `RequestsEnabled = true`, the viewer is authenticated, and has a profile. If any of these is false, the TMDB query does not fire and the section is not rendered. +- **Submission eligibility** is per-viewer policy: quota-exhausted, individually blocked (`UserLimit.LimitMode = "blocked"`), or otherwise restricted. When discovery is allowed but submission is not, the section still renders, each row's request affordance is disabled, and a tooltip surfaces the reason. Rows remain clickable and still navigate to the detail page, which is responsible for displaying the full policy state. + +This keeps search-side UX consistent with what the detail page would show for the same viewer. ### Performance - Library results never wait on TMDB. The two queries fire concurrently from the client; the library section paints as soon as FTS returns. - TMDB query is debounced at 400ms; library query stays at the current 200ms. -- TMDB query is cancelled in-flight when the query string changes, using the existing react-query `{ signal }` pattern. +- TMDB query is cancelled in-flight when the query string changes. This requires extending `useRequestSearch` to accept and forward `{ signal }` to `api` (it does not today); see Architecture. - TMDB error or timeout silently omits the section; no error banner. -- React-query `staleTime`: 5 minutes for TMDB results (reduces external calls and respects TMDB rate limits), 60 seconds for library results (matches the existing `GlobalSearch` preview). +- React-query `staleTime`: 5 minutes for TMDB results (reduces external calls and respects TMDB rate limits), 60 seconds for library results (matches the existing `GlobalSearch` preview). The 5-minute window is only safe because the cache key includes viewer identity (see Architecture); cross-viewer reuse is impossible. ## Architecture - No backend changes to existing endpoints. The frontend coordinates two parallel queries. - Library on the results page: existing `useCatalogWindow` against `/api/v1/catalog?source=query`. - Library in the Cmd+K dialog: existing `previewQuery` pattern using `fetchCatalogPage` against the same endpoint. -- TMDB: existing `useRequestSearch` hook against `/api/v1/requests/search`, used by both surfaces. +- TMDB: existing `useRequestSearch` hook against `/api/v1/requests/search`, used by both surfaces — see required extensions below. - Deduplication is handled server-side by the existing `enrichPage()` → `presence.Lookup()` flow on `/requests/search`. Client filters TMDB results where `availability == "available"` so they don't shadow library rows. -- A new `useCanRequest()` hook centralizes the gating logic. It reads admin settings (`RequestsEnabled`) and viewer policy (`EffectivePolicy.LimitMode`, quota state) and returns `{ enabled, disabledReason }`. When `enabled === false`, the TMDB query is not fired. + +### Gating hook (`useCanRequest`) + +The new hook splits its return into two independent signals: + +- `discoveryEnabled: boolean` — true when admin `RequestsEnabled = true` AND the viewer is authenticated with a profile. This is the only signal that controls whether the TMDB query fires. +- `submitDisabledReason: string | null` — null when the viewer can submit; otherwise one of `"blocked"`, `"quota_exhausted"`, or a future reason key. Passed through `RequestToAddSection` to per-row UI to disable the request CTA and populate its tooltip. + +Per-viewer policy state (`EffectivePolicy.LimitMode`, quota counters) feeds `submitDisabledReason` and is never used to suppress the query. + +### `useRequestSearch` extensions + +The existing hook is reused but must be extended before it can back this feature safely: + +- **Pass through `{ signal }`**: the query function currently does not accept the react-query `signal`. Update it to accept the signal and forward it to `api` so in-flight TMDB requests are cancelled on query change, unmount, or viewer change. +- **Key by viewer identity**: extend `requestKeys.search(...)` to include the active `profile_id` (and `user_id` if profile alone is insufficient to identify the policy holder). This prevents cached results from being served across viewer changes and makes the 5-minute `staleTime` safe. +- **Invalidate on policy or identity change**: invalidate `requestKeys.search()` queries when any of the following occurs in the SPA: login/logout, profile switch, admin `RequestsEnabled` toggle, `UserLimit` mutation affecting the current viewer, or quota reset/refresh. The invalidation hooks live alongside the existing auth/profile/settings stores. ## Components -- `web/src/hooks/useCanRequest.ts` (new): exposes `{ enabled, disabledReason }` derived from settings + viewer policy. +- `web/src/hooks/useCanRequest.ts` (new): exposes `{ discoveryEnabled, submitDisabledReason }` derived from settings + viewer identity + policy as described in Architecture. +- `web/src/hooks/queries/useRequests.ts` (modified): extend `useRequestSearch` and `requestKeys.search(...)` to accept/forward `{ signal }`, include viewer identity in the query key, and expose invalidation helpers used by the auth/profile/settings stores. - `web/src/components/RequestToAddSection.tsx` (new): renders the section in two variants: - `variant="dialog"` — compact row layout for `GlobalSearch`. - `variant="grid"` — poster grid using existing `RequestPosterCard` for `Catalog`. + Accepts `submitDisabledReason` and propagates it to per-row CTAs. - `web/src/components/GlobalSearch.tsx` (modified): wires the second query, passes results into `RequestToAddSection` with `variant="dialog"`. - `web/src/pages/Catalog.tsx` (modified): renders `RequestToAddSection` with `variant="grid"` below the existing `ItemGrid` when the source is `query`. @@ -58,8 +81,9 @@ Surface TMDB-backed "requestable" results inside the main catalog search so user - TMDB returns only items already available in the library: section is omitted (after client filter). - Library has hits but TMDB is still loading: library renders immediately; section shows a compact skeleton in its slot, then either renders or vanishes. - Library has 0 hits and TMDB is still pending: the page suppresses the "No matches" empty state and shows a single loading indicator until TMDB resolves. Only after TMDB returns 0 (or errors) does the empty state render. -- TMDB query never fires (gated off): library follows its existing behavior including the standard empty state. -- Viewer logs out / profile changes mid-query: `useCanRequest()` re-evaluates and cancels the TMDB query if it becomes ineligible. +- TMDB query never fires (discovery gated off): library follows its existing behavior including the standard empty state. +- Viewer logs out, switches profile, or admin disables `RequestsEnabled` mid-query: `useCanRequest()` re-evaluates and `discoveryEnabled` flips to false; the in-flight TMDB request is cancelled via its forwarded `signal`, and cached entries under the previous viewer identity are invalidated so they cannot be re-served. +- Admin updates `UserLimit` for the current viewer while results are cached: the settings/limit mutation triggers a `requestKeys.search()` invalidation; the next paint re-fetches with the new `submitDisabledReason`. - Source is not `query` (e.g., `favorites`, `watchlist`, `history`, `section`): section never renders. ## Out of scope @@ -75,5 +99,7 @@ Commands assume the repository root is the cwd. - `cd web && pnpm run lint` - `cd web && pnpm run format:check` -- Frontend component tests for `GlobalSearch`, `Catalog`, and `RequestToAddSection` covering: library-only results, library + TMDB, TMDB-only (library empty), both-empty, TMDB error, quota-disabled viewer, requests-globally-off. +- Frontend component tests for `GlobalSearch`, `Catalog`, and `RequestToAddSection` covering: library-only results, library + TMDB, TMDB-only (library empty), both-empty, TMDB error, blocked viewer (section renders, CTAs disabled), quota-exhausted viewer (section renders, CTAs disabled), requests-globally-off (no TMDB query fired, no section). +- Hook tests for `useCanRequest` across the matrix of `RequestsEnabled`, auth state, profile presence, and policy states, asserting that `discoveryEnabled` and `submitDisabledReason` are independent. +- Hook/integration tests for the extended `useRequestSearch`: confirm `signal` forwarding cancels in-flight requests on query change, confirm cache entries are not shared across `profile_id` keys, and confirm the relevant store mutations invalidate `requestKeys.search()`. - Manual smoke in the dev frontend: confirm library results are not delayed when TMDB is slow or errors; confirm the dialog and full-page surfaces both show the section under matching conditions. From 91dc896b430a21cea69925d122a400141d48daed Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 14:35:10 -0400 Subject: [PATCH 11/53] docs(plans): add implementation plan for search request section Twelve TDD tasks covering: api() signal contract test, useCanRequest hook, viewer-keyed requestKeys.search, useRequestSearch extension (signal + viewer key + 5min staleTime + enabled override), invalidation cascade tests, RequestPosterCard optional onRequest, RequestToAddSection component (dialog + grid variants), GlobalSearch and Catalog wiring with empty-state suppression for the library-0/TMDB-pending edge case, final lint/test pass, and manual smoke. Notes a single deviation from the spec: submitDisabledReason is null in the initial implementation, with per-row request data driving disabled UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-25-search-request-section.md | 1858 +++++++++++++++++ 1 file changed, 1858 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-25-search-request-section.md diff --git a/docs/superpowers/plans/2026-05-25-search-request-section.md b/docs/superpowers/plans/2026-05-25-search-request-section.md new file mode 100644 index 00000000..29a72ed5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-search-request-section.md @@ -0,0 +1,1858 @@ +# Search Request Section Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a TMDB-backed "Request to Add" section beneath library results in both the Cmd+K search dialog (`GlobalSearch`) and the Catalog search results page, so users can discover and request items missing from the library without leaving the search flow. + +**Architecture:** No backend changes. Frontend fires two parallel react-query queries — library FTS via the existing `/api/v1/catalog` endpoint, and TMDB via the existing `/api/v1/requests/search` endpoint. A new `useCanRequest()` hook gates whether the TMDB query fires (admin `RequestsEnabled` + authenticated viewer with a profile). Per-row UI state (blocked / quota / pending / etc.) is driven by the backend-enriched `request.requestable` and `request.reason` fields already returned per result. The existing `useRequestSearch` hook is extended to forward `AbortSignal`, key its cache by viewer identity, and be invalidated on auth/profile/policy mutations. + +**Tech Stack:** React 18, TypeScript, vitest, @tanstack/react-query, react-router, Tailwind. All changes are in `web/` (Go backend untouched). + +**Reference spec:** `docs/superpowers/specs/2026-05-25-search-request-section-design.md` + +--- + +## File Structure + +**New files:** + +- `web/src/hooks/useCanRequest.ts` — gating hook returning `{ discoveryEnabled, submitDisabledReason }`. +- `web/src/hooks/useCanRequest.test.ts` — hook unit tests. +- `web/src/components/RequestToAddSection.tsx` — shared section component with `variant="dialog"` and `variant="grid"`. +- `web/src/components/RequestToAddSection.test.tsx` — component tests. + +**Modified files:** + +- `web/src/api/client.ts` — extend `api()` to forward `AbortSignal` from `RequestInit`. +- `web/src/hooks/queries/keys.ts` — extend `requestKeys.search()` to include viewer key. +- `web/src/hooks/queries/useRequests.ts` — extend `useRequestSearch` to accept `signal`, include viewer in key, and add invalidation helpers; wire invalidation into existing settings/limit mutations. +- `web/src/components/RequestPosterCard.tsx` — make `onRequest` and `isSubmitting` optional on `DiscoverProps`; suppress the hover Request button when `onRequest` is undefined. +- `web/src/components/GlobalSearch.tsx` — wire the second query and render `RequestToAddSection` with `variant="dialog"`. +- `web/src/components/GlobalSearch.test.tsx` — add tests for the new section behavior. +- `web/src/pages/Catalog.tsx` — render `RequestToAddSection` with `variant="grid"` below the existing `ItemGrid` when `source === "query"`. +- `web/src/pages/Catalog.test.ts` (or `.tsx` if new) — add tests for the section behavior in the full-page surface. + +--- + +## Design notes on `submitDisabledReason` + +The spec calls for `useCanRequest()` to return `submitDisabledReason: string | null`. The backend already enriches each TMDB result with per-row `request.requestable: boolean` and `request.reason?: string` via `enrichPage()` → `presence.Lookup()`. That per-row data is the canonical source of truth for the disabled state. The viewer-level field is included in the hook's return type for spec conformance and future use, but its value is `null` in this implementation. Per-row UI uses the result's own `request.requestable` and `request.reason` directly. This is consistent with the existing `RequestPosterCard` which already renders a "blocked" StatusRibbon when a row is not requestable. + +--- + +## Task 1: Pin `api()` `AbortSignal` forwarding via test + +**Files:** +- Create: `web/src/api/client.test.ts` + +`api()` at `web/src/api/client.ts:337` calls `fetch(\`/api/v1${path}\`, { ...options, headers })`. The `...options` spread already forwards `signal` to `fetch`, so behavior is correct today. This task does NOT change behavior — it adds a regression test that locks in the contract so a future refactor cannot accidentally drop signal forwarding. + +- [ ] **Step 1: Write the test** + +Create `web/src/api/client.test.ts`: + +```typescript +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { api } from "./client"; + +describe("api()", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("forwards AbortSignal from options to fetch", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + + const controller = new AbortController(); + await api("/test", { signal: controller.signal }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0]!; + const init = call[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/api/client.test.ts` +Expected: PASS — the existing `...options` spread already forwards `signal`. No code change required. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/api/client.test.ts +git commit -m "test(api): pin AbortSignal forwarding contract on api()" +``` + +--- + +## Task 2: Create `useCanRequest()` gating hook + +**Files:** +- Create: `web/src/hooks/useCanRequest.ts` +- Create: `web/src/hooks/useCanRequest.test.ts` + +`useCanRequest()` reads `useRequestFeatureStatus()` and `useCurrentProfile()` and returns `{ discoveryEnabled, submitDisabledReason }`. Discovery is enabled only when the admin flag is on AND there is a profile loaded. Per the design note above, `submitDisabledReason` is always `null` in this implementation — per-row data drives the actual UI. + +- [ ] **Step 1: Write the failing test** + +Create `web/src/hooks/useCanRequest.test.ts`: + +```typescript +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useRequestFeatureStatus: vi.fn(), + useCurrentProfile: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestFeatureStatus: () => mocks.useRequestFeatureStatus(), +})); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +import { useCanRequest } from "./useCanRequest"; + +function CaptureHook({ onResult }: { onResult: (r: ReturnType) => void }) { + const result = useCanRequest(); + onResult(result); + return null; +} + +function render(child: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({child}); +} + +describe("useCanRequest", () => { + it("returns discoveryEnabled=false when requests_enabled is false", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: false } }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=false when there is no profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } }); + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=true when requests are enabled and there is a profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: true, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=false while the feature status is still loading", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( { captured = r; }} />); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && pnpm vitest run src/hooks/useCanRequest.test.ts` +Expected: FAIL — `useCanRequest` does not exist yet. + +- [ ] **Step 3: Create the hook** + +Create `web/src/hooks/useCanRequest.ts`: + +```typescript +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; + +export interface CanRequestState { + discoveryEnabled: boolean; + submitDisabledReason: string | null; +} + +export function useCanRequest(): CanRequestState { + const status = useRequestFeatureStatus(); + const { profile } = useCurrentProfile(); + const discoveryEnabled = Boolean(status.data?.requests_enabled) && Boolean(profile?.id); + return { + discoveryEnabled, + submitDisabledReason: null, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/useCanRequest.test.ts` +Expected: PASS, all four cases. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/hooks/useCanRequest.ts web/src/hooks/useCanRequest.test.ts +git commit -m "feat(hooks): add useCanRequest gating hook for discovery eligibility" +``` + +--- + +## Task 3: Extend `requestKeys.search` to include viewer identity + +**Files:** +- Modify: `web/src/hooks/queries/keys.ts:135-136` + +Add a `viewerKey` parameter so the cache cannot serve results across viewer changes. + +- [ ] **Step 1: Update the key shape** + +Open `web/src/hooks/queries/keys.ts` and replace lines 135-136: + +```typescript + search: (mediaType: string, query: string, page: number, viewerKey: string) => + ["requests", "search", viewerKey, mediaType, query, page] as const, +``` + +- [ ] **Step 2: Run the type check to see callers that need updating** + +Run: `cd web && pnpm tsc --noEmit` +Expected: TypeScript errors at every call site of `requestKeys.search(...)`. Note the file paths reported. + +- [ ] **Step 3: Commit the key change alone** + +The next task updates the callers. Keep this commit focused. + +```bash +git add web/src/hooks/queries/keys.ts +git commit -m "refactor(keys): add viewerKey to requestKeys.search" +``` + +--- + +## Task 4: Extend `useRequestSearch` with signal, viewer key, staleTime, and enabled option + +**Files:** +- Modify: `web/src/hooks/queries/useRequests.ts:151-166` + +Update `useRequestSearch` so it (a) accepts and forwards a `signal` from react-query, (b) keys the cache by the current viewer's `profile.id`, (c) uses a 5-minute `staleTime` (the spec value), and (d) accepts an optional `enabled` override so callers can gate it on `discoveryEnabled` without firing the query when disallowed. + +- [ ] **Step 1: Write the failing test** + +Append to `web/src/hooks/queries/useRequests.test.ts` (create the file if missing): + +```typescript +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useQuery: vi.fn(), + useCurrentProfile: vi.fn(), + api: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (...args: unknown[]) => mocks.useQuery(...args), + }; +}); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +vi.mock("@/api/client", () => ({ + api: (...args: unknown[]) => mocks.api(...args), +})); + +import { useRequestSearch } from "./useRequests"; + +function render(node: React.ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({node}); +} + +function CallHook(props: { mediaType: "movie" | "series" | "all"; q: string; page?: number }) { + useRequestSearch(props.mediaType, props.q, props.page ?? 1); + return null; +} + +describe("useRequestSearch", () => { + it("includes the current profile id in the query key", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "profile-1", "all", "dune", 1]); + }); + + it("uses 'anon' as the viewer key when there is no profile", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "anon", "movie", "dune", 1]); + }); + + it("forwards the react-query signal to api()", async () => { + mocks.useQuery.mockReset(); + mocks.api.mockResolvedValue({ page: 1, total_pages: 0, total_results: 0, results: [] }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { + queryFn: (ctx: { signal: AbortSignal }) => unknown; + }; + const controller = new AbortController(); + await options.queryFn({ signal: controller.signal }); + + expect(mocks.api).toHaveBeenCalledTimes(1); + const apiCall = mocks.api.mock.calls[0]!; + expect(apiCall[0]).toContain("/requests/search?"); + const init = apiCall[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); + + it("uses a 5-minute staleTime", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number }; + expect(options.staleTime).toBe(5 * 60 * 1000); + }); + + it("respects the enabled option override", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + + function CallHookWithOpt({ enabled }: { enabled: boolean }) { + useRequestSearch("all", "dune", 1, { enabled }); + return null; + } + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(false); + }); + + it("does not include enabled override when option omitted (defaults to true)", () => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + // Internally `normalizedQuery.length > 1` is true, and the default enabled override + // is true, so this should resolve to true. + expect(options.enabled).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: FAIL — the existing hook does not include profile in the key, does not pass signal, and uses `REQUESTS_STALE_TIME` (30s). + +- [ ] **Step 3: Update the hook** + +Replace lines 151-166 of `web/src/hooks/queries/useRequests.ts` with: + +```typescript +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; + +const REQUEST_SEARCH_STALE_TIME = 5 * 60 * 1000; + +export interface UseRequestSearchOptions { + /** When false, suppresses the query regardless of the query string. Default: true. */ + enabled?: boolean; +} + +export function useRequestSearch( + mediaType: RequestSearchMediaType, + query: string, + page = 1, + options: UseRequestSearchOptions = {}, +) { + const normalizedQuery = query.trim(); + const { profile } = useCurrentProfile(); + const viewerKey = profile?.id ?? "anon"; + const enabledOverride = options.enabled ?? true; + return useQuery({ + queryKey: requestKeys.search(mediaType, normalizedQuery, page, viewerKey), + queryFn: ({ signal }) => { + const params = new URLSearchParams({ + q: normalizedQuery, + media_type: mediaType, + page: String(page), + }); + return api(`/requests/search?${params}`, { signal }); + }, + enabled: enabledOverride && normalizedQuery.length > 1, + staleTime: REQUEST_SEARCH_STALE_TIME, + }); +} +``` + +Note: the `useCurrentProfile` import must be added near the top of the file. The `REQUEST_SEARCH_STALE_TIME` constant goes near the top alongside `REQUESTS_STALE_TIME`. Existing callers (e.g., `Requests.tsx:140`) pass three arguments and continue to work — the new fourth `options` parameter defaults to `{}`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: PASS, all four cases. + +- [ ] **Step 5: Run the full type check** + +Run: `cd web && pnpm tsc --noEmit` +Expected: PASS. No call sites should break (this hook's external signature is unchanged). + +- [ ] **Step 6: Commit** + +```bash +git add web/src/hooks/queries/useRequests.ts web/src/hooks/queries/useRequests.test.ts +git commit -m "feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime" +``` + +--- + +## Task 5: Invalidate request search cache on policy & settings mutations + +**Files:** +- Modify: `web/src/hooks/queries/useRequests.ts:53-56` (extend `invalidateRequestSurfaces`) +- Modify: `web/src/hooks/queries/useRequests.ts:262-288` (`useUpdateRequestSettings`) +- Modify: `web/src/hooks/queries/useRequests.ts:345-362` (`useUpdateRequestUserLimit`) + +The existing `invalidateRequestSurfaces` invalidates `requestKeys.all`, which is `["requests"]`. React-query's invalidation matches by key prefix, so this *already* invalidates `requestKeys.search(...)` because that key starts with `["requests", "search", ...]`. Verify this and add a focused test rather than introducing new helpers. + +- [ ] **Step 1: Add a test asserting invalidation behavior** + +Append to `web/src/hooks/queries/useRequests.test.ts`: + +```typescript +import { QueryClient as RealQueryClient } from "@tanstack/react-query"; +import { requestKeys } from "./keys"; + +describe("requestKeys.all invalidation", () => { + it("invalidates entries under requestKeys.search() when invalidating requestKeys.all", async () => { + const client = new RealQueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { sentinel: true }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-1"))).toEqual({ + sentinel: true, + }); + + await client.invalidateQueries({ queryKey: requestKeys.all }); + + const state = client.getQueryState(requestKeys.search("all", "dune", 1, "profile-1")); + expect(state?.isInvalidated).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: PASS. This documents that the existing `invalidateRequestSurfaces` already cascades to search results. + +- [ ] **Step 3: Add a comment in useRequests.ts** + +In `web/src/hooks/queries/useRequests.ts`, replace the `invalidateRequestSurfaces` function (lines 53-56) with: + +```typescript +function invalidateRequestSurfaces(queryClient: ReturnType) { + // requestKeys.all = ["requests"] — invalidating it cascades to every nested key, + // including requestKeys.search(...). Settings and per-user limit mutations rely + // on this to re-fetch viewer-scoped search results when policy changes. + queryClient.invalidateQueries({ queryKey: requestKeys.all }); + queryClient.invalidateQueries({ queryKey: adminKeys.requestsRoot() }); +} +``` + +- [ ] **Step 4: Add a test that profile change invalidates results** + +Append to `web/src/hooks/queries/useRequests.test.ts`: + +```typescript +describe("viewer-scoped cache isolation", () => { + it("does not return profile-1 results when keyed by profile-2", () => { + const client = new RealQueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { + results: [{ tmdb_id: 1 }], + }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-2"))).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/hooks/queries/useRequests.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/hooks/queries/useRequests.ts web/src/hooks/queries/useRequests.test.ts +git commit -m "test(requests): document viewer-keyed cache isolation and invalidation cascade" +``` + +--- + +## Task 6: Make `RequestPosterCard.DiscoverProps` request handler optional + +**Files:** +- Modify: `web/src/components/RequestPosterCard.tsx:9-16` (DiscoverProps) +- Modify: `web/src/components/RequestPosterCard.tsx:40-50` (DiscoverCard signature) +- Modify: `web/src/components/RequestPosterCard.tsx:95-120` (hover button render) + +For the new search context, we don't want the inline-submit hover button. Make `onRequest` and `isSubmitting` optional, and only render the hover button when `onRequest` is defined. + +- [ ] **Step 1: Write the failing test** + +Create `web/src/components/RequestPosterCard.test.tsx`: + +```typescript +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import RequestPosterCard from "./RequestPosterCard"; +import type { RequestMediaResult } from "@/api/types"; + +const requestable: RequestMediaResult = { + media_type: "movie", + tmdb_id: 42, + title: "Test Movie", + availability: "missing", + request: { requestable: true }, +}; + +describe("RequestPosterCard (discover variant)", () => { + it("renders the hover Request button when onRequest is provided", () => { + const markup = renderToStaticMarkup( + + {}} + /> + , + ); + expect(markup).toContain("Request"); + }); + + it("does not render the hover Request button when onRequest is omitted", () => { + const markup = renderToStaticMarkup( + + + , + ); + // The hover button has class "rounded-full bg-white"; check that pattern is absent. + expect(markup).not.toContain("rounded-full bg-white"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd web && pnpm vitest run src/components/RequestPosterCard.test.tsx` +Expected: FAIL — the second test fails because `RequestPosterCard` currently requires `onRequest` and `isSubmitting`, and even with placeholder values it would still render the button. + +- [ ] **Step 3: Update DiscoverProps** + +In `web/src/components/RequestPosterCard.tsx`, replace lines 9-16 with: + +```typescript +type DiscoverProps = { + variant: "discover"; + item: RequestMediaResult; + /** Called when the inline hover Request button is clicked. Omit to suppress the button. */ + onRequest?: () => void; + /** Displays the spinner state on the hover Request button. Ignored when onRequest is omitted. */ + isSubmitting?: boolean; + /** When true, fills the parent (use inside grids). Default: fixed carousel width. */ + fluid?: boolean; +}; +``` + +- [ ] **Step 4: Update the DiscoverCard component signature and render** + +Replace lines 40-50 of `RequestPosterCard.tsx`: + +```typescript +function DiscoverCard({ + item, + isSubmitting, + onRequest, + fluid, +}: { + item: RequestMediaResult; + isSubmitting?: boolean; + onRequest?: () => void; + fluid?: boolean; +}) { +``` + +Replace lines 95-120 (the conditional hover button) with: + +```typescript + {requestable && onRequest && ( +
+ +
+ )} +``` + +Also update the call site at line 30 (in the dispatcher) to spread props correctly: + +```typescript + return ( + + ); +``` + +(This is already the existing shape — verify it still type-checks now that the inner DiscoverProps fields are optional.) + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd web && pnpm vitest run src/components/RequestPosterCard.test.tsx` +Expected: PASS, both cases. + +- [ ] **Step 6: Run the full type check** + +Run: `cd web && pnpm tsc --noEmit` +Expected: PASS. Existing callers still pass both fields, so no breaks. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/components/RequestPosterCard.tsx web/src/components/RequestPosterCard.test.tsx +git commit -m "feat(request-card): make onRequest optional on discover variant" +``` + +--- + +## Task 7: Create `RequestToAddSection` — dialog variant + +**Files:** +- Create: `web/src/components/RequestToAddSection.tsx` +- Create: `web/src/components/RequestToAddSection.test.tsx` + +A self-contained component that owns: +- The TMDB query (via `useRequestSearch`) gated by `useCanRequest().discoveryEnabled` +- Filtering out results already in the library (`availability === "available"`) +- Section header copy: "Request to Add" when `libraryHadHits=true`, "Not in your library, but you can request" when `libraryHadHits=false` +- Two render variants: `dialog` (compact rows, max 4) and `grid` (poster cards, max 20) +- Silent omit on error or empty TMDB + +This task implements the dialog variant only; Task 8 adds the grid variant. + +- [ ] **Step 1: Write the failing test for the dialog variant** + +Create `web/src/components/RequestToAddSection.test.tsx`: + +```typescript +import type { ReactNode } from "react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useCanRequest: vi.fn(), + useRequestSearch: vi.fn(), + useDebounce: vi.fn(), +})); + +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mocks.useCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args), +})); + +vi.mock("@/hooks/useDebounce", () => ({ + useDebounce: (v: T) => mocks.useDebounce(v) ?? v, +})); + +import { RequestToAddSection } from "./RequestToAddSection"; +import type { RequestMediaResult } from "@/api/types"; + +function render(child: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup( + + {child} + , + ); +} + +const missingResult = (overrides: Partial = {}): RequestMediaResult => ({ + media_type: "movie", + tmdb_id: 1, + title: "Dune: Prophecy", + year: 2024, + availability: "missing", + request: { requestable: true }, + ...overrides, +}); + +const availableResult = (overrides: Partial = {}): RequestMediaResult => ({ + media_type: "movie", + tmdb_id: 2, + title: "Dune", + year: 2021, + availability: "available", + request: { requestable: false }, + ...overrides, +}); + +describe("RequestToAddSection (dialog variant)", () => { + beforeEach(() => { + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useDebounce.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useDebounce.mockImplementation((v: unknown) => v); + }); + + it("renders nothing when discovery is disabled", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); + + const markup = render(); + expect(markup).toBe(""); + }); + + it("renders 'Request to Add' header when library had hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Request to Add"); + expect(markup).toContain("Dune: Prophecy"); + }); + + it("renders soft framing when library had 0 hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] }, + isLoading: false, + isError: false, + }); + const markup = render( + , + ); + expect(markup).toContain("Not in your library, but you can request"); + expect(markup).not.toContain("Request to Add"); + }); + + it("filters out results already available in the library", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 2, + results: [availableResult(), missingResult()], + }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Dune: Prophecy"); + expect(markup).not.toContain('"Dune"'); + }); + + it("renders nothing when TMDB returned an error", () => { + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + const markup = render(); + expect(markup).toBe(""); + }); + + it("renders nothing when all TMDB results are already in the library", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [availableResult()] }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toBe(""); + }); + + it("limits the dialog variant to at most 4 rows", () => { + const many = Array.from({ length: 10 }, (_, i) => + missingResult({ tmdb_id: i + 100, title: `Result ${i}` }), + ); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: many.length, results: many }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Result 0"); + expect(markup).toContain("Result 3"); + expect(markup).not.toContain("Result 4"); + }); + + it("renders the disabled affordance and reason when a row is not requestable", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + missingResult({ + tmdb_id: 7, + title: "Quota Capped Movie", + request: { requestable: false, reason: "quota_exhausted" }, + }), + ], + }, + isLoading: false, + isError: false, + }); + + const markup = render(); + + expect(markup).toContain("Quota Capped Movie"); + // The active "Request" amber chip is suppressed; a muted reason chip is shown instead. + expect(markup).not.toContain("bg-amber-400/15"); + // formatRequestReason("quota_exhausted") yields a human label that must be present. + expect(markup).toMatch(/title="[^"]+"/); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: FAIL — the component does not exist yet. + +- [ ] **Step 3: Create the component** + +Create `web/src/components/RequestToAddSection.tsx`: + +```typescript +import { Link } from "react-router"; +import { Film, Tv } from "lucide-react"; +import { useCanRequest } from "@/hooks/useCanRequest"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +import type { RequestMediaResult } from "@/api/types"; +import { formatRequestReason, tmdbImageURL } from "@/lib/mediaRequests"; +import { cn } from "@/lib/utils"; + +const DIALOG_LIMIT = 4; +const GRID_LIMIT = 20; + +export type RequestToAddSectionProps = { + variant: "dialog" | "grid"; + query: string; + /** True when the library FTS returned ≥1 hit. Drives header copy. */ + libraryHadHits: boolean; +}; + +export function RequestToAddSection({ variant, query, libraryHadHits }: RequestToAddSectionProps) { + const { discoveryEnabled } = useCanRequest(); + const search = useRequestSearch("all", query, 1); + + if (!discoveryEnabled) return null; + if (search.isError) return null; + + const filtered = (search.data?.results ?? []).filter( + (item) => item.availability !== "available", + ); + if (filtered.length === 0) return null; + + const limit = variant === "dialog" ? DIALOG_LIMIT : GRID_LIMIT; + const visible = filtered.slice(0, limit); + + if (variant === "dialog") { + return ; + } + return ; +} + +function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count: number }) { + if (libraryHadHits) { + return ( +
+ Request to Add + + {count} + +
+ ); + } + return ( +
+ Not in your library, but you can request: +
+ ); +} + +function DialogVariant({ + items, + libraryHadHits, +}: { + items: RequestMediaResult[]; + libraryHadHits: boolean; +}) { + return ( +
+ +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+
+ ); +} + +function DialogRow({ item }: { item: RequestMediaResult }) { + const poster = tmdbImageURL(item.poster_path); + const Icon = item.media_type === "series" ? Tv : Film; + const requestable = item.request.requestable; + const reasonLabel = !requestable + ? item.request.reason + ? formatRequestReason(item.request.reason) + : "Blocked" + : null; + return ( + +
+ {poster ? ( + + ) : ( +
+ +
+ )} +
+
+
{item.title}
+
+ {item.year ? `${item.year} · ` : ""} + {item.media_type === "series" ? "Series" : "Movie"} +
+
+ {requestable ? ( + + Request + + ) : ( + + {reasonLabel} + + )} + + ); +} + +function GridVariant({ + items: _items, + libraryHadHits: _libraryHadHits, +}: { + items: RequestMediaResult[]; + libraryHadHits: boolean; +}) { + // Implemented in Task 8. + return null; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: PASS, all dialog-variant cases. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/RequestToAddSection.tsx web/src/components/RequestToAddSection.test.tsx +git commit -m "feat(search): add RequestToAddSection dialog variant" +``` + +--- + +## Task 8: Add the grid variant to `RequestToAddSection` + +**Files:** +- Modify: `web/src/components/RequestToAddSection.tsx` (`GridVariant`) +- Modify: `web/src/components/RequestToAddSection.test.tsx` (add grid coverage) + +- [ ] **Step 1: Write the failing test** + +Append to `web/src/components/RequestToAddSection.test.tsx`: + +```typescript +describe("RequestToAddSection (grid variant)", () => { + beforeEach(() => { + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + }); + + it("renders a card per result with the Request to Add header when library had hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 2, + results: [ + missingResult({ tmdb_id: 1, title: "Dune: Prophecy" }), + missingResult({ tmdb_id: 2, title: "Dune (1984)" }), + ], + }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Request to Add"); + expect(markup).toContain("Dune: Prophecy"); + expect(markup).toContain("Dune (1984)"); + }); + + it("renders the soft framing in the grid variant when library had 0 hits", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [missingResult({ tmdb_id: 1, title: "Dune: Prophecy" })], + }, + isLoading: false, + isError: false, + }); + const markup = render( + , + ); + expect(markup).toContain("Not in your library, but you can request"); + }); + + it("limits the grid to at most 20 cards", () => { + const many = Array.from({ length: 30 }, (_, i) => + missingResult({ tmdb_id: i + 100, title: `Result ${i}` }), + ); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: many.length, results: many }, + isLoading: false, + isError: false, + }); + const markup = render(); + expect(markup).toContain("Result 0"); + expect(markup).toContain("Result 19"); + expect(markup).not.toContain("Result 20"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: FAIL — the grid variant renders `null`. + +- [ ] **Step 3: Implement `GridVariant`** + +Replace the `GridVariant` placeholder in `web/src/components/RequestToAddSection.tsx`: + +```typescript +import RequestPosterCard from "./RequestPosterCard"; + +function GridVariant({ + items, + libraryHadHits, +}: { + items: RequestMediaResult[]; + libraryHadHits: boolean; +}) { + return ( +
+
+
+

+ {libraryHadHits ? "Request to Add" : "Not in your library, but you can request"} +

+
+
+
+ {items.map((item) => ( + + ))} +
+
+ ); +} +``` + +(`onRequest` and `isSubmitting` are intentionally omitted — Task 6 made them optional so the hover button is suppressed.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd web && pnpm vitest run src/components/RequestToAddSection.test.tsx` +Expected: PASS, all dialog and grid cases. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/RequestToAddSection.tsx web/src/components/RequestToAddSection.test.tsx +git commit -m "feat(search): add RequestToAddSection grid variant for Catalog page" +``` + +--- + +## Task 9: Integrate `RequestToAddSection` into `GlobalSearch` + +**Files:** +- Modify: `web/src/components/GlobalSearch.tsx` +- Modify: `web/src/components/GlobalSearch.test.tsx` + +GlobalSearch hoists the TMDB query alongside the library query so it can suppress the "No matches" empty state while TMDB is still pending or has results to show. The section renders inside the same scrollable list. The TMDB debounce is 400ms (vs library's 200ms). + +- [ ] **Step 1: Write the failing tests** + +The existing `GlobalSearch.test.tsx` mocks `useQuery` globally. Because GlobalSearch now calls multiple hooks that internally use `useQuery` (library preview + TMDB search), the mock returns the same response for both. Switch the test scaffolding to mock the specific hooks we use rather than `useQuery` itself. + +Replace the top of `web/src/components/GlobalSearch.test.tsx` (the existing `mocks`, the `useQuery` mock, and the `useDebounce` mock) with: + +```typescript +const mocks = vi.hoisted(() => ({ + useQuery: vi.fn(), + useCanRequest: vi.fn(), + useRequestSearch: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (...args: unknown[]) => mocks.useQuery(...args), + }; +}); + +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mocks.useCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args), +})); + +vi.mock("@/hooks/useDebounce", () => ({ + useDebounce: (v: T) => v, +})); +``` + +Then update the `beforeEach` to set default mocks: + +```typescript + beforeEach(() => { + mocks.useQuery.mockReset(); + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }); + mocks.useQuery.mockReturnValue({ + data: { total: 50, has_more: true, items: [browseFixture] }, + isFetching: false, + isError: false, + }); + }); +``` + +Now add a section-wiring `describe` block at the end of the file: + +```typescript +vi.mock("@/components/RequestToAddSection", () => ({ + RequestToAddSection: ({ + variant, + query, + libraryHadHits, + }: { + variant: string; + query: string; + libraryHadHits: boolean; + }) => ( +
+ variant={variant} query={query} libraryHadHits={String(libraryHadHits)} +
+ ), +})); + +describe("GlobalSearch + RequestToAddSection wiring", () => { + it("renders the section with libraryHadHits=true when library returned results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); + + expect(markup).toContain('data-testid="request-section"'); + expect(markup).toContain('libraryHadHits="true"'); + expect(markup).toContain('variant="dialog"'); + }); + + it("renders the section with libraryHadHits=false when library returned 0 results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ThisDoesNotExist" }); + + expect(markup).toContain('libraryHadHits="false"'); + }); + + it("does not call useRequestSearch with enabled=true when discoveryEnabled is false", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("suppresses 'No matches' when library is empty and TMDB is still loading", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Pending" }); + + expect(markup).not.toContain("No matches"); + }); + + it("suppresses 'No matches' when library is empty and TMDB has missing results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "FoundOnTmdb" }); + + expect(markup).not.toContain("No matches"); + }); + + it("still shows 'No matches' when both library and TMDB are empty", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useQuery.mockReturnValue({ + data: { total: 0, has_more: false, items: [] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ZzzNothing" }); + + expect(markup).toContain("No matches"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd web && pnpm vitest run src/components/GlobalSearch.test.tsx` +Expected: FAIL — the section is not yet wired in, and `useRequestSearch` is not called from GlobalSearch. + +- [ ] **Step 3: Wire the section into GlobalSearch** + +In `web/src/components/GlobalSearch.tsx`, add imports near the top: + +```typescript +import { RequestToAddSection } from "./RequestToAddSection"; +import { useCanRequest } from "@/hooks/useCanRequest"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +``` + +Add a new constant near the top with the other constants: + +```typescript +const TMDB_DEBOUNCE_MS = 400; +``` + +Inside the `GlobalSearch` component, after the existing `debouncedQuery` line, add a second debounce for TMDB and lift the TMDB query: + +```typescript + const tmdbDebouncedQuery = useDebounce(query.trim(), TMDB_DEBOUNCE_MS); + const canRequest = useCanRequest(); + const tmdbQuery = useRequestSearch("all", tmdbDebouncedQuery, 1, { + enabled: canRequest.discoveryEnabled, + }); + const tmdbMissingCount = + tmdbQuery.data?.results?.filter((r) => r.availability !== "available").length ?? 0; + const tmdbStillLoading = + canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1 && tmdbQuery.isLoading; + const tmdbWillRender = canRequest.discoveryEnabled && tmdbMissingCount > 0; +``` + +Update the existing `showEmpty` computation to suppress the empty state while TMDB might still produce a result: + +```typescript + const showEmpty = + !previewQuery.isFetching && + debouncedQuery.length > 0 && + items.length === 0 && + !previewQuery.isError && + !tmdbStillLoading && + !tmdbWillRender; +``` + +Then in the `showResultsPanel` JSX block, add the `` render below the `items.map(...)` loop. Replace lines 237-281 with: + +```typescript + {showResultsPanel && ( +
+
+ {showLoading && ( +
+ Searching... +
+ )} + {showError && ( +
+ Could not load results. Press Enter to open the search page. +
+ )} + {showEmpty && ( +
+ No matches +
+ )} + {items.map((item, i) => ( + + ))} + {tmdbDebouncedQuery.length > 1 && ( + 0} + /> + )} +
+
+ {items.length} results found +
+
+ {total > PREVIEW_LIMIT ? ( +

+ Showing top {PREVIEW_LIMIT} of {total}. Press Enter for all results. +

+ ) : ( +

Press Enter to open the full search page.

+ )} +
+
+ )} +``` + +Note that `RequestToAddSection` ALSO calls `useRequestSearch` internally — react-query dedupes by query key, so this is a single network call. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd web && pnpm vitest run src/components/GlobalSearch.test.tsx` +Expected: PASS, including the existing tests plus the new section-wiring tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/GlobalSearch.tsx web/src/components/GlobalSearch.test.tsx +git commit -m "feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression" +``` + +--- + +## Task 10: Integrate `RequestToAddSection` into `Catalog` + +**Files:** +- Modify: `web/src/pages/Catalog.tsx` +- Create: `web/src/pages/Catalog.test.tsx` (if not present) + +Add the grid variant below the existing `ItemGrid` when `state.source === "query"` and there is a query. The page also lifts the TMDB query so it can keep the `ItemGrid` in a loading state (instead of showing "No items found") while TMDB is still pending or has missing results. + +- [ ] **Step 1: Write the failing tests** + +Inspect `web/src/pages/`. If a `Catalog.test.tsx` already exists, append to it; otherwise create it. + +```typescript +import type { ReactNode } from "react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useCatalogWindow: vi.fn(), + useCanRequest: vi.fn(), + useRequestSearch: vi.fn(), +})); + +vi.mock("@/hooks/queries/catalog", () => ({ + useCatalogWindow: (...args: unknown[]) => mocks.useCatalogWindow(...args), + createCatalogSearchState: (source: string, params: Record) => ({ + source, + ...params, + }), + fetchCatalogPage: vi.fn(), +})); + +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mocks.useCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args), +})); + +vi.mock("@/components/RequestToAddSection", () => ({ + RequestToAddSection: ({ + variant, + query, + libraryHadHits, + }: { + variant: string; + query: string; + libraryHadHits: boolean; + }) => ( +
+ variant={variant} query={query} libraryHadHits={String(libraryHadHits)} +
+ ), +})); + +vi.mock("@/components/ItemGrid", () => ({ + default: ({ totalItems, loading }: { totalItems: number; loading: boolean }) => ( +
+ ), +})); + +import Catalog from "./Catalog"; + +function render(initialEntry: string) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("Catalog + RequestToAddSection wiring", () => { + beforeEach(() => { + mocks.useCatalogWindow.mockReset(); + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); + }); + + it("renders the grid variant when source=query and library has results", () => { + mocks.useCatalogWindow.mockReturnValue({ + data: { + title: 'Results for "dune"', + totalItems: 2, + pages: new Map([[0, [{ content_id: "lib-1", title: "Dune", type: "movie", year: 2021 }]]]), + }, + isLoading: false, + }); + + const markup = render("/catalog?source=query&q=dune"); + + expect(markup).toContain('data-testid="request-section"'); + expect(markup).toContain('variant="grid"'); + expect(markup).toContain('libraryHadHits="true"'); + }); + + it("renders the grid variant with libraryHadHits=false when library has 0 hits", () => { + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "noresults"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + const markup = render("/catalog?source=query&q=noresults"); + + expect(markup).toContain('libraryHadHits="false"'); + }); + + it("does not render the section when source is not query", () => { + mocks.useCatalogWindow.mockReturnValue({ + data: { title: "Favorites", totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + const markup = render("/catalog?source=favorites"); + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("passes enabled=false to useRequestSearch when discoveryEnabled is false", () => { + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + render("/catalog?source=query&q=dune"); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("keeps ItemGrid in a loading state when library is empty and TMDB is still loading", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + + const markup = render("/catalog?source=query&q=dune"); + + expect(markup).toContain('data-loading="true"'); + }); + + it("keeps ItemGrid in a loading state when library is empty and TMDB has missing results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { media_type: "movie", tmdb_id: 1, title: "X", availability: "missing", request: { requestable: true } }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = render("/catalog?source=query&q=dune"); + + expect(markup).toContain('data-loading="true"'); + }); + + it("renders the normal ItemGrid empty state when both library and TMDB are empty", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCatalogWindow.mockReturnValue({ + data: { title: 'Results for "zzz"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + + const markup = render("/catalog?source=query&q=zzz"); + + expect(markup).toContain('data-loading="false"'); + expect(markup).toContain('data-total="0"'); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd web && pnpm vitest run src/pages/Catalog.test.tsx` +Expected: FAIL — the section is not rendered and the loading-state coordination is not implemented. + +- [ ] **Step 3: Wire the section into Catalog** + +In `web/src/pages/Catalog.tsx`, add imports: + +```typescript +import { RequestToAddSection } from "@/components/RequestToAddSection"; +import { useCanRequest } from "@/hooks/useCanRequest"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +``` + +Inside `CatalogResults`, after the existing `useCatalogWindow` call (around line 99-103), add: + +```typescript + const canRequest = useCanRequest(); + const isQuerySource = state.source === "query" && Boolean(state.q); + const tmdbQuery = useRequestSearch("all", state.q ?? "", 1, { + enabled: canRequest.discoveryEnabled && isQuerySource, + }); + const tmdbMissingCount = + tmdbQuery.data?.results?.filter((r) => r.availability !== "available").length ?? 0; + const libraryEmpty = (catalogQuery.data?.totalItems ?? 0) === 0; + const tmdbPendingForEmptyLibrary = + isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbQuery.isLoading; + const tmdbWillRenderForEmptyLibrary = + isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbMissingCount > 0; + const itemGridLoading = + catalogQuery.isLoading || tmdbPendingForEmptyLibrary || tmdbWillRenderForEmptyLibrary; +``` + +Update the `` prop: + +```typescript + +``` + +After the `ItemGrid`, before the `ConfirmDialog`, render the section: + +```typescript + {isQuerySource ? ( + 0} + /> + ) : null} + + /`. +5. In an admin context, toggle `RequestsEnabled` off via the admin UI. Re-open Cmd+K and confirm the section does NOT appear. +6. Slow the network (devtools throttling) and search again. Confirm library results appear immediately while the section is pending; the section appears once TMDB returns. + +- [ ] **Step 4: If any scenario fails, file the gap and stop here** + +Do not paper over UI regressions. Each failing scenario gets a short bug report (file path, expected, actual). The implementation plan ends with manual confirmation, not with a brittle "looks good". + +--- + +## Verification summary (run before opening MR) + +- `cd web && pnpm run lint` → PASS +- `cd web && pnpm run format:check` → PASS +- `cd web && pnpm test` → PASS +- `make verify-local-paths` → PASS +- Manual smoke per Task 12 → PASS + +--- + +## Deviations from the spec (recorded for the MR description) + +- **`submitDisabledReason` is always `null` in this implementation.** The spec defines this as a viewer-level signal fed by `EffectivePolicy.LimitMode` and quota state, but the frontend has no API surface today that exposes the viewer's effective policy as a single value. Per-row disabled state is driven by `result.request.requestable` and `result.request.reason`, which the backend already enriches per result. The `submitDisabledReason` field is retained in the `useCanRequest()` return type as a forward-compatible stub. Populating it would require a small backend addition to `/api/v1/requests/status` (out of scope here per the spec's "no backend changes" framing). From 4715b8940f89793daafb5b02e48c399f9c46ed75 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:02:58 -0400 Subject: [PATCH 12/53] docs(plans): address Codex adversarial review for search request section Fixes the high-severity finding that RequestToAddSection's internal useRequestSearch call was not gated on discoveryEnabled, allowing /api/v1/requests/search and TMDB lookups to fire for users without request access. The plan now (1) passes { enabled: discoveryEnabled } to the section's hook, (2) gates the parent mount in GlobalSearch and Catalog on canRequest.discoveryEnabled as defense in depth, and (3) adds tests asserting both the enabled forwarding and the no-mount behavior when discovery is disabled. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-25-search-request-section.md | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-05-25-search-request-section.md b/docs/superpowers/plans/2026-05-25-search-request-section.md index 29a72ed5..269666b9 100644 --- a/docs/superpowers/plans/2026-05-25-search-request-section.md +++ b/docs/superpowers/plans/2026-05-25-search-request-section.md @@ -790,6 +790,33 @@ describe("RequestToAddSection (dialog variant)", () => { expect(markup).toBe(""); }); + it("passes enabled=false to useRequestSearch when discovery is disabled so no network call fires", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); + + render(); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[0]).toBe("all"); + expect(call?.[1]).toBe("dune"); + expect(call?.[2]).toBe(1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("passes enabled=true to useRequestSearch when discovery is enabled", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + + render(); + + const call = mocks.useRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: true }); + }); + it("renders 'Request to Add' header when library had hits", () => { mocks.useRequestSearch.mockReturnValue({ data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] }, @@ -920,7 +947,10 @@ export type RequestToAddSectionProps = { export function RequestToAddSection({ variant, query, libraryHadHits }: RequestToAddSectionProps) { const { discoveryEnabled } = useCanRequest(); - const search = useRequestSearch("all", query, 1); + // Gate the TMDB query firing on discovery eligibility. The `!discoveryEnabled` + // early return below hides the UI, but the hook still runs unconditionally + // (rules of hooks) — passing `enabled` is what prevents the network call. + const search = useRequestSearch("all", query, 1, { enabled: discoveryEnabled }); if (!discoveryEnabled) return null; if (search.isError) return null; @@ -1323,6 +1353,13 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { expect(call?.[3]).toEqual({ enabled: false }); }); + it("does not mount RequestToAddSection when discovery is disabled", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); + + expect(markup).not.toContain('data-testid="request-section"'); + }); + it("suppresses 'No matches' when library is empty and TMDB is still loading", () => { mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); mocks.useQuery.mockReturnValue({ @@ -1464,7 +1501,7 @@ Then in the `showResultsPanel` JSX block, add the `` render onPick={handlePickItem} /> ))} - {tmdbDebouncedQuery.length > 1 && ( + {tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && ( { }); it("renders the grid variant when source=query and library has results", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); mocks.useCatalogWindow.mockReturnValue({ data: { title: 'Results for "dune"', @@ -1608,6 +1646,7 @@ describe("Catalog + RequestToAddSection wiring", () => { }); it("renders the grid variant with libraryHadHits=false when library has 0 hits", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); mocks.useCatalogWindow.mockReturnValue({ data: { title: 'Results for "noresults"', totalItems: 0, pages: new Map() }, isLoading: false, @@ -1619,6 +1658,7 @@ describe("Catalog + RequestToAddSection wiring", () => { }); it("does not render the section when source is not query", () => { + mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); mocks.useCatalogWindow.mockReturnValue({ data: { title: "Favorites", totalItems: 0, pages: new Map() }, isLoading: false, @@ -1628,6 +1668,21 @@ describe("Catalog + RequestToAddSection wiring", () => { expect(markup).not.toContain('data-testid="request-section"'); }); + it("does not render the section when discovery is disabled", () => { + // Default beforeEach sets discoveryEnabled=false; assert the parent gate blocks the mount. + mocks.useCatalogWindow.mockReturnValue({ + data: { + title: 'Results for "dune"', + totalItems: 2, + pages: new Map([[0, [{ content_id: "lib-1", title: "Dune", type: "movie", year: 2021 }]]]), + }, + isLoading: false, + }); + + const markup = render("/catalog?source=query&q=dune"); + expect(markup).not.toContain('data-testid="request-section"'); + }); + it("passes enabled=false to useRequestSearch when discoveryEnabled is false", () => { mocks.useCatalogWindow.mockReturnValue({ data: { title: 'Results for "dune"', totalItems: 0, pages: new Map() }, @@ -1749,7 +1804,7 @@ Update the `` prop: After the `ItemGrid`, before the `ConfirmDialog`, render the section: ```typescript - {isQuerySource ? ( + {isQuerySource && canRequest.discoveryEnabled ? ( Date: Mon, 25 May 2026 18:10:06 -0400 Subject: [PATCH 13/53] test(api): pin AbortSignal forwarding contract on api() --- web/src/api/client.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 8906e50d..a663d158 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -121,6 +121,34 @@ describe("client helper inventory", () => { }); describe("api", () => { + it("forwards AbortSignal from options to fetch", async () => { + Object.defineProperty(globalThis, "sessionStorage", { + value: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + }, + configurable: true, + }); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const controller = new AbortController(); + await api("/test", { signal: controller.signal }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0]!; + const init = call[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); + it("treats 202 responses with an empty body as success", async () => { Object.defineProperty(globalThis, "sessionStorage", { value: { From d923051513f61bba7888c357824e3bf0117f83bc Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:10:58 -0400 Subject: [PATCH 14/53] feat(hooks): add useCanRequest gating hook for discovery eligibility --- web/src/hooks/useCanRequest.test.tsx | 96 ++++++++++++++++++++++++++++ web/src/hooks/useCanRequest.ts | 18 ++++++ 2 files changed, 114 insertions(+) create mode 100644 web/src/hooks/useCanRequest.test.tsx create mode 100644 web/src/hooks/useCanRequest.ts diff --git a/web/src/hooks/useCanRequest.test.tsx b/web/src/hooks/useCanRequest.test.tsx new file mode 100644 index 00000000..75f15344 --- /dev/null +++ b/web/src/hooks/useCanRequest.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useRequestFeatureStatus: vi.fn(), + useCurrentProfile: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestFeatureStatus: () => mocks.useRequestFeatureStatus(), +})); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +import { useCanRequest } from "./useCanRequest"; + +function CaptureHook({ onResult }: { onResult: (r: ReturnType) => void }) { + const result = useCanRequest(); + onResult(result); + return null; +} + +function render(child: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({child}); +} + +describe("useCanRequest", () => { + it("returns discoveryEnabled=false when requests_enabled is false", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: false } }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=false when there is no profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } }); + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=true when requests are enabled and there is a profile", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ discoveryEnabled: true, submitDisabledReason: null }); + }); + + it("returns discoveryEnabled=false while the feature status is still loading", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } }); + + let captured: ReturnType | null = null; + render( + { + captured = r; + }} + />, + ); + + expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null }); + }); +}); diff --git a/web/src/hooks/useCanRequest.ts b/web/src/hooks/useCanRequest.ts new file mode 100644 index 00000000..f702c98b --- /dev/null +++ b/web/src/hooks/useCanRequest.ts @@ -0,0 +1,18 @@ +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; + +export interface CanRequestState { + discoveryEnabled: boolean; + submitDisabledReason: string | null; +} + +export function useCanRequest(): CanRequestState { + const status = useRequestFeatureStatus(); + const { profile } = useCurrentProfile(); + const discoveryEnabled = Boolean(status.data?.requests_enabled) && Boolean(profile?.id); + + return { + discoveryEnabled, + submitDisabledReason: null, + }; +} From 70bb7a3e2b1b2fca47e790d96f67f990d58f5d2b Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:11:11 -0400 Subject: [PATCH 15/53] refactor(keys): add viewerKey to requestKeys.search --- web/src/hooks/queries/keys.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 4e98afd1..cf139be3 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -132,8 +132,8 @@ export const requestKeys = { sort: string, page: number, ) => ["requests", "discover", "browse", kind, slug, mediaType ?? "", sort, page] as const, - search: (mediaType: string, query: string, page: number) => - ["requests", "search", mediaType, query, page] as const, + search: (mediaType: string, query: string, page: number, viewerKey: string) => + ["requests", "search", viewerKey, mediaType, query, page] as const, detail: (mediaType: string, tmdbID: number) => ["requests", "detail", mediaType, tmdbID] as const, mine: (params: Record) => ["requests", "mine", params] as const, }; From a2a34953613a01a0a8922fde64314781a749ca9e Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:12:19 -0400 Subject: [PATCH 16/53] feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime --- web/src/hooks/queries/useRequests.test.tsx | 110 +++++++++++++++++++++ web/src/hooks/queries/useRequests.ts | 28 ++++-- 2 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 web/src/hooks/queries/useRequests.test.tsx diff --git a/web/src/hooks/queries/useRequests.test.tsx b/web/src/hooks/queries/useRequests.test.tsx new file mode 100644 index 00000000..5767e33a --- /dev/null +++ b/web/src/hooks/queries/useRequests.test.tsx @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const mocks = vi.hoisted(() => ({ + useQuery: vi.fn(), + useCurrentProfile: vi.fn(), + api: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (...args: unknown[]) => mocks.useQuery(...args), + }; +}); + +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => mocks.useCurrentProfile(), +})); + +vi.mock("@/api/client", () => ({ + api: (...args: unknown[]) => mocks.api(...args), +})); + +import { useRequestSearch } from "./useRequests"; + +function render(node: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup({node}); +} + +function CallHook(props: { mediaType: "movie" | "series" | "all"; q: string; page?: number }) { + useRequestSearch(props.mediaType, props.q, props.page ?? 1); + return null; +} + +describe("useRequestSearch", () => { + beforeEach(() => { + mocks.useQuery.mockReset(); + mocks.useCurrentProfile.mockReset(); + mocks.api.mockReset(); + }); + + it("includes the current profile id in the query key", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "profile-1", "all", "dune", 1]); + }); + + it("uses 'anon' as the viewer key when there is no profile", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: null }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] }; + expect(options.queryKey).toEqual(["requests", "search", "anon", "movie", "dune", 1]); + }); + + it("forwards the react-query signal to api()", async () => { + mocks.api.mockResolvedValue({ page: 1, total_pages: 0, total_results: 0, results: [] }); + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { + queryFn: (ctx: { signal: AbortSignal }) => unknown; + }; + const controller = new AbortController(); + await options.queryFn({ signal: controller.signal }); + + expect(mocks.api).toHaveBeenCalledTimes(1); + const apiCall = mocks.api.mock.calls[0]!; + expect(apiCall[0]).toContain("/requests/search?"); + const init = apiCall[1] as RequestInit; + expect(init.signal).toBe(controller.signal); + }); + + it("uses a 5-minute staleTime", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number }; + expect(options.staleTime).toBe(5 * 60 * 1000); + }); + + it("respects the enabled option override", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + + function CallHookWithOpt({ enabled }: { enabled: boolean }) { + useRequestSearch("all", "dune", 1, { enabled }); + return null; + } + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(false); + }); + + it("does not include enabled override when option omitted (defaults to true)", () => { + mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } }); + render(); + + const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean }; + expect(options.enabled).toBe(true); + }); +}); diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index 1566ee08..4ba1bab9 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { api } from "@/api/client"; +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; import type { CreateMediaRequestInput, DiscoverBrowseKind, @@ -28,6 +29,7 @@ import type { import { adminKeys, requestKeys } from "./keys"; const REQUESTS_STALE_TIME = 30_000; +const REQUEST_SEARCH_STALE_TIME = 5 * 60 * 1000; const DISCOVER_BRAND_STALE_TIME = 24 * 60 * 60 * 1000; const BROWSE_STALE_TIME = 60 * 1000; @@ -148,20 +150,34 @@ export function useRequestMediaDetail(mediaType: RequestMediaType, tmdbID: numbe }); } -export function useRequestSearch(mediaType: RequestSearchMediaType, query: string, page = 1) { +export interface UseRequestSearchOptions { + /** When false, suppresses the query regardless of the query string. Default: true. */ + enabled?: boolean; +} + +export function useRequestSearch( + mediaType: RequestSearchMediaType, + query: string, + page = 1, + options: UseRequestSearchOptions = {}, +) { const normalizedQuery = query.trim(); + const { profile } = useCurrentProfile(); + const viewerKey = profile?.id ?? "anon"; + const enabledOverride = options.enabled ?? true; + return useQuery({ - queryKey: requestKeys.search(mediaType, normalizedQuery, page), - queryFn: () => { + queryKey: requestKeys.search(mediaType, normalizedQuery, page, viewerKey), + queryFn: ({ signal }) => { const params = new URLSearchParams({ q: normalizedQuery, media_type: mediaType, page: String(page), }); - return api(`/requests/search?${params}`); + return api(`/requests/search?${params}`, { signal }); }, - enabled: normalizedQuery.length > 1, - staleTime: REQUESTS_STALE_TIME, + enabled: enabledOverride && normalizedQuery.length > 1, + staleTime: REQUEST_SEARCH_STALE_TIME, }); } From c2ea1966f5ee11d757e525a30c6f53146935e187 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:12:45 -0400 Subject: [PATCH 17/53] test(requests): document viewer-keyed cache isolation and invalidation cascade --- web/src/hooks/queries/useRequests.test.tsx | 28 ++++++++++++++++++++++ web/src/hooks/queries/useRequests.ts | 3 +++ 2 files changed, 31 insertions(+) diff --git a/web/src/hooks/queries/useRequests.test.tsx b/web/src/hooks/queries/useRequests.test.tsx index 5767e33a..a59213b6 100644 --- a/web/src/hooks/queries/useRequests.test.tsx +++ b/web/src/hooks/queries/useRequests.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import type { ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { requestKeys } from "./keys"; const mocks = vi.hoisted(() => ({ useQuery: vi.fn(), @@ -108,3 +109,30 @@ describe("useRequestSearch", () => { expect(options.enabled).toBe(true); }); }); + +describe("requestKeys.all invalidation", () => { + it("invalidates entries under requestKeys.search() when invalidating requestKeys.all", async () => { + const client = new QueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { sentinel: true }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-1"))).toEqual({ + sentinel: true, + }); + + await client.invalidateQueries({ queryKey: requestKeys.all }); + + const state = client.getQueryState(requestKeys.search("all", "dune", 1, "profile-1")); + expect(state?.isInvalidated).toBe(true); + }); +}); + +describe("viewer-scoped cache isolation", () => { + it("does not return profile-1 results when keyed by profile-2", () => { + const client = new QueryClient(); + client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { + results: [{ tmdb_id: 1 }], + }); + + expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-2"))).toBeUndefined(); + }); +}); diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index 4ba1bab9..a7b9ea14 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -53,6 +53,9 @@ function buildListQuery(params: RequestListParams = {}) { } function invalidateRequestSurfaces(queryClient: ReturnType) { + // requestKeys.all = ["requests"], so invalidating it cascades to nested keys, + // including requestKeys.search(...). Policy mutations rely on this to refresh + // viewer-scoped search results when request eligibility changes. queryClient.invalidateQueries({ queryKey: requestKeys.all }); queryClient.invalidateQueries({ queryKey: adminKeys.requestsRoot() }); } From 09148cac4659c51dd26a4db5a8c8affb39852285 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:13:31 -0400 Subject: [PATCH 18/53] feat(request-card): make onRequest optional on discover variant --- web/src/components/RequestPosterCard.test.tsx | 39 +++++++++++++++++++ web/src/components/RequestPosterCard.tsx | 14 ++++--- 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 web/src/components/RequestPosterCard.test.tsx diff --git a/web/src/components/RequestPosterCard.test.tsx b/web/src/components/RequestPosterCard.test.tsx new file mode 100644 index 00000000..215a5ccf --- /dev/null +++ b/web/src/components/RequestPosterCard.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import RequestPosterCard from "./RequestPosterCard"; +import type { RequestMediaResult } from "@/api/types"; + +const requestable: RequestMediaResult = { + media_type: "movie", + tmdb_id: 42, + title: "Test Movie", + availability: "missing", + request: { requestable: true }, +}; + +describe("RequestPosterCard (discover variant)", () => { + it("renders the hover Request button when onRequest is provided", () => { + const markup = renderToStaticMarkup( + + {}} + /> + , + ); + expect(markup).toContain("Request"); + }); + + it("does not render the hover Request button when onRequest is omitted", () => { + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).not.toContain("rounded-full bg-white"); + }); +}); diff --git a/web/src/components/RequestPosterCard.tsx b/web/src/components/RequestPosterCard.tsx index f2599c97..08cd6cc6 100644 --- a/web/src/components/RequestPosterCard.tsx +++ b/web/src/components/RequestPosterCard.tsx @@ -9,8 +9,10 @@ const POSTER_WIDTH = "w-[148px] sm:w-[164px] lg:w-[184px]"; type DiscoverProps = { variant: "discover"; item: RequestMediaResult; - isSubmitting: boolean; - onRequest: () => void; + /** Called when the inline hover Request button is clicked. Omit to suppress the button. */ + onRequest?: () => void; + /** Displays the spinner state on the hover Request button. Ignored when onRequest is omitted. */ + isSubmitting?: boolean; /** When true, fills the parent (use inside grids). Default: fixed carousel width. */ fluid?: boolean; }; @@ -44,8 +46,8 @@ function DiscoverCard({ fluid, }: { item: RequestMediaResult; - isSubmitting: boolean; - onRequest: () => void; + isSubmitting?: boolean; + onRequest?: () => void; fluid?: boolean; }) { const poster = tmdbImageURL(item.poster_path); @@ -92,11 +94,11 @@ function DiscoverCard({ /> - {requestable && ( + {requestable && onRequest && (
{items.length} results found From a64f41da4566b8e548657f2a468742b24d189271 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:19:42 -0400 Subject: [PATCH 22/53] feat(catalog): render RequestToAddSection grid with empty-state suppression --- web/src/pages/Catalog.test.tsx | 222 ++++++++++++++++++++++++++++++++- web/src/pages/Catalog.tsx | 27 +++- 2 files changed, 245 insertions(+), 4 deletions(-) diff --git a/web/src/pages/Catalog.test.tsx b/web/src/pages/Catalog.test.tsx index 16d69715..e176cdd2 100644 --- a/web/src/pages/Catalog.test.tsx +++ b/web/src/pages/Catalog.test.tsx @@ -10,6 +10,8 @@ let latestNavigateTo: string | null = null; const mockUseCatalogWindow = vi.fn(); const mockUseCatalogFilters = vi.fn(); const mockItemGrid = vi.fn(); +const mockUseCanRequest = vi.fn(); +const mockUseRequestSearch = vi.fn(); vi.mock("react-router", async () => { const actual = await vi.importActual("react-router"); @@ -38,6 +40,30 @@ vi.mock("@/hooks/queries/catalog", () => ({ useCatalogMetadataFilters: (...args: unknown[]) => mockUseCatalogFilters(...args), })); +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => mockUseCanRequest(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: (...args: unknown[]) => mockUseRequestSearch(...args), +})); + +vi.mock("@/components/RequestToAddSection", () => ({ + RequestToAddSection: ({ + variant, + query, + libraryHadHits, + }: { + variant: string; + query: string; + libraryHadHits: boolean; + }) => ( +
+ {`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`} +
+ ), +})); + vi.mock("@/hooks/useAuth", () => ({ AuthProvider: ({ children }: { children: ReactNode }) => <>{children}, useAuth: () => ({ @@ -89,10 +115,19 @@ vi.mock("@/components/ItemGrid", () => ({ items?: Array<{ title: string }>; totalItems?: number; pageSize?: number; + loading?: boolean; onVisibleRangeChange?: (start: number, end: number) => void; }) => { mockItemGrid(props); - return
{props.items?.map((item) => item.title).join(",")}
; + return ( +
+ {props.items?.map((item) => item.title).join(",")} +
+ ); }, })); @@ -152,6 +187,10 @@ describe("Catalog page", () => { mockUseCatalogWindow.mockReset(); mockUseCatalogFilters.mockReset(); mockItemGrid.mockReset(); + mockUseCanRequest.mockReset(); + mockUseRequestSearch.mockReset(); + mockUseCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mockUseRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false }); mockUseCatalogWindow.mockReturnValue({ data: { @@ -273,7 +312,7 @@ describe("Catalog page", () => { expect(markup).toContain("Settings"); }); - it("redirects the retired user plugins settings route back to appearance settings", () => { + it("redirects the retired user plugins settings route back to playback settings", () => { appInitialEntries = ["/settings/plugins"]; renderToStaticMarkup( @@ -282,6 +321,183 @@ describe("Catalog page", () => { , ); - expect(latestNavigateTo).toBe("appearance"); + expect(latestNavigateTo).toBe("/settings/playback"); + }); + + it("renders the request grid variant when source=query and library has results", () => { + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + media_type: "movie", + tmdb_id: 1, + title: "X", + availability: "missing", + request: { requestable: true }, + }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-testid="request-section"'); + expect(markup).toContain("variant="grid""); + expect(markup).toContain("libraryHadHits="true""); + }); + + it("renders the request grid variant with libraryHadHits=false when library has 0 hits", () => { + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + media_type: "movie", + tmdb_id: 1, + title: "X", + availability: "missing", + request: { requestable: true }, + }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("libraryHadHits="false""); + }); + + it("does not render the request section when source is not query", () => { + appInitialEntries = ["/catalog?source=favorites"]; + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: "Favorites", totalItems: 0, pages: new Map() }, + isLoading: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("does not render the request section when discovery is disabled", () => { + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).not.toContain('data-testid="request-section"'); + }); + + it("passes enabled=false to useRequestSearch when discoveryEnabled is false", () => { + renderToStaticMarkup( + + + , + ); + + const call = mockUseRequestSearch.mock.calls.at(-1); + expect(call?.[3]).toEqual({ enabled: false }); + }); + + it("keeps ItemGrid in a loading state when library is empty and TMDB is still loading", () => { + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-loading="true"'); + }); + + it("keeps ItemGrid in a loading state when library is empty and TMDB has missing results", () => { + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + media_type: "movie", + tmdb_id: 1, + title: "X", + availability: "missing", + request: { requestable: true }, + }, + ], + }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-loading="true"'); + }); + + it("renders the normal ItemGrid empty state when both library and TMDB are empty", () => { + mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mockUseCatalogWindow.mockReturnValue({ + data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() }, + isLoading: false, + }); + mockUseRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 0, results: [] }, + isLoading: false, + isError: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-loading="false"'); + expect(markup).toContain('data-total="0"'); }); }); diff --git a/web/src/pages/Catalog.tsx b/web/src/pages/Catalog.tsx index ec28a0cf..5d6f767e 100644 --- a/web/src/pages/Catalog.tsx +++ b/web/src/pages/Catalog.tsx @@ -4,10 +4,13 @@ import { CheckSquare, Search, Trash2, X } from "lucide-react"; import type { BrowseItem } from "@/api/types"; import ItemGrid from "@/components/ItemGrid"; +import { RequestToAddSection } from "@/components/RequestToAddSection"; import { Button } from "@/components/ui/button"; import CatalogFiltersPanel from "@/components/catalog/CatalogFiltersPanel"; import { useCatalogWindow } from "@/hooks/queries/catalog"; import { useRemoveHistory } from "@/hooks/queries/history"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +import { useCanRequest } from "@/hooks/useCanRequest"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import SearchBar from "@/components/SearchBar"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -101,6 +104,20 @@ function CatalogResults({ visibleRange, includeTotal: showExactResultCount, }); + const canRequest = useCanRequest(); + const isQuerySource = state.source === "query" && Boolean(state.q); + const tmdbQuery = useRequestSearch("all", state.q ?? "", 1, { + enabled: canRequest.discoveryEnabled && isQuerySource, + }); + const tmdbMissingCount = + tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0; + const libraryEmpty = (catalogQuery.data?.totalItems ?? 0) === 0; + const tmdbPendingForEmptyLibrary = + isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbQuery.isLoading; + const tmdbWillRenderForEmptyLibrary = + isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbMissingCount > 0; + const itemGridLoading = + catalogQuery.isLoading || tmdbPendingForEmptyLibrary || tmdbWillRenderForEmptyLibrary; const loadedHistoryItems = useMemo(() => { if (!isHistorySource) { return [] as BrowseItem[]; @@ -253,13 +270,21 @@ function CatalogResults({ totalItems={catalogQuery.data?.totalItems ?? 0} pages={catalogQuery.data?.pages ?? new Map()} pageSize={limit} - loading={catalogQuery.isLoading} + loading={itemGridLoading} onVisibleRangeChange={handleVisibleRangeChange} selectionMode={isHistorySource && selectionMode} selectedIds={selectedIds} onToggleSelect={toggleHistorySelection} /> + {isQuerySource && canRequest.discoveryEnabled ? ( + 0} + /> + ) : null} + Date: Mon, 25 May 2026 18:20:39 -0400 Subject: [PATCH 23/53] chore(web): format request search section --- web/src/components/RequestToAddSection.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/src/components/RequestToAddSection.tsx b/web/src/components/RequestToAddSection.tsx index 6e7492e2..f1954e55 100644 --- a/web/src/components/RequestToAddSection.tsx +++ b/web/src/components/RequestToAddSection.tsx @@ -24,9 +24,7 @@ export function RequestToAddSection({ variant, query, libraryHadHits }: RequestT if (!discoveryEnabled) return null; if (search.isError) return null; - const filtered = (search.data?.results ?? []).filter( - (item) => item.availability !== "available", - ); + const filtered = (search.data?.results ?? []).filter((item) => item.availability !== "available"); if (filtered.length === 0) return null; const limit = variant === "dialog" ? DIALOG_LIMIT : GRID_LIMIT; From e1bf9f56660867e55388a47c575f5af445497bc9 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 18:22:27 -0400 Subject: [PATCH 24/53] test(web): avoid unsupported Array.at in search request tests --- web/src/components/GlobalSearch.test.tsx | 2 +- web/src/components/RequestToAddSection.test.tsx | 4 ++-- web/src/pages/Catalog.test.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/components/GlobalSearch.test.tsx b/web/src/components/GlobalSearch.test.tsx index dd3c2083..9758a0a2 100644 --- a/web/src/components/GlobalSearch.test.tsx +++ b/web/src/components/GlobalSearch.test.tsx @@ -219,7 +219,7 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); - const call = mocks.useRequestSearch.mock.calls.at(-1); + const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1]; expect(call?.[3]).toEqual({ enabled: false }); }); diff --git a/web/src/components/RequestToAddSection.test.tsx b/web/src/components/RequestToAddSection.test.tsx index 22002e36..2e0053f1 100644 --- a/web/src/components/RequestToAddSection.test.tsx +++ b/web/src/components/RequestToAddSection.test.tsx @@ -77,7 +77,7 @@ describe("RequestToAddSection (dialog variant)", () => { render(); - const call = mocks.useRequestSearch.mock.calls.at(-1); + const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1]; expect(call?.[0]).toBe("all"); expect(call?.[1]).toBe("dune"); expect(call?.[2]).toBe(1); @@ -94,7 +94,7 @@ describe("RequestToAddSection (dialog variant)", () => { render(); - const call = mocks.useRequestSearch.mock.calls.at(-1); + const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1]; expect(call?.[3]).toEqual({ enabled: true }); }); diff --git a/web/src/pages/Catalog.test.tsx b/web/src/pages/Catalog.test.tsx index e176cdd2..72a24cc0 100644 --- a/web/src/pages/Catalog.test.tsx +++ b/web/src/pages/Catalog.test.tsx @@ -424,7 +424,7 @@ describe("Catalog page", () => { , ); - const call = mockUseRequestSearch.mock.calls.at(-1); + const call = mockUseRequestSearch.mock.calls[mockUseRequestSearch.mock.calls.length - 1]; expect(call?.[3]).toEqual({ enabled: false }); }); From 17a2cb0844c1a6b5b4e1aad5c5a591e5166c2c3a Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 22:27:46 -0400 Subject: [PATCH 25/53] commit message {"subject":"fix(search): prevent empty-state flash before TMDB fallback renders","body":"- Add isResolving to useCanRequest and gate empty states on it across GlobalSearch and Catalog\n- Debounce TMDB query in Catalog and hide ItemGrid when the request section may rescue an empty library\n- Track per-card submit state in RequestToAddSection grid so concurrent requests don't trample each other\n- Suppress anonymous TMDB request-search fetches to avoid cross-viewer cache leakage"} --- web/src/components/GlobalSearch.test.tsx | 54 ++++++++-- web/src/components/GlobalSearch.tsx | 69 +++++++------ web/src/components/RequestPosterCard.test.tsx | 8 +- .../components/RequestToAddSection.test.tsx | 53 ++++++++-- web/src/components/RequestToAddSection.tsx | 99 +++++++++++++++---- web/src/hooks/queries/useRequests.ts | 6 +- web/src/hooks/useCanRequest.test.tsx | 43 ++++++-- web/src/hooks/useCanRequest.ts | 8 ++ web/src/pages/Catalog.test.tsx | 75 +++++++++++--- web/src/pages/Catalog.tsx | 49 +++++---- 10 files changed, 354 insertions(+), 110 deletions(-) diff --git a/web/src/components/GlobalSearch.test.tsx b/web/src/components/GlobalSearch.test.tsx index 9758a0a2..ca67ae5d 100644 --- a/web/src/components/GlobalSearch.test.tsx +++ b/web/src/components/GlobalSearch.test.tsx @@ -94,7 +94,11 @@ describe("GlobalSearch", () => { mocks.useQuery.mockReset(); mocks.useCanRequest.mockReset(); mocks.useRequestSearch.mockReset(); - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, @@ -145,7 +149,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { mocks.useQuery.mockReset(); mocks.useCanRequest.mockReset(); mocks.useRequestSearch.mockReset(); - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, @@ -159,7 +167,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("renders the section with libraryHadHits=true when library returned results", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); mocks.useRequestSearch.mockReturnValue({ data: { page: 1, @@ -186,7 +198,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("renders the section with libraryHadHits=false when library returned 0 results", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); mocks.useQuery.mockReturnValue({ data: { total: 0, has_more: false, items: [] }, isFetching: false, @@ -216,7 +232,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("does not call useRequestSearch with enabled=true when discoveryEnabled is false", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1]; @@ -224,14 +244,22 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("does not mount RequestToAddSection when discovery is disabled", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: false, + isResolving: false, + submitDisabledReason: null, + }); const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" }); expect(markup).not.toContain('data-testid="request-section"'); }); it("suppresses 'No matches' when library is empty and TMDB is still loading", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); mocks.useQuery.mockReturnValue({ data: { total: 0, has_more: false, items: [] }, isFetching: false, @@ -248,7 +276,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("suppresses 'No matches' when library is empty and TMDB has missing results", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); mocks.useQuery.mockReturnValue({ data: { total: 0, has_more: false, items: [] }, isFetching: false, @@ -278,7 +310,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => { }); it("still shows 'No matches' when both library and TMDB are empty", () => { - mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null }); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); mocks.useQuery.mockReturnValue({ data: { total: 0, has_more: false, items: [] }, isFetching: false, diff --git a/web/src/components/GlobalSearch.tsx b/web/src/components/GlobalSearch.tsx index 9d910a55..5eaf3f80 100644 --- a/web/src/components/GlobalSearch.tsx +++ b/web/src/components/GlobalSearch.tsx @@ -111,9 +111,15 @@ export function GlobalSearch({ }); const tmdbMissingCount = tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0; + // Cap at DIALOG_LIMIT (4) — RequestToAddSection slices results to that many rows. + const tmdbVisibleCount = Math.min(tmdbMissingCount, 4); const tmdbStillLoading = canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1 && tmdbQuery.isLoading; const tmdbWillRender = canRequest.discoveryEnabled && tmdbMissingCount > 0; + // Hide empty state while the TMDB debounce trails the library debounce; otherwise + // the user sees "No matches" flash between t=200ms and t=400ms after typing. + const tmdbDebounceCatchingUp = + canRequest.discoveryEnabled && tmdbDebouncedQuery !== debouncedQuery; const searchState = useMemo( () => createCatalogSearchState("query", { q: debouncedQuery || undefined }), @@ -195,7 +201,9 @@ export function GlobalSearch({ items.length === 0 && !previewQuery.isError && !tmdbStillLoading && - !tmdbWillRender; + !tmdbWillRender && + !canRequest.isResolving && + !tmdbDebounceCatchingUp; const showError = previewQuery.isError; return ( @@ -252,34 +260,33 @@ export function GlobalSearch({ {showResultsPanel && (
-
- {showLoading && ( -
- Searching... -
- )} - {showError && ( -
- Could not load results. Press Enter to open the search page. -
- )} - {showEmpty && ( -
- No matches -
- )} - {items.map((item, i) => ( - - ))} +
+
+ {showLoading && ( +
+ Searching... +
+ )} + {showError && ( +
+ Could not load results. Press Enter to open the search page. +
+ )} + {showEmpty && ( +
+ No matches +
+ )} + {items.map((item, i) => ( + + ))} +
{tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && (
- {items.length} results found + {tmdbVisibleCount > 0 + ? `${items.length} library results, ${tmdbVisibleCount} request suggestions` + : `${items.length} results found`}
{total > PREVIEW_LIMIT ? ( diff --git a/web/src/components/RequestPosterCard.test.tsx b/web/src/components/RequestPosterCard.test.tsx index 215a5ccf..904c9bcd 100644 --- a/web/src/components/RequestPosterCard.test.tsx +++ b/web/src/components/RequestPosterCard.test.tsx @@ -24,7 +24,9 @@ describe("RequestPosterCard (discover variant)", () => { /> , ); - expect(markup).toContain("Request"); + // Must render an actual
+ +
+ + setReleaseName(event.target.value)} + className="font-mono text-xs" + /> +
+ +
+
+ +

Marks this track as SDH/CC.

+
+ +
+
+ + + + + + + ); +} diff --git a/web/src/components/admin/subtitles/AdminSubtitlesFilters.tsx b/web/src/components/admin/subtitles/AdminSubtitlesFilters.tsx new file mode 100644 index 00000000..ea57242c --- /dev/null +++ b/web/src/components/admin/subtitles/AdminSubtitlesFilters.tsx @@ -0,0 +1,118 @@ +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { LANGUAGES } from "@/player/utils/languageNames"; +import { SUBTITLE_PROVIDER_OPTIONS } from "./subtitleAdminStyles"; + +const ALL = "all"; + +interface AdminSubtitlesFiltersProps { + provider: string; + language: string; + userId: string; + search: string; + users: Array<{ id: number; username: string }>; + onProviderChange: (value: string) => void; + onLanguageChange: (value: string) => void; + onUserChange: (value: string) => void; + onSearchChange: (value: string) => void; + onReset: () => void; +} + +export default function AdminSubtitlesFilters({ + provider, + language, + userId, + search, + users, + onProviderChange, + onLanguageChange, + onUserChange, + onSearchChange, + onReset, +}: AdminSubtitlesFiltersProps) { + return ( +
+
+
+ onSearchChange(event.target.value)} + placeholder="Search release name…" + className="font-mono text-xs sm:max-w-sm" + aria-label="Search subtitle release name" + /> +
+ +
+ {SUBTITLE_PROVIDER_OPTIONS.map((option) => { + const active = provider === option.value; + return ( + + ); + })} +
+ +
+ + + + + +
+
+
+ ); +} + +export { ALL as FILTER_ALL }; diff --git a/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx b/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx new file mode 100644 index 00000000..bdedf8ee --- /dev/null +++ b/web/src/components/admin/subtitles/AdminSubtitlesTable.tsx @@ -0,0 +1,272 @@ +import { useState } from "react"; +import { Link } from "react-router"; +import type { AdminDownloadedSubtitle } from "@/api/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { downloadAdminSubtitle } from "@/hooks/queries/admin/subtitles"; +import { getLanguageName } from "@/player/utils/languageNames"; +import { cn } from "@/lib/utils"; +import { Download, Ear, Loader2, Pencil, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import AdminSubtitleEditSheet from "./AdminSubtitleEditSheet"; +import { + basenameFromPath, + formatChipClass, + languageChipClass, + providerBadgeClass, + providerLabel, + staggerRowClass, +} from "./subtitleAdminStyles"; + +interface AdminSubtitlesTableProps { + subtitles: AdminDownloadedSubtitle[]; + hasActiveFilters: boolean; + onResetFilters: () => void; + onDelete: (subtitle: AdminDownloadedSubtitle) => void; + isDeleting: boolean; +} + +function formatRelative(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const deltaMs = Date.now() - date.getTime(); + const minutes = Math.floor(deltaMs / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return date.toLocaleDateString(); +} + +export default function AdminSubtitlesTable({ + subtitles, + hasActiveFilters, + onResetFilters, + onDelete, + isDeleting, +}: AdminSubtitlesTableProps) { + const [editTarget, setEditTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [downloadingId, setDownloadingId] = useState(null); + + async function handleDownload(subtitle: AdminDownloadedSubtitle) { + setDownloadingId(subtitle.id); + try { + await downloadAdminSubtitle(subtitle); + toast.success("Subtitle downloaded"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to download subtitle"); + } finally { + setDownloadingId(null); + } + } + + if (subtitles.length === 0) { + return ( +
+
+ + + +
+

+ {hasActiveFilters ? "No subtitles match these filters" : "No stored subtitles yet"} +

+

+ {hasActiveFilters + ? "Try widening the provider, language, or uploader filters to see more results." + : "User uploads and provider downloads will appear here once subtitles are stored in S3."} +

+ {hasActiveFilters && ( + + )} +
+ ); + } + + return ( + <> +
+ + + + Media + File + Language + Provider + Release + Format + HI + Uploader + Added + Actions + + + + {subtitles.map((subtitle, index) => ( + + +
+ {subtitle.media_content_id ? ( + + {subtitle.media_title || subtitle.media_content_id} + + ) : ( +
+ {subtitle.media_title || "Unknown media"} +
+ )} + {subtitle.media_type === "episode" && ( + + Episode + + )} +
+
+ + {basenameFromPath(subtitle.file_path)} + + + + + {subtitle.language} + + + {getLanguageName(subtitle.language)} + + + + + + {providerLabel(subtitle.provider)} + + + + {subtitle.release_name || "—"} + + + + .{subtitle.format} + + + + {subtitle.hearing_impaired ? ( + + + ) : null} + + {subtitle.uploader_username || "—"} + + {formatRelative(subtitle.created_at)} + + +
+ + + +
+
+
+ ))} +
+
+
+ + { + if (!open) setEditTarget(null); + }} + /> + + { + if (!open) setDeleteTarget(null); + }} + title="Delete subtitle?" + description={ + deleteTarget + ? `Remove ${providerLabel(deleteTarget.provider)} ${deleteTarget.language.toUpperCase()} subtitles for "${deleteTarget.media_title || "this media"}"? This deletes the stored file from S3.` + : "" + } + confirmLabel="Delete" + variant="destructive" + isPending={isDeleting} + onConfirm={() => { + if (deleteTarget) { + onDelete(deleteTarget); + setDeleteTarget(null); + } + }} + /> + + ); +} diff --git a/web/src/components/admin/subtitles/subtitleAdminStyles.ts b/web/src/components/admin/subtitles/subtitleAdminStyles.ts new file mode 100644 index 00000000..4f5b4411 --- /dev/null +++ b/web/src/components/admin/subtitles/subtitleAdminStyles.ts @@ -0,0 +1,57 @@ +import { cn } from "@/lib/utils"; + +export const SUBTITLE_PROVIDER_OPTIONS = [ + { value: "all", label: "All" }, + { value: "upload", label: "Upload" }, + { value: "opensubtitles", label: "OpenSubtitles" }, + { value: "subdl", label: "SubDL" }, + { value: "subsource", label: "SubSource" }, +] as const; + +export function providerBadgeClass(provider: string): string { + switch (provider) { + case "upload": + return "border-amber-500/35 bg-amber-500/12 text-amber-100"; + case "opensubtitles": + return "border-sky-500/30 bg-sky-500/10 text-sky-100"; + case "subdl": + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-100"; + case "subsource": + return "border-violet-500/30 bg-violet-500/10 text-violet-100"; + default: + return "border-border/70 bg-muted/40 text-muted-foreground"; + } +} + +export function providerLabel(provider: string): string { + return SUBTITLE_PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider; +} + +export function languageChipClass(): string { + return "border-primary/25 bg-primary/10 text-foreground"; +} + +export function formatChipClass(): string { + return "border-border/60 bg-muted/30 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground"; +} + +export function staggerRowClass(index: number): string { + const capped = Math.min(index, 8); + return cn("motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300", { + "motion-safe:delay-0": capped === 0, + "motion-safe:delay-[40ms]": capped === 1, + "motion-safe:delay-[80ms]": capped === 2, + "motion-safe:delay-[120ms]": capped === 3, + "motion-safe:delay-[160ms]": capped === 4, + "motion-safe:delay-[200ms]": capped === 5, + "motion-safe:delay-[240ms]": capped === 6, + "motion-safe:delay-[280ms]": capped === 7, + "motion-safe:delay-[320ms]": capped >= 8, + }); +} + +export function basenameFromPath(filePath: string): string { + if (!filePath) return "—"; + const parts = filePath.split(/[/\\]/); + return parts[parts.length - 1] || filePath; +} diff --git a/web/src/components/subtitles/SubtitleUploadForm.tsx b/web/src/components/subtitles/SubtitleUploadForm.tsx new file mode 100644 index 00000000..543ac340 --- /dev/null +++ b/web/src/components/subtitles/SubtitleUploadForm.tsx @@ -0,0 +1,406 @@ +import { useCallback, useRef, useState } from "react"; +import { Loader2, Upload } from "lucide-react"; + +import type { SubtitleLanguageDetection } from "@/api/types"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames"; + +const ACCEPTED_SUBTITLE_EXTENSIONS = ".srt,.vtt,.ass,.ssa,.sub"; +const ACCEPTED_SUBTITLE_EXTENSION_LIST = ["srt", "vtt", "ass", "ssa", "sub"] as const; + +export interface SubtitleUploadInput { + mediaFileId: number; + file: File; + language?: string; + languageOverride?: boolean; + hearingImpaired: boolean; +} + +interface SubtitleUploadFormProps { + mediaFileId: number; + upload: (input: SubtitleUploadInput) => Promise; + detectLanguage?: (file: File, fallbackLanguage?: string) => Promise; + onSuccess: () => void; + onError?: (message: string) => void; + variant?: "player" | "default"; + defaultLanguage?: string; +} + +function isAcceptedSubtitleFile(file: File): boolean { + const extension = file.name.split(".").pop()?.toLowerCase() ?? ""; + return ACCEPTED_SUBTITLE_EXTENSION_LIST.includes( + extension as (typeof ACCEPTED_SUBTITLE_EXTENSION_LIST)[number], + ); +} + +function detectionSourceLabel(source: SubtitleLanguageDetection["source"]): string { + switch (source) { + case "filename": + return "filename"; + case "metadata": + return "file metadata"; + case "content": + return "subtitle text"; + case "manual": + return "manual selection"; + default: + return "detection"; + } +} + +export function SubtitleUploadForm({ + mediaFileId, + upload, + detectLanguage, + onSuccess, + onError, + variant = "default", + defaultLanguage = "en", +}: SubtitleUploadFormProps) { + const fileInputRef = useRef(null); + const dragDepthRef = useRef(0); + const detectRequestRef = useRef(0); + const [language, setLanguage] = useState(defaultLanguage); + const [hearingImpaired, setHearingImpaired] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [detectingLanguage, setDetectingLanguage] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [detectionSource, setDetectionSource] = useState< + SubtitleLanguageDetection["source"] | null + >(null); + const [languageOverride, setLanguageOverride] = useState(false); + const [error, setError] = useState(null); + + const isPlayer = variant === "player"; + + const reportError = useCallback( + (message: string) => { + setError(message); + onError?.(message); + }, + [onError], + ); + + const runLanguageDetection = useCallback( + async (file: File, fallbackLanguage: string) => { + if (!detectLanguage) { + return; + } + + const requestId = ++detectRequestRef.current; + setDetectingLanguage(true); + + try { + const result = await detectLanguage(file, fallbackLanguage); + if (requestId !== detectRequestRef.current) { + return; + } + if (result.language) { + setLanguage(result.language); + setDetectionSource(result.source); + setLanguageOverride(false); + } + } catch (err) { + if (requestId !== detectRequestRef.current) { + return; + } + setDetectionSource(null); + reportError(err instanceof Error ? err.message : "Failed to detect subtitle language"); + } finally { + if (requestId === detectRequestRef.current) { + setDetectingLanguage(false); + } + } + }, + [detectLanguage, reportError], + ); + + const selectFile = useCallback( + (file: File | null | undefined) => { + if (!file) { + return; + } + if (!isAcceptedSubtitleFile(file)) { + reportError("Unsupported file type. Use SRT, VTT, ASS, SSA, or SUB."); + return; + } + setSelectedFile(file); + setError(null); + void runLanguageDetection(file, language); + }, + [language, reportError, runLanguageDetection], + ); + + const handleFileChange = (event: React.ChangeEvent) => { + selectFile(event.target.files?.[0]); + }; + + const handleBrowseClick = () => { + fileInputRef.current?.click(); + }; + + const handleDragEnter = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current += 1; + setIsDragging(true); + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsDragging(false); + } + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = 0; + setIsDragging(false); + + const file = event.dataTransfer.files[0]; + selectFile(file); + }; + + const handleLanguageChange = (value: string) => { + setLanguage(value); + setDetectionSource("manual"); + setLanguageOverride(true); + }; + + const handleUpload = async () => { + if (!selectedFile) { + reportError("Choose a subtitle file to upload"); + return; + } + + setUploading(true); + setError(null); + + try { + await upload({ + mediaFileId, + file: selectedFile, + language, + languageOverride, + hearingImpaired, + }); + setSelectedFile(null); + setDetectionSource(null); + setLanguageOverride(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + onSuccess(); + } catch (err) { + reportError(err instanceof Error ? err.message : "Upload failed"); + } finally { + setUploading(false); + } + }; + + return ( +
+
+

+ Upload subtitle +

+

+ Drag and drop or browse for SRT, VTT, ASS, SSA, or SUB files up to 5 MB. Language is + detected automatically when possible. +

+
+ + + +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleBrowseClick(); + } + }} + onDragEnter={handleDragEnter} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={cn( + "flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors", + isPlayer + ? isDragging + ? "border-white/50 bg-white/10" + : "border-white/20 bg-white/5 hover:border-white/35 hover:bg-white/10" + : isDragging + ? "border-primary bg-primary/5" + : "border-border/70 bg-muted/20 hover:border-border hover:bg-muted/40", + )} + > +
+ +
+
+ {isPlayer ? ( + + ) : ( + + )} + {detectingLanguage ? ( +

+ Detecting language… +

+ ) : detectionSource && detectionSource !== "manual" ? ( +

+ Detected {getLanguageName(language)} from {detectionSourceLabel(detectionSource)} +

+ ) : null} +
+ + {isPlayer ? ( + + ) : ( +
+ + +
+ )} + + {isPlayer ? ( + + ) : ( + + )} +
+ + {selectedFile && ( +

+ Selected: {selectedFile.name} +

+ )} + + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/web/src/hooks/queries/admin/subtitles.ts b/web/src/hooks/queries/admin/subtitles.ts index 278b2296..e95d60b8 100644 --- a/web/src/hooks/queries/admin/subtitles.ts +++ b/web/src/hooks/queries/admin/subtitles.ts @@ -1,6 +1,10 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { api } from "@/api/client"; +import { api, apiDownload } from "@/api/client"; import type { + AdminDownloadedSubtitle, + AdminDownloadedSubtitlesFilters, + AdminDownloadedSubtitlesResponse, + AdminUpdateDownloadedSubtitleRequest, SubtitleProviderConfig, SubtitleProviderUpdateRequest, SubtitleProviderTestResponse, @@ -10,6 +14,71 @@ import { toast } from "sonner"; const ADMIN_STALE_TIME = 30_000; +function buildDownloadedSubtitlesQuery(filters: AdminDownloadedSubtitlesFilters): string { + const params = new URLSearchParams(); + if (filters.provider) params.set("provider", filters.provider); + if (filters.language) params.set("language", filters.language); + if (filters.userId != null) params.set("user_id", String(filters.userId)); + if (filters.mediaFileId != null) params.set("media_file_id", String(filters.mediaFileId)); + if (filters.q) params.set("q", filters.q); + params.set("limit", String(filters.limit ?? 50)); + params.set("offset", String(filters.offset ?? 0)); + const query = params.toString(); + return query ? `/admin/subtitles?${query}` : "/admin/subtitles"; +} + +export function useAdminDownloadedSubtitles(filters: AdminDownloadedSubtitlesFilters) { + return useQuery({ + queryKey: adminKeys.downloadedSubtitles(filters), + queryFn: () => + api(buildDownloadedSubtitlesQuery(filters)).then( + (data) => data ?? { subtitles: [], total: 0, uploads: 0, provider_downloads: 0 }, + ), + staleTime: ADMIN_STALE_TIME, + }); +} + +export function useAdminUpdateDownloadedSubtitle() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, patch }: { id: number; patch: AdminUpdateDownloadedSubtitleRequest }) => + api<{ subtitle: AdminDownloadedSubtitle }>(`/admin/subtitles/${id}`, { + method: "PATCH", + body: JSON.stringify(patch), + }), + onSuccess: () => { + toast.success("Subtitle updated"); + queryClient.invalidateQueries({ queryKey: ["admin", "downloadedSubtitles"] }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to update subtitle"); + }, + }); +} + +export function useAdminDeleteDownloadedSubtitle() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + api(`/admin/subtitles/${id}`, { + method: "DELETE", + }), + onSuccess: () => { + toast.success("Subtitle deleted"); + queryClient.invalidateQueries({ queryKey: ["admin", "downloadedSubtitles"] }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to delete subtitle"); + }, + }); +} + +export async function downloadAdminSubtitle(subtitle: AdminDownloadedSubtitle): Promise { + const base = subtitle.release_name?.trim() || `subtitle-${subtitle.id}`; + const filename = base.includes(".") ? base : `${base}.${subtitle.format}`; + await apiDownload(`/admin/subtitles/${subtitle.id}/download`, filename); +} + export function useSubtitleProviders() { return useQuery({ queryKey: adminKeys.subtitleProviders(), diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index cf139be3..b0e8272c 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -360,6 +360,15 @@ export const adminKeys = { operationalLogs: (params: Record) => ["admin", "logs", "app", params] as const, auditLogs: (params: Record) => ["admin", "logs", "audit", params] as const, subtitleProviders: () => ["admin", "subtitleProviders"] as const, + downloadedSubtitles: (params: { + provider?: string; + language?: string; + userId?: number; + mediaFileId?: number; + q?: string; + limit?: number; + offset?: number; + }) => ["admin", "downloadedSubtitles", params] as const, historyImportSources: () => ["admin", "historyImportSources"] as const, historyImportExternalUsers: (sourceId: number) => ["admin", "historyImportSources", sourceId, "users"] as const, diff --git a/web/src/hooks/queries/subtitles.ts b/web/src/hooks/queries/subtitles.ts index c9267598..b56f6594 100644 --- a/web/src/hooks/queries/subtitles.ts +++ b/web/src/hooks/queries/subtitles.ts @@ -5,8 +5,10 @@ import { api } from "@/api/client"; import type { DownloadedSubtitle, SubtitleDownloadRequest, + SubtitleLanguageDetection, SubtitleSearchRequest, SubtitleSearchResponse, + SubtitleUploadRequest, } from "@/api/types"; import { subtitleKeys } from "./keys"; @@ -15,6 +17,34 @@ interface DownloadSubtitleResponse { subtitle: DownloadedSubtitle; } +function buildSubtitleUploadFormData(request: SubtitleUploadRequest): FormData { + const form = new FormData(); + form.set("media_file_id", String(request.media_file_id)); + if (request.language) { + form.set("language", request.language); + } + if (request.language_override) { + form.set("language_override", "true"); + } + form.set("file", request.file); + if (request.release_name) { + form.set("release_name", request.release_name); + } + if (request.hearing_impaired) { + form.set("hearing_impaired", "true"); + } + return form; +} + +function buildSubtitleDetectFormData(file: File, language?: string): FormData { + const form = new FormData(); + form.set("file", file); + if (language) { + form.set("language", language); + } + return form; +} + export async function fetchDownloadedSubtitles( mediaFileId: number, options?: RequestInit, @@ -48,6 +78,29 @@ export async function downloadSubtitle( }); } +export async function uploadSubtitle( + request: SubtitleUploadRequest, + options?: RequestInit, +): Promise { + return api("/subtitles/upload", { + ...options, + method: "POST", + body: buildSubtitleUploadFormData(request), + }); +} + +export async function detectSubtitleLanguage( + file: File, + language?: string, + options?: RequestInit, +): Promise { + return api("/subtitles/detect-language", { + ...options, + method: "POST", + body: buildSubtitleDetectFormData(file, language), + }); +} + export function useDownloadedSubtitles(mediaFileId: number | undefined) { return useQuery({ queryKey: mediaFileId != null ? subtitleKeys.downloaded(mediaFileId) : subtitleKeys.all, @@ -72,3 +125,20 @@ export function useDownloadSubtitle() { }, }); } + +export function useUploadSubtitle() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (request: SubtitleUploadRequest) => uploadSubtitle(request), + onSuccess: async (_response, request) => { + toast.success("Subtitle uploaded"); + await queryClient.invalidateQueries({ + queryKey: subtitleKeys.downloaded(request.media_file_id), + }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to upload subtitle"); + }, + }); +} diff --git a/web/src/lib/documentTitle.ts b/web/src/lib/documentTitle.ts index 93e68a36..a4f02200 100644 --- a/web/src/lib/documentTitle.ts +++ b/web/src/lib/documentTitle.ts @@ -31,6 +31,7 @@ const ADMIN_TITLES: Record = { recommendations: "Admin Recommendations", requests: "Admin Requests", sections: "Admin Sections", + subtitles: "Admin Subtitles", settings: "Admin Settings", tasks: "Admin Tasks", users: "Admin Users", diff --git a/web/src/pages/AdminSubtitles.tsx b/web/src/pages/AdminSubtitles.tsx new file mode 100644 index 00000000..8e4eac59 --- /dev/null +++ b/web/src/pages/AdminSubtitles.tsx @@ -0,0 +1,200 @@ +import { useMemo, useState } from "react"; +import { useSearchParams } from "react-router"; +import type { AdminDownloadedSubtitle } from "@/api/types"; +import AdminSubtitlesFilters, { + FILTER_ALL, +} from "@/components/admin/subtitles/AdminSubtitlesFilters"; +import AdminSubtitlesTable from "@/components/admin/subtitles/AdminSubtitlesTable"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + useAdminDeleteDownloadedSubtitle, + useAdminDownloadedSubtitles, +} from "@/hooks/queries/admin/subtitles"; +import { useAdminUsers } from "@/hooks/queries/admin/users"; + +const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const; + +export default function AdminSubtitles() { + const [searchParams, setSearchParams] = useSearchParams(); + const { data: users = [] } = useAdminUsers(); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(25); + const deleteMutation = useAdminDeleteDownloadedSubtitle(); + + const provider = searchParams.get("provider") ?? FILTER_ALL; + const language = searchParams.get("language") ?? FILTER_ALL; + const userId = searchParams.get("user_id") ?? FILTER_ALL; + const search = searchParams.get("q") ?? ""; + + const filters = useMemo( + () => ({ + provider: provider !== FILTER_ALL ? provider : undefined, + language: language !== FILTER_ALL ? language : undefined, + userId: userId !== FILTER_ALL ? Number(userId) : undefined, + q: search.trim() || undefined, + limit: pageSize, + offset: page * pageSize, + }), + [language, page, pageSize, provider, search, userId], + ); + + const subtitlesQuery = useAdminDownloadedSubtitles(filters); + const subtitles = subtitlesQuery.data?.subtitles ?? []; + const total = subtitlesQuery.data?.total ?? 0; + const uploads = subtitlesQuery.data?.uploads ?? 0; + const providerDownloads = subtitlesQuery.data?.provider_downloads ?? 0; + const languageCount = new Set(subtitles.map((row) => row.language)).size; + + const hasActiveFilters = + provider !== FILTER_ALL || + language !== FILTER_ALL || + userId !== FILTER_ALL || + search.trim().length > 0; + + function updateFilter(key: string, value: string) { + const next = new URLSearchParams(searchParams); + if (value === FILTER_ALL || value.trim() === "") { + next.delete(key); + } else { + next.set(key, value); + } + setPage(0); + setSearchParams(next, { replace: true }); + } + + function resetFilters() { + setPage(0); + setSearchParams(new URLSearchParams(), { replace: true }); + } + + function handleDelete(subtitle: AdminDownloadedSubtitle) { + deleteMutation.mutate(subtitle.id); + } + + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const canPrev = page > 0; + const canNext = (page + 1) * pageSize < total; + + if (subtitlesQuery.isLoading) { + return ( +
+
+ + +
+ + + {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ); + } + + return ( +
+
+
+

Subtitles

+

+ Manage stored subtitle files across the library — user uploads and provider downloads. +

+
+
+ +
+ + + + +
+ + updateFilter("provider", value)} + onLanguageChange={(value) => updateFilter("language", value)} + onUserChange={(value) => updateFilter("user_id", value)} + onSearchChange={(value) => updateFilter("q", value)} + onReset={resetFilters} + /> + + + + {total > 0 && ( +
+

+ Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total} +

+
+ + + + Page {page + 1} of {pageCount} + + +
+
+ )} +
+ ); +} + +function StatBlock({ label, value }: { label: string; value: number }) { + return ( +
+
+ {label} +
+
{value.toLocaleString()}
+
+ ); +} diff --git a/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx b/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx index 8aeba9e0..f29288f8 100644 --- a/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx +++ b/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx @@ -21,11 +21,14 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { searchSubtitles, + detectSubtitleLanguage, useDownloadSubtitle, useDownloadedSubtitles, + useUploadSubtitle, } from "@/hooks/queries/subtitles"; import { cn } from "@/lib/utils"; import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames"; +import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm"; import { buildQualitySummary } from "./VersionFlyout"; interface SubtitleSearchDialogProps { @@ -39,6 +42,7 @@ const providerInfo: Record = { opensubtitles: { abbr: "OS", className: "bg-amber-500/15 text-amber-700 dark:text-amber-300" }, subdl: { abbr: "SDL", className: "bg-sky-500/15 text-sky-700 dark:text-sky-300" }, subsource: { abbr: "SS", className: "bg-rose-500/15 text-rose-700 dark:text-rose-300" }, + upload: { abbr: "UP", className: "bg-violet-500/15 text-violet-700 dark:text-violet-300" }, }; function scoreTone(score: number): { text: string; ring: string; bg: string } { @@ -77,6 +81,7 @@ export default function SubtitleSearchDialog({ title, }: SubtitleSearchDialogProps) { const downloadSubtitleMutation = useDownloadSubtitle(); + const uploadSubtitleMutation = useUploadSubtitle(); const downloadedQuery = useDownloadedSubtitles(open ? version?.file_id : undefined); const searchAbortRef = useRef(null); @@ -190,13 +195,41 @@ export default function SubtitleSearchDialog({ [downloadSubtitleMutation, downloadedQuery, version], ); + const handleUpload = useCallback( + async (input: { + mediaFileId: number; + file: File; + language?: string; + languageOverride?: boolean; + hearingImpaired: boolean; + }) => { + await uploadSubtitleMutation.mutateAsync({ + media_file_id: input.mediaFileId, + file: input.file, + language: input.language, + language_override: input.languageOverride, + hearing_impaired: input.hearingImpaired, + }); + }, + [uploadSubtitleMutation], + ); + + const handleDetectLanguage = useCallback( + (file: File, fallbackLanguage?: string) => detectSubtitleLanguage(file, fallbackLanguage), + [], + ); + + const handleUploadSuccess = useCallback(async () => { + await downloadedQuery.refetch(); + }, [downloadedQuery]); + const versionLabel = version ? buildQualitySummary(version) : ""; return ( - Search Subtitles + Add Subtitles {title} {versionLabel ? ` \u00B7 ${versionLabel}` : ""} @@ -205,28 +238,42 @@ export default function SubtitleSearchDialog({
-
- + {version && ( + + )} - +
+

Search online

+
+ + + +
{searchError && ( diff --git a/web/src/player/components/SubtitleSearchModal.tsx b/web/src/player/components/SubtitleSearchModal.tsx index a18a5f40..0a87cc5b 100644 --- a/web/src/player/components/SubtitleSearchModal.tsx +++ b/web/src/player/components/SubtitleSearchModal.tsx @@ -2,7 +2,12 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import type { PlayerConfig } from "../context/PlayerConfigContext"; import { playerFetch } from "../player-fetch"; -import type { SubtitleSearchResponse, SubtitleResult } from "@/api/types"; +import type { + SubtitleLanguageDetection, + SubtitleSearchResponse, + SubtitleResult, +} from "@/api/types"; +import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm"; import { LANGUAGES } from "../utils/languageNames"; interface SubtitleSearchModalProps { @@ -22,6 +27,7 @@ const providerInfo: Record = { opensubtitles: { abbr: "OS", color: "#eab308" }, subdl: { abbr: "SDL", color: "#3b82f6" }, subsource: { abbr: "SS", color: "#ef4444" }, + upload: { abbr: "UP", color: "#a855f7" }, }; function scoreColor(score: number): string { @@ -132,6 +138,56 @@ export function SubtitleSearchModal({ } }, [playerConfig, mediaFileId, selectedLang]); + const handleUpload = useCallback( + async (input: { + mediaFileId: number; + file: File; + language?: string; + languageOverride?: boolean; + hearingImpaired: boolean; + }) => { + const form = new FormData(); + form.set("media_file_id", String(input.mediaFileId)); + if (input.language) { + form.set("language", input.language); + } + if (input.languageOverride) { + form.set("language_override", "true"); + } + form.set("file", input.file); + if (input.hearingImpaired) { + form.set("hearing_impaired", "true"); + } + + await playerFetch(playerConfig, "/subtitles/upload", { + method: "POST", + body: form, + }); + }, + [playerConfig], + ); + + const handleDetectLanguage = useCallback( + async (file: File, fallbackLanguage?: string): Promise => { + const form = new FormData(); + form.set("file", file); + if (fallbackLanguage) { + form.set("language", fallbackLanguage); + } + + return playerFetch(playerConfig, "/subtitles/detect-language", { + method: "POST", + body: form, + }); + }, + [playerConfig], + ); + + const handleUploadSuccess = useCallback(() => { + onSubtitleDownloaded(); + handleClose(); + }, [onSubtitleDownloaded, handleClose]); + const handleDownload = useCallback( async (result: SubtitleResult) => { const key = `${result.provider}:${result.id}`; @@ -171,7 +227,7 @@ export function SubtitleSearchModal({ onClick={handleClose} role="dialog" aria-modal="true" - aria-label="Subtitle Search" + aria-label="Add Subtitles" onKeyDown={handleFocusTrap} >
{/* Header */}
-

Search Subtitles

+

Add Subtitles

+ + +
+

Search online

+
+ {/* Search controls */}
setField("title", e.target.value)} /> @@ -389,7 +397,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
)} - {activeSection === "dates" && ( + {effectiveActiveSection === "dates" && (
{(item.type === "movie" || item.type === "series") && (
@@ -528,7 +536,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
)} - {activeSection === "tags" && ( + {effectiveActiveSection === "tags" && (
)} - {activeSection === "ids" && ( + {effectiveActiveSection === "ids" && (
)} -
- -
+ {canEditImages && ( +
+ +
+ )}
diff --git a/web/src/lib/permissions.ts b/web/src/lib/permissions.ts index 2bc3ecc3..81d28771 100644 --- a/web/src/lib/permissions.ts +++ b/web/src/lib/permissions.ts @@ -14,3 +14,17 @@ export function hasPermission( export function canCurateMetadata(user: Pick | null | undefined) { return hasPermission(user, PERMISSION_METADATA_CURATION); } + +export function hasAssignedPermission(permissions: string[] | undefined, permission: string) { + return Array.isArray(permissions) && permissions.includes(permission); +} + +export function setAssignedPermission(permissions: string[], permission: string, enabled: boolean) { + const next = new Set(permissions); + if (enabled) { + next.add(permission); + } else { + next.delete(permission); + } + return Array.from(next).sort(); +} diff --git a/web/src/pages/AdminUserDetail.tsx b/web/src/pages/AdminUserDetail.tsx index 41879235..2f9fe769 100644 --- a/web/src/pages/AdminUserDetail.tsx +++ b/web/src/pages/AdminUserDetail.tsx @@ -66,7 +66,11 @@ import { playbackQualityValueFromPreset, type PlaybackQualityPreset, } from "@/lib/playback-quality"; -import { PERMISSION_METADATA_CURATION } from "@/lib/permissions"; +import { + PERMISSION_METADATA_CURATION, + hasAssignedPermission, + setAssignedPermission, +} from "@/lib/permissions"; import { RegistrySettingControl } from "@/components/settings/RegistrySettingControl"; import { formatSettingValue, getSettingDefinition } from "@/lib/settingsManifest"; import { @@ -317,20 +321,6 @@ function DetailRow({ label, value }: { label: string; value: string }) { ); } -function hasAssignedPermission(permissions: string[] | undefined, permission: string) { - return Array.isArray(permissions) && permissions.includes(permission); -} - -function setAssignedPermission(permissions: string[], permission: string, enabled: boolean) { - const next = new Set(permissions); - if (enabled) { - next.add(permission); - } else { - next.delete(permission); - } - return Array.from(next).sort(); -} - function ProfilesTab({ userId }: { userId: number }) { const { data: profiles, isLoading } = useAdminUserProfiles(userId); diff --git a/web/src/pages/AdminUsers.tsx b/web/src/pages/AdminUsers.tsx index 8d490466..eedf6d78 100644 --- a/web/src/pages/AdminUsers.tsx +++ b/web/src/pages/AdminUsers.tsx @@ -59,7 +59,11 @@ import { playbackQualityValueFromPreset, type PlaybackQualityPreset, } from "@/lib/playback-quality"; -import { PERMISSION_METADATA_CURATION } from "@/lib/permissions"; +import { + PERMISSION_METADATA_CURATION, + hasAssignedPermission, + setAssignedPermission, +} from "@/lib/permissions"; const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const; type UserSortField = "username" | "email" | "role" | "enabled" | "created_at" | "last_active_at"; @@ -515,20 +519,6 @@ function formatRelativeTime(value?: string | null, fallback = "-") { return fallback; } -function hasAssignedPermission(permissions: string[] | undefined, permission: string) { - return Array.isArray(permissions) && permissions.includes(permission); -} - -function setAssignedPermission(permissions: string[], permission: string, enabled: boolean) { - const next = new Set(permissions); - if (enabled) { - next.add(permission); - } else { - next.delete(permission); - } - return Array.from(next).sort(); -} - function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => void }) { const { data: settings } = useAdminServerSettings(); const { data: libraries = [] } = useAdminLibraries(); From c7d69e9ea2d11bae9545a756fc508757971b3303 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 19:34:16 -0400 Subject: [PATCH 40/53] fix(auth): tighten curator job response review fixes --- internal/api/handlers/admin_jobs.go | 1 + internal/api/handlers/admin_jobs_test.go | 6 +++++- internal/api/handlers/admin_test.go | 11 +++++++++++ internal/api/middleware/permissions.go | 3 +++ internal/api/router.go | 2 ++ 5 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 internal/api/handlers/admin_test.go diff --git a/internal/api/handlers/admin_jobs.go b/internal/api/handlers/admin_jobs.go index e1cffb19..20ae2ea6 100644 --- a/internal/api/handlers/admin_jobs.go +++ b/internal/api/handlers/admin_jobs.go @@ -171,6 +171,7 @@ func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth. } response.RequestPayload = json.RawMessage(`{}`) response.ResultPayload = sanitizeNonAdminAdminJobResultPayload(response.JobType, response.ResultPayload) + response.ErrorMessage = "" response.PublicURL = "" response.DownloadURL = "" response.DownloadExpiresAt = nil diff --git a/internal/api/handlers/admin_jobs_test.go b/internal/api/handlers/admin_jobs_test.go index c64eda56..d7dd5ec1 100644 --- a/internal/api/handlers/admin_jobs_test.go +++ b/internal/api/handlers/admin_jobs_test.go @@ -53,7 +53,8 @@ func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *tes ResultPayload: json.RawMessage( `{"requested_content_id":"item-1","detail_content_id":"item-2","scan_path":"/srv/media/private/movie","scan_result":{"New":1,"RootObservations":[{"RootPath":"/srv/media/private","SampleFilePath":"/srv/media/private/movie.mkv"}]}}`, ), - PublicURL: "https://example.test/public", + ErrorMessage: "scan scope: stat /srv/media/private/movie: permission denied", + PublicURL: "https://example.test/public", } resp := adminJobToResponseForClaims(nil, job, nil, claims) @@ -74,4 +75,7 @@ func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *tes !bytes.Contains(resp.ResultPayload, []byte("detail_content_id")) { t.Fatalf("ResultPayload = %s, want safe item refresh summary fields", resp.ResultPayload) } + if resp.ErrorMessage != "" { + t.Fatalf("ErrorMessage = %q, want stripped for non-admin", resp.ErrorMessage) + } } diff --git a/internal/api/handlers/admin_test.go b/internal/api/handlers/admin_test.go new file mode 100644 index 00000000..57240e77 --- /dev/null +++ b/internal/api/handlers/admin_test.go @@ -0,0 +1,11 @@ +package handlers + +import "testing" + +func TestUpdateRequiresSessionRevocation_ForPermissions(t *testing.T) { + if !updateRequiresSessionRevocation(updateUserRequest{ + Permissions: updateStringSliceField{Set: true, Value: []string{"metadata_curation"}}, + }) { + t.Fatal("permission updates should revoke sessions") + } +} diff --git a/internal/api/middleware/permissions.go b/internal/api/middleware/permissions.go index 9ce18f1b..aa7059d6 100644 --- a/internal/api/middleware/permissions.go +++ b/internal/api/middleware/permissions.go @@ -30,6 +30,9 @@ func NewPermissionMiddleware(users PermissionUserLoader, libraries MetadataTarge return &PermissionMiddleware{users: users, libraries: libraries} } +// RequireMetadataCurationForItem allows admins or users with metadata_curation +// permission when every library containing the target item is within the user's +// assigned libraries. A nil user library list means unrestricted library access. func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) diff --git a/internal/api/router.go b/internal/api/router.go index fb36f2f2..75e38dfa 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1678,6 +1678,8 @@ func NewRouter(deps Dependencies) chi.Router { }) if adminJobsHandler != nil { + // Curators must poll their own item-refresh jobs, so this stays outside + // the admin-only group. HandleGet enforces per-job authorization. r.Get("/jobs/{id}", adminJobsHandler.HandleGet) } From ce6b4eee0dac15ee4e2fa8d2bcca9d3e721aa0c7 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 19:40:00 -0400 Subject: [PATCH 41/53] docs: add metadata curation permission plan --- ...2026-05-26-metadata-curation-permission.md | 1780 +++++++++++++++++ 1 file changed, 1780 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-metadata-curation-permission.md diff --git a/docs/superpowers/plans/2026-05-26-metadata-curation-permission.md b/docs/superpowers/plans/2026-05-26-metadata-curation-permission.md new file mode 100644 index 00000000..6a5a7673 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-metadata-curation-permission.md @@ -0,0 +1,1780 @@ +# Metadata Curation Permission Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one assignable `metadata_curation` account permission that lets non-admin users edit, refresh, and rematch metadata only for items in libraries they are allowed to access. + +**Architecture:** Store durable permission keys on `users.permissions` and keep server authorization out of JWT claims so permission changes take effect from current database state. Add a shared item-scoped permission middleware that grants admins all access, grants `metadata_curation` users only when every library containing the target item is inside `users.library_ids`, and leaves unrelated admin surfaces admin-only. Frontend item-detail metadata controls use effective permissions from `/auth/me`; admin user-management screens edit the assigned permission array. + +**Tech Stack:** Go, chi middleware, PostgreSQL migrations, pgx, React, TypeScript, TanStack Query, existing Silo admin/user APIs. + +--- + +## Commands + +Commands assume the repository root is the cwd. + +--- + +## File Structure + +- Create `migrations/140_user_permissions.up.sql` + - Add `users.permissions text[] NOT NULL DEFAULT '{}'::text[]`. + +- Create `migrations/140_user_permissions.down.sql` + - Drop `users.permissions`. + +- Modify `migrations/001_schema.up.sql` + - Add `permissions text[] DEFAULT '{}'::text[] NOT NULL` to the base `users` table. + +- Modify `internal/database/testdata/migrations/001_create_users.up.sql` + - Keep lightweight test schema aligned with `models.User` scanning. + +- Modify `internal/models/user.go` + - Add assigned permission fields to user models and create/update inputs. + +- Create `internal/auth/permissions.go` + - Define permission constants, validation, assigned/effective permission helpers. + +- Create `internal/auth/permissions_test.go` + - Cover permission validation, de-duplication, and admin effective permissions. + +- Modify `internal/auth/repository.go` + - Read/write `users.permissions`. + - Bump `access_policy_revision` when permissions change. + +- Modify `internal/api/handlers/auth.go` + - Add effective `permissions` to `/auth/me` and login responses. + +- Modify `internal/api/handlers/admin.go` + - Add assigned `permissions` to admin user create/update/list/detail APIs. + - Include permission changes in session revocation. + +- Create `internal/api/middleware/permissions.go` + - Add item-scoped metadata curation authorization middleware and PostgreSQL target-library resolver. + +- Create `internal/api/middleware/permissions_test.go` + - Unit test authorization behavior without a database by faking user and target-library resolvers. + +- Modify `internal/api/router.go` + - Instantiate the permission middleware. + - Move item metadata edit/refresh/match routes out from the admin-only group and behind metadata curation middleware. + - Keep image, people, marker, library, settings, users, jobs list, and full admin routes admin-only. + +- Modify `internal/api/handlers/admin_jobs.go` + - Allow non-admin callers to read only their own `item_refresh` job by ID so refresh polling works. + - Keep list access admin-only. + +- Create or modify `internal/api/handlers/admin_jobs_test.go` + - Test the job read predicate. + +- Modify `web/src/api/types.ts` + - Add `permissions` to `User`, `AdminUser`, `CreateUserRequest`, and `UpdateUserRequest`. + +- Create `web/src/lib/permissions.ts` + - Add shared frontend permission constants and helpers. + +- Modify `web/src/pages/AdminUsers.tsx` + - Add a Metadata Curation switch to create/edit user forms. + +- Modify `web/src/pages/AdminUserDetail.tsx` + - Display and edit assigned Metadata Curation permission on the user detail page. + +- Modify `web/src/pages/ItemDetail/components/ActionBar.tsx` + - Split full-admin overflow actions from metadata-curation actions. + +- Modify item detail content files: + - `web/src/pages/ItemDetail/MovieContent.tsx` + - `web/src/pages/ItemDetail/SeriesContent.tsx` + - `web/src/pages/ItemDetail/SeasonContent.tsx` + - `web/src/pages/ItemDetail/EpisodeContent.tsx` + - Use metadata curation permission for refresh/edit/match controls while preserving admin-only controls such as media locations, play history, and intro marker redetection. + +Do not add permission groups. Do not make metadata curation a profile setting. Do not broaden this first pass to people metadata, image selection, marker refresh, or library-wide refresh. + +--- + +## Behavioral Contract + +- Admin users can do everything they can do today. +- Non-admin users with assigned `metadata_curation` can: + - `PATCH /api/v1/admin/items/{id}/metadata` + - `POST /api/v1/admin/items/{id}/refresh-metadata` + - `POST /api/v1/admin/items/{id}/match/search` + - `POST /api/v1/admin/items/{id}/match/apply` +- Non-admin metadata curators cannot use: + - library-wide metadata refresh + - image apply/search routes + - people metadata routes + - marker/intro refresh routes + - full admin navigation/routes + - admin job list +- `users.library_ids IS NULL` means unrestricted library access. +- `users.library_ids = '{}'` means no library access. +- A non-admin curator may mutate an item only when every library containing the target item is inside `users.library_ids`. +- For seasons and episodes, the target library set is resolved from the parent series library membership. +- Permission checks load current user policy from the database. JWTs continue to carry only coarse `role`. +- `/auth/me` returns effective permissions for UI decisions. Admin role implies `metadata_curation` in that effective list. +- Admin user APIs return assigned permissions, not effective permissions, so admins can see what is explicitly granted. + +--- + +### Task 1: Add Permission Storage And Domain Helpers + +**Files:** +- Create: `migrations/140_user_permissions.up.sql` +- Create: `migrations/140_user_permissions.down.sql` +- Modify: `migrations/001_schema.up.sql` +- Modify: `internal/database/testdata/migrations/001_create_users.up.sql` +- Modify: `internal/models/user.go` +- Create: `internal/auth/permissions.go` +- Create: `internal/auth/permissions_test.go` +- Modify: `internal/auth/repository.go` + +- [ ] **Step 1: Add the database migration** + +Create `migrations/140_user_permissions.up.sql`: + +```sql +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS permissions text[] NOT NULL DEFAULT '{}'::text[]; + +UPDATE public.users +SET permissions = '{}'::text[] +WHERE permissions IS NULL; +``` + +Create `migrations/140_user_permissions.down.sql`: + +```sql +ALTER TABLE public.users + DROP COLUMN IF EXISTS permissions; +``` + +Update `migrations/001_schema.up.sql` so the base `public.users` definition contains: + +```sql + role text, + permissions text[] DEFAULT '{}'::text[] NOT NULL, + enabled boolean DEFAULT true, +``` + +Update `internal/database/testdata/migrations/001_create_users.up.sql` so its `users` table has the same `permissions text[] DEFAULT '{}'::text[] NOT NULL` column near `role`. + +- [ ] **Step 2: Add permission fields to user models** + +Update `internal/models/user.go`: + +```go +type User struct { + ID int + Email string + Username string + PasswordHash string + LocalPasswordLoginEnabled bool + Role string + Permissions []string + Enabled bool + LibraryIDs []int // nullable in PG (nil = all libraries) + MaxPlaybackQuality string + AccessPolicyRevision int64 + MaxStreams int + MaxTranscodes int + MaxProfiles int + DownloadAllowed bool + DownloadTranscodeAllowed bool + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +Add permissions to create/update inputs: + +```go +type CreateUserInput struct { + Email string + Username string + Password string + LocalPasswordLoginEnabled *bool + Role string + Permissions []string + LibraryIDs []int + MaxPlaybackQuality string + MaxStreams *int + MaxTranscodes *int + MaxProfiles *int + DownloadAllowed *bool + DownloadTranscodeAllowed *bool +} + +type UpdateUserInput struct { + Email *string + Username *string + Password *string + LocalPasswordLoginEnabled *bool + Role *string + Permissions *[]string + Enabled *bool + LibraryIDs *[]int + MaxPlaybackQuality *string + MaxStreams *int + MaxTranscodes *int + MaxProfiles *int + DownloadAllowed *bool + DownloadTranscodeAllowed *bool +} +``` + +- [ ] **Step 3: Add permission constants and validation** + +Create `internal/auth/permissions.go`: + +```go +package auth + +import ( + "fmt" + "sort" + "strings" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type Permission string + +const PermissionMetadataCuration Permission = "metadata_curation" + +var assignablePermissions = map[Permission]struct{}{ + PermissionMetadataCuration: {}, +} + +var effectiveAdminPermissions = []string{ + string(PermissionMetadataCuration), +} + +func NormalizePermissions(values []string) ([]string, error) { + if len(values) == 0 { + return []string{}, nil + } + + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, raw := range values { + key := strings.TrimSpace(raw) + if key == "" { + continue + } + permission := Permission(key) + if _, ok := assignablePermissions[permission]; !ok { + return nil, fmt.Errorf("unknown permission %q", key) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + sort.Strings(out) + return out, nil +} + +func HasAssignedPermission(user *models.User, permission Permission) bool { + if user == nil { + return false + } + for _, value := range user.Permissions { + if value == string(permission) { + return true + } + } + return false +} + +func HasEffectivePermission(user *models.User, permission Permission) bool { + if user == nil || !user.Enabled { + return false + } + if user.Role == "admin" { + return true + } + return HasAssignedPermission(user, permission) +} + +func EffectivePermissions(user *models.User) []string { + if user == nil || !user.Enabled { + return []string{} + } + if user.Role == "admin" { + return append([]string(nil), effectiveAdminPermissions...) + } + permissions, err := NormalizePermissions(user.Permissions) + if err != nil { + return []string{} + } + return permissions +} +``` + +- [ ] **Step 4: Add permission helper tests** + +Create `internal/auth/permissions_test.go`: + +```go +package auth + +import ( + "reflect" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestNormalizePermissions_DeduplicatesAndSorts(t *testing.T) { + got, err := NormalizePermissions([]string{ + " metadata_curation ", + "metadata_curation", + "", + }) + if err != nil { + t.Fatalf("NormalizePermissions returned error: %v", err) + } + want := []string{"metadata_curation"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("permissions = %#v, want %#v", got, want) + } +} + +func TestNormalizePermissions_RejectsUnknownPermission(t *testing.T) { + if _, err := NormalizePermissions([]string{"server_owner"}); err == nil { + t.Fatal("expected unknown permission error") + } +} + +func TestHasEffectivePermission_AdminImpliesMetadataCuration(t *testing.T) { + user := &models.User{Role: "admin", Enabled: true} + if !HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("admin should have metadata curation") + } +} + +func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) { + user := &models.User{Role: "user", Enabled: true} + if HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("plain user should not have metadata curation") + } + user.Permissions = []string{"metadata_curation"} + if !HasEffectivePermission(user, PermissionMetadataCuration) { + t.Fatal("assigned user should have metadata curation") + } +} +``` + +- [ ] **Step 5: Update `internal/auth/repository.go` scanning and writes** + +Update `allColumns`: + +```go +const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, permissions, enabled, + library_ids, max_playback_quality, access_policy_revision, + max_streams, max_transcodes, max_profiles, download_allowed, + download_transcode_allowed, created_at, updated_at` +``` + +Add `&u.Permissions` immediately after `&u.Role` in both `scanUser` and `scanUsers`. + +In `Create`, normalize permissions and insert them: + +```go +permissions, err := NormalizePermissions(input.Permissions) +if err != nil { + return nil, err +} + +cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"} +args := []any{ + input.Email, + input.Username, + string(hash), + localPasswordLoginEnabled, + input.Role, + permissions, + input.LibraryIDs, + input.MaxPlaybackQuality, +} +``` + +In `Update`, add: + +```go +if input.Permissions != nil { + permissions, err := NormalizePermissions(*input.Permissions) + if err != nil { + return err + } + setClauses = append(setClauses, fmt.Sprintf("permissions = $%d", argIndex)) + args = append(args, permissions) + argIndex++ +} +``` + +Before appending `updated_at = NOW()`, bump policy revision when access policy changes: + +```go +if input.Role != nil || + input.Enabled != nil || + input.LibraryIDs != nil || + input.MaxPlaybackQuality != nil || + input.Permissions != nil { + setClauses = append(setClauses, "access_policy_revision = access_policy_revision + 1") +} +``` + +- [ ] **Step 6: Run focused auth tests** + +Run: + +```bash +go test ./internal/auth -run 'TestNormalizePermissions|TestHasEffectivePermission' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add migrations/140_user_permissions.up.sql migrations/140_user_permissions.down.sql migrations/001_schema.up.sql internal/database/testdata/migrations/001_create_users.up.sql internal/models/user.go internal/auth/permissions.go internal/auth/permissions_test.go internal/auth/repository.go +git commit -m "feat(auth): add assignable user permissions" +``` + +--- + +### Task 2: Surface Permissions In Auth And Admin User APIs + +**Files:** +- Modify: `internal/api/handlers/auth.go` +- Modify: `internal/api/handlers/admin.go` +- Modify: `web/src/api/types.ts` + +- [ ] **Step 1: Add permissions to auth user responses** + +In `internal/api/handlers/auth.go`, update `userResponse`: + +```go +type userResponse struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + Permissions []string `json:"permissions"` + DownloadAllowed bool `json:"download_allowed"` + Impersonation *impersonationResponse `json:"impersonation,omitempty"` +} +``` + +Update `buildUserResponse`: + +```go +resp := userResponse{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + Role: user.Role, + Permissions: auth.EffectivePermissions(user), + DownloadAllowed: user.DownloadAllowed, +} +``` + +- [ ] **Step 2: Add assigned permissions to admin user requests/responses** + +In `internal/api/handlers/admin.go`, add `Permissions []string` to `createUserRequest`: + +```go +type createUserRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + Permissions []string `json:"permissions"` + CreateDefaultProfile bool `json:"create_default_profile"` + DefaultProfileName string `json:"default_profile_name,omitempty"` + LibraryIDs []int `json:"library_ids"` + MaxPlaybackQuality string `json:"max_playback_quality"` + MaxStreams *int `json:"max_streams,omitempty"` + MaxTranscodes *int `json:"max_transcodes,omitempty"` + MaxProfiles *int `json:"max_profiles,omitempty"` + DownloadAllowed *bool `json:"download_allowed,omitempty"` + DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"` +} +``` + +Add a reusable JSON field for optional string slices: + +```go +type updateStringSliceField struct { + Set bool + Value []string +} + +func (f *updateStringSliceField) UnmarshalJSON(data []byte) error { + f.Set = true + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + f.Value = []string{} + return nil + } + return json.Unmarshal(data, &f.Value) +} + +func (f updateStringSliceField) Ptr() *[]string { + if !f.Set { + return nil + } + value := append([]string(nil), f.Value...) + return &value +} +``` + +Add it to `updateUserRequest`: + +```go +Permissions updateStringSliceField `json:"permissions,omitempty"` +``` + +Add assigned permissions to `adminUserResponse`: + +```go +Permissions []string `json:"permissions"` +``` + +Update `toAdminUserResponse`: + +```go +Permissions: append([]string(nil), u.Permissions...), +``` + +- [ ] **Step 3: Validate and persist admin user permissions** + +In `HandleCreateUser`, normalize before calling the provisioner: + +```go +permissions, err := auth.NormalizePermissions(req.Permissions) +if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return +} +``` + +Pass into `models.CreateUserInput`: + +```go +Permissions: permissions, +``` + +In `HandleUpdateUser`, normalize only when present: + +```go +var permissions *[]string +if req.Permissions.Set { + normalized, err := auth.NormalizePermissions(req.Permissions.Value) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + permissions = &normalized +} +``` + +Pass into `models.UpdateUserInput`: + +```go +Permissions: permissions, +``` + +Update session revocation: + +```go +func updateRequiresSessionRevocation(req updateUserRequest) bool { + return req.Password != nil || + req.Role != nil || + req.Enabled != nil || + req.LibraryIDs.Set || + req.Permissions.Set || + req.MaxPlaybackQuality != nil +} +``` + +- [ ] **Step 4: Update frontend API types** + +In `web/src/api/types.ts`, update `User`: + +```ts +export interface User { + id: number; + username: string; + email: string; + role: string; + permissions: string[]; + download_allowed: boolean; + impersonation?: ImpersonationInfo | null; +} +``` + +Update `AdminUser`: + +```ts +export interface AdminUser { + id: number; + username: string; + email: string; + role: string; + permissions: string[]; + enabled: boolean; + library_ids: number[] | null; + max_playback_quality: string; + max_streams: number; + max_transcodes: number; + max_profiles: number; + download_allowed: boolean; + download_transcode_allowed: boolean; + created_at: string; + updated_at: string; + last_active_at?: string; +} +``` + +Update request types: + +```ts +export interface CreateUserRequest { + username: string; + email: string; + password: string; + role: string; + permissions?: string[]; + create_default_profile?: boolean; + default_profile_name?: string; + library_ids?: number[] | null; + max_playback_quality?: string; + max_streams?: number; + max_transcodes?: number; + max_profiles?: number; + download_allowed?: boolean; + download_transcode_allowed?: boolean; +} + +export interface UpdateUserRequest { + username?: string; + email?: string; + password?: string; + role?: string; + permissions?: string[]; + enabled?: boolean; + library_ids?: number[] | null; + max_playback_quality?: string; + max_streams?: number; + max_transcodes?: number; + max_profiles?: number; + download_allowed?: boolean; + download_transcode_allowed?: boolean; +} +``` + +- [ ] **Step 5: Run focused compile checks** + +Run: + +```bash +go test ./internal/api/handlers -run 'TestNonExistent' -count=1 +``` + +Expected: package compiles and reports no tests to run or PASS. + +Run: + +```bash +cd web && pnpm exec tsc --noEmit +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/api/handlers/auth.go internal/api/handlers/admin.go web/src/api/types.ts +git commit -m "feat(auth): expose user permissions" +``` + +--- + +### Task 3: Add Item-Scoped Metadata Curation Middleware + +**Files:** +- Create: `internal/api/middleware/permissions.go` +- Create: `internal/api/middleware/permissions_test.go` + +- [ ] **Step 1: Write middleware tests first** + +Create `internal/api/middleware/permissions_test.go`: + +```go +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakePermissionUserLoader struct { + user *models.User + err error +} + +func (f fakePermissionUserLoader) GetByID(context.Context, int) (*models.User, error) { + return f.user, f.err +} + +type fakeTargetLibraryResolver struct { + ids []int + err error +} + +func (f fakeTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(context.Context, string) ([]int, error) { + return f.ids, f.err +} + +func requestWithItemID(role string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/refresh-metadata", nil) + ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: role, TokenType: auth.TokenTypeAccess}) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("id", "item-1") + ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx) + return req.WithContext(ctx) +} + +func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role string) int { + mw := NewPermissionMiddleware( + fakePermissionUserLoader{user: user}, + fakeTargetLibraryResolver{ids: libraryIDs}, + ) + next := mw.RequireMetadataCurationForItem(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + rec := httptest.NewRecorder() + next.ServeHTTP(rec, requestWithItemID(role)) + return rec.Code +} + +func TestRequireMetadataCurationForItem_AllowsAdmin(t *testing.T) { + code := runMetadataCurationMiddleware(nil, nil, "admin") + if code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", code, http.StatusNoContent) + } +} + +func TestRequireMetadataCurationForItem_RejectsUserWithoutPermission(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: nil} + code := runMetadataCurationMiddleware(user, []int{1}, "user") + if code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", code, http.StatusForbidden) + } +} + +func TestRequireMetadataCurationForItem_AllowsUnrestrictedCurator(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, []int{1, 2}, "user") + if code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", code, http.StatusNoContent) + } +} + +func TestRequireMetadataCurationForItem_AllowsWhenAllTargetLibrariesAreAllowed(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, []int{1, 3}, "user") + if code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", code, http.StatusNoContent) + } +} + +func TestRequireMetadataCurationForItem_RejectsWhenAnyTargetLibraryIsOutsideAccess(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, []int{1, 2}, "user") + if code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", code, http.StatusForbidden) + } +} + +func TestRequireMetadataCurationForItem_NotFoundWhenTargetHasNoLibraries(t *testing.T) { + user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}} + code := runMetadataCurationMiddleware(user, nil, "user") + if code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", code, http.StatusNotFound) + } +} +``` + +- [ ] **Step 2: Run tests and verify they fail to compile** + +Run: + +```bash +go test ./internal/api/middleware -run 'TestRequireMetadataCurationForItem' -count=1 +``` + +Expected: FAIL because `NewPermissionMiddleware` does not exist. + +- [ ] **Step 3: Implement middleware and target-library resolver** + +Create `internal/api/middleware/permissions.go`: + +```go +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +type PermissionUserLoader interface { + GetByID(ctx context.Context, id int) (*models.User, error) +} + +type MetadataTargetLibraryResolver interface { + ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error) +} + +type PermissionMiddleware struct { + users PermissionUserLoader + libraries MetadataTargetLibraryResolver +} + +func NewPermissionMiddleware(users PermissionUserLoader, libraries MetadataTargetLibraryResolver) *PermissionMiddleware { + return &PermissionMiddleware{users: users, libraries: libraries} +} + +func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r.Context()) + if claims == nil { + writeUnauthorized(w, "Authentication required") + return + } + if claims.Role == "admin" { + next.ServeHTTP(w, r) + return + } + if m == nil || m.users == nil || m.libraries == nil { + writeForbidden(w, "Metadata curation permission required") + return + } + + contentID := chi.URLParam(r, "id") + if contentID == "" { + writePermissionError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + + user, err := m.users.GetByID(r.Context(), claims.UserID) + if err != nil || user == nil || !user.Enabled { + writeForbidden(w, "Metadata curation permission required") + return + } + if !auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) { + writeForbidden(w, "Metadata curation permission required") + return + } + + targetLibraries, err := m.libraries.ResolveMetadataTargetLibraryIDs(r.Context(), contentID) + if err != nil { + writePermissionError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item libraries") + return + } + if len(targetLibraries) == 0 { + writePermissionError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + if !metadataTargetWithinUserLibraries(user.LibraryIDs, targetLibraries) { + writeForbidden(w, "Item is outside your assigned libraries") + return + } + + next.ServeHTTP(w, r) + }) +} + +func metadataTargetWithinUserLibraries(allowed []int, target []int) bool { + if allowed == nil { + return true + } + if len(target) == 0 { + return false + } + allowedSet := make(map[int]struct{}, len(allowed)) + for _, id := range allowed { + allowedSet[id] = struct{}{} + } + for _, id := range target { + if _, ok := allowedSet[id]; !ok { + return false + } + } + return true +} + +type PGMetadataTargetLibraryResolver struct { + Pool *pgxpool.Pool +} + +func NewPGMetadataTargetLibraryResolver(pool *pgxpool.Pool) *PGMetadataTargetLibraryResolver { + return &PGMetadataTargetLibraryResolver{Pool: pool} +} + +func (r *PGMetadataTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(ctx context.Context, contentID string) ([]int, error) { + if r == nil || r.Pool == nil { + return nil, fmt.Errorf("database not configured") + } + rows, err := r.Pool.Query(ctx, ` + WITH target_root AS ( + SELECT mi.content_id + FROM media_items mi + WHERE mi.content_id = $1 + UNION + SELECT s.series_id + FROM seasons s + WHERE s.content_id = $1 + UNION + SELECT e.series_id + FROM episodes e + WHERE e.content_id = $1 + ) + SELECT DISTINCT mil.media_folder_id + FROM target_root tr + JOIN media_item_libraries mil ON mil.content_id = tr.content_id + ORDER BY mil.media_folder_id`, contentID) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func writePermissionError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: code, Message: message}) +} +``` + +- [ ] **Step 4: Run middleware tests** + +Run: + +```bash +go test ./internal/api/middleware -run 'TestRequireMetadataCurationForItem' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/api/middleware/permissions.go internal/api/middleware/permissions_test.go +git commit -m "feat(api): authorize item metadata curation" +``` + +--- + +### Task 4: Wire Routes And Scoped Job Polling + +**Files:** +- Modify: `internal/api/router.go` +- Modify: `internal/api/handlers/admin_jobs.go` +- Create or modify: `internal/api/handlers/admin_jobs_test.go` + +- [ ] **Step 1: Add job access predicate tests** + +Create `internal/api/handlers/admin_jobs_test.go` if it does not exist, or append to it: + +```go +package handlers + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/adminjob" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) { + claims := &auth.Claims{UserID: 1, Role: "admin"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport} + if !canReadAdminJob(claims, job) { + t.Fatal("admin should be allowed to read any job") + } +} + +func TestCanReadAdminJob_CreatorCanReadOwnItemRefreshJob(t *testing.T) { + claims := &auth.Claims{UserID: 2, Role: "user"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh} + if !canReadAdminJob(claims, job) { + t.Fatal("creator should be allowed to read own item refresh job") + } +} + +func TestCanReadAdminJob_CreatorCannotReadOwnNonItemRefreshJob(t *testing.T) { + claims := &auth.Claims{UserID: 2, Role: "user"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport} + if canReadAdminJob(claims, job) { + t.Fatal("non-admin should not read non-item-refresh jobs") + } +} + +func TestCanReadAdminJob_OtherUserCannotReadItemRefreshJob(t *testing.T) { + claims := &auth.Claims{UserID: 3, Role: "user"} + job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh} + if canReadAdminJob(claims, job) { + t.Fatal("non-admin should not read another user's item refresh job") + } +} +``` + +- [ ] **Step 2: Run tests and verify they fail** + +Run: + +```bash +go test ./internal/api/handlers -run 'TestCanReadAdminJob' -count=1 +``` + +Expected: FAIL because `canReadAdminJob` does not exist. + +- [ ] **Step 3: Implement scoped job reads** + +In `internal/api/handlers/admin_jobs.go`, update `HandleGet` after loading the job: + +```go +claims := apimw.GetClaims(r.Context()) +if !canReadAdminJob(claims, job) { + writeError(w, http.StatusForbidden, "forbidden", "Admin access required") + return +} + +response := adminJobToResponse(r, job, h.store) +if claims == nil || claims.Role != "admin" { + response.RequestPayload = json.RawMessage(`{}`) + response.PublicURL = "" + response.DownloadURL = "" + response.DownloadExpiresAt = nil +} +writeJSON(w, http.StatusOK, response) +``` + +Add the helper near `currentAdminUserID`: + +```go +func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool { + if claims == nil || job == nil { + return false + } + if claims.Role == "admin" { + return true + } + return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID +} +``` + +Add the `auth` import if it is not already present: + +```go +"github.com/Silo-Server/silo-server/internal/auth" +``` + +- [ ] **Step 4: Instantiate permission middleware in the router** + +In `internal/api/router.go`, after `viewerAccessMiddleware` setup, add: + +```go +var permissionMiddleware *apimw.PermissionMiddleware +if userRepo != nil && deps.DB != nil { + permissionMiddleware = apimw.NewPermissionMiddleware( + userRepo, + apimw.NewPGMetadataTargetLibraryResolver(deps.DB), + ) +} +``` + +- [ ] **Step 5: Split `/admin` routes** + +Replace the single admin route group: + +```go +r.Route("/admin", func(r chi.Router) { + r.Use(apimw.RequireAdmin) + // current admin route declarations +}) +``` + +with this shape: + +```go +r.Route("/admin", func(r chi.Router) { + metadataItemAccess := apimw.RequireAdmin + if permissionMiddleware != nil { + metadataItemAccess = permissionMiddleware.RequireMetadataCurationForItem + } + + r.Group(func(r chi.Router) { + r.Use(metadataItemAccess) + r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata) + r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata) + if adminMatchHandler != nil { + r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates) + r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch) + } + }) + + if adminJobsHandler != nil { + r.Get("/jobs/{id}", adminJobsHandler.HandleGet) + } + + r.Group(func(r chi.Router) { + r.Use(apimw.RequireAdmin) + + r.Get("/users", adminHandler.HandleListUsers) + r.Post("/users", adminHandler.HandleCreateUser) + r.Get("/users/{id}", adminHandler.HandleGetUser) + r.Put("/users/{id}", adminHandler.HandleUpdateUser) + r.Delete("/users/{id}", adminHandler.HandleDeleteUser) + r.Post("/users/{id}/impersonate", adminHandler.HandleImpersonateUser) + + // Move these existing route declarations into this admin-only group + // without changing their handler names: + // users, user profiles/settings/device settings, devices, sessions, + // playback history, unmatched, stats, settings, section settings, + // item marker/intro refresh, people refresh/update, item images, + // filesystem browse, catalog seed import/export, plugins, logs, + // subtitle providers, tasks, task metrics, scans, nodes, requests, + // history imports, sections, collections, collection groups, + // recommendation admin routes, system routes, API keys, and rate limits. + // + // Do not duplicate /items/{id}/refresh-metadata, + // /items/{id}/metadata, /items/{id}/match/search, + // /items/{id}/match/apply, or /jobs/{id}. + + if adminJobsHandler != nil { + r.Route("/jobs", func(r chi.Router) { + r.Get("/", adminJobsHandler.HandleList) + }) + } + }) +}) +``` + +When moving route declarations, compare against the current `r.Route("/admin", ...)` block and keep every admin-only path not listed in the duplication warning in the `RequireAdmin` group with the same path and handler. + +- [ ] **Step 6: Run focused backend checks** + +Run: + +```bash +go test ./internal/api/handlers -run 'TestCanReadAdminJob' -count=1 +``` + +Expected: PASS. + +Run: + +```bash +go test ./internal/api/middleware -run 'TestRequireMetadataCurationForItem' -count=1 +``` + +Expected: PASS. + +Run: + +```bash +go test ./internal/api -run 'TestNonExistent' -count=1 +``` + +Expected: package compiles and reports no tests to run or PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/api/router.go internal/api/handlers/admin_jobs.go internal/api/handlers/admin_jobs_test.go +git commit -m "feat(api): route metadata curation by permission" +``` + +--- + +### Task 5: Add Frontend Permission Helpers + +**Files:** +- Create: `web/src/lib/permissions.ts` + +- [ ] **Step 1: Add shared helper** + +Create `web/src/lib/permissions.ts`: + +```ts +import type { User } from "@/api/types"; + +export const PERMISSION_METADATA_CURATION = "metadata_curation"; + +export function hasPermission( + user: Pick | null | undefined, + permission: string, +) { + if (!user) return false; + if (user.role === "admin") return true; + return Array.isArray(user.permissions) && user.permissions.includes(permission); +} + +export function canCurateMetadata(user: Pick | null | undefined) { + return hasPermission(user, PERMISSION_METADATA_CURATION); +} +``` + +- [ ] **Step 2: Run frontend type check** + +Run: + +```bash +cd web && pnpm exec tsc --noEmit +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/lib/permissions.ts +git commit -m "feat(web): add permission helpers" +``` + +--- + +### Task 6: Add Metadata Curation Toggle To User Management + +**Files:** +- Modify: `web/src/pages/AdminUsers.tsx` +- Modify: `web/src/pages/AdminUserDetail.tsx` + +- [ ] **Step 1: Add helpers local to each user form file** + +In both files, import: + +```ts +import { PERMISSION_METADATA_CURATION } from "@/lib/permissions"; +``` + +Add local helpers near other small helpers: + +```ts +function hasAssignedPermission(permissions: string[] | undefined, permission: string) { + return Array.isArray(permissions) && permissions.includes(permission); +} + +function setAssignedPermission(permissions: string[], permission: string, enabled: boolean) { + const next = new Set(permissions); + if (enabled) { + next.add(permission); + } else { + next.delete(permission); + } + return Array.from(next).sort(); +} +``` + +- [ ] **Step 2: Update `AdminUsers.tsx` create/edit form state and submit bodies** + +Inside `UserForm`, add: + +```ts +const [permissions, setPermissions] = useState(user?.permissions ?? []); +const metadataCurationId = useId(); +``` + +In the update body: + +```ts +permissions, +``` + +In the create body: + +```ts +permissions, +``` + +In the Access tab, after `LibraryAccessSelector`, add: + +```tsx +
+
+ +

+ Edit, refresh, and rematch metadata within assigned libraries. +

+
+ + setPermissions((current) => + setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked), + ) + } + /> +
+``` + +- [ ] **Step 3: Update `AdminUserDetail.tsx` edit form and summary** + +In the user detail summary near role/library/download rows, add a row: + +```tsx + +``` + +Inside `EditUserForm`, add: + +```ts +const [permissions, setPermissions] = useState(user.permissions ?? []); +const metadataCurationId = useId(); +``` + +In the update body: + +```ts +permissions, +``` + +In the Access tab, after `LibraryAccessSelector`, add: + +```tsx +
+
+ +

+ Edit, refresh, and rematch metadata within assigned libraries. +

+
+ + setPermissions((current) => + setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked), + ) + } + /> +
+``` + +- [ ] **Step 4: Run frontend lint/type check** + +Run: + +```bash +cd web && pnpm exec tsc --noEmit +``` + +Expected: PASS. + +Run: + +```bash +cd web && pnpm run lint +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/pages/AdminUsers.tsx web/src/pages/AdminUserDetail.tsx +git commit -m "feat(web): assign metadata curation permission" +``` + +--- + +### Task 7: Show Item Metadata Controls For Curators + +**Files:** +- Modify: `web/src/pages/ItemDetail/components/ActionBar.tsx` +- Modify: `web/src/pages/ItemDetail/MovieContent.tsx` +- Modify: `web/src/pages/ItemDetail/SeriesContent.tsx` +- Modify: `web/src/pages/ItemDetail/SeasonContent.tsx` +- Modify: `web/src/pages/ItemDetail/EpisodeContent.tsx` + +- [ ] **Step 1: Split ActionBar full-admin and metadata-curation actions** + +In `ActionBarProps`, add: + +```ts +canCurateMetadata?: boolean; +``` + +Destructure it: + +```ts +canCurateMetadata = false, +``` + +Add derived booleans near `hasOverflowActions`: + +```ts +const hasAdminActions = Boolean( + isAdmin && (contentId || onRedetectIntro), +); +const hasMetadataActions = Boolean( + canCurateMetadata && (onRefresh || onEditMetadata || onMatchItem), +); +``` + +Replace the existing `{isAdmin && (...)}` block in the overflow menu with: + +```tsx +{(hasAdminActions || hasMetadataActions) && ( + <> + {hasOverflowActions && } + {isAdmin && contentId && ( + + navigate(`/admin/history?media_item_id=${encodeURIComponent(contentId)}`) + } + > + View Play History + + )} + {canCurateMetadata && onRefresh && ( + { + setRefreshDialogOpen(true); + }} + > + {isRefreshing && } + Refresh Metadata + + )} + {isAdmin && onRedetectIntro && ( + + + Re-detect Intro Markers + + )} + {canCurateMetadata && onEditMetadata && ( + + + Edit Metadata + + )} + {canCurateMetadata && onMatchItem && ( + + + Match Item + + )} + +)} +``` + +Keep `RefreshMetadataDialog` mounted as it is today. + +- [ ] **Step 2: Update movie item detail** + +In `web/src/pages/ItemDetail/MovieContent.tsx`, import: + +```ts +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; +``` + +After `isAdmin`: + +```ts +const canCurateMetadata = canCurateMetadataForUser(user); +``` + +Update `ActionBar` props: + +```tsx +isAdmin={isAdmin} +canCurateMetadata={canCurateMetadata} +onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} +onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined} +``` + +Update dialog rendering: + +```tsx +{canCurateMetadata && } +{canCurateMetadata && ( + +)} +``` + +Keep media locations admin-only: + +```tsx +{isAdmin && } +``` + +- [ ] **Step 3: Update series item detail** + +In `web/src/pages/ItemDetail/SeriesContent.tsx`, import: + +```ts +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; +``` + +After `isAdmin`: + +```ts +const canCurateMetadata = canCurateMetadataForUser(user); +``` + +Update `ActionBar`: + +```tsx +isAdmin={isAdmin} +canCurateMetadata={canCurateMetadata} +onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} +onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined} +``` + +Update dialog rendering: + +```tsx +{canCurateMetadata && } +{canCurateMetadata && ( + +)} +``` + +- [ ] **Step 4: Update season item detail** + +In `web/src/pages/ItemDetail/SeasonContent.tsx`, import: + +```ts +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; +``` + +After `isAdmin`: + +```ts +const canCurateMetadata = canCurateMetadataForUser(user); +``` + +Update `ActionBar`: + +```tsx +isAdmin={isAdmin} +canCurateMetadata={canCurateMetadata} +onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} +``` + +Update dialog rendering: + +```tsx +{canCurateMetadata && } +``` + +- [ ] **Step 5: Update episode item detail** + +In `web/src/pages/ItemDetail/EpisodeContent.tsx`, import: + +```ts +import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions"; +``` + +After `isAdmin`: + +```ts +const canCurateMetadata = canCurateMetadataForUser(user); +``` + +Update `ActionBar`: + +```tsx +isAdmin={isAdmin} +canCurateMetadata={canCurateMetadata} +onRedetectIntro={isAdmin ? () => redetectIntroMutation.mutate(item.content_id) : undefined} +onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} +``` + +Keep media locations and intro redetection admin-only. Update dialog rendering to use `canCurateMetadata`. + +- [ ] **Step 6: Run frontend checks** + +Run: + +```bash +cd web && pnpm exec tsc --noEmit +``` + +Expected: PASS. + +Run: + +```bash +cd web && pnpm run lint +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/pages/ItemDetail/components/ActionBar.tsx web/src/pages/ItemDetail/MovieContent.tsx web/src/pages/ItemDetail/SeriesContent.tsx web/src/pages/ItemDetail/SeasonContent.tsx web/src/pages/ItemDetail/EpisodeContent.tsx +git commit -m "feat(web): show metadata tools to curators" +``` + +--- + +### Task 8: End-To-End Verification + +**Files:** +- No new files. + +- [ ] **Step 1: Run focused backend tests** + +Run: + +```bash +go test ./internal/auth ./internal/api/middleware ./internal/api/handlers -run 'TestNormalizePermissions|TestHasEffectivePermission|TestRequireMetadataCurationForItem|TestCanReadAdminJob' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 2: Run broader API compile/test check** + +Run: + +```bash +go test ./internal/api/... ./internal/auth/... -count=1 +``` + +Expected: PASS. + +- [ ] **Step 3: Run frontend checks** + +Run: + +```bash +cd web && pnpm exec tsc --noEmit +``` + +Expected: PASS. + +Run: + +```bash +cd web && pnpm run lint +``` + +Expected: PASS. + +- [ ] **Step 4: Verify local path hygiene** + +Run: + +```bash +make verify-local-paths +``` + +Expected: PASS. + +- [ ] **Step 5: Manual behavior verification** + +Use an admin account to create or edit a normal user with: + +```text +permissions = ["metadata_curation"] +library_ids = [one library containing a known item] +``` + +Then verify: + +```text +1. The user can open that item. +2. The item detail overflow menu shows Refresh Metadata, Edit Metadata, and Match Item. +3. The user can save a small metadata edit for that item. +4. The user can search match candidates for that item. +5. The user can queue a metadata refresh and the web UI observes the job completion. +6. The same user cannot edit, refresh, or rematch an item whose target library set includes a library outside their assigned library IDs. +7. The same user cannot open full admin pages such as /admin/users or /admin/settings. +8. The same user cannot call image apply, people update, marker refresh, library refresh, or admin job list endpoints. +9. An admin account can still use all existing admin metadata and non-metadata routes. +``` + +- [ ] **Step 6: Commit verification-only fixes if any** + +If verification exposes small follow-up fixes, commit them with a scoped message: + +```bash +git add +git commit -m "fix(auth): tighten metadata curation access" +``` + +--- + +## Acceptance Criteria + +- `users.permissions` stores assigned account permission keys. +- `metadata_curation` is the only assignable permission in this first pass. +- `/auth/me` and login responses include effective permissions. +- Admin user APIs include assigned permissions and reject unknown permission keys. +- Non-admin users without `metadata_curation` remain forbidden from item metadata mutation routes. +- Non-admin users with `metadata_curation` can edit, refresh, and match only items fully contained by their account-level allowed libraries. +- Seasons and episodes inherit library scope from their parent series. +- Metadata refresh polling works for curators without exposing the admin job list. +- Full admin UI and unrelated admin APIs remain admin-only. +- Frontend item metadata controls appear for admins and metadata curators; admin-only controls remain admin-only. + +--- + +## Risks And Notes + +- Existing access tokens still carry `role`, but permission checks must load the user from the database. Do not add permission claims to JWTs for server authorization. +- Revoking sessions on permission changes follows the existing admin user update pattern and prevents stale frontend auth state from lingering. +- Item metadata is global. The subset check must require all target libraries to be allowed, not merely one matching library. +- Do not use profile library restrictions for this authorization check. This is an account-level permission bounded by `users.library_ids`. +- The first pass intentionally excludes custom permission groups. The `users.permissions text[]` shape is enough to add future permission keys without redesigning storage. From 3d791e2e9fc69b8a3ad3744ef3ad676fdc0a6a11 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 19:41:55 -0400 Subject: [PATCH 42/53] test(auth): expand session revocation coverage --- internal/api/handlers/admin_test.go | 72 +++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/internal/api/handlers/admin_test.go b/internal/api/handlers/admin_test.go index 57240e77..d434a029 100644 --- a/internal/api/handlers/admin_test.go +++ b/internal/api/handlers/admin_test.go @@ -2,10 +2,72 @@ package handlers import "testing" -func TestUpdateRequiresSessionRevocation_ForPermissions(t *testing.T) { - if !updateRequiresSessionRevocation(updateUserRequest{ - Permissions: updateStringSliceField{Set: true, Value: []string{"metadata_curation"}}, - }) { - t.Fatal("permission updates should revoke sessions") +func TestUpdateRequiresSessionRevocation(t *testing.T) { + role := "admin" + enabled := true + libraryIDs := []int{1, 2} + maxPlaybackQuality := "1080p" + password := "new-password" + username := "renamed" + maxStreams := 4 + + tests := []struct { + name string + req updateUserRequest + want bool + }{ + { + name: "permissions set", + req: updateUserRequest{Permissions: updateStringSliceField{Set: true, Value: []string{"metadata_curation"}}}, + want: true, + }, + { + name: "permissions unset", + req: updateUserRequest{Permissions: updateStringSliceField{Set: false, Value: []string{"metadata_curation"}}}, + want: false, + }, + { + name: "role", + req: updateUserRequest{Role: &role}, + want: true, + }, + { + name: "enabled", + req: updateUserRequest{Enabled: &enabled}, + want: true, + }, + { + name: "library ids", + req: updateUserRequest{LibraryIDs: updateLibraryIDsField{Set: true, Value: libraryIDs}}, + want: true, + }, + { + name: "max playback quality", + req: updateUserRequest{MaxPlaybackQuality: &maxPlaybackQuality}, + want: true, + }, + { + name: "password", + req: updateUserRequest{Password: &password}, + want: true, + }, + { + name: "non access fields", + req: updateUserRequest{Username: &username, MaxStreams: &maxStreams}, + want: false, + }, + { + name: "empty update", + req: updateUserRequest{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := updateRequiresSessionRevocation(tt.req); got != tt.want { + t.Fatalf("updateRequiresSessionRevocation() = %v, want %v", got, tt.want) + } + }) } } From 6c0983c34ec55721a3dbfa805a5b4af41c8fb83c Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 19:56:45 -0400 Subject: [PATCH 43/53] docs: add PageBack component design spec --- .../2026-05-26-page-back-component-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-page-back-component-design.md diff --git a/docs/superpowers/specs/2026-05-26-page-back-component-design.md b/docs/superpowers/specs/2026-05-26-page-back-component-design.md new file mode 100644 index 00000000..a4a1614b --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-page-back-component-design.md @@ -0,0 +1,110 @@ +# PageBack Component Design + +## Goal + +Replace the inconsistent collection of inline back affordances across user-facing pages with a single shared `PageBack` component, placed in the top-left of every non-root page at a stable pixel offset that does not drift with title length, hero content, or page layout. + +Today, back navigation is implemented eight different ways across the app (`DetailBreadcrumb` chevron inside the hero, outline ` + ); +} diff --git a/web/src/pages/CollectionEditor.tsx b/web/src/pages/CollectionEditor.tsx index df6343f4..6b0b8769 100644 --- a/web/src/pages/CollectionEditor.tsx +++ b/web/src/pages/CollectionEditor.tsx @@ -1,8 +1,7 @@ -import { Link, useNavigate, useParams } from "react-router"; -import { ArrowLeft } from "lucide-react"; +import { useNavigate, useParams } from "react-router"; import type { Collection, UserCollectionType } from "@/api/types"; -import { Button } from "@/components/ui/button"; +import PageBack from "@/components/PageBack"; import { Card, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { useCollections } from "@/hooks/queries/collections"; @@ -32,13 +31,8 @@ export default function CollectionEditor() { if (id && !collection && !isLoading) { return ( -
- +
+ Collection not found @@ -51,21 +45,14 @@ export default function CollectionEditor() { if (collection && isImportedCollection(collection)) { return ( -
-
- -
-

{collection.name}

-

- Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule, - item ordering) are locked. -

-
+
+ +
+

{collection.name}

+

+ Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule, + item ordering) are locked. +

-
- -
-

Edit {collection.name}

-

- Manual collections are curated by adding titles directly. -

-
+
+ +
+

Edit {collection.name}

+

+ Manual collections are curated by adding titles directly. +

navigate("/collections")} />
diff --git a/web/src/pages/ItemDetail/DetailHero.tsx b/web/src/pages/ItemDetail/DetailHero.tsx index 01cc0934..52169f41 100644 --- a/web/src/pages/ItemDetail/DetailHero.tsx +++ b/web/src/pages/ItemDetail/DetailHero.tsx @@ -22,6 +22,7 @@ interface DetailHeroProps { scoreRow?: ReactNode; crewLine?: ReactNode; variant?: "full" | "compact"; + topNav?: ReactNode; } export default function DetailHero({ @@ -45,6 +46,7 @@ export default function DetailHero({ scoreRow, crewLine, variant = "full", + topNav, }: DetailHeroProps) { const [backdropLoaded, setBackdropLoaded] = useState(false); const [posterLoaded, setPosterLoaded] = useState(false); @@ -65,6 +67,7 @@ export default function DetailHero({ return (
+ {topNav} {(backdropUrl || backdropPlaceholder) && (
{ mocks.useRating.mockReturnValue({ data: { rating: 3, rated_at: "2026-03-22T00:00:00Z" } }); }); - it("links the season breadcrumb and back button to the resolved season page", () => { + it("links the season breadcrumb segment to the resolved season page", () => { const markup = renderToStaticMarkup( @@ -233,7 +233,7 @@ describe("EpisodeContent", () => { ); expect(countOccurrences(markup, 'href="/item/series-1"')).toBe(1); - expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(2); + expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(1); expect(markup).toContain(">Season 1<"); }); @@ -258,7 +258,7 @@ describe("EpisodeContent", () => { , ); - expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(2); + expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(1); expect(markup).toContain(">Season 99<"); }); diff --git a/web/src/pages/ItemDetail/EpisodeContent.tsx b/web/src/pages/ItemDetail/EpisodeContent.tsx index aaa380f1..1aa799a9 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.tsx @@ -16,6 +16,7 @@ import CrewList from "@/components/CrewList"; import DownloadVersionPicker from "@/components/DownloadVersionPicker"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MediaLocations from "@/components/MediaLocations"; +import PageBack from "@/components/PageBack"; import EpisodeCarousel from "./components/EpisodeCarousel"; import DetailHero from "./DetailHero"; import MetadataBadges from "./components/MetadataBadges"; @@ -221,6 +222,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
} context={
diff --git a/web/src/pages/ItemDetail/MovieContent.tsx b/web/src/pages/ItemDetail/MovieContent.tsx index e78ea0d5..e83acc4d 100644 --- a/web/src/pages/ItemDetail/MovieContent.tsx +++ b/web/src/pages/ItemDetail/MovieContent.tsx @@ -16,6 +16,7 @@ import DownloadVersionPicker from "@/components/DownloadVersionPicker"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MediaLocations from "@/components/MediaLocations"; import MatchItemDialog from "@/components/MatchItemDialog"; +import PageBack from "@/components/PageBack"; import RecommendationGrid from "@/components/RecommendationGrid"; import DetailHero from "./DetailHero"; import MetadataBadges from "./components/MetadataBadges"; @@ -179,6 +180,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
} context="Movie" studioLabel={firstStudio} backdropUrl={item.backdrop_url} diff --git a/web/src/pages/ItemDetail/SeasonContent.tsx b/web/src/pages/ItemDetail/SeasonContent.tsx index 06910754..6a5c2347 100644 --- a/web/src/pages/ItemDetail/SeasonContent.tsx +++ b/web/src/pages/ItemDetail/SeasonContent.tsx @@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/useAuth"; import CastCarousel from "@/components/CastCarousel"; import CrewList from "@/components/CrewList"; import EditMetadataDialog from "@/components/EditMetadataDialog"; +import PageBack from "@/components/PageBack"; import DetailHero from "./DetailHero"; import MetadataBadges from "./components/MetadataBadges"; import ActionBar from "./components/ActionBar"; @@ -82,6 +83,7 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se } context={breadcrumb} backdropUrl={item.backdrop_url} backdropThumbhash={item.backdrop_thumbhash} diff --git a/web/src/pages/ItemDetail/SeriesContent.tsx b/web/src/pages/ItemDetail/SeriesContent.tsx index de46882a..dbe0683f 100644 --- a/web/src/pages/ItemDetail/SeriesContent.tsx +++ b/web/src/pages/ItemDetail/SeriesContent.tsx @@ -14,6 +14,7 @@ import CastCarousel from "@/components/CastCarousel"; import CrewList from "@/components/CrewList"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MatchItemDialog from "@/components/MatchItemDialog"; +import PageBack from "@/components/PageBack"; import RecommendationGrid from "@/components/RecommendationGrid"; import DetailHero from "./DetailHero"; import SeasonCarousel from "./SeasonCarousel"; @@ -116,6 +117,7 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
} context="Series" studioLabel={firstNetwork} backdropUrl={item.backdrop_url} diff --git a/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx b/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx index 259c9fc5..95eb7627 100644 --- a/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx +++ b/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx @@ -1,5 +1,5 @@ import { Link } from "react-router"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ChevronRight } from "lucide-react"; interface BreadcrumbSegment { label: string; @@ -13,44 +13,32 @@ interface DetailBreadcrumbProps { export default function DetailBreadcrumb({ segments }: DetailBreadcrumbProps) { if (segments.length === 0) return null; - const backSegment = [...segments].reverse().find((segment) => segment.href); - return ( ); diff --git a/web/src/pages/PersonDetail.tsx b/web/src/pages/PersonDetail.tsx index 44905f94..60719075 100644 --- a/web/src/pages/PersonDetail.tsx +++ b/web/src/pages/PersonDetail.tsx @@ -8,6 +8,7 @@ import { createEmptyQueryDefinition } from "@/api/types"; import type { CatalogSearchState } from "@/pages/catalogSearchParams"; import EditPersonDialog from "@/components/EditPersonDialog"; import ItemGrid from "@/components/ItemGrid"; +import PageBack from "@/components/PageBack"; import { Button } from "@/components/ui/button"; import { useCatalogWindow } from "@/hooks/queries/catalog"; import { personKeys } from "@/hooks/queries/keys"; @@ -76,7 +77,8 @@ export default function PersonDetail() { return (
{/* Person Header */} -
+
+
{/* Photo */}
diff --git a/web/src/pages/ProfileCustomizeHome.tsx b/web/src/pages/ProfileCustomizeHome.tsx index 460d01af..2f47694e 100644 --- a/web/src/pages/ProfileCustomizeHome.tsx +++ b/web/src/pages/ProfileCustomizeHome.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import PageBack from "@/components/PageBack"; import ProfileSectionRow from "@/components/ProfileSectionRow"; import RecipeGalleryModal from "@/components/RecipeGallery/RecipeGalleryModal"; import RecipeConfigDrawer from "@/components/RecipeGallery/RecipeConfigDrawer"; @@ -207,7 +208,8 @@ export default function ProfileCustomizeHome() { } return ( -
+
+

Customize home

diff --git a/web/src/pages/RecommendationsSection.tsx b/web/src/pages/RecommendationsSection.tsx index 1f26d3a4..f0fa428d 100644 --- a/web/src/pages/RecommendationsSection.tsx +++ b/web/src/pages/RecommendationsSection.tsx @@ -1,6 +1,7 @@ -import { Link, useParams } from "react-router"; -import { ArrowLeft, RefreshCw, Sparkles } from "lucide-react"; +import { useParams } from "react-router"; +import { RefreshCw, Sparkles } from "lucide-react"; +import PageBack from "@/components/PageBack"; import SectionItemCard from "@/components/SectionItemCard"; import { Skeleton } from "@/components/ui/skeleton"; import { useRecommendationSection } from "@/hooks/queries/recommendations"; @@ -75,23 +76,15 @@ export default function RecommendationsSection() { useDocumentTitle(title); return ( -
-
- - - Recommendations - -
-

{title}

- {data && data.items.length > 0 && ( -

- {data.items.length} {data.items.length === 1 ? "title" : "titles"} -

- )} -
+
+ +
+

{title}

+ {data && data.items.length > 0 && ( +

+ {data.items.length} {data.items.length === 1 ? "title" : "titles"} +

+ )}
{isLoading ? ( diff --git a/web/src/pages/RequestBrowse.tsx b/web/src/pages/RequestBrowse.tsx index a85b967f..448447d8 100644 --- a/web/src/pages/RequestBrowse.tsx +++ b/web/src/pages/RequestBrowse.tsx @@ -1,5 +1,5 @@ -import { Link, useParams, useSearchParams } from "react-router"; -import { ArrowLeft } from "lucide-react"; +import { useParams, useSearchParams } from "react-router"; +import PageBack from "@/components/PageBack"; import RequestPosterCard from "@/components/RequestPosterCard"; import { Button } from "@/components/ui/button"; import { @@ -83,29 +83,19 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) { if (browse.isError && (browse.error as { status?: number }).status === 404) { return ( -
+
+

{kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found.

- - Back to Requests -
); } return ( -
+
+
- - Back to Requests -
diff --git a/web/src/pages/RequestDetail.tsx b/web/src/pages/RequestDetail.tsx index 304dee77..2585cc2a 100644 --- a/web/src/pages/RequestDetail.tsx +++ b/web/src/pages/RequestDetail.tsx @@ -1,7 +1,8 @@ -import { useNavigate, useParams } from "react-router"; -import { ArrowLeft, Check, Clock, Loader2, Plus, Star } from "lucide-react"; +import { useParams } from "react-router"; +import { Check, Clock, Loader2, Plus, Star } from "lucide-react"; import CastCarousel from "@/components/CastCarousel"; import MediaCarousel from "@/components/MediaCarousel"; +import PageBack from "@/components/PageBack"; import RequestPosterCard from "@/components/RequestPosterCard"; import DetailHero from "@/pages/ItemDetail/DetailHero"; import { Button } from "@/components/ui/button"; @@ -23,7 +24,6 @@ import { } from "@/lib/mediaRequests"; export default function RequestDetail() { - const navigate = useNavigate(); const params = useParams<{ mediaType: string; tmdbId: string }>(); const mediaType = (params.mediaType === "series" ? "series" : "movie") as "movie" | "series"; const tmdbID = Number(params.tmdbId) || 0; @@ -39,17 +39,12 @@ export default function RequestDetail() { if (detail.isError || !detail.data) { return ( -
+
+

Couldn't load this title.

The TMDB record may be temporarily unavailable.

-
- -
); } @@ -63,6 +58,7 @@ export default function RequestDetail() {
} context={} studioLabel={studioLabel} backdropUrl={backdropUrl} @@ -79,7 +75,6 @@ export default function RequestDetail() { createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id } onRequest={() => createRequest.mutate(requestInputFromMediaResult(item))} - onBack={() => navigate(-1)} /> } /> @@ -198,12 +193,10 @@ function RequestActions({ item, isSubmitting, onRequest, - onBack, }: { item: RequestMediaDetail; isSubmitting: boolean; onRequest: () => void; - onBack: () => void; }) { const requestable = item.request.requestable; const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null; @@ -213,16 +206,6 @@ function RequestActions({ return (
- - {requestable ? ( -
-
-

{title}

-

- {step === 1 - ? "Tune the filters until the cards below show the collection you want." - : isEdit - ? "Update naming, artwork, and sharing for this collection." - : "Give your new collection a name, artwork, and sharing rules."} -

-
- +
+
+

{title}

+

+ {step === 1 + ? "Tune the filters until the cards below show the collection you want." + : isEdit + ? "Update naming, artwork, and sharing for this collection." + : "Give your new collection a name, artwork, and sharing rules."} +

+
); } From a8ef653c11ff70d3a65bb7b6292a03805cbdbc78 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 20:51:52 -0400 Subject: [PATCH 47/53] feat(ui): add floating variant to PageBack for sticky nav - Add `floating` prop to pin PageBack to viewport on lg+ screens - Switch styling from glass-subtle to glass with shadow for better contrast - Use floating variant on SettingsLayout --- web/src/components/PageBack.test.tsx | 17 ++++++++++++++--- web/src/components/PageBack.tsx | 13 +++++++++++-- web/src/pages/SettingsLayout.tsx | 4 ++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/web/src/components/PageBack.test.tsx b/web/src/components/PageBack.test.tsx index 27077150..149391e1 100644 --- a/web/src/components/PageBack.test.tsx +++ b/web/src/components/PageBack.test.tsx @@ -52,7 +52,7 @@ describe("PageBack", () => { expect(mocks.navigate).toHaveBeenCalledWith(-1); }); - it("applies the documented positioning and glass-subtle styling", () => { + it("applies the documented positioning and glass styling", () => { render( @@ -61,13 +61,24 @@ describe("PageBack", () => { const button = screen.getByRole("button", { name: "Go back" }); expect(button).toHaveClass( - "glass-subtle", + "glass", "absolute", "top-4", - "left-4", + "left-2", "z-20", "rounded-full", "p-1.5", ); }); + + it("pins to the viewport on lg+ when floating is set", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Go back" }); + expect(button).toHaveClass("lg:fixed", "lg:left-[268px]"); + }); }); diff --git a/web/src/components/PageBack.tsx b/web/src/components/PageBack.tsx index d6b87565..345091ce 100644 --- a/web/src/components/PageBack.tsx +++ b/web/src/components/PageBack.tsx @@ -3,16 +3,25 @@ import { useNavigate } from "react-router"; interface PageBackProps { label?: string; + /** + * When true, pins the button to the viewport on lg+ so it stays visible + * while scrolling. The offset matches the app sidebar (260px) so the + * button sits just inside the page content area. + */ + floating?: boolean; } -export default function PageBack({ label = "Go back" }: PageBackProps) { +export default function PageBack({ label = "Go back", floating = false }: PageBackProps) { const navigate = useNavigate(); + const position = floating + ? "absolute top-4 left-2 sm:top-6 lg:fixed lg:left-[268px]" + : "absolute top-4 left-2 sm:top-6"; return ( diff --git a/web/src/pages/SettingsLayout.tsx b/web/src/pages/SettingsLayout.tsx index 128bc3d9..f31f683a 100644 --- a/web/src/pages/SettingsLayout.tsx +++ b/web/src/pages/SettingsLayout.tsx @@ -156,8 +156,8 @@ export default function SettingsLayout() { return (
- -
+ +

Settings

From 602fc5b4138abf7814dc98cf8642ef8f16b140e0 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 21:18:39 -0400 Subject: [PATCH 48/53] fix(ui): make PageBack destinations deterministic --- web/src/components/PageBack.test.tsx | 38 ++++++++++++++++++++++-- web/src/components/PageBack.tsx | 25 ++++++++++++++-- web/src/pages/CollectionEditor.tsx | 12 ++++---- web/src/pages/PersonDetail.tsx | 2 +- web/src/pages/ProfileCustomizeHome.tsx | 2 +- web/src/pages/RecommendationsSection.tsx | 4 +-- web/src/pages/RequestBrowse.tsx | 8 ++--- web/src/pages/RequestDetail.tsx | 8 +++-- web/src/pages/SettingsLayout.tsx | 2 +- web/src/pages/SmartCollectionWizard.tsx | 11 +++++-- 10 files changed, 86 insertions(+), 26 deletions(-) diff --git a/web/src/components/PageBack.test.tsx b/web/src/components/PageBack.test.tsx index 149391e1..c6c8108c 100644 --- a/web/src/components/PageBack.test.tsx +++ b/web/src/components/PageBack.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ navigate: vi.fn(), @@ -18,6 +18,11 @@ vi.mock("react-router", async () => { import PageBack from "./PageBack"; describe("PageBack", () => { + afterEach(() => { + mocks.navigate.mockClear(); + window.history.replaceState(null, ""); + }); + it("renders a button with the default 'Go back' aria-label", () => { render( @@ -38,8 +43,21 @@ describe("PageBack", () => { expect(screen.getByRole("button", { name: "Return to library" })).toBeInTheDocument(); }); - it("calls navigate(-1) on click", async () => { - mocks.navigate.mockClear(); + it("falls back to the default route when there is no router history", async () => { + render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(mocks.navigate).toHaveBeenCalledTimes(1); + expect(mocks.navigate).toHaveBeenCalledWith("/"); + }); + + it("uses browser history when a router history entry is available", async () => { + window.history.replaceState({ idx: 1 }, ""); render( @@ -52,6 +70,20 @@ describe("PageBack", () => { expect(mocks.navigate).toHaveBeenCalledWith(-1); }); + it("uses the explicit target when history preference is disabled", async () => { + window.history.replaceState({ idx: 1 }, ""); + render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(mocks.navigate).toHaveBeenCalledTimes(1); + expect(mocks.navigate).toHaveBeenCalledWith("/collections"); + }); + it("applies the documented positioning and glass styling", () => { render( diff --git a/web/src/components/PageBack.tsx b/web/src/components/PageBack.tsx index 345091ce..a2814fd3 100644 --- a/web/src/components/PageBack.tsx +++ b/web/src/components/PageBack.tsx @@ -1,8 +1,10 @@ import { ChevronLeft } from "lucide-react"; -import { useNavigate } from "react-router"; +import { type To, useNavigate } from "react-router"; interface PageBackProps { label?: string; + to?: To; + preferHistory?: boolean; /** * When true, pins the button to the viewport on lg+ so it stays visible * while scrolling. The offset matches the app sidebar (260px) so the @@ -11,16 +13,33 @@ interface PageBackProps { floating?: boolean; } -export default function PageBack({ label = "Go back", floating = false }: PageBackProps) { +export default function PageBack({ + label = "Go back", + to = "/", + preferHistory = true, + floating = false, +}: PageBackProps) { const navigate = useNavigate(); const position = floating ? "absolute top-4 left-2 sm:top-6 lg:fixed lg:left-[268px]" : "absolute top-4 left-2 sm:top-6"; + + function goBack() { + const historyIndex = window.history.state?.idx; + + if (preferHistory && typeof historyIndex === "number" && historyIndex > 0) { + navigate(-1); + return; + } + + navigate(to); + } + return ( )} -

+
    {episodes.map((ep) => { const isCurrent = ep.episode_number === currentEpisodeNumber; @@ -79,12 +80,16 @@ export default function EpisodeCarousel({
    )} + {isCurrent && ( +
    + + + + + Now Viewing +
    + )} {ep.user_data?.played && (
    @@ -135,7 +149,12 @@ export default function EpisodeCarousel({ hasPartialProgress={progress != null} />
    - +

    Episode {ep.episode_number}

    From 93ed484cf65e2244a6fe8ade20a74d2956c69c50 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 21:34:44 -0400 Subject: [PATCH 50/53] feat(jellycompat): sign image tags and accept them without session - HMAC-sign image tags using the configured JWT secret - Serve item/season/episode images via signed tag without requiring a session or cache hit --- internal/jellycompat/handlers_images.go | 126 +++++++++++++++++++- internal/jellycompat/image_tag_signer.go | 43 +++++++ internal/jellycompat/images_test.go | 78 ++++++++++++ internal/jellycompat/mapping.go | 34 +++--- internal/jellycompat/mapping_images_test.go | 23 ++++ internal/jellycompat/router.go | 5 +- 6 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 internal/jellycompat/image_tag_signer.go diff --git a/internal/jellycompat/handlers_images.go b/internal/jellycompat/handlers_images.go index 0103a667..82a8b572 100644 --- a/internal/jellycompat/handlers_images.go +++ b/internal/jellycompat/handlers_images.go @@ -5,8 +5,11 @@ import ( "errors" "fmt" "net/http" + "strings" + "time" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" ) // ImagesHandler serves Jellyfin-compatible image routes. @@ -18,14 +21,28 @@ type ImagesHandler struct { images *ImageCache personRepo *catalog.PersonRepository detailSvc *catalog.DetailService - itemRepo *catalog.ItemRepository - seasonRepo *catalog.SeasonRepository - episodeRepo *catalog.EpisodeRepository + itemRepo imageItemRepository + seasonRepo imageSeasonRepository + episodeRepo imageEpisodeRepository accessFilter AccessFilterResolver + imageTags *imageTagSigner +} + +type imageItemRepository interface { + GetByID(ctx context.Context, contentID string) (*models.MediaItem, error) + EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error +} + +type imageSeasonRepository interface { + GetByID(ctx context.Context, contentID string) (*models.Season, error) +} + +type imageEpisodeRepository interface { + GetByID(ctx context.Context, contentID string) (*models.Episode, error) } // NewImagesHandler creates an image proxy handler. -func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver) *ImagesHandler { +func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, imageTagSecret string) *ImagesHandler { if httpClient == nil { httpClient = http.DefaultClient } @@ -41,6 +58,7 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient seasonRepo: seasonRepo, episodeRepo: episodeRepo, accessFilter: accessFilter, + imageTags: newImageTagSigner(imageTagSecret), } } @@ -55,6 +73,14 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request) h.proxyImageURL(w, r, imageURL) return } + if imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, r); ok || err != nil { + if err != nil { + writeCompatUpstreamError(w, err) + return + } + h.proxyImageURL(w, r, imageURL.URL) + return + } if session == nil && h.sessions != nil { if token, ok := ExtractToken(r); ok { @@ -202,6 +228,98 @@ func (h *ImagesHandler) resolveItemImageURLFromRepos(ctx context.Context, sessio return catalog.ResolvedImageURL{}, false, nil } +func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID, imageType string, r *http.Request) (catalog.ResolvedImageURL, bool, error) { + tag := strings.TrimSpace(r.URL.Query().Get("tag")) + if tag == "" { + return catalog.ResolvedImageURL{}, false, nil + } + contentID, err := decodeContentID(h.codec, routeID) + if err != nil { + return catalog.ResolvedImageURL{}, false, nil + } + return h.resolveItemImageURLFromReposWithoutSession(ctx, contentID, imageType, compatRequestImageSize(r, imageType), tag) +} + +func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.Context, contentID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) { + if h.itemRepo != nil { + if item, err := h.itemRepo.GetByID(ctx, contentID); err == nil { + if !h.signedImageTagMatches(contentID, imageType, tag, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.UpdatedAt) { + return catalog.ResolvedImageURL{}, false, nil + } + if imageURL := h.imageURLForItem(ctx, item.PosterPath, "poster", item.BackdropPath, item.LogoPath, imageType, imageSize); imageURL.URL != "" { + return imageURL, true, nil + } + } else if !errors.Is(err, catalog.ErrItemNotFound) { + return catalog.ResolvedImageURL{}, false, wrapCatalogError(err) + } + } + + if h.episodeRepo != nil && h.itemRepo != nil { + if episode, err := h.episodeRepo.GetByID(ctx, contentID); err == nil { + series, seriesErr := h.itemRepo.GetByID(ctx, episode.SeriesID) + if seriesErr != nil { + if !errors.Is(seriesErr, catalog.ErrItemNotFound) { + return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr) + } + } else { + if !h.signedImageTagMatches(contentID, imageType, tag, episode.StillPath, episode.StillThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, episode.UpdatedAt) { + return catalog.ResolvedImageURL{}, false, nil + } + if imageURL := h.imageURLForItem(ctx, episode.StillPath, "still", series.BackdropPath, series.LogoPath, imageType, imageSize); imageURL.URL != "" { + return imageURL, true, nil + } + } + } else if !errors.Is(err, catalog.ErrEpisodeNotFound) { + return catalog.ResolvedImageURL{}, false, wrapCatalogError(err) + } + } + + if h.seasonRepo != nil && h.itemRepo != nil { + if season, err := h.seasonRepo.GetByID(ctx, contentID); err == nil { + series, seriesErr := h.itemRepo.GetByID(ctx, season.SeriesID) + if seriesErr != nil { + if errors.Is(seriesErr, catalog.ErrItemNotFound) { + return catalog.ResolvedImageURL{}, false, nil + } + return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr) + } + if !h.signedImageTagMatches(contentID, imageType, tag, season.PosterPath, season.PosterThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, season.UpdatedAt) { + return catalog.ResolvedImageURL{}, false, nil + } + if imageURL := h.imageURLForItem(ctx, season.PosterPath, "poster", series.BackdropPath, series.LogoPath, imageType, imageSize); imageURL.URL != "" { + return imageURL, true, nil + } + } else if !errors.Is(err, catalog.ErrSeasonNotFound) { + return catalog.ResolvedImageURL{}, false, wrapCatalogError(err) + } + } + + return catalog.ResolvedImageURL{}, false, nil +} + +func (h *ImagesHandler) signedImageTagMatches(contentID, imageType, tag, primaryPath, primaryThumbhash, backdropPath, backdropThumbhash, logoPath string, updatedAt time.Time) bool { + var path, thumbhash, tagImageType string + switch imageType { + case "Primary": + path = primaryPath + thumbhash = primaryThumbhash + tagImageType = "Primary" + case "Backdrop", "Thumb": + path = backdropPath + thumbhash = backdropThumbhash + tagImageType = "Backdrop" + case "Logo": + path = logoPath + tagImageType = "Logo" + default: + return false + } + if path == "" { + return false + } + return h.imageTags.Equal(imageTagSeed(contentID, tagImageType, compatCardImageSize, path, thumbhash, updatedAt), path, tag) +} + func (h *ImagesHandler) imageURLForItem(ctx context.Context, primaryPath, primaryImageType, backdropPath, logoPath, imageType, size string) catalog.ResolvedImageURL { primaryURL := compatPresignImageWithExpiry(h.detailSvc, ctx, primaryPath, primaryImageType, size) backdropURL := compatPresignImageWithExpiry(h.detailSvc, ctx, backdropPath, "backdrop", size) diff --git a/internal/jellycompat/image_tag_signer.go b/internal/jellycompat/image_tag_signer.go new file mode 100644 index 00000000..0f0dab64 --- /dev/null +++ b/internal/jellycompat/image_tag_signer.go @@ -0,0 +1,43 @@ +package jellycompat + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "strings" +) + +const imageTagSignatureDomain = "silo:jellycompat:image-tag:v1" + +type imageTagSigner struct { + secret []byte +} + +func newImageTagSigner(secret string) *imageTagSigner { + return &imageTagSigner{secret: []byte(secret)} +} + +func (s *imageTagSigner) Tag(seed, fallbackURL string) string { + if strings.TrimSpace(seed) == "" { + return tagValue(fallbackURL) + } + if s == nil { + return tagValue(seed) + } + mac := hmac.New(sha256.New, s.secret) + _, _ = mac.Write([]byte(imageTagSignatureDomain)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(seed)) + sum := mac.Sum(nil) + return hex.EncodeToString(sum[:8]) +} + +func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool { + actual = strings.TrimSpace(actual) + expected := s.Tag(seed, fallbackURL) + if expected == "" || actual == "" || len(expected) != len(actual) { + return false + } + return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1 +} diff --git a/internal/jellycompat/images_test.go b/internal/jellycompat/images_test.go index d8059425..0b09b260 100644 --- a/internal/jellycompat/images_test.go +++ b/internal/jellycompat/images_test.go @@ -1,11 +1,19 @@ package jellycompat import ( + "context" "io" "net/http" "net/http/httptest" "strings" "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/models" ) func TestProxyImageDefaultsToRevalidatingCachePolicy(t *testing.T) { @@ -86,3 +94,73 @@ func TestProxyImageURLForwardsConditionalHeaders(t *testing.T) { t.Fatalf("status = %d, want 304", rec.Code) } } + +func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("image-bytes")) + })) + defer upstream.Close() + + codec := NewResourceIDCodec() + contentID := "movie-1" + routeID := codec.EncodeStringID(EncodedIDItem, contentID) + updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + item := &models.MediaItem{ + ContentID: contentID, + PosterPath: upstream.URL, + PosterThumbhash: "poster-thumbhash", + UpdatedAt: updatedAt, + } + cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}} + tag := newMapper(codec, cfg).itemFromList(upstreamListItem{ + ContentID: contentID, + Type: "movie", + Title: "Movie", + PosterURL: item.PosterPath, + PosterPath: item.PosterPath, + PosterThumbhash: item.PosterThumbhash, + UpdatedAt: item.UpdatedAt, + }, false, nil, nil).ImageTags["Primary"] + h := &ImagesHandler{ + codec: codec, + httpClient: upstream.Client(), + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner(cfg.Auth.JWTSecret), + } + + req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil) + req = withImageRouteParams(req, routeID, "Primary") + rec := httptest.NewRecorder() + + h.HandleItemImage(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != "image-bytes" { + t.Fatalf("body = %q, want image bytes", got) + } +} + +type fakeImageItemRepo struct { + item *models.MediaItem +} + +func (r fakeImageItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) { + if r.item != nil && r.item.ContentID == contentID { + return r.item, nil + } + return nil, catalog.ErrItemNotFound +} + +func (r fakeImageItemRepo) EnsureAccessible(context.Context, string, catalog.AccessFilter) error { + return nil +} + +func withImageRouteParams(r *http.Request, routeID, imageType string) *http.Request { + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("id", routeID) + routeCtx.URLParams.Add("imageType", imageType) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx)) +} diff --git a/internal/jellycompat/mapping.go b/internal/jellycompat/mapping.go index fbbc01de..679b7f4a 100644 --- a/internal/jellycompat/mapping.go +++ b/internal/jellycompat/mapping.go @@ -23,16 +23,19 @@ var allDetailFields = map[string]bool{ } type mapper struct { - codec *ResourceIDCodec - serverID string + codec *ResourceIDCodec + serverID string + imageTagSigner *imageTagSigner } func newMapper(codec *ResourceIDCodec, cfg *config.Config) *mapper { serverID := "" + imageTagSecret := "" if cfg != nil { serverID = cfg.JellyfinCompat.ServerID + imageTagSecret = cfg.Auth.JWTSecret } - return &mapper{codec: codec, serverID: serverID} + return &mapper{codec: codec, serverID: serverID, imageTagSigner: newImageTagSigner(imageTagSecret)} } func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO { @@ -102,13 +105,13 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress * dto.ChildCount = *item.SeasonCount dto.RecursiveItemCount = *item.SeasonCount } - if tags := imageTagsWithSeed( + if tags := imageTagsWithSeed(m.imageTagSigner, imageTagSeed(item.ContentID, "Primary", compatCardImageSize, firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash, item.UpdatedAt), item.PosterURL, ); tags != nil { dto.ImageTags = tags } - if tags := backdropTagsWithSeed( + if tags := backdropTagsWithSeed(m.imageTagSigner, imageTagSeed(item.ContentID, "Backdrop", compatCardImageSize, item.BackdropPath, item.BackdropThumbhash, item.UpdatedAt), item.BackdropURL, ); tags != nil { @@ -397,7 +400,7 @@ func (m *mapper) seasonFromUpstream(season upstreamSeason, seriesID string, isFa RecursiveItemCount: season.EpisodeCount, } dto.IndexNumber = &season.SeasonNumber - if tags := imageTagsWithSeed( + if tags := imageTagsWithSeed(m.imageTagSigner, imageTagSeed(season.ContentID, "Primary", compatCardImageSize, season.PosterPath, season.PosterThumbhash, season.UpdatedAt), season.PosterURL, ); tags != nil { @@ -430,7 +433,7 @@ func (m *mapper) episodeFromUpstream(ep upstreamEpisode, isFavorite bool, progre dto.SeasonID = m.codec.EncodeStringID(EncodedIDSeason, ep.SeasonID) dto.ParentID = m.codec.EncodeStringID(EncodedIDSeason, ep.SeasonID) } - if tags := imageTagsWithSeed( + if tags := imageTagsWithSeed(m.imageTagSigner, imageTagSeed(ep.ContentID, "Primary", compatCardImageSize, ep.StillPath, ep.StillThumbhash, ep.UpdatedAt), ep.StillURL, ); tags != nil { @@ -638,22 +641,22 @@ func resumePositionTicks(position, duration float64, played bool) int64 { return secondsToTicks(position) } -func imageTagsWithSeed(seed, imageURL string) map[string]string { +func imageTagsWithSeed(signer *imageTagSigner, seed, imageURL string) map[string]string { if imageURL == "" { return nil } - return map[string]string{"Primary": imageTagValue(seed, imageURL)} + return map[string]string{"Primary": signer.Tag(seed, imageURL)} } func backdropTags(imageURL string) []string { - return backdropTagsWithSeed("", imageURL) + return backdropTagsWithSeed(nil, "", imageURL) } -func backdropTagsWithSeed(seed, imageURL string) []string { +func backdropTagsWithSeed(signer *imageTagSigner, seed, imageURL string) []string { if imageURL == "" { return nil } - return []string{imageTagValue(seed, imageURL)} + return []string{signer.Tag(seed, imageURL)} } func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt time.Time) string { @@ -675,13 +678,6 @@ func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt return strings.Join(parts, "\x00") } -func imageTagValue(seed, fallbackURL string) string { - if seed != "" { - return tagValue(seed) - } - return tagValue(fallbackURL) -} - func tagValue(raw string) string { if raw == "" { return "" diff --git a/internal/jellycompat/mapping_images_test.go b/internal/jellycompat/mapping_images_test.go index 9e185cca..1dd68465 100644 --- a/internal/jellycompat/mapping_images_test.go +++ b/internal/jellycompat/mapping_images_test.go @@ -55,3 +55,26 @@ func TestItemImageTagsFallbackToURLWhenCanonicalSeedMissing(t *testing.T) { t.Fatalf("fallback image tag did not change with URL: %q", first.ImageTags["Primary"]) } } + +func TestItemImageTagsUseConfiguredSecret(t *testing.T) { + item := upstreamListItem{ + ContentID: "movie-1", + Type: "movie", + Title: "Movie", + PosterURL: "https://cdn.example.test/poster.jpg?sig=one", + PosterPath: "metadb://poster/movie-1", + PosterThumbhash: "thumbhash", + UpdatedAt: time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC), + } + + first := newMapper(NewResourceIDCodec(), &config.Config{ + Auth: config.AuthConfig{JWTSecret: "secret-one"}, + }).itemFromList(item, false, nil, nil) + second := newMapper(NewResourceIDCodec(), &config.Config{ + Auth: config.AuthConfig{JWTSecret: "secret-two"}, + }).itemFromList(item, false, nil, nil) + + if first.ImageTags["Primary"] == second.ImageTags["Primary"] { + t.Fatalf("signed image tag did not change with configured secret: %q", first.ImageTags["Primary"]) + } +} diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index f7c2a03d..2abffdd2 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -98,7 +98,7 @@ func NewRouter(deps Dependencies) chi.Router { playbackHandler.S3Client = deps.S3Client playbackHandler.S3Bucket = deps.S3Bucket } - imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn) + imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.JWTSecret) displayPrefsHandler := NewDisplayPreferencesHandler(deps.UserStoreProvider) recsHandler := NewRecommendationsHandler(deps.Recommender, deps.ItemRepo, deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.AccessFilterFn) @@ -228,6 +228,9 @@ func withDefaults(deps Dependencies) Dependencies { if deps.Now == nil { deps.Now = timeNow } + if deps.JWTSecret == "" && deps.Config != nil { + deps.JWTSecret = deps.Config.Auth.JWTSecret + } if deps.TokenGenerator == nil { deps.TokenGenerator = uuidNewString } From f930c4f96a454bd759ce3e03b25df8eeccb8d162 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 21:50:10 -0400 Subject: [PATCH 51/53] fix(jellycompat): harden signed image tags --- internal/jellycompat/batch_loaders.go | 57 ++++++++----- internal/jellycompat/handlers_images.go | 23 ++--- internal/jellycompat/image_tag_signer.go | 6 ++ internal/jellycompat/images_test.go | 95 +++++++++++++++++++++ internal/jellycompat/mapping.go | 10 ++- internal/jellycompat/mapping_images_test.go | 28 ++++++ internal/jellycompat/upstream_types.go | 1 + 7 files changed, 187 insertions(+), 33 deletions(-) diff --git a/internal/jellycompat/batch_loaders.go b/internal/jellycompat/batch_loaders.go index ad42130b..910c92f5 100644 --- a/internal/jellycompat/batch_loaders.go +++ b/internal/jellycompat/batch_loaders.go @@ -204,6 +204,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context e.rating_tmdb, e.air_date, e.still_path, + COALESCE(e.still_thumbhash, ''), + e.updated_at, e.season_number, e.episode_number, si.content_id, @@ -212,6 +214,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context si.content_rating, si.poster_path, si.backdrop_path, + COALESCE(si.backdrop_thumbhash, ''), si.logo_path, si.status FROM %s @@ -236,6 +239,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context ratingTMDB *float64 airDate *time.Time stillPath string + stillThumbhash string + updatedAt time.Time seasonNumber int episodeNumber int seriesID string @@ -244,6 +249,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context contentRating string seriesPosterPath string seriesBackdrop string + seriesBackdropTH string seriesLogoPath string status string ) @@ -256,6 +262,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context &ratingTMDB, &airDate, &stillPath, + &stillThumbhash, + &updatedAt, &seasonNumber, &episodeNumber, &seriesID, @@ -264,6 +272,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context &contentRating, &seriesPosterPath, &seriesBackdrop, + &seriesBackdropTH, &seriesLogoPath, &status, ); err != nil { @@ -271,28 +280,31 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context } listItem := upstreamListItem{ - ContentID: contentID, - Type: "episode", - Title: title, - Genres: genres, - ContentRating: contentRating, - Status: status, - RatingIMDB: ratingIMDB, - RatingTMDB: ratingTMDB, - Overview: overview, - PosterURL: h.presignCompatImagePath(ctx, stillPath, "still"), - BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"), - LogoURL: h.presignCompatImagePath(ctx, seriesLogoPath, "logo"), - StillURL: h.presignCompatImagePath(ctx, stillPath, "still"), - PosterPath: stillPath, - BackdropPath: seriesBackdrop, - LogoPath: seriesLogoPath, - StillPath: stillPath, - SeriesID: seriesID, - SeriesTitle: seriesTitle, - SeasonNumber: intPtr(seasonNumber), - EpisodeNumber: intPtr(episodeNumber), - Runtime: runtime, + ContentID: contentID, + Type: "episode", + Title: title, + Genres: genres, + ContentRating: contentRating, + Status: status, + RatingIMDB: ratingIMDB, + RatingTMDB: ratingTMDB, + Overview: overview, + PosterURL: h.presignCompatImagePath(ctx, stillPath, "still"), + BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"), + LogoURL: h.presignCompatImagePath(ctx, seriesLogoPath, "logo"), + StillURL: h.presignCompatImagePath(ctx, stillPath, "still"), + PosterPath: stillPath, + BackdropPath: seriesBackdrop, + BackdropThumbhash: seriesBackdropTH, + LogoPath: seriesLogoPath, + StillPath: stillPath, + StillThumbhash: stillThumbhash, + UpdatedAt: updatedAt, + SeriesID: seriesID, + SeriesTitle: seriesTitle, + SeasonNumber: intPtr(seasonNumber), + EpisodeNumber: intPtr(episodeNumber), + Runtime: runtime, } if airDate != nil { listItem.AirDate = airDate.Format(time.DateOnly) @@ -385,6 +397,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context BackdropThumbhash: series.BackdropThumbhash, LogoPath: series.LogoPath, StillPath: episode.StillPath, + StillThumbhash: episode.StillThumbhash, UpdatedAt: episode.UpdatedAt, SeriesID: episode.SeriesID, SeriesTitle: series.Title, diff --git a/internal/jellycompat/handlers_images.go b/internal/jellycompat/handlers_images.go index 82a8b572..8eb19c69 100644 --- a/internal/jellycompat/handlers_images.go +++ b/internal/jellycompat/handlers_images.go @@ -69,16 +69,20 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request) routeID := chiURLParam(r, "id") imageType := chiURLParam(r, "imageType") imageSize := compatRequestImageSize(r, imageType) - if imageURL, ok := h.images.LookupSized(routeID, imageType, r.URL.Query().Get("tag"), imageSize); ok { - h.proxyImageURL(w, r, imageURL) - return - } - if imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, r); ok || err != nil { + tag := strings.TrimSpace(r.URL.Query().Get("tag")) + if tag != "" { + imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag) if err != nil { writeCompatUpstreamError(w, err) return } - h.proxyImageURL(w, r, imageURL.URL) + if ok { + h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt) + h.proxyImageURL(w, r, imageURL.URL) + return + } + } else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok { + h.proxyImageURL(w, r, imageURL) return } @@ -228,16 +232,15 @@ func (h *ImagesHandler) resolveItemImageURLFromRepos(ctx context.Context, sessio return catalog.ResolvedImageURL{}, false, nil } -func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID, imageType string, r *http.Request) (catalog.ResolvedImageURL, bool, error) { - tag := strings.TrimSpace(r.URL.Query().Get("tag")) - if tag == "" { +func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) { + if h.imageTags == nil || tag == "" { return catalog.ResolvedImageURL{}, false, nil } contentID, err := decodeContentID(h.codec, routeID) if err != nil { return catalog.ResolvedImageURL{}, false, nil } - return h.resolveItemImageURLFromReposWithoutSession(ctx, contentID, imageType, compatRequestImageSize(r, imageType), tag) + return h.resolveItemImageURLFromReposWithoutSession(ctx, contentID, imageType, imageSize, tag) } func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.Context, contentID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) { diff --git a/internal/jellycompat/image_tag_signer.go b/internal/jellycompat/image_tag_signer.go index 0f0dab64..a2669043 100644 --- a/internal/jellycompat/image_tag_signer.go +++ b/internal/jellycompat/image_tag_signer.go @@ -15,6 +15,9 @@ type imageTagSigner struct { } func newImageTagSigner(secret string) *imageTagSigner { + if strings.TrimSpace(secret) == "" { + return nil + } return &imageTagSigner{secret: []byte(secret)} } @@ -34,6 +37,9 @@ func (s *imageTagSigner) Tag(seed, fallbackURL string) string { } func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool { + if s == nil { + return false + } actual = strings.TrimSpace(actual) expected := s.Tag(seed, fallbackURL) if expected == "" || actual == "" || len(expected) != len(actual) { diff --git a/internal/jellycompat/images_test.go b/internal/jellycompat/images_test.go index 0b09b260..d164f3e8 100644 --- a/internal/jellycompat/images_test.go +++ b/internal/jellycompat/images_test.go @@ -125,6 +125,7 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { h := &ImagesHandler{ codec: codec, httpClient: upstream.Client(), + images: NewImageCache(time.Hour, func() time.Time { return updatedAt }), itemRepo: fakeImageItemRepo{item: item}, imageTags: newImageTagSigner(cfg.Auth.JWTSecret), } @@ -141,6 +142,100 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { if got := rec.Body.String(); got != "image-bytes" { t.Fatalf("body = %q, want image bytes", got) } + if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" { + t.Fatal("signed-tag image URL was not cached after resolution") + } +} + +func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) { + codec := NewResourceIDCodec() + contentID := "movie-1" + routeID := codec.EncodeStringID(EncodedIDItem, contentID) + updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + item := &models.MediaItem{ + ContentID: contentID, + PosterPath: "https://cdn.example.test/poster.jpg", + PosterThumbhash: "poster-thumbhash", + UpdatedAt: updatedAt, + } + tag := newMapper(codec, &config.Config{}).itemFromList(upstreamListItem{ + ContentID: contentID, + Type: "movie", + Title: "Movie", + PosterURL: item.PosterPath, + PosterPath: item.PosterPath, + PosterThumbhash: item.PosterThumbhash, + UpdatedAt: item.UpdatedAt, + }, false, nil, nil).ImageTags["Primary"] + h := &ImagesHandler{ + codec: codec, + httpClient: http.DefaultClient, + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner(""), + } + + req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil) + req = withImageRouteParams(req, routeID, "Primary") + rec := httptest.NewRecorder() + + h.HandleItemImage(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, body = %s; want 401", rec.Code, rec.Body.String()) + } +} + +func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) { + called := false + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + _, _ = w.Write([]byte("stale-image")) + })) + defer upstream.Close() + + codec := NewResourceIDCodec() + contentID := "movie-1" + routeID := codec.EncodeStringID(EncodedIDItem, contentID) + updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + item := &models.MediaItem{ + ContentID: contentID, + PosterPath: upstream.URL, + PosterThumbhash: "poster-thumbhash", + UpdatedAt: updatedAt, + } + cache := NewImageCache(time.Hour, func() time.Time { return updatedAt }) + cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize) + tag := newMapper(codec, &config.Config{ + Auth: config.AuthConfig{JWTSecret: "old-secret"}, + }).itemFromList(upstreamListItem{ + ContentID: contentID, + Type: "movie", + Title: "Movie", + PosterURL: item.PosterPath, + PosterPath: item.PosterPath, + PosterThumbhash: item.PosterThumbhash, + UpdatedAt: item.UpdatedAt, + }, false, nil, nil).ImageTags["Primary"] + h := &ImagesHandler{ + codec: codec, + httpClient: upstream.Client(), + images: cache, + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner("new-secret"), + } + + req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil) + req = withImageRouteParams(req, routeID, "Primary") + rec := httptest.NewRecorder() + + h.HandleItemImage(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, body = %s; want 401", rec.Code, rec.Body.String()) + } + if called { + t.Fatal("served cached image before validating the signed tag") + } } type fakeImageItemRepo struct { diff --git a/internal/jellycompat/mapping.go b/internal/jellycompat/mapping.go index 679b7f4a..4cfd0b24 100644 --- a/internal/jellycompat/mapping.go +++ b/internal/jellycompat/mapping.go @@ -105,8 +105,9 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress * dto.ChildCount = *item.SeasonCount dto.RecursiveItemCount = *item.SeasonCount } + primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item) if tags := imageTagsWithSeed(m.imageTagSigner, - imageTagSeed(item.ContentID, "Primary", compatCardImageSize, firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash, item.UpdatedAt), + imageTagSeed(item.ContentID, "Primary", compatCardImageSize, primaryPath, primaryThumbhash, item.UpdatedAt), item.PosterURL, ); tags != nil { dto.ImageTags = tags @@ -659,6 +660,13 @@ func backdropTagsWithSeed(signer *imageTagSigner, seed, imageURL string) []strin return []string{signer.Tag(seed, imageURL)} } +func listItemPrimaryImageSeedParts(item upstreamListItem) (string, string) { + if item.Type == "episode" && item.StillPath != "" { + return item.StillPath, item.StillThumbhash + } + return firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash +} + func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt time.Time) string { rawPath = strings.TrimSpace(rawPath) thumbhash = strings.TrimSpace(thumbhash) diff --git a/internal/jellycompat/mapping_images_test.go b/internal/jellycompat/mapping_images_test.go index 1dd68465..7726fc67 100644 --- a/internal/jellycompat/mapping_images_test.go +++ b/internal/jellycompat/mapping_images_test.go @@ -78,3 +78,31 @@ func TestItemImageTagsUseConfiguredSecret(t *testing.T) { t.Fatalf("signed image tag did not change with configured secret: %q", first.ImageTags["Primary"]) } } + +func TestEpisodeListImageTagsUseStillThumbhash(t *testing.T) { + secret := "image-secret" + updatedAt := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + item := upstreamListItem{ + ContentID: "episode-1", + Type: "episode", + Title: "Episode", + PosterURL: "https://cdn.example.test/still.jpg?sig=one", + PosterPath: "metadb://still/episode-1", + PosterThumbhash: "poster-thumbhash", + StillPath: "metadb://still/episode-1", + StillThumbhash: "still-thumbhash", + UpdatedAt: updatedAt, + } + + dto := newMapper(NewResourceIDCodec(), &config.Config{ + Auth: config.AuthConfig{JWTSecret: secret}, + }).itemFromList(item, false, nil, nil) + expected := newImageTagSigner(secret).Tag( + imageTagSeed(item.ContentID, "Primary", compatCardImageSize, item.StillPath, item.StillThumbhash, updatedAt), + item.PosterURL, + ) + + if dto.ImageTags["Primary"] != expected { + t.Fatalf("primary tag = %q, want still-thumbhash seed %q", dto.ImageTags["Primary"], expected) + } +} diff --git a/internal/jellycompat/upstream_types.go b/internal/jellycompat/upstream_types.go index 03e6b982..7a3c60e6 100644 --- a/internal/jellycompat/upstream_types.go +++ b/internal/jellycompat/upstream_types.go @@ -36,6 +36,7 @@ type upstreamListItem struct { BackdropThumbhash string `json:"-"` LogoPath string `json:"-"` StillPath string `json:"-"` + StillThumbhash string `json:"-"` UpdatedAt time.Time `json:"-"` SeasonCount *int `json:"season_count,omitempty"` SeriesID string `json:"series_id,omitempty"` From 101aa8c429de87f2892a27561a0a9c88d886c7d4 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 22:29:49 -0400 Subject: [PATCH 52/53] fix(jellycompat): stabilize signed image tags across restarts {"subject":"fix(jellycompat): stabilize signed image tags across restarts","body":"- Sign library poster and episode parent series image tags from canonical paths/thumbhashes instead of presigned URLs so tags survive restarts\n- Accept signed canonical tags in the image handler without a session and fall back to legacy URL-derived cache tags\n- Always fetch series detail for episodes to build stable parent image tags"} --- internal/jellycompat/batch_loaders.go | 41 ++++-- internal/jellycompat/content_direct.go | 7 +- internal/jellycompat/handlers_images.go | 95 +++++++++++--- internal/jellycompat/handlers_items.go | 62 ++++----- internal/jellycompat/handlers_items_test.go | 14 +- internal/jellycompat/image_cache.go | 12 +- internal/jellycompat/images_test.go | 134 ++++++++++++++++++++ internal/jellycompat/mapping.go | 44 +++++-- internal/jellycompat/mapping_images_test.go | 79 ++++++++++++ internal/jellycompat/router.go | 2 +- internal/jellycompat/upstream_types.go | 9 +- 11 files changed, 406 insertions(+), 93 deletions(-) diff --git a/internal/jellycompat/batch_loaders.go b/internal/jellycompat/batch_loaders.go index 910c92f5..23001a22 100644 --- a/internal/jellycompat/batch_loaders.go +++ b/internal/jellycompat/batch_loaders.go @@ -14,9 +14,8 @@ import ( ) type compatEpisodeTarget struct { - Item upstreamListItem - SeriesPosterURL string - SeriesBackdropURL string + Item upstreamListItem + SeriesImages seriesImageSet } type libraryMembershipChecker interface { @@ -213,10 +212,12 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context si.genres, si.content_rating, si.poster_path, + COALESCE(si.poster_thumbhash, ''), si.backdrop_path, COALESCE(si.backdrop_thumbhash, ''), si.logo_path, - si.status + si.status, + si.updated_at FROM %s WHERE %s ORDER BY e.content_id @@ -248,10 +249,12 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context genres []string contentRating string seriesPosterPath string + seriesPosterTH string seriesBackdrop string seriesBackdropTH string seriesLogoPath string status string + seriesUpdatedAt time.Time ) if err := rows.Scan( &contentID, @@ -271,10 +274,12 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context &genres, &contentRating, &seriesPosterPath, + &seriesPosterTH, &seriesBackdrop, &seriesBackdropTH, &seriesLogoPath, &status, + &seriesUpdatedAt, ); err != nil { return nil, fmt.Errorf("scanning compat episode target: %w", err) } @@ -311,9 +316,17 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context } result[contentID] = compatEpisodeTarget{ - Item: listItem, - SeriesPosterURL: h.presignCompatImagePath(ctx, seriesPosterPath, "poster"), - SeriesBackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"), + Item: listItem, + SeriesImages: seriesImageSet{ + ContentID: seriesID, + PosterURL: h.presignCompatImagePath(ctx, seriesPosterPath, "poster"), + PosterPath: seriesPosterPath, + PosterThumbhash: seriesPosterTH, + BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"), + BackdropPath: seriesBackdrop, + BackdropThumbhash: seriesBackdropTH, + UpdatedAt: seriesUpdatedAt, + }, } } if err := rows.Err(); err != nil { @@ -409,9 +422,17 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context listItem.AirDate = episode.AirDate.Format(time.DateOnly) } result[episode.ContentID] = compatEpisodeTarget{ - Item: listItem, - SeriesPosterURL: h.presignCompatImagePath(ctx, series.PosterPath, "poster"), - SeriesBackdropURL: h.presignCompatImagePath(ctx, series.BackdropPath, "backdrop"), + Item: listItem, + SeriesImages: seriesImageSet{ + ContentID: series.ContentID, + PosterURL: h.presignCompatImagePath(ctx, series.PosterPath, "poster"), + PosterPath: series.PosterPath, + PosterThumbhash: series.PosterThumbhash, + BackdropURL: h.presignCompatImagePath(ctx, series.BackdropPath, "backdrop"), + BackdropPath: series.BackdropPath, + BackdropThumbhash: series.BackdropThumbhash, + UpdatedAt: series.UpdatedAt, + }, } } diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index 2aa2f0df..05e0bd4d 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -142,9 +142,10 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S libraries := make([]upstreamUserLibrary, 0, len(folders)) for _, f := range folders { lib := upstreamUserLibrary{ - ID: f.ID, - Name: f.Name, - Type: f.Type, + ID: f.ID, + Name: f.Name, + Type: f.Type, + PosterPath: f.PosterPath, } if f.PosterPath != "" && s.posterPresigner != nil { ttl := s.presignTTL diff --git a/internal/jellycompat/handlers_images.go b/internal/jellycompat/handlers_images.go index 8eb19c69..5dd65885 100644 --- a/internal/jellycompat/handlers_images.go +++ b/internal/jellycompat/handlers_images.go @@ -22,9 +22,12 @@ type ImagesHandler struct { personRepo *catalog.PersonRepository detailSvc *catalog.DetailService itemRepo imageItemRepository + folderRepo imageFolderRepository seasonRepo imageSeasonRepository episodeRepo imageEpisodeRepository accessFilter AccessFilterResolver + posterSigner LibraryPosterPresigner + presignTTL time.Duration imageTags *imageTagSigner } @@ -41,8 +44,12 @@ type imageEpisodeRepository interface { GetByID(ctx context.Context, contentID string) (*models.Episode, error) } +type imageFolderRepository interface { + GetByID(ctx context.Context, id int) (*models.MediaFolder, error) +} + // NewImagesHandler creates an image proxy handler. -func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, imageTagSecret string) *ImagesHandler { +func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string) *ImagesHandler { if httpClient == nil { httpClient = http.DefaultClient } @@ -55,9 +62,12 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient personRepo: personRepo, detailSvc: detailSvc, itemRepo: itemRepo, + folderRepo: folderRepo, seasonRepo: seasonRepo, episodeRepo: episodeRepo, accessFilter: accessFilter, + posterSigner: posterSigner, + presignTTL: presignTTL, imageTags: newImageTagSigner(imageTagSecret), } } @@ -81,6 +91,10 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request) h.proxyImageURL(w, r, imageURL.URL) return } + if imageURL, ok := h.images.LookupTag(tag); ok { + h.proxyImageURL(w, r, imageURL) + return + } } else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok { h.proxyImageURL(w, r, imageURL) return @@ -236,20 +250,60 @@ func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID, if h.imageTags == nil || tag == "" { return catalog.ResolvedImageURL{}, false, nil } + if libraryID, err := h.codec.DecodeIntID(EncodedIDLibrary, routeID); err == nil { + return h.resolveLibraryImageURLFromTag(ctx, routeID, int(libraryID), imageType, imageSize, tag) + } contentID, err := decodeContentID(h.codec, routeID) if err != nil { return catalog.ResolvedImageURL{}, false, nil } - return h.resolveItemImageURLFromReposWithoutSession(ctx, contentID, imageType, imageSize, tag) + return h.resolveItemImageURLFromReposWithoutSession(ctx, routeID, contentID, imageType, imageSize, tag) } -func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.Context, contentID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) { +func (h *ImagesHandler) resolveLibraryImageURLFromTag(ctx context.Context, routeID string, libraryID int, imageType, _ string, tag string) (catalog.ResolvedImageURL, bool, error) { + if imageType != "Primary" || h.folderRepo == nil || h.posterSigner == nil { + return catalog.ResolvedImageURL{}, false, nil + } + folder, err := h.folderRepo.GetByID(ctx, libraryID) + if err != nil { + return catalog.ResolvedImageURL{}, false, nil + } + if folder.PosterPath == "" || !h.imageTags.Equal( + imageTagSeed(routeID, "Primary", compatCardImageSize, folder.PosterPath, "", time.Time{}), + "", + tag, + ) { + return catalog.ResolvedImageURL{}, false, nil + } + imageURL := h.presignLibraryPosterURL(ctx, folder.PosterPath) + if imageURL == "" { + return catalog.ResolvedImageURL{}, false, nil + } + return catalog.ResolvedImageURL{URL: imageURL}, true, nil +} + +func (h *ImagesHandler) presignLibraryPosterURL(ctx context.Context, posterPath string) string { + if posterPath == "" || h.posterSigner == nil { + return "" + } + ttl := h.presignTTL + if ttl <= 0 { + ttl = 4 * time.Hour + } + imageURL, err := h.posterSigner.PresignGetURL(ctx, h.posterSigner.Bucket(), posterPath, ttl) + if err != nil { + return "" + } + return imageURL +} + +func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.Context, routeID, contentID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) { if h.itemRepo != nil { if item, err := h.itemRepo.GetByID(ctx, contentID); err == nil { - if !h.signedImageTagMatches(contentID, imageType, tag, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.UpdatedAt) { - return catalog.ResolvedImageURL{}, false, nil - } if imageURL := h.imageURLForItem(ctx, item.PosterPath, "poster", item.BackdropPath, item.LogoPath, imageType, imageSize); imageURL.URL != "" { + if !h.signedImageTagMatches(routeID, contentID, imageType, tag, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.UpdatedAt, imageURL.URL) { + return catalog.ResolvedImageURL{}, false, nil + } return imageURL, true, nil } } else if !errors.Is(err, catalog.ErrItemNotFound) { @@ -265,10 +319,10 @@ func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.C return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr) } } else { - if !h.signedImageTagMatches(contentID, imageType, tag, episode.StillPath, episode.StillThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, episode.UpdatedAt) { - return catalog.ResolvedImageURL{}, false, nil - } if imageURL := h.imageURLForItem(ctx, episode.StillPath, "still", series.BackdropPath, series.LogoPath, imageType, imageSize); imageURL.URL != "" { + if !h.signedImageTagMatches(routeID, contentID, imageType, tag, episode.StillPath, episode.StillThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, episode.UpdatedAt, imageURL.URL) { + return catalog.ResolvedImageURL{}, false, nil + } return imageURL, true, nil } } @@ -286,10 +340,10 @@ func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.C } return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr) } - if !h.signedImageTagMatches(contentID, imageType, tag, season.PosterPath, season.PosterThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, season.UpdatedAt) { - return catalog.ResolvedImageURL{}, false, nil - } if imageURL := h.imageURLForItem(ctx, season.PosterPath, "poster", series.BackdropPath, series.LogoPath, imageType, imageSize); imageURL.URL != "" { + if !h.signedImageTagMatches(routeID, contentID, imageType, tag, season.PosterPath, season.PosterThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, season.UpdatedAt, imageURL.URL) { + return catalog.ResolvedImageURL{}, false, nil + } return imageURL, true, nil } } else if !errors.Is(err, catalog.ErrSeasonNotFound) { @@ -300,7 +354,7 @@ func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.C return catalog.ResolvedImageURL{}, false, nil } -func (h *ImagesHandler) signedImageTagMatches(contentID, imageType, tag, primaryPath, primaryThumbhash, backdropPath, backdropThumbhash, logoPath string, updatedAt time.Time) bool { +func (h *ImagesHandler) signedImageTagMatches(routeID, contentID, imageType, tag, primaryPath, primaryThumbhash, backdropPath, backdropThumbhash, logoPath string, updatedAt time.Time, resolvedURL string) bool { var path, thumbhash, tagImageType string switch imageType { case "Primary": @@ -317,10 +371,21 @@ func (h *ImagesHandler) signedImageTagMatches(contentID, imageType, tag, primary default: return false } - if path == "" { + if path != "" && h.imageTags.Equal( + imageTagSeed(contentID, tagImageType, compatCardImageSize, path, thumbhash, updatedAt), + path, + tag, + ) { + return true + } + if resolvedURL == "" { return false } - return h.imageTags.Equal(imageTagSeed(contentID, tagImageType, compatCardImageSize, path, thumbhash, updatedAt), path, tag) + return h.imageTags.Equal( + imageTagSeed(routeID, tagImageType, compatCardImageSize, resolvedURL, "", time.Time{}), + resolvedURL, + tag, + ) } func (h *ImagesHandler) imageURLForItem(ctx context.Context, primaryPath, primaryImageType, backdropPath, logoPath, imageType, size string) catalog.ResolvedImageURL { diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index f0d6d459..f9986152 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -212,26 +212,8 @@ func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) { } } if strings.EqualFold(detail.Type, "episode") && detail.SeriesID != "" { - seriesRouteID := h.codec.EncodeStringID(EncodedIDItem, detail.SeriesID) - cachedPoster, _ := h.images.LookupSized(seriesRouteID, "Primary", "", compatCardImageSize) - cachedBackdrop, _ := h.images.LookupSized(seriesRouteID, "Backdrop", "", compatCardImageSize) - - if cachedPoster != "" && cachedBackdrop != "" { - // Both poster and backdrop hit — populate from cache and skip the - // second GetItemDetail call against the parent series. Cache is - // populated by browse/list/recommendation responses for the series. - // Audit 2026-05-01 §3.4. We require BOTH because a partial hit - // (only one URL cached) would silently degrade the response — the - // fallback fetch can populate both. - h.mapper.applySeriesImages(&dto, cachedPoster, cachedBackdrop) - if h.images != nil { - h.images.RememberSized(dto.SeriesID, "Thumb", cachedBackdrop, compatCardImageSize) - } - } else { - // Cache miss or partial — fall back to original series-detail fetch. - seriesImgCache := make(map[string]seriesImageURLs) - h.enrichEpisodeSeriesImages(r.Context(), session, &dto, detail.SeriesID, seriesImgCache) - } + seriesImgCache := make(map[string]seriesImageSet) + h.enrichEpisodeSeriesImages(r.Context(), session, &dto, detail.SeriesID, seriesImgCache) if detail.SeasonNumber != nil { season, seasonErr := h.content.GetSeason(r.Context(), session, detail.SeriesID, *detail.SeasonNumber, nil) if seasonErr == nil && season != nil { @@ -1910,22 +1892,22 @@ func (h *ItemsHandler) presignCompatImagePath(ctx context.Context, path, imageTy return compatPresignImage(h.detailSvc, ctx, path, imageType, compatCardImageSize) } -func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL, seriesPosterURL, seriesBackdropURL string) { +func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL string, series seriesImageSet) { if h.images == nil { return } h.images.RememberSized(dto.ID, "Primary", stillURL, compatCardImageSize) - h.images.RememberSized(dto.ID, "Backdrop", seriesBackdropURL, compatCardImageSize) + h.images.RememberSized(dto.ID, "Backdrop", series.BackdropURL, compatCardImageSize) if dto.SeriesID != "" { - h.images.RememberSized(dto.SeriesID, "Primary", seriesPosterURL, compatCardImageSize) - h.images.RememberSized(dto.SeriesID, "Backdrop", seriesBackdropURL, compatCardImageSize) - h.images.RememberSized(dto.SeriesID, "Thumb", seriesBackdropURL, compatCardImageSize) + h.images.RememberSized(dto.SeriesID, "Primary", series.PosterURL, compatCardImageSize) + h.images.RememberSized(dto.SeriesID, "Backdrop", series.BackdropURL, compatCardImageSize) + h.images.RememberSized(dto.SeriesID, "Thumb", series.BackdropURL, compatCardImageSize) } } func (h *ItemsHandler) applyCompatEpisodeTarget(dto *baseItemDTO, target compatEpisodeTarget) { - h.mapper.applySeriesImages(dto, target.SeriesPosterURL, target.SeriesBackdropURL) - h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesPosterURL, target.SeriesBackdropURL) + h.mapper.applySeriesImages(dto, target.SeriesImages) + h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesImages) } func (h *ItemsHandler) listSeriesEpisodes(ctx context.Context, session *Session, seriesID string, seasons []upstreamSeason, requestedSeasonID string) ([]*models.Episode, error) { @@ -2164,17 +2146,10 @@ func (h *ItemsHandler) rememberEpisodeImages(episodes []upstreamEpisode) { } } -// seriesImageURLs holds poster/backdrop URLs for a series, used to populate -// series image tags on episode DTOs for clients like Infuse. -type seriesImageURLs struct { - posterURL string - backdropURL string -} - // enrichEpisodeSeriesImages looks up the parent series poster/backdrop and // applies them to an episode DTO. The cache avoids repeated lookups when // multiple episodes belong to the same series. -func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *Session, dto *baseItemDTO, seriesContentID string, cache map[string]seriesImageURLs) { +func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *Session, dto *baseItemDTO, seriesContentID string, cache map[string]seriesImageSet) { if seriesContentID == "" || dto.SeriesID == "" { return } @@ -2182,14 +2157,23 @@ func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *S if !ok { detail, err := h.content.GetItemDetail(ctx, session, seriesContentID, nil) if err == nil { - imgs = seriesImageURLs{posterURL: detail.PosterURL, backdropURL: detail.BackdropURL} + imgs = seriesImageSet{ + ContentID: detail.ContentID, + PosterURL: detail.PosterURL, + PosterPath: detail.PosterPath, + PosterThumbhash: detail.PosterThumbhash, + BackdropURL: detail.BackdropURL, + BackdropPath: detail.BackdropPath, + BackdropThumbhash: detail.BackdropThumbhash, + UpdatedAt: detail.UpdatedAt, + } h.rememberDetailImages(*detail) } cache[seriesContentID] = imgs } - h.mapper.applySeriesImages(dto, imgs.posterURL, imgs.backdropURL) - if imgs.backdropURL != "" && h.images != nil { - h.images.RememberSized(dto.SeriesID, "Thumb", imgs.backdropURL, compatCardImageSize) + h.mapper.applySeriesImages(dto, imgs) + if imgs.BackdropURL != "" && h.images != nil { + h.images.RememberSized(dto.SeriesID, "Thumb", imgs.BackdropURL, compatCardImageSize) } } diff --git a/internal/jellycompat/handlers_items_test.go b/internal/jellycompat/handlers_items_test.go index 2eae9614..91006012 100644 --- a/internal/jellycompat/handlers_items_test.go +++ b/internal/jellycompat/handlers_items_test.go @@ -70,11 +70,11 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url. panic("unused") } -// TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail verifies that when an -// episode detail is requested and the series's poster/backdrop are already in -// the ImageCache (e.g. from a prior browse response), the handler does NOT -// fetch the parent series detail a second time. Audit 2026-05-01 §3.4. -func TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail(t *testing.T) { +// TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies +// that episode detail responses fetch parent series image metadata even when +// image URLs are already cached. Cached URLs are not enough to build stable +// signed tags after Jellycompat restarts. +func TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags(t *testing.T) { codec := NewResourceIDCodec() episodeContentID := "ep1" seriesContentID := "series-1" @@ -123,8 +123,8 @@ func TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail(t *testing.T) { if rec.Code != 200 { t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String()) } - if contentSvc.getItemDetailCalls != 1 { - t.Errorf("expected exactly 1 GetItemDetail (episode only); got %d", + if contentSvc.getItemDetailCalls != 2 { + t.Errorf("expected episode and series GetItemDetail calls for stable parent image tags; got %d", contentSvc.getItemDetailCalls) } } diff --git a/internal/jellycompat/image_cache.go b/internal/jellycompat/image_cache.go index e965bf9d..ab0939f4 100644 --- a/internal/jellycompat/image_cache.go +++ b/internal/jellycompat/image_cache.go @@ -94,9 +94,7 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string, } if tag = strings.TrimSpace(tag); tag != "" { - if url, ok := c.lookupTag(tag); ok { - return url, true - } + return c.LookupTag(tag) } if routeID == "" || imageType == "" { @@ -105,6 +103,14 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string, return c.lookupRoute(routeImageKey(routeID, imageType, size)) } +// LookupTag resolves a cached image URL only by its legacy URL-derived tag. +func (c *ImageCache) LookupTag(tag string) (string, bool) { + if c == nil { + return "", false + } + return c.lookupTag(strings.TrimSpace(tag)) +} + // lookupTag resolves a tag without size partitioning. Tags are sha1 of the // presigned URL: for S3-cached paths the size variant is embedded in the URL // (so different sizes produce different tags), and for HTTP-passthrough URLs diff --git a/internal/jellycompat/images_test.go b/internal/jellycompat/images_test.go index d164f3e8..43e38e29 100644 --- a/internal/jellycompat/images_test.go +++ b/internal/jellycompat/images_test.go @@ -185,6 +185,117 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) { } } +func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("backdrop-bytes")) + })) + defer upstream.Close() + + codec := NewResourceIDCodec() + contentID := "series-1" + routeID := codec.EncodeStringID(EncodedIDItem, contentID) + secret := "image-secret" + tag := newImageTagSigner(secret).Tag( + imageTagSeed(contentID, "Backdrop", compatCardImageSize, upstream.URL, "", time.Time{}), + upstream.URL, + ) + h := &ImagesHandler{ + codec: codec, + httpClient: upstream.Client(), + images: NewImageCache(time.Hour, time.Now), + itemRepo: fakeImageItemRepo{item: &models.MediaItem{ + ContentID: contentID, + BackdropPath: upstream.URL, + }}, + imageTags: newImageTagSigner(secret), + } + + req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Thumb?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil) + req = withImageRouteParams(req, routeID, "Thumb") + rec := httptest.NewRecorder() + + h.HandleItemImage(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != "backdrop-bytes" { + t.Fatalf("body = %q, want backdrop bytes", got) + } +} + +func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("library-poster")) + })) + defer upstream.Close() + + codec := NewResourceIDCodec() + libraryID := 1 + routeID := codec.EncodeIntID(EncodedIDLibrary, int64(libraryID)) + posterPath := "library-posters/1/original.jpg" + secret := "image-secret" + tag := newImageTagSigner(secret).Tag( + imageTagSeed(routeID, "Primary", compatCardImageSize, posterPath, "", time.Time{}), + "", + ) + h := &ImagesHandler{ + codec: codec, + httpClient: upstream.Client(), + images: NewImageCache(time.Hour, time.Now), + folderRepo: fakeImageFolderRepo{folder: &models.MediaFolder{ID: libraryID, PosterPath: posterPath}}, + posterSigner: fakeLibraryPosterPresigner{url: upstream.URL}, + imageTags: newImageTagSigner(secret), + } + + req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil) + req = withImageRouteParams(req, routeID, "Primary") + rec := httptest.NewRecorder() + + h.HandleItemImage(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != "library-poster" { + t.Fatalf("body = %q, want library poster", got) + } +} + +func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("cached-image")) + })) + defer upstream.Close() + + codec := NewResourceIDCodec() + routeID := codec.EncodeStringID(EncodedIDItem, "movie-1") + cache := NewImageCache(time.Hour, time.Now) + cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize) + h := &ImagesHandler{ + codec: codec, + httpClient: upstream.Client(), + images: cache, + imageTags: newImageTagSigner("image-secret"), + } + + req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tagValue(upstream.URL), nil) + req = withImageRouteParams(req, routeID, "Primary") + rec := httptest.NewRecorder() + + h.HandleItemImage(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != "cached-image" { + t.Fatalf("body = %q, want cached image", got) + } +} + func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) { called := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -253,6 +364,29 @@ func (r fakeImageItemRepo) EnsureAccessible(context.Context, string, catalog.Acc return nil } +type fakeImageFolderRepo struct { + folder *models.MediaFolder +} + +func (r fakeImageFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) { + if r.folder != nil && r.folder.ID == id { + return r.folder, nil + } + return nil, catalog.ErrFolderNotFound +} + +type fakeLibraryPosterPresigner struct { + url string +} + +func (p fakeLibraryPosterPresigner) PresignGetURL(context.Context, string, string, time.Duration) (string, error) { + return p.url, nil +} + +func (p fakeLibraryPosterPresigner) Bucket() string { + return "test-bucket" +} + func withImageRouteParams(r *http.Request, routeID, imageType string) *http.Request { routeCtx := chi.NewRouteContext() routeCtx.URLParams.Add("id", routeID) diff --git a/internal/jellycompat/mapping.go b/internal/jellycompat/mapping.go index 4cfd0b24..e9609bdb 100644 --- a/internal/jellycompat/mapping.go +++ b/internal/jellycompat/mapping.go @@ -40,12 +40,16 @@ func newMapper(codec *ResourceIDCodec, cfg *config.Config) *mapper { func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO { imgTags := map[string]string{} - if library.PosterURL != "" { - imgTags["Primary"] = tagValue(library.PosterURL) + routeID := m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)) + if library.PosterPath != "" { + imgTags["Primary"] = m.imageTagSigner.Tag( + imageTagSeed(routeID, "Primary", compatCardImageSize, library.PosterPath, "", time.Time{}), + library.PosterURL, + ) } return baseItemDTO{ - ID: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)), + ID: routeID, Type: "CollectionFolder", MediaType: "Unknown", IsFolder: true, @@ -55,8 +59,8 @@ func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO { SortName: strings.ToLower(library.Name), ImageTags: imgTags, UserData: &itemUserDataDTO{ - Key: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)), - ItemID: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)), + Key: routeID, + ItemID: routeID, }, } } @@ -443,19 +447,37 @@ func (m *mapper) episodeFromUpstream(ep upstreamEpisode, isFavorite bool, progre return dto } +type seriesImageSet struct { + ContentID string + PosterURL string + PosterPath string + PosterThumbhash string + BackdropURL string + BackdropPath string + BackdropThumbhash string + UpdatedAt time.Time +} + // applySeriesImages sets series/parent image tags on an episode DTO so clients // can display the series poster and backdrop in Continue Watching / Next Up. -func (m *mapper) applySeriesImages(dto *baseItemDTO, seriesPosterURL, seriesBackdropURL string) { +func (m *mapper) applySeriesImages(dto *baseItemDTO, series seriesImageSet) { if dto.SeriesID == "" { return } - if seriesPosterURL != "" { - dto.SeriesPrimaryImageTag = tagValue(seriesPosterURL) + if series.PosterURL != "" { + dto.SeriesPrimaryImageTag = m.imageTagSigner.Tag( + imageTagSeed(series.ContentID, "Primary", compatCardImageSize, series.PosterPath, series.PosterThumbhash, series.UpdatedAt), + series.PosterURL, + ) } - if seriesBackdropURL != "" { - dto.ParentBackdropImageTags = backdropTags(seriesBackdropURL) + if series.BackdropURL != "" { + tag := m.imageTagSigner.Tag( + imageTagSeed(series.ContentID, "Backdrop", compatCardImageSize, series.BackdropPath, series.BackdropThumbhash, series.UpdatedAt), + series.BackdropURL, + ) + dto.ParentBackdropImageTags = []string{tag} dto.ParentBackdropItemID = dto.SeriesID - dto.ParentThumbImageTag = tagValue(seriesBackdropURL) + dto.ParentThumbImageTag = tag dto.ParentThumbItemID = dto.SeriesID } } diff --git a/internal/jellycompat/mapping_images_test.go b/internal/jellycompat/mapping_images_test.go index 7726fc67..0b141e8f 100644 --- a/internal/jellycompat/mapping_images_test.go +++ b/internal/jellycompat/mapping_images_test.go @@ -106,3 +106,82 @@ func TestEpisodeListImageTagsUseStillThumbhash(t *testing.T) { t.Fatalf("primary tag = %q, want still-thumbhash seed %q", dto.ImageTags["Primary"], expected) } } + +func TestLibraryImageTagsUseStablePosterPath(t *testing.T) { + secret := "image-secret" + codec := NewResourceIDCodec() + library := upstreamUserLibrary{ + ID: 1, + Name: "Movies", + Type: "movies", + PosterURL: "https://cdn.example.test/library.jpg?sig=one", + PosterPath: "library-posters/1/original.jpg", + } + + first := newMapper(codec, &config.Config{ + Auth: config.AuthConfig{JWTSecret: secret}, + }).viewFromLibrary(library) + library.PosterURL = "https://cdn.example.test/library.jpg?sig=two" + second := newMapper(codec, &config.Config{ + Auth: config.AuthConfig{JWTSecret: secret}, + }).viewFromLibrary(library) + + routeID := codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)) + expected := newImageTagSigner(secret).Tag( + imageTagSeed(routeID, "Primary", compatCardImageSize, library.PosterPath, "", time.Time{}), + library.PosterURL, + ) + + if first.ImageTags["Primary"] == "" { + t.Fatal("library primary image tag is empty") + } + if first.ImageTags["Primary"] != second.ImageTags["Primary"] { + t.Fatalf("library tag changed when only signed URL changed: %q vs %q", first.ImageTags["Primary"], second.ImageTags["Primary"]) + } + if second.ImageTags["Primary"] != expected { + t.Fatalf("library tag = %q, want %q", second.ImageTags["Primary"], expected) + } +} + +func TestApplySeriesImagesUsesCanonicalSeriesSeeds(t *testing.T) { + secret := "image-secret" + codec := NewResourceIDCodec() + seriesContentID := "series-1" + seriesRouteID := codec.EncodeStringID(EncodedIDItem, seriesContentID) + updatedAt := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + dto := baseItemDTO{SeriesID: seriesRouteID} + series := seriesImageSet{ + ContentID: seriesContentID, + PosterURL: "https://cdn.example.test/poster.jpg?sig=one", + PosterPath: "metadb://poster/series-1", + PosterThumbhash: "poster-thumbhash", + BackdropURL: "https://cdn.example.test/backdrop.jpg?sig=one", + BackdropPath: "metadb://backdrop/series-1", + BackdropThumbhash: "backdrop-thumbhash", + UpdatedAt: updatedAt, + } + + newMapper(codec, &config.Config{ + Auth: config.AuthConfig{JWTSecret: secret}, + }).applySeriesImages(&dto, series) + + signer := newImageTagSigner(secret) + expectedPrimary := signer.Tag( + imageTagSeed(series.ContentID, "Primary", compatCardImageSize, series.PosterPath, series.PosterThumbhash, updatedAt), + series.PosterURL, + ) + expectedBackdrop := signer.Tag( + imageTagSeed(series.ContentID, "Backdrop", compatCardImageSize, series.BackdropPath, series.BackdropThumbhash, updatedAt), + series.BackdropURL, + ) + + if dto.SeriesPrimaryImageTag != expectedPrimary { + t.Fatalf("SeriesPrimaryImageTag = %q, want %q", dto.SeriesPrimaryImageTag, expectedPrimary) + } + if len(dto.ParentBackdropImageTags) != 1 || dto.ParentBackdropImageTags[0] != expectedBackdrop { + t.Fatalf("ParentBackdropImageTags = %#v, want [%q]", dto.ParentBackdropImageTags, expectedBackdrop) + } + if dto.ParentThumbImageTag != expectedBackdrop { + t.Fatalf("ParentThumbImageTag = %q, want %q", dto.ParentThumbImageTag, expectedBackdrop) + } +} diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index 2abffdd2..ae6bf322 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -98,7 +98,7 @@ func NewRouter(deps Dependencies) chi.Router { playbackHandler.S3Client = deps.S3Client playbackHandler.S3Bucket = deps.S3Bucket } - imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.JWTSecret) + imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret) displayPrefsHandler := NewDisplayPreferencesHandler(deps.UserStoreProvider) recsHandler := NewRecommendationsHandler(deps.Recommender, deps.ItemRepo, deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.AccessFilterFn) diff --git a/internal/jellycompat/upstream_types.go b/internal/jellycompat/upstream_types.go index 7a3c60e6..5b4d5a11 100644 --- a/internal/jellycompat/upstream_types.go +++ b/internal/jellycompat/upstream_types.go @@ -11,10 +11,11 @@ import ( // catalog/service layer and the Jellyfin DTO mapping layer. type upstreamUserLibrary struct { - ID int `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - PosterURL string `json:"poster_url,omitempty"` + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + PosterURL string `json:"poster_url,omitempty"` + PosterPath string `json:"-"` } type upstreamListItem struct { From 673a0e6c555a1e27c5a0c75c803b3947f66aae5b Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 09:13:30 -0400 Subject: [PATCH 53/53] fix(metadata): accept exact cross-provider match ties --- internal/metadata/match_candidates.go | 67 +++++++++++++++++++++- internal/metadata/match_candidates_test.go | 61 ++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index d6827dee..4b9acaa3 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -376,11 +376,76 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) return &best.candidate, true } if best.score-scoredCandidates[1].score < 15 { - return duplicateTieBreakWinner(hints, scoredCandidates) + if winner, ok := duplicateTieBreakWinner(hints, scoredCandidates); ok { + return winner, true + } + return providerOrderExactTieBreakWinner(hints, scoredCandidates) } return &best.candidate, true } +func providerOrderExactTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) { + if hints == nil || len(scoredCandidates) < 2 { + return nil, false + } + + best := scoredCandidates[0] + contenders := []scoredMatchCandidate{best} + for i := 1; i < len(scoredCandidates); i++ { + next := scoredCandidates[i] + if best.score-next.score >= 15 { + break + } + contenders = append(contenders, next) + } + if len(contenders) < 2 { + return nil, false + } + + seenPrimaryProviders := make(map[string]struct{}, len(contenders)) + for _, contender := range contenders { + if !exactTitleYearTypeMatch(hints, contender.candidate) { + return nil, false + } + + primaryProvider := candidatePrimaryProvider(contender.candidate) + if primaryProvider == "" { + return nil, false + } + if _, exists := seenPrimaryProviders[primaryProvider]; exists { + return nil, false + } + seenPrimaryProviders[primaryProvider] = struct{}{} + } + + return &best.candidate, true +} + +func exactTitleYearTypeMatch(hints *MatchHints, candidate MatchCandidate) bool { + if hints == nil || hints.Year == 0 || candidate.Year == 0 { + return false + } + if candidate.Year != hints.Year { + return false + } + if !candidateTypeMatchesHint(hints.Type, candidate.ContentType) { + return false + } + return inferTitleSimilarity(hints.Title, candidate.Title, hints.Year) == 1 +} + +func candidatePrimaryProvider(candidate MatchCandidate) string { + for _, key := range canonicalCandidateIDKeys { + if strings.TrimSpace(candidate.ProviderIDs[key]) != "" { + return key + } + } + if len(candidate.Sources) == 1 { + return strings.TrimSpace(candidate.Sources[0]) + } + return "" +} + func selectRefreshMatchCandidate(existing *models.MediaItem, candidates []MatchCandidate) (*MatchCandidate, bool) { if existing == nil || len(candidates) == 0 { return nil, false diff --git a/internal/metadata/match_candidates_test.go b/internal/metadata/match_candidates_test.go index 905a0015..acb5c37e 100644 --- a/internal/metadata/match_candidates_test.go +++ b/internal/metadata/match_candidates_test.go @@ -485,6 +485,67 @@ func TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap(t } } +func TestSelectInitialMatchCandidate_UsesProviderOrderForExactCrossProviderTie(t *testing.T) { + winner, ok := selectInitialMatchCandidate( + &MatchHints{ + Title: "100 Days Wild", + Year: 2020, + Type: "series", + }, + []MatchCandidate{ + { + Title: "100 Days Wild", + Year: 2020, + ContentType: "series", + ProviderIDs: map[string]string{"tvdb": "383893"}, + Sources: []string{"tvdb"}, + }, + { + Title: "100 Days Wild", + Year: 2020, + ContentType: "series", + ProviderIDs: map[string]string{"tmdb": "109792"}, + Sources: []string{"tmdb"}, + }, + }, + ) + if !ok || winner == nil { + t.Fatal("expected exact cross-provider tie to use provider order") + } + if got := winner.ProviderIDs["tvdb"]; got != "383893" { + t.Fatalf("winner tvdb = %q, want 383893", got) + } +} + +func TestSelectInitialMatchCandidate_ProviderOrderTieRequiresExactTitleYear(t *testing.T) { + winner, ok := selectInitialMatchCandidate( + &MatchHints{ + Title: "100 Days Wild", + Year: 2020, + Type: "series", + }, + []MatchCandidate{ + { + Title: "100 Days Wild", + Year: 2020, + ContentType: "series", + ProviderIDs: map[string]string{"tvdb": "383893"}, + Sources: []string{"tvdb"}, + }, + { + Title: "Step Brothers", + Year: 2020, + ContentType: "series", + ProviderIDs: map[string]string{"tmdb": "109792", "imdb": "tt1234567"}, + Sources: []string{"imdb", "metadb", "tmdb", "xattr"}, + }, + }, + ) + if ok || winner != nil { + t.Fatal("expected non-equivalent cross-provider tie to remain unmatched") + } +} + func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie(t *testing.T) { winner, ok := selectInitialMatchCandidate( &MatchHints{