From 39e98a3e90664f8eac14d4dedc36481d69ba16e9 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Sun, 24 May 2026 08:57:42 +0200 Subject: [PATCH 001/201] fix(api): allow HEAD on /api/v1/direct-download Firefox (and some download managers) issue a HEAD request before starting a download. The route only registered GET, so HEAD returned 405 Method Not Allowed and the browser aborted the download. Mirrors the pattern already used by /stream/{session_id}, which registers both GET and HEAD on the same handler. ServeDirect is built on http.ServeContent / ServeFile, which natively handle HEAD by writing headers without a body, so no handler changes are needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/router.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/api/router.go b/internal/api/router.go index 6b73b108..06b7aae8 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1547,6 +1547,7 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/{id}/file", downloadHandler.HandleDownloadFile) }) r.Get("/direct-download", downloadHandler.HandleDirectDownload) + r.Head("/direct-download", downloadHandler.HandleDirectDownload) // Recipe gallery catalog (no profile required — purely static metadata). recipeHandler := &handlers.RecipeHandler{} From 8d9250707b8cc5d8d94634dade67dac4529fe37f Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:52:16 -0400 Subject: [PATCH 002/201] 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 003/201] 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 004/201] 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 005/201] 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 006/201] 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 007/201] 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 008/201] 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 98ea57ead8bbf6087549cb5b27dbd9090fe39873 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 12:07:50 -0400 Subject: [PATCH 009/201] chore: add planning docs and requests updates - Add plans for date-named episodes and Jellyfin autoscan compat - Update requests handlers, service, and UI hooks - Remove Makefile.local.example --- Makefile.local.example | 110 -- .../plans/2026-05-24-date-named-episodes.md | 884 +++++++++++ ...026-05-24-jellyfin-autoscan-scan-compat.md | 1365 +++++++++++++++++ internal/api/handlers/requests.go | 14 + internal/api/handlers/requests_test.go | 4 + internal/api/router.go | 1 + internal/requests/discover_brand.go | 36 +- internal/requests/service.go | 51 + internal/requests/service_test.go | 103 +- internal/requests/types.go | 4 + web/src/App.tsx | 51 +- web/src/api/types.ts | 4 + web/src/components/AppSidebar.tsx | 39 +- web/src/hooks/queries/keys.ts | 1 + web/src/hooks/queries/useRequests.ts | 10 + 15 files changed, 2532 insertions(+), 145 deletions(-) delete mode 100644 Makefile.local.example create mode 100644 docs/superpowers/plans/2026-05-24-date-named-episodes.md create mode 100644 docs/superpowers/plans/2026-05-24-jellyfin-autoscan-scan-compat.md diff --git a/Makefile.local.example b/Makefile.local.example deleted file mode 100644 index 83047b6c..00000000 --- a/Makefile.local.example +++ /dev/null @@ -1,110 +0,0 @@ -.PHONY: deploy-dev dev-deploy deploy-dev-image dev-remote-setup dev-remote-stop deploy-dev-transcode dev-deploy-transcode deploy-dev-import dev-deploy-import deploy-dev-import-reset dev-deploy-import-reset - -# Copy this file to Makefile.local and adjust the host/path values for your -# private development environment. Makefile.local is intentionally gitignored. -DEV_DEPLOY_HOST ?= root@silo-dev.example.invalid -DEV_DEPLOY_DIR ?= /opt/git/silo-dev -DEV_TRANSCODE_DEPLOY_HOST ?= -DEV_TRANSCODE_DEPLOY_DIR ?= /opt/git/silo -DEV_IMPORT_MEDIA_ROOT ?= /srv/media -DEV_MEDIA_ROOT ?= /srv/media -DEV_IMPORT_CATALOG_SEED_PATH ?= /tmp/silo-catalog-seed -DEV_PLUGIN_SDK_DIR ?= ../silo-plugin-sdk -DEV_LOCAL_IMAGE ?= silo:dev-local -DEV_JELLYCOMPAT_DEBUG_LOG ?= /opt/silo/jellycompat-debug.log -DEV_COMPOSE_FILE ?= docker-compose.yml -RSYNC ?= $(shell command -v rsync || echo /usr/bin/rsync) -SILO_IMAGE ?= ghcr.io/silo-server/silo-server:latest - -RSYNC_EXCLUDE := --exclude='.git' --exclude='node_modules' --exclude='web/dist' \ - --exclude='.env' --exclude='.env.*' --exclude='*.md' --exclude='.agents' \ - --exclude='.claude' --exclude='.codex' --exclude='.cursor' \ - --exclude='.worktrees' --exclude='.playwright-mcp' --exclude='demo' \ - --exclude='output' - -dev-remote-setup: - DEV_HOST=$(DEV_DEPLOY_HOST) DEV_DIR=$(DEV_DEPLOY_DIR) ./scripts/dev-remote-setup.sh - -deploy-dev: - @echo "==> Syncing source to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/..." - $(RSYNC) -az --delete $(RSYNC_EXCLUDE) ./ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ - @if [ -f .env ]; then \ - $(RSYNC) -az .env $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/; \ - fi - @echo "==> Syncing plugin SDK to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/silo-plugin-sdk/..." - $(RSYNC) -az --delete --exclude='.git' $(DEV_PLUGIN_SDK_DIR)/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/silo-plugin-sdk/ - @echo "==> Stopping air tmux session if running..." - ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true' - @echo "==> Normalizing jellycompat debug log path..." - ssh $(DEV_DEPLOY_HOST) 'set -e; docker rm -f silo >/dev/null 2>&1 || true; mkdir -p $$(dirname "$(DEV_JELLYCOMPAT_DEBUG_LOG)"); if [ -d "$(DEV_JELLYCOMPAT_DEBUG_LOG)" ]; then if ! rmdir "$(DEV_JELLYCOMPAT_DEBUG_LOG)" 2>/dev/null; then mv "$(DEV_JELLYCOMPAT_DEBUG_LOG)" "$(DEV_JELLYCOMPAT_DEBUG_LOG).dir.$$(date +%s)"; fi; fi; touch "$(DEV_JELLYCOMPAT_DEBUG_LOG)"' - @echo "==> Building $(DEV_LOCAL_IMAGE) on remote..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker buildx build \ - -f Dockerfile.dev \ - --build-context silo_plugin_sdk=$(DEV_DEPLOY_DIR)/silo-plugin-sdk \ - -t $(DEV_LOCAL_IMAGE) \ - --load \ - .' - @echo "==> Starting services via Docker Compose..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && SILO_IMAGE=$(DEV_LOCAL_IMAGE) MEDIA_ROOT="$(DEV_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f $(DEV_COMPOSE_FILE) up -d --force-recreate silo' - @echo "==> Pruning old images..." - ssh $(DEV_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' - @echo "==> Verifying deployment..." - @sleep 5 - ssh $(DEV_DEPLOY_HOST) 'docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"' - @echo "==> Deploy complete (local build)." - -dev-deploy: deploy-dev - -deploy-dev-image: - @echo "==> Syncing compose config to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/..." - $(RSYNC) -az $(DEV_COMPOSE_FILE) $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ - $(RSYNC) -az postgres/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/postgres/ - @if [ -f .env ]; then \ - $(RSYNC) -az .env $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/; \ - fi - @echo "==> Stopping air tmux session if running..." - ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true' - @echo "==> Normalizing jellycompat debug log path..." - ssh $(DEV_DEPLOY_HOST) 'set -e; docker rm -f silo >/dev/null 2>&1 || true; mkdir -p $$(dirname "$(DEV_JELLYCOMPAT_DEBUG_LOG)"); if [ -d "$(DEV_JELLYCOMPAT_DEBUG_LOG)" ]; then if ! rmdir "$(DEV_JELLYCOMPAT_DEBUG_LOG)" 2>/dev/null; then mv "$(DEV_JELLYCOMPAT_DEBUG_LOG)" "$(DEV_JELLYCOMPAT_DEBUG_LOG).dir.$$(date +%s)"; fi; fi; touch "$(DEV_JELLYCOMPAT_DEBUG_LOG)"' - @echo "==> Pulling $(SILO_IMAGE) on remote..." - ssh $(DEV_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' - @echo "==> Starting services via Docker Compose..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f $(DEV_COMPOSE_FILE) up -d --force-recreate silo' - @echo "==> Verifying deployment..." - @sleep 5 - ssh $(DEV_DEPLOY_HOST) 'docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"' - @echo "==> Deploy complete (image pull)." - -dev-remote-stop: - @echo "==> Stopping air tmux session if running..." - ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true' - @echo "==> Stopping dev stack..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -f $(DEV_COMPOSE_FILE) down' - @echo "==> Done." - -deploy-dev-transcode: - @if [ -z "$(strip $(DEV_TRANSCODE_DEPLOY_HOST))" ]; then \ - echo "DEV_TRANSCODE_DEPLOY_HOST is not set; skipping remote transcode deploy."; \ - exit 0; \ - fi - ssh $(DEV_TRANSCODE_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' - $(RSYNC) -az docker-compose.remote-transcode.yml $(DEV_TRANSCODE_DEPLOY_HOST):$(DEV_TRANSCODE_DEPLOY_DIR)/ - ssh $(DEV_TRANSCODE_DEPLOY_HOST) 'set -e; cd $(DEV_TRANSCODE_DEPLOY_DIR) && docker compose -f docker-compose.remote-transcode.yml up -d --force-recreate silo-transcode && sleep 5 && docker compose -f docker-compose.remote-transcode.yml ps && docker inspect -f "{{.State.Status}}" silo-transcode | grep -qx running || { echo; echo "silo-transcode container is not running; recent logs:"; docker logs silo-transcode --tail 120; exit 1; }' - ssh $(DEV_TRANSCODE_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' - -dev-deploy-transcode: deploy-dev-transcode - -deploy-dev-import: - $(RSYNC) -az $(DEV_COMPOSE_FILE) $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ - $(RSYNC) -az postgres/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/postgres/ - ssh $(DEV_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" CATALOG_SEED_PATH="$(DEV_IMPORT_CATALOG_SEED_PATH)" docker compose -p silo-import -f $(DEV_COMPOSE_FILE) up -d --force-recreate' - ssh $(DEV_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' - -dev-deploy-import: deploy-dev-import - -deploy-dev-import-reset: - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -p silo-import -f $(DEV_COMPOSE_FILE) down -v --remove-orphans || true' - $(MAKE) deploy-dev-import - -dev-deploy-import-reset: deploy-dev-import-reset diff --git a/docs/superpowers/plans/2026-05-24-date-named-episodes.md b/docs/superpowers/plans/2026-05-24-date-named-episodes.md new file mode 100644 index 00000000..f418c455 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-date-named-episodes.md @@ -0,0 +1,884 @@ +# Date-Named Episodes 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:** Make date-named TV episode files visible as episodes even when TVDB/TMDB child episode metadata has not caught up yet. + +**Architecture:** Keep the fix server-side in the metadata layer. The naming parser already extracts `AirDate` and season-folder `SeasonNum`; metadata linking should use that season hint to disambiguate existing episode rows, synthesize conservative `scanner_fallback` episode rows when provider rows are missing, and reconcile those fallback rows by `air_date` when provider metadata later arrives with real numbering. + +**Tech Stack:** Go, PostgreSQL via `pgx`, existing `catalog` repositories, `internal/metadata` fallback/linking flow, targeted Go tests. + +--- + +## Validated Direction + +Three subagents independently reviewed scanner/naming, metadata/linking, and provider/refresh implications. + +- Parser support already exists. `naming.ParseFilename` returns both `AirDate` and a season-folder `SeasonNum` for date-named files in `Season NN` folders. +- Do not depend on plugin changes. TVDB/TMDB plugins already expose season episode lists; the issue is provider incompleteness/latency and server fallback policy. +- Do not make scanner persistence the core fix. The scanner currently drops date-only season hints from `media_files`, but metadata reparses file paths and can link/synthesize without a schema migration. +- Add provider reconciliation. Fallback episodes use provisional episode numbers, so provider refresh must adopt an existing `scanner_fallback` row by exact `air_date` instead of creating a duplicate if provider numbering differs. + +## File Structure + +- Modify `internal/naming/filename_test.go` + - Add a regression for the exact Late Show date-name shape so parser behavior stays pinned. +- Modify `internal/catalog/episode_repo.go` + - Add unfiltered episode listing for one season. + - Add a repository method that upgrades one exact `scanner_fallback` episode by air date to provider metadata. +- Modify `internal/metadata/service.go` + - Preserve season hints for date-named files. + - Filter air-date matches by explicit season when present. + - Synthesize date-named fallback episodes for explicit season folders. + - Use provider-by-air-date reconciliation before inserting provider episode rows. +- Modify `internal/metadata/fallback_episode_test.go` + - Extend fakes for the new repository methods. + - Cover season-hinted air-date disambiguation, Late Show fallback synthesis, safe skips, and provider adoption. + +No migration is planned. No plugin repo changes are planned. + +--- + +### Task 1: Pin Date-Name Parser Behavior + +**Files:** +- Modify: `internal/naming/filename_test.go` + +- [ ] **Step 1: Add the Late Show parser regression** + +Add this case to the existing table that covers date-named series files, or create `TestParseFilenameDateNamedLateShowSeasonFolder` if the surrounding table shape is awkward: + +```go +func TestParseFilenameDateNamedLateShowSeasonFolder(t *testing.T) { + hints := ParseFilename( + "/mnt/sharedrives/zd-storage-ceph/television/10s/The Late Show with Stephen Colbert (2015) {tvdb-289574}/Season 11/The Late Show with Stephen Colbert (2015) - 2026-05-21 - A Goodbye Celebration [WEBDL-1080p 8-bit h264 AAC 2.0]-ILP.mkv", + "series", + ) + if hints == nil { + t.Fatal("ParseFilename returned nil") + } + if hints.Type != "series" { + t.Fatalf("Type = %q, want series", hints.Type) + } + if hints.SeasonNum != 11 { + t.Fatalf("SeasonNum = %d, want 11", hints.SeasonNum) + } + if hints.EpisodeNum != 0 { + t.Fatalf("EpisodeNum = %d, want 0 for date-named file", hints.EpisodeNum) + } + if hints.AirDate != "2026-05-21" { + t.Fatalf("AirDate = %q, want 2026-05-21", hints.AirDate) + } + if hints.Title != "The Late Show with Stephen Colbert" { + t.Fatalf("Title = %q, want The Late Show with Stephen Colbert", hints.Title) + } + if hints.Year != 2015 { + t.Fatalf("Year = %d, want 2015", hints.Year) + } +} +``` + +- [ ] **Step 2: Run the parser test** + +Run: + +```bash +go test ./internal/naming -run TestParseFilenameDateNamedLateShowSeasonFolder -count=1 +``` + +Expected: pass. This is a guardrail test for existing parser behavior, not a failing implementation test. + +- [ ] **Step 3: Commit** + +```bash +git add internal/naming/filename_test.go +git commit -m "test(metadata): pin date-named season filename parsing" +``` + +--- + +### Task 2: Use Season Hints When Linking Existing Air-Date Episodes + +**Files:** +- Modify: `internal/metadata/service.go` +- Modify: `internal/metadata/fallback_episode_test.go` + +- [ ] **Step 1: Add a failing ambiguity regression** + +Add this test near the existing air-date linking tests in `internal/metadata/fallback_episode_test.go`: + +```go +func TestEnsureSeriesEpisodeLinks_UsesSeasonHintForAirDateAmbiguity(t *testing.T) { + h := newFallbackTestHarness() + ctx := context.Background() + + seriesID := "series-late-show-season-hint" + h.itemRepo.Upsert(ctx, &models.MediaItem{ + ContentID: seriesID, + Title: "The Late Show with Stephen Colbert", + Type: "series", + Status: "matched", + TvdbID: "289574", + Studios: []string{}, + Networks: []string{}, + Countries: []string{}, + Genres: []string{}, + }) + h.episodeRepo.Upsert(ctx, &models.Episode{ + ContentID: "special-2026-02-16", + SeriesID: seriesID, + SeasonID: "season-0", + SeasonNumber: 0, + EpisodeNumber: 16, + Title: "Texas Legislature Special", + AirDate: mustDate(t, "2026-02-16"), + MetadataSource: "provider", + }) + h.episodeRepo.Upsert(ctx, &models.Episode{ + ContentID: "season11-episode73", + SeriesID: seriesID, + SeasonID: "season-11", + SeasonNumber: 11, + EpisodeNumber: 73, + Title: "Jennifer Garner, Robert Duvall", + AirDate: mustDate(t, "2026-02-16"), + MetadataSource: "provider", + }) + + file := &models.MediaFile{ + ID: 120, + MediaFolderID: 10, + FilePath: "/media/tv/The Late Show with Stephen Colbert (2015) {tvdb-289574}/Season 11/The Late Show with Stephen Colbert (2015) - 2026-02-16 - Jennifer Garner - [ WEBDL-1080p h264 EAC3 2.0 ]-JOAN.mkv", + } + h.fileRepo.addFile(file) + h.fileRepo.contentIDs[file.ID] = seriesID + + if err := h.service.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil { + t.Fatalf("ensureSeriesEpisodeLinks failed: %v", err) + } + if got := h.fileRepo.episodeLinks[file.ID]; got != "season11-episode73" { + t.Fatalf("episode link = %q, want season11-episode73", got) + } + linked := h.fileRepo.files[file.ID] + if linked.SeasonNumber != 11 || linked.EpisodeNumber != 73 { + t.Fatalf("linked season/episode = S%dE%d, want S11E73", linked.SeasonNumber, linked.EpisodeNumber) + } +} +``` + +- [ ] **Step 2: Run the failing regression** + +Run: + +```bash +go test ./internal/metadata -run TestEnsureSeriesEpisodeLinks_UsesSeasonHintForAirDateAmbiguity -count=1 +``` + +Expected before implementation: fail because the current air-date hint discards `SeasonNum`, leaving two candidates for `2026-02-16`. + +- [ ] **Step 3: Preserve season hints for air-date files** + +In `internal/metadata/service.go`, change the air-date branch in `parseEpisodeLinkHint` from: + +```go +if fnh.AirDate != "" { + return episodeLinkHint{airDate: fnh.AirDate, ok: true} +} +``` + +to: + +```go +if fnh.AirDate != "" { + return episodeLinkHint{seasonNum: fnh.SeasonNum, airDate: fnh.AirDate, ok: true} +} +``` + +- [ ] **Step 4: Filter candidates by explicit season before provider preference** + +Change `selectAirDateEpisodeCandidate` to accept `seasonHint int` and filter when `seasonHint > 0`: + +```go +func selectAirDateEpisodeCandidate(candidates []*models.Episode, seriesItem *models.MediaItem, seasonHint int) (*models.Episode, bool) { + if seasonHint > 0 { + filtered := make([]*models.Episode, 0, len(candidates)) + for _, candidate := range candidates { + if candidate != nil && candidate.SeasonNumber == seasonHint { + filtered = append(filtered, candidate) + } + } + if len(filtered) == 1 { + return filtered[0], true + } + if len(filtered) > 1 { + candidates = filtered + } + } + if len(candidates) == 0 { + return nil, false + } + if len(candidates) == 1 { + return candidates[0], true + } + for _, provider := range preferredEpisodeProviders(seriesItem) { + filtered := filterEpisodesByProviderID(candidates, provider) + if len(filtered) == 1 { + return filtered[0], true + } + if len(filtered) > 1 { + return nil, false + } + } + return nil, false +} +``` + +Update the call site in `linkSeriesFilesToEpisodesWithOptions`: + +```go +selected, ok := selectAirDateEpisodeCandidate(candidates, seriesItem, hint.seasonNum) +``` + +- [ ] **Step 5: Run the targeted metadata test** + +Run: + +```bash +go test ./internal/metadata -run 'TestEnsureSeriesEpisodeLinks_(UsesSeasonHintForAirDateAmbiguity|SkipsAmbiguousAirDateMatch|PrefersSeriesProviderForAirDateMatch|LinksDateNamedFileByAirDate)' -count=1 +``` + +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add internal/metadata/service.go internal/metadata/fallback_episode_test.go +git commit -m "fix(metadata): use season hints for air-date episode links" +``` + +--- + +### Task 3: Add Episode Repository Primitives For Fallback Inference And Adoption + +**Files:** +- Modify: `internal/catalog/episode_repo.go` +- Modify: `internal/metadata/service.go` +- Modify: `internal/metadata/fallback_episode_test.go` + +- [ ] **Step 1: Extend the metadata episode repository interface** + +In `internal/metadata/service.go`, extend `metadataEpisodeRepo` with: + +```go + ListBySeriesAndSeasonUnscoped(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error) + AdoptScannerFallbackEpisode(ctx context.Context, ep *models.Episode) (bool, error) +``` + +- [ ] **Step 2: Add unfiltered season listing to `EpisodeRepository`** + +Add this method in `internal/catalog/episode_repo.go` near `ListBySeason`: + +```go +// ListBySeriesAndSeasonUnscoped returns all episode rows for a series season, +// including rows without episode_libraries memberships. Metadata fallback +// inference needs provider rows before local files exist for every episode. +func (r *EpisodeRepository) ListBySeriesAndSeasonUnscoped(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error) { + query := `SELECT ` + episodeColumns + ` + FROM episodes + WHERE series_id = $1 AND season_number = $2 + ORDER BY episode_number ASC` + + rows, err := r.pool.Query(ctx, query, seriesID, seasonNum) + if err != nil { + return nil, fmt.Errorf("listing unscoped episodes by series season: %w", err) + } + defer rows.Close() + + return scanEpisodes(rows) +} +``` + +- [ ] **Step 3: Add provider adoption to `EpisodeRepository`** + +Add a method named `AdoptScannerFallbackEpisode`. The method must: + +- return `false, nil` when `ep` is nil, `ep.AirDate` is nil, or required identity fields are empty +- run in a transaction +- clear stale external IDs for `imdb_id`, `tmdb_id`, and `tvdb_id` in the same series before updating +- find fallback rows with the exact same `series_id`, exact same `air_date`, and `metadata_source = 'scanner_fallback'` +- require the fallback row's current `season_number` to equal the provider episode's `SeasonNumber` +- adopt only when exactly one fallback row exists +- refuse adoption if another row already owns the target `(series_id, season_number, episode_number)` +- update the fallback row in place, preserving its `content_id` +- run `updateSeriesLastAirDateSQL` before committing + +Use this SQL shape inside the transaction: + +```go +rows, err := tx.Query(ctx, ` + SELECT content_id + FROM episodes + WHERE series_id = $1 + AND air_date = $2 + AND season_number = $3 + AND metadata_source = 'scanner_fallback' + ORDER BY content_id ASC +`, ep.SeriesID, ep.AirDate, ep.SeasonNumber) +``` + +Then verify the target key: + +```go +var existingTarget string +targetErr := tx.QueryRow(ctx, ` + SELECT content_id + FROM episodes + WHERE series_id = $1 AND season_number = $2 AND episode_number = $3 +`, ep.SeriesID, ep.SeasonNumber, ep.EpisodeNumber).Scan(&existingTarget) +if targetErr == nil && existingTarget != fallbackID { + return false, nil +} +if targetErr != nil && !errors.Is(targetErr, pgx.ErrNoRows) { + return false, fmt.Errorf("checking provider episode target before fallback adoption: %w", targetErr) +} +``` + +Update the fallback row: + +```go +_, err = tx.Exec(ctx, ` + UPDATE episodes + SET season_id = $2, + season_number = $3, + episode_number = $4, + title = $5, + default_metadata_language = $6, + overview = $7, + air_date = $8, + runtime = $9, + rating_imdb = $10, + rating_tmdb = $11, + imdb_id = COALESCE(NULLIF($12, ''), imdb_id), + tmdb_id = COALESCE(NULLIF($13, ''), tmdb_id), + tvdb_id = COALESCE(NULLIF($14, ''), tvdb_id), + still_path = $15, + still_thumbhash = $16, + metadata_s3_path = $17, + metadata_etag = $18, + metadata_source = $19, + updated_at = NOW() + WHERE content_id = $1 +`, fallbackID, nilIfEmpty(ep.SeasonID), ep.SeasonNumber, ep.EpisodeNumber, + ep.Title, ep.DefaultMetadataLanguage, ep.Overview, ep.AirDate, ep.Runtime, + ep.RatingIMDB, ep.RatingTMDB, ep.ImdbID, ep.TmdbID, ep.TvdbID, + ep.StillPath, ep.StillThumbhash, ep.MetadataS3Path, ep.MetadataEtag, + ep.MetadataSource) +``` + +Set `ep.ContentID = fallbackID` when adoption succeeds. + +- [ ] **Step 4: Extend the fake episode repo** + +In `internal/metadata/fallback_episode_test.go`, add fake methods matching the new interface. `ListBySeriesAndSeasonUnscoped` should return all episodes for the series/season without availability filtering. `AdoptScannerFallbackEpisode` should find exactly one fallback row by `SeriesID`, `SeasonNumber`, `AirDate`, and `MetadataSource == "scanner_fallback"`, move it from its old `episodeKey` to the provider key, copy provider fields, preserve `ContentID`, and return `true`. + +- [ ] **Step 5: Add provider adoption regression** + +Add this test: + +```go +func TestPersistSeasonsAndEpisodes_AdoptsScannerFallbackByAirDate(t *testing.T) { + h := newFallbackTestHarness() + ctx := context.Background() + + seriesID := "series-provider-adopts-airdate" + h.itemRepo.Upsert(ctx, &models.MediaItem{ + ContentID: seriesID, + Title: "Daily Show", + Type: "series", + Status: "matched", + Studios: []string{}, + Networks: []string{}, + Countries: []string{}, + Genres: []string{}, + }) + h.seasonRepo.Upsert(ctx, &models.Season{ + ContentID: "season-11", + SeriesID: seriesID, + SeasonNumber: 11, + Title: "Season 11", + MetadataSource: "provider", + }) + h.episodeRepo.Upsert(ctx, &models.Episode{ + ContentID: "fallback-episode", + SeriesID: seriesID, + SeasonID: "season-11", + SeasonNumber: 11, + EpisodeNumber: 103, + Title: "Episode 103", + AirDate: mustDate(t, "2026-05-21"), + MetadataSource: "scanner_fallback", + }) + + h.service.persistSeasonsAndEpisodes(ctx, seriesID, "en", "en", []SeasonResult{{ + SeasonNumber: 11, + Title: "Season 11", + }}, []EpisodeResult{{ + ProviderIDs: map[string]string{"tvdb": "real-tvdb-episode"}, + SeasonNumber: 11, + EpisodeNumber: 120, + Title: "A Goodbye Celebration", + AirDate: "2026-05-21", + }}, MergeReplace) + + if _, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 11, 103); err == nil { + t.Fatal("old fallback natural key still exists; expected provider adoption to move it") + } + adopted, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 11, 120) + if err != nil { + t.Fatalf("adopted provider episode not found: %v", err) + } + if adopted.ContentID != "fallback-episode" { + t.Fatalf("ContentID = %q, want fallback-episode", adopted.ContentID) + } + if adopted.MetadataSource != "provider" { + t.Fatalf("MetadataSource = %q, want provider", adopted.MetadataSource) + } + if adopted.TvdbID != "real-tvdb-episode" { + t.Fatalf("TvdbID = %q, want real-tvdb-episode", adopted.TvdbID) + } +} +``` + +- [ ] **Step 6: Add cross-season adoption safety regression** + +Add a regression that creates one `scanner_fallback` row for `S11` with `air_date = 2026-02-16`, then persists a provider `EpisodeResult` for `S00E16` with the same air date. Assert that `AdoptScannerFallbackEpisode` returns false through `persistSeasonsAndEpisodes`, the `S11` fallback keeps its original `content_id` and natural key, and the provider special is inserted or updated as its own row. This protects the real Late Show ambiguity where specials and regular episodes can share an air date. + +- [ ] **Step 7: Wire provider adoption into persistence** + +In `persistSeasonsAndEpisodes`, after `dbEp` is built and before `s.episodeRepo.Upsert(ctx, dbEp)`, add: + +```go +adopted := false +if existingEpisode == nil && dbEp.AirDate != nil { + var adoptErr error + adopted, adoptErr = s.episodeRepo.AdoptScannerFallbackEpisode(ctx, dbEp) + if adoptErr != nil { + slog.Warn("metadata: failed to adopt scanner fallback episode", + "series_id", seriesID, + "season", dbEp.SeasonNumber, + "episode", dbEp.EpisodeNumber, + "air_date", dbEp.AirDate.Format("2006-01-02"), + "error", adoptErr) + adopted = false + } +} +if !adopted { + if err := s.episodeRepo.Upsert(ctx, dbEp); err != nil { + slog.Warn("metadata: failed to upsert episode", + "series_id", seriesID, + "season", ep.SeasonNumber, + "episode", ep.EpisodeNumber, + "error", err) + continue + } +} +``` + +Keep the existing localization block immediately after this upsert/adoption branch. Adopted episodes skip only the normal `Upsert` call; non-canonical localization still runs with `dbEp.ContentID`, which `AdoptScannerFallbackEpisode` has set to the preserved fallback `content_id`. Do not duplicate localization logic. + +- [ ] **Step 8: Run adoption tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestPersistSeasonsAndEpisodes_(AdoptsScannerFallbackByAirDate|DoesNotAdoptFallbackAcrossSeasonByAirDate)' -count=1 +``` + +Expected: pass. + +- [ ] **Step 9: Commit** + +```bash +git add internal/catalog/episode_repo.go internal/metadata/service.go internal/metadata/fallback_episode_test.go +git commit -m "fix(metadata): adopt date fallback episodes during provider refresh" +``` + +--- + +### Task 4: Synthesize Date-Named Fallback Episodes + +**Files:** +- Modify: `internal/metadata/service.go` +- Modify: `internal/metadata/fallback_episode_test.go` + +- [ ] **Step 1: Add failing Late Show fallback regression** + +Add this test: + +```go +func TestEnsureSeriesEpisodeLinks_SynthesizesDateNamedEpisodesAfterLatestKnownEpisode(t *testing.T) { + h := newFallbackTestHarness() + ctx := context.Background() + + seriesID := "series-late-show-date-fallback" + h.itemRepo.Upsert(ctx, &models.MediaItem{ + ContentID: seriesID, + Title: "The Late Show with Stephen Colbert", + Type: "series", + Status: "matched", + TvdbID: "289574", + Studios: []string{}, + Networks: []string{}, + Countries: []string{}, + Genres: []string{}, + }) + h.seasonRepo.Upsert(ctx, &models.Season{ + ContentID: "season-11", + SeriesID: seriesID, + SeasonNumber: 11, + Title: "Season 11", + MetadataSource: "provider", + }) + h.episodeRepo.Upsert(ctx, &models.Episode{ + ContentID: "season11-episode102", + SeriesID: seriesID, + SeasonID: "season-11", + SeasonNumber: 11, + EpisodeNumber: 102, + Title: "Anderson Cooper, Patton Oswalt", + AirDate: mustDate(t, "2026-04-16"), + MetadataSource: "provider", + }) + + files := []*models.MediaFile{ + { + ID: 201, + MediaFolderID: 10, + FilePath: "/media/tv/The Late Show with Stephen Colbert (2015) {tvdb-289574}/Season 11/The Late Show with Stephen Colbert (2015) - 2026-04-20 - Don Cheadle Jake Tapper [WEBDL-1080p 8-bit h264 AAC 2.0]-JOAN.mkv", + }, + { + ID: 202, + MediaFolderID: 10, + FilePath: "/media/tv/The Late Show with Stephen Colbert (2015) {tvdb-289574}/Season 11/The Late Show with Stephen Colbert (2015) - 2026-04-21 - Neil deGrasse Tyson RAYE John Kerry [WEBDL-1080p 8-bit h264 EAC3 2.0]-JOAN.mkv", + }, + } + for _, file := range files { + h.fileRepo.addFile(file) + h.fileRepo.contentIDs[file.ID] = seriesID + } + + if err := h.service.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil { + t.Fatalf("ensureSeriesEpisodeLinks failed: %v", err) + } + + ep103, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 11, 103) + if err != nil { + t.Fatalf("S11E103 fallback not created: %v", err) + } + if ep103.MetadataSource != "scanner_fallback" { + t.Fatalf("S11E103 MetadataSource = %q, want scanner_fallback", ep103.MetadataSource) + } + if ep103.AirDate == nil || ep103.AirDate.Format("2006-01-02") != "2026-04-20" { + t.Fatalf("S11E103 AirDate = %v, want 2026-04-20", ep103.AirDate) + } + if got := h.fileRepo.episodeLinks[201]; got != ep103.ContentID { + t.Fatalf("file 201 episode link = %q, want %q", got, ep103.ContentID) + } + + ep104, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 11, 104) + if err != nil { + t.Fatalf("S11E104 fallback not created: %v", err) + } + if ep104.AirDate == nil || ep104.AirDate.Format("2006-01-02") != "2026-04-21" { + t.Fatalf("S11E104 AirDate = %v, want 2026-04-21", ep104.AirDate) + } + if got := h.fileRepo.episodeLinks[202]; got != ep104.ContentID { + t.Fatalf("file 202 episode link = %q, want %q", got, ep104.ContentID) + } + + item, err := h.itemRepo.GetByID(ctx, seriesID) + if err != nil { + t.Fatalf("series item not found: %v", err) + } + if !item.EpisodeMetadataIncomplete { + t.Fatal("expected EpisodeMetadataIncomplete=true after scanner fallback synthesis") + } +} +``` + +- [ ] **Step 2: Add a safety regression for seasonless date files** + +Add: + +```go +func TestEnsureSeriesEpisodeLinks_DoesNotSynthesizeDateFallbackWithoutSeasonHint(t *testing.T) { + h := newFallbackTestHarness() + ctx := context.Background() + + seriesID := "series-date-no-season" + h.itemRepo.Upsert(ctx, &models.MediaItem{ + ContentID: seriesID, + Title: "Daily Show", + Type: "series", + Status: "matched", + Studios: []string{}, + Networks: []string{}, + Countries: []string{}, + Genres: []string{}, + }) + file := &models.MediaFile{ + ID: 220, + MediaFolderID: 10, + FilePath: "/media/tv/Daily Show/Daily Show - 2026-04-24.mkv", + } + h.fileRepo.addFile(file) + h.fileRepo.contentIDs[file.ID] = seriesID + + if err := h.service.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil { + t.Fatalf("ensureSeriesEpisodeLinks failed: %v", err) + } + if got := h.fileRepo.episodeLinks[file.ID]; got != "" { + t.Fatalf("unexpected episode link = %q", got) + } + if episodes := h.episodeRepo.listBySeries(seriesID); len(episodes) != 0 { + t.Fatalf("synthesized %d episodes without explicit season hint", len(episodes)) + } +} +``` + +- [ ] **Step 3: Run the failing fallback tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestEnsureSeriesEpisodeLinks_(SynthesizesDateNamedEpisodesAfterLatestKnownEpisode|DoesNotSynthesizeDateFallbackWithoutSeasonHint)' -count=1 +``` + +Expected before implementation: the synthesis test fails because date-only files are skipped. + +- [ ] **Step 4: Add date fallback helper types** + +In `internal/metadata/service.go`, add helper structs near `episodeLinkHint`: + +```go +type dateFallbackCandidate struct { + file *models.MediaFile + hint episodeLinkHint + airDate time.Time + airDateID string +} + +type dateFallbackPlan struct { + file *models.MediaFile + seasonNum int + episodeNum int + airDate time.Time +} +``` + +- [ ] **Step 5: Add date fallback synthesis entrypoint** + +Add `synthesizeDateFallbackEpisodes` to `internal/metadata/service.go`. The method should: + +- list unlinked series files +- parse `episodeLinkHint` +- keep only hints where `airDate != ""`, `seasonNum > 0`, and `episodeNum == 0` +- group by season +- load all existing episodes for each season using `ListBySeriesAndSeasonUnscoped` +- skip any date that already has an episode in that same season +- infer provisional episode numbers with append/gap-safe logic +- create missing season rows if needed using the same season creation pattern as numeric fallback +- create `scanner_fallback` episode rows with `AirDate` +- call `linkSeriesFilesToEpisodesWithOptions(ctx, seriesID, true)` after inserts + +Use this inference policy: + +```text +For each explicit season: +1. Sort existing episodes by air_date, then episode_number. +2. Sort missing local dates ascending. +3. If the season has no existing episodes, assign missing dates episode numbers 1..N. +4. If a missing date is after the latest existing dated episode, assign latest episode_number + ordinal_after_latest. +5. If a missing date is between two existing dated provider episodes, assign only when every local missing date in that date range exactly fills the numeric gap between those provider episode numbers. +6. Skip leading dates before the first existing dated episode. +7. Skip season 0. +8. Never overwrite an existing episode row. +``` + +- [ ] **Step 6: Call date fallback from ensure flow** + +In `ensureSeriesEpisodeLinksCore`, after the existing `linkSeriesFilesToEpisodesWithOptions` call for the non-numeric path, call date fallback synthesis and then refresh metadata state: + +```go +if !needsSynthesis { + if err := s.linkSeriesFilesToEpisodesWithOptions(ctx, seriesID, item.EpisodeMetadataIncomplete); err != nil { + return err + } + if err := s.synthesizeDateFallbackEpisodes(ctx, seriesID); err != nil { + return err + } + s.refreshSeriesEpisodeMetadataState(ctx, seriesID, time.Now()) + return nil +} +``` + +Also call `s.synthesizeDateFallbackEpisodes(ctx, seriesID)` inside `synthesizeFallbackSeriesStructure` after the existing numeric fallback loop and before the final link call. This keeps explicit numeric and date fallback paths consistent. + +- [ ] **Step 7: Keep fallback titles simple and stable** + +Use `fallbackEpisodeTitle(episodeNum)` for synthesized date fallback rows in this task. Do not parse guest names from filenames in this implementation. Provider refresh will replace fallback titles, and avoiding title parsing keeps the matching fix focused and deterministic. + +- [ ] **Step 8: Run fallback tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestEnsureSeriesEpisodeLinks_(SynthesizesDateNamedEpisodesAfterLatestKnownEpisode|DoesNotSynthesizeDateFallbackWithoutSeasonHint|SkipsMissingAirDateMatch|UsesSeasonHintForAirDateAmbiguity)' -count=1 +``` + +Expected: pass. + +- [ ] **Step 9: Commit** + +```bash +git add internal/metadata/service.go internal/metadata/fallback_episode_test.go +git commit -m "fix(metadata): synthesize fallback episodes for date-named files" +``` + +--- + +### Task 5: Full Regression Sweep And Static Checks + +**Files:** +- No new files. +- Verify changes from prior tasks. + +- [ ] **Step 1: Run focused packages** + +Run: + +```bash +go test ./internal/naming ./internal/catalog ./internal/metadata -count=1 +``` + +Expected: pass. + +- [ ] **Step 2: Run scanner package to confirm no scanner regression** + +Run: + +```bash +go test ./internal/scanner -count=1 +``` + +Expected: pass. This plan intentionally avoids scanner persistence changes; this command guards the shared naming/parser usage. + +- [ ] **Step 3: Run formatting** + +Run: + +```bash +gofmt -w internal/naming/filename_test.go internal/catalog/episode_repo.go internal/metadata/service.go internal/metadata/fallback_episode_test.go +``` + +Expected: no output. + +- [ ] **Step 4: Re-run focused packages after formatting** + +Run: + +```bash +go test ./internal/naming ./internal/catalog ./internal/metadata ./internal/scanner -count=1 +``` + +Expected: pass. + +- [ ] **Step 5: Optional full backend test** + +Run when time allows: + +```bash +go test ./internal/... -count=1 +``` + +Expected: pass. If unrelated packages fail, capture the failing package and error in the final handoff instead of hiding it. + +- [ ] **Step 6: Commit verification-only cleanup if formatting changed files** + +If `gofmt` changed files not already committed: + +```bash +git add internal/naming/filename_test.go internal/catalog/episode_repo.go internal/metadata/service.go internal/metadata/fallback_episode_test.go +git commit -m "chore(metadata): format date episode fallback changes" +``` + +If no files changed, do not create an empty commit. + +--- + +### Task 6: Dev Runtime Verification + +**Files:** +- No code files. +- Use the deployed dev server and database after code is merged/deployed. + +- [ ] **Step 1: Confirm pre-deploy symptom on dev** + +Run: + +```bash +ssh root@100.86.116.20 'docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c "SELECT count(*) AS unlinked_date_named FROM media_files WHERE content_id = '\''120822227783385091'\'' AND episode_id IS NULL AND file_path LIKE '\''%/Season 11/%'\'' AND file_path ~ '\'' - [0-9]{4}-[0-9]{2}-[0-9]{2} - '\'';"' +``` + +Expected before deploy: greater than zero. + +- [ ] **Step 2: Deploy using the repo's normal dev workflow** + +Use the existing Silo dev deploy command for this repository. If no deploy command is available in the current branch, build locally first: + +```bash +make build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Trigger metadata processing for the affected series** + +Use the admin UI refresh action or the existing admin refresh endpoint for item `120822227783385091`. If an auth token is not available to the agent, open the item in the admin UI and trigger "Refresh metadata" manually, then continue verification. + +- [ ] **Step 4: Verify fallback rows and links** + +Run: + +```bash +ssh root@100.86.116.20 'docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c "SELECT e.air_date, e.season_number, e.episode_number, e.title, e.metadata_source, mf.id AS file_id FROM episodes e JOIN media_files mf ON mf.episode_id = e.content_id WHERE e.series_id = '\''120822227783385091'\'' AND e.season_number = 11 AND e.air_date >= DATE '\''2026-04-20'\'' ORDER BY e.air_date;"' +``` + +Expected after refresh: rows exist for the date-named files after `2026-04-16`, `metadata_source` is `scanner_fallback` until provider metadata catches up, and each row has a `file_id`. + +- [ ] **Step 5: Verify catalog sort date improves** + +Run: + +```bash +ssh root@100.86.116.20 'docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c "SELECT content_id, last_air_date, last_air_date_at, episode_metadata_incomplete FROM media_items WHERE content_id = '\''120822227783385091'\'';"' +``` + +Expected: `last_air_date_at` reflects the newest fallback episode air date that is not in the future, and `episode_metadata_incomplete` is true while fallback rows remain. + +--- + +## Implementation Notes + +- `scanner_fallback` rows are intentionally provisional. The real stable identity remains the episode `content_id`; provider adoption preserves that `content_id` while updating the natural key when exact `air_date` provider metadata appears. +- Do not synthesize season `0` date fallback rows. +- Do not infer episode numbers from day-of-year, weekdays, show schedules, or filenames. +- Do not change plugin repos for this fix. +- Do not add a `media_files.air_date` column in this implementation. If later performance profiling shows repeated filename parsing is expensive, add a separate migration-backed optimization. + +## Self-Review + +- Spec coverage: parser pinning, air-date season disambiguation, date fallback synthesis, same-season provider adoption, cross-season adoption refusal, refresh-debt visibility, and dev verification are covered. +- Placeholder scan: no TBD/TODO placeholders remain. +- Type consistency: new metadata interface methods are named consistently across `service.go`, `episode_repo.go`, and fake repos. +- Scope check: one server-side feature, no plugin or client changes required. diff --git a/docs/superpowers/plans/2026-05-24-jellyfin-autoscan-scan-compat.md b/docs/superpowers/plans/2026-05-24-jellyfin-autoscan-scan-compat.md new file mode 100644 index 00000000..0cdc2380 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-jellyfin-autoscan-scan-compat.md @@ -0,0 +1,1365 @@ +# Jellyfin Autoscan Scan Compatibility 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:** Make Autoscan's stock Jellyfin target work against Silo by supporting Jellyfin library discovery and scan notification routes backed by Silo's existing scan queue. + +**Architecture:** Extract native scan path resolution into a shared `internal/scantrigger` package, then use it from both the native `/api/v1/scan` handler and a new Jellyfin Autoscan compatibility handler. Add narrow admin API-key authentication for Autoscan routes without broadening normal Jellyfin playback/browse auth. + +**Tech Stack:** Go, chi, existing Silo catalog/scanner/scanqueue/auth packages, focused Go unit tests. + +--- + +## File Structure + +- Create: `internal/scantrigger/scantrigger.go` + - Owns shared library/path scan target resolution and queue enqueue helpers. +- Create: `internal/scantrigger/scantrigger_test.go` + - Pins root, subtree, file, disabled library, missing path, and all-or-fail validation behavior. +- Modify: `internal/api/handlers/libraries.go` + - Replaces local resolver/path helpers with `scantrigger`. +- Create: `internal/jellycompat/auth_api_key.go` + - Validates Silo admin API keys from Jellyfin token locations and provides session-or-admin route middleware. +- Create: `internal/jellycompat/handlers_autoscan.go` + - Handles Autoscan-facing `GET /Library/VirtualFolders` and `POST /Library/Media/Updated`. +- Create: `internal/jellycompat/handlers_autoscan_test.go` + - Tests admin API-key auth, library locations, scan enqueue, and all-or-fail behavior. +- Modify: `internal/jellycompat/router.go` + - Registers Autoscan compatibility routes and keeps existing session behavior for normal clients. +- Modify: `internal/jellycompat/server.go` + - Adds dependencies for API-key validation and scan enqueueing. +- Modify: `cmd/silo/main.go` + - Wires API-key repository, user repository, and scan queue into Jellyfin compatibility dependencies. +- Modify: `docs/scan-api.md` + - Documents using Autoscan's stock Jellyfin target with a Silo admin API key. + +--- + +### Task 1: Extract Shared Scan Target Resolution + +**Files:** +- Create: `internal/scantrigger/scantrigger.go` +- Create: `internal/scantrigger/scantrigger_test.go` + +- [ ] **Step 1: Write failing resolver tests** + +Create `internal/scantrigger/scantrigger_test.go`: + +```go +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) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +go test ./internal/scantrigger +``` + +Expected: fail because `internal/scantrigger` does not exist yet. + +- [ ] **Step 3: Add shared resolver implementation** + +Create `internal/scantrigger/scantrigger.go`: + +```go +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)) +} +``` + +- [ ] **Step 4: Run resolver tests** + +Run: + +```bash +go test ./internal/scantrigger +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/scantrigger/scantrigger.go internal/scantrigger/scantrigger_test.go +git commit -m "refactor(scan): extract scan trigger resolver" +``` + +--- + +### Task 2: Use Shared Resolver in Native Scan API + +**Files:** +- Modify: `internal/api/handlers/libraries.go` + +- [ ] **Step 1: Update imports** + +In `internal/api/handlers/libraries.go`, add: + +```go + "github.com/Silo-Server/silo-server/internal/scantrigger" +``` + +Remove now-unused imports after the refactor: + +```go + "os" + "path/filepath" +``` + +Keep `strings` if other functions still use it in the file. + +- [ ] **Step 2: Replace local scan mode constants and resolver types** + +Remove the local `scanMode`, `resolvedScanTarget`, and `scanRequestError` declarations. Use `scantrigger.Target` and `scantrigger.RequestError` instead. + +Update the start of `HandleScan` to: + +```go + target, err := scantrigger.NewResolver(h.folderRepo).Resolve(r.Context(), scantrigger.Request{ + LibraryID: req.LibraryID, + Path: req.Path, + }) + if err != nil { + var reqErr *scantrigger.RequestError + if errors.As(err, &reqErr) { + writeError(w, reqErr.Status, reqErr.Code, reqErr.Message) + return + } + slog.Error("resolving scan target", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve scan target") + return + } +``` + +- [ ] **Step 3: Update enqueue and direct-scan dispatch** + +In `HandleScan`, replace `target.folder`, `target.mode`, and `target.path` with exported fields: + +```go + 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) + 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 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) + } + } else { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available") + return + } + + writeJSON(w, http.StatusAccepted, scanResponse{ + Status: "accepted", + Mode: target.Mode, + LibraryID: target.LibraryID, + }) +``` + +- [ ] **Step 4: Update `recordAcceptedScan` signature** + +Change: + +```go +func (h *LibraryHandler) recordAcceptedScan(scanID string, target *resolvedScanTarget) { +``` + +to: + +```go +func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.Target) { +``` + +Inside it, use: + +```go +if h == nil || h.ScanRegistry == nil || target == nil || target.Folder == nil { + return +} +h.ScanRegistry.Upsert(evt.ScanRun{ + ID: scanID, + LibraryID: target.LibraryID, + Mode: target.Mode, + Path: target.Path, + Trigger: target.Trigger, + Status: "accepted", +}) +``` + +Keep existing fields that are already in the local `evt.ScanRun` literal; only update the target field names. + +- [ ] **Step 5: Delete old resolver helpers** + +Remove these functions from `internal/api/handlers/libraries.go` after the native handler compiles against `scantrigger`: + +```go +resolveScanTarget +longestMatchingRoot +matchFolderForPath +classifyScanPath +pathWithinRoot +``` + +- [ ] **Step 6: Run targeted tests** + +Run: + +```bash +go test ./internal/api/handlers ./internal/scantrigger +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/api/handlers/libraries.go internal/scantrigger/scantrigger.go internal/scantrigger/scantrigger_test.go +git commit -m "refactor(api): share scan target resolution" +``` + +--- + +### Task 3: Add Jellyfin Admin API-Key Auth for Autoscan + +**Files:** +- Modify: `internal/jellycompat/server.go` +- Create: `internal/jellycompat/auth_api_key.go` +- Modify: `cmd/silo/main.go` + +- [ ] **Step 1: Add dependency fields** + +In `internal/jellycompat/server.go`, extend `Dependencies`: + +```go + // Autoscan / admin compatibility support. + APIKeyValidator apiKeyValidator + APIKeyUserLoader apiKeyUserLoader + ScanQueue scantrigger.Queuer +``` + +Add the import: + +```go + "github.com/Silo-Server/silo-server/internal/scantrigger" +``` + +- [ ] **Step 2: Add failing auth tests** + +Add tests to `internal/jellycompat/auth_test.go`: + +```go +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()) + } +} +``` + +Add supporting fakes in the same file: + +```go +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 +} +``` + +Update imports in `auth_test.go`: + +```go + "context" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +``` + +- [ ] **Step 3: Run auth tests to verify failure** + +Run: + +```bash +go test ./internal/jellycompat -run 'TestRequireAdminAPIKey' +``` + +Expected: fail because `NewAdminAPIKeyAuthenticator` and `AdminAPIKeyFromContext` do not exist. + +- [ ] **Step 4: Add API-key auth helper** + +Create `internal/jellycompat/auth_api_key.go`: + +```go +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" +} +``` + +- [ ] **Step 5: Wire dependencies in `cmd/silo/main.go`** + +Inside the compat DB wiring block, after `userRepo := auth.NewUserRepository(deps.DB)`, add: + +```go + compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB) + compatDeps.APIKeyUserLoader = userRepo + compatDeps.ScanQueue = deps.LibraryScanQueue +``` + +- [ ] **Step 6: Run auth tests** + +Run: + +```bash +go test ./internal/jellycompat -run 'TestRequireAdminAPIKey' +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/jellycompat/server.go internal/jellycompat/auth_api_key.go internal/jellycompat/auth_test.go cmd/silo/main.go +git commit -m "feat(jellycompat): accept admin api keys for autoscan" +``` + +--- + +### Task 4: Add Jellyfin Autoscan Handlers and Routes + +**Files:** +- Create: `internal/jellycompat/handlers_autoscan.go` +- Create: `internal/jellycompat/handlers_autoscan_test.go` +- Modify: `internal/jellycompat/router.go` + +- [ ] **Step 1: Write failing handler tests** + +Create `internal/jellycompat/handlers_autoscan_test.go`: + +```go +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) + } +} +``` + +- [ ] **Step 2: Run handler tests to verify failure** + +Run: + +```bash +go test ./internal/jellycompat -run 'TestAutoscan' +``` + +Expected: fail because `NewAutoscanHandler` does not exist. + +- [ ] **Step 3: Add Autoscan handler** + +Create `internal/jellycompat/handlers_autoscan.go`: + +```go +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 !AdminAPIKeyFromContext(r.Context()) { + if h.fallback != nil { + h.fallback.HandleVirtualFolders(w, r) + return + } + writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") + return + } + if h == nil || 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)) +} +``` + +- [ ] **Step 4: Register routes** + +In `internal/jellycompat/router.go`, after `itemsHandler` is created, add: + +```go + 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 + } +``` + +Inside the existing authenticated group, replace: + +```go + r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders) +``` + +with: + +```go + if !autoscanVirtualFoldersRegistered { + r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders) + } +``` + +- [ ] **Step 5: Run handler tests** + +Run: + +```bash +go test ./internal/jellycompat -run 'TestAutoscan' +``` + +Expected: PASS. + +- [ ] **Step 6: Run broader compat tests** + +Run: + +```bash +go test ./internal/jellycompat +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/jellycompat/handlers_autoscan.go internal/jellycompat/handlers_autoscan_test.go internal/jellycompat/router.go internal/jellycompat/server.go +git commit -m "feat(jellycompat): add autoscan media update route" +``` + +--- + +### Task 5: Update Documentation and Full Verification + +**Files:** +- Modify: `docs/scan-api.md` + +- [ ] **Step 1: Update Autoscan documentation** + +Replace the opening paragraph under `## Integration with Autoscan` in `docs/scan-api.md` with: + +```markdown +[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. + +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. +``` + +Keep the custom script section below it, but rename the heading: + +```markdown +### Alternative: Autoscan Custom Script Target +``` + +- [ ] **Step 2: Run final Go tests** + +Run: + +```bash +go test ./internal/scantrigger ./internal/api/handlers ./internal/jellycompat ./cmd/silo +``` + +Expected: PASS. + +- [ ] **Step 3: Run formatting** + +Run: + +```bash +gofmt -w internal/scantrigger/scantrigger.go internal/scantrigger/scantrigger_test.go internal/api/handlers/libraries.go internal/jellycompat/auth_api_key.go internal/jellycompat/auth_test.go internal/jellycompat/handlers_autoscan.go internal/jellycompat/handlers_autoscan_test.go internal/jellycompat/router.go internal/jellycompat/server.go cmd/silo/main.go +``` + +Then rerun: + +```bash +go test ./internal/scantrigger ./internal/api/handlers ./internal/jellycompat ./cmd/silo +``` + +Expected: PASS. + +- [ ] **Step 4: Inspect diff** + +Run: + +```bash +git diff --stat +git diff -- internal/scantrigger internal/api/handlers/libraries.go internal/jellycompat cmd/silo/main.go docs/scan-api.md +``` + +Expected: only the planned scan resolver, Jellyfin Autoscan compatibility, and docs changes are present. + +- [ ] **Step 5: Commit docs and final adjustments** + +```bash +git add docs/scan-api.md internal/scantrigger internal/api/handlers/libraries.go internal/jellycompat cmd/silo/main.go +git commit -m "docs: document jellyfin autoscan setup" +``` + +--- + +## Plan Self-Review + +- Spec coverage: + - Jellyfin only: Task 4 registers `/Library/Media/Updated` without Emby aliases. + - Admin Silo API keys as Jellyfin token: Task 3. + - Real library locations: Task 4. + - Existing scan behavior reused: Tasks 1 and 2. + - All-or-fail multi-update validation: Tasks 1 and 4. + - Tests and docs: Tasks 1, 3, 4, and 5. +- Marker scan: no incomplete-work markers are intentionally left in the tasks. +- Type consistency: + - `scantrigger.Request`, `scantrigger.Target`, and `scantrigger.Queuer` are introduced before use. + - `AdminAPIKeyFromContext`, `NewAdminAPIKeyAuthenticator`, and route middleware are introduced before handler routing. diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index ed908a2c..a7ca5f6b 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -29,6 +29,7 @@ type RequestService interface { Decline(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error) Cancel(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error) Retry(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error) + GetFeatureStatus(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.FeatureStatus, error) GetSettings(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.Settings, error) UpdateSettings(ctx context.Context, viewer mediarequests.Viewer, settings mediarequests.Settings) (mediarequests.Settings, error) GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error) @@ -362,6 +363,19 @@ func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, req) } +func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + status, err := h.service.GetFeatureStatus(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, status) +} + func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) { viewer, ok := requestViewer(w, r, false) if !ok { diff --git a/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go index c84934d4..2f0da73e 100644 --- a/internal/api/handlers/requests_test.go +++ b/internal/api/handlers/requests_test.go @@ -103,6 +103,10 @@ func (f *fakeRequestService) Retry(context.Context, mediarequests.Viewer, string return nil, nil } +func (f *fakeRequestService) GetFeatureStatus(context.Context, mediarequests.Viewer) (mediarequests.FeatureStatus, error) { + return mediarequests.FeatureStatus{}, nil +} + func (f *fakeRequestService) GetSettings(context.Context, mediarequests.Viewer) (mediarequests.Settings, error) { return mediarequests.Settings{}, nil } diff --git a/internal/api/router.go b/internal/api/router.go index 20983d51..26a6169a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1404,6 +1404,7 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/discover/browse/genre/{slug}", requestHandler.HandleBrowseGenre) r.Get("/discover/{section}", requestHandler.HandleDiscoverSection) r.Get("/detail/{media_type}/{tmdb_id}", requestHandler.HandleGetDetail) + r.Get("/status", requestHandler.HandleGetStatus) r.Post("/", requestHandler.HandleCreate) r.Get("/mine", requestHandler.HandleListMine) r.Get("/{id}", requestHandler.HandleGet) diff --git a/internal/requests/discover_brand.go b/internal/requests/discover_brand.go index cf95ff09..17ca01ea 100644 --- a/internal/requests/discover_brand.go +++ b/internal/requests/discover_brand.go @@ -22,10 +22,13 @@ type DiscoverBrandCard struct { } // ListStudios returns the bundled studios with their curated logo URLs. -func (s *Service) ListStudios(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { - if s == nil { +func (s *Service) ListStudios(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil || s.store == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } out := make([]DiscoverBrandCard, 0, len(BundledStudios)) for _, studio := range BundledStudios { out = append(out, DiscoverBrandCard{ @@ -39,10 +42,13 @@ func (s *Service) ListStudios(_ context.Context, _ Viewer) ([]DiscoverBrandCard, } // ListNetworks returns the bundled TV networks with their curated logo URLs. -func (s *Service) ListNetworks(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { - if s == nil { +func (s *Service) ListNetworks(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil || s.store == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } out := make([]DiscoverBrandCard, 0, len(BundledNetworks)) for _, network := range BundledNetworks { out = append(out, DiscoverBrandCard{ @@ -58,10 +64,13 @@ func (s *Service) ListNetworks(_ context.Context, _ Viewer) ([]DiscoverBrandCard // ListGenres returns the bundled genres. Each card carries gradient hints // (no logo URL) and a SeriesSupported flag for the browse page to decide // whether to show the Series tab. -func (s *Service) ListGenres(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { - if s == nil { +func (s *Service) ListGenres(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil || s.store == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } out := make([]DiscoverBrandCard, 0, len(BundledGenres)) for _, genre := range BundledGenres { out = append(out, DiscoverBrandCard{ @@ -113,9 +122,12 @@ const defaultBrowseSort = "popularity" // BrowseStudio returns a page of movies from a bundled studio, enriched with // Silo availability and request state. func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) { - if s == nil || s.tmdb == nil { + if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } studio, ok := FindStudioBySlug(strings.TrimSpace(slug)) if !ok { return nil, ErrNotFound @@ -151,9 +163,12 @@ func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort st // BrowseNetwork returns a page of series from a bundled TV network. func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) { - if s == nil || s.tmdb == nil { + if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } network, ok := FindNetworkBySlug(strings.TrimSpace(slug)) if !ok { return nil, ErrNotFound @@ -189,9 +204,12 @@ func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort s // BrowseGenre returns a page of movies or series from a bundled genre. func (s *Service) BrowseGenre(ctx context.Context, viewer Viewer, slug string, rawMediaType MediaType, sort string, page int) (*DiscoverBrowseResponse, error) { - if s == nil || s.tmdb == nil { + if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } genre, ok := FindGenreBySlug(strings.TrimSpace(slug)) if !ok { return nil, ErrNotFound diff --git a/internal/requests/service.go b/internal/requests/service.go index b6456210..dc265871 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -95,6 +95,9 @@ func (s *Service) Search(ctx context.Context, viewer Viewer, query string, media if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } mediaType, err := normalizeSearchMediaType(mediaType) if err != nil { return nil, err @@ -114,6 +117,9 @@ func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, p if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } section = strings.TrimSpace(section) if _, ok := discoverySectionTitles[section]; !ok { return nil, fmt.Errorf("%w: invalid discovery section", ErrInvalidInput) @@ -137,6 +143,12 @@ func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, p } func (s *Service) DiscoverAll(ctx context.Context, viewer Viewer) ([]DiscoverySection, error) { + if s == nil || s.store == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } sections := make([]DiscoverySection, len(discoverySectionOrder)) group, gctx := errgroup.WithContext(ctx) group.SetLimit(externalIDHydrationConcurrency) @@ -164,6 +176,9 @@ func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaT if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } mediaType, err := normalizeMediaType(mediaType) if err != nil { return nil, err @@ -256,6 +271,12 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create if err := validateViewer(viewer); err != nil { return nil, err } + if s == nil || s.store == nil { + return nil, fmt.Errorf("request service is not configured") + } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } normalized, err := normalizeCreateInput(input) if err != nil { return nil, err @@ -342,6 +363,9 @@ func (s *Service) ListMine(ctx context.Context, viewer Viewer, filter ListFilter if viewer.UserID == 0 { return nil, ErrForbidden } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } return s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter)) } @@ -353,6 +377,9 @@ func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilte } func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Request, error) { + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) if err != nil { return nil, err @@ -412,6 +439,11 @@ func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) if viewer.UserID == 0 { return nil, ErrForbidden } + if !viewer.IsAdmin { + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } + } req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) if err != nil { return nil, err @@ -506,6 +538,25 @@ func (s *Service) GetSettings(ctx context.Context, viewer Viewer) (Settings, err return s.store.GetSettings(ctx) } +func (s *Service) GetFeatureStatus(ctx context.Context, _ Viewer) (FeatureStatus, error) { + settings, err := s.store.GetSettings(ctx) + if err != nil { + return FeatureStatus{}, err + } + return FeatureStatus{RequestsEnabled: settings.RequestsEnabled}, nil +} + +func (s *Service) ensureRequestsEnabled(ctx context.Context) error { + settings, err := s.store.GetSettings(ctx) + if err != nil { + return err + } + if !settings.RequestsEnabled { + return ErrRequestsDisabled + } + return nil +} + func (s *Service) UpdateSettings(ctx context.Context, viewer Viewer, settings Settings) (Settings, error) { if !viewer.IsAdmin { return Settings{}, ErrForbidden diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 4f0689de..b25af25f 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -37,10 +37,10 @@ func TestCreateRequestQuotaExceeded(t *testing.T) { func TestNormalizeListFilterCapsLimit(t *testing.T) { cases := []struct { - name string - in ListFilter - wantLim int - wantOff int + name string + in ListFilter + wantLim int + wantOff int }{ {"zero defaults", ListFilter{}, defaultRequestListLimit, 0}, {"negative defaults", ListFilter{Limit: -10, Offset: -5}, defaultRequestListLimit, 0}, @@ -597,6 +597,100 @@ func TestSearchWithoutMediaTypeSearchesMoviesAndSeries(t *testing.T) { } } +func TestDisabledRequestsBlockUserSurfaces(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = false + store.requests["req-1"] = &Request{ + ID: "req-1", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusPending, + Outcome: OutcomeActive, + RequestedByUserID: 1, + } + tmdbClient := &fakeTMDBClient{ + page: &tmdb.MediaPage{Results: []tmdb.MediaResult{{ID: 550, MediaType: "movie", Title: "Fight Club"}}}, + detail: &tmdb.MediaDetail{ID: 550, MediaType: "movie", Title: "Fight Club"}, + discoverPage: &tmdb.MediaPage{Results: []tmdb.MediaResult{{ID: 550, MediaType: "movie", Title: "Fight Club"}}}, + } + service := newTestServiceWithTMDB(store, tmdbClient) + viewer := testViewer(1) + + cases := []struct { + name string + call func() error + }{ + {"search", func() error { + _, err := service.Search(context.Background(), viewer, "fight", MediaTypeMovie, 1) + return err + }}, + {"discover all", func() error { + _, err := service.DiscoverAll(context.Background(), viewer) + return err + }}, + {"discover section", func() error { + _, err := service.Discover(context.Background(), viewer, "popular_movies", 1) + return err + }}, + {"detail", func() error { + _, err := service.GetDetail(context.Background(), viewer, MediaTypeMovie, 550) + return err + }}, + {"create", func() error { + _, err := service.CreateRequest(context.Background(), viewer, CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + return err + }}, + {"mine", func() error { + _, err := service.ListMine(context.Background(), viewer, ListFilter{}) + return err + }}, + {"get", func() error { + _, err := service.GetRequest(context.Background(), viewer, "req-1") + return err + }}, + {"cancel", func() error { + _, err := service.Cancel(context.Background(), viewer, "req-1", "") + return err + }}, + {"studios", func() error { + _, err := service.ListStudios(context.Background(), viewer) + return err + }}, + {"networks", func() error { + _, err := service.ListNetworks(context.Background(), viewer) + return err + }}, + {"genres", func() error { + _, err := service.ListGenres(context.Background(), viewer) + return err + }}, + {"browse studio", func() error { + _, err := service.BrowseStudio(context.Background(), viewer, "marvel-studios", "popularity", 1) + return err + }}, + {"browse network", func() error { + _, err := service.BrowseNetwork(context.Background(), viewer, "netflix", "popularity", 1) + return err + }}, + {"browse genre", func() error { + _, err := service.BrowseGenre(context.Background(), viewer, "action", MediaTypeMovie, "popularity", 1) + return err + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := tc.call(); !errors.Is(err, ErrRequestsDisabled) { + t.Fatalf("err = %v, want ErrRequestsDisabled", err) + } + }) + } +} + func TestReconcileRequestsCompletesFromCatalogPresence(t *testing.T) { store := newFakeStore() store.candidates = []*Request{{ @@ -875,6 +969,7 @@ type fakeStore struct { func newFakeStore() *fakeStore { return &fakeStore{ settings: Settings{ + RequestsEnabled: true, GlobalMaxRequests: 5, GlobalWindowDays: 7, }, diff --git a/internal/requests/types.go b/internal/requests/types.go index b7331821..b4c5b6c0 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -70,6 +70,10 @@ type Settings struct { UpdatedAt time.Time `json:"updated_at"` } +type FeatureStatus struct { + RequestsEnabled bool `json:"requests_enabled"` +} + type UserLimit struct { UserID int `json:"user_id"` LimitMode LimitMode `json:"limit_mode"` diff --git a/web/src/App.tsx b/web/src/App.tsx index d83574a9..2d85ddc3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -62,6 +62,7 @@ import Calendar from "@/pages/Calendar"; import Signup from "@/pages/Signup"; import TasteSeed from "@/pages/TasteSeed"; import { useFavorites } from "@/hooks/queries/favorites"; +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; import { isTasteSeedDismissed } from "@/lib/tasteSeed"; import SettingsLayout from "@/pages/SettingsLayout"; import AppearanceSettings from "@/pages/settings/AppearanceSettings"; @@ -177,6 +178,20 @@ function RequirePrimaryOrAdmin({ children }: { children: ReactNode }) { return <>{children}; } +function RequireRequestsEnabled({ children }: { children: ReactNode }) { + const status = useRequestFeatureStatus(); + if (status.isLoading) { + return ( +
+ Loading request availability + Loading... +
+ ); + } + if (status.data?.requests_enabled !== true) return ; + return <>{children}; +} + /** * Redirects new profiles (no favorites yet, no skip flag) to the taste-seed * onboarding screen the first time they land on Home. Only checks on Home so @@ -449,19 +464,45 @@ function AppRoutes() { path="/collections/:id" element={} /> - } /> - } /> + + + + } + /> + + + + } + /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } /> } /> { const installations = pluginSettings?.installations ?? []; const links: { id: string; basePath: string; label: string; pluginId: string }[] = []; @@ -495,23 +498,25 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar Recommendations -
  • - - {isActive("/requests") && ( - - )} - - Requests - -
  • + {showRequestsNav && ( +
  • + + {isActive("/requests") && ( + + )} + + Requests + +
  • + )}
  • ["requests", "status"] as const, discovery: () => ["requests", "discovery"] as const, discoverySection: (section: string, page: number) => ["requests", "discovery", section, page] as const, diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index dc85e2ba..1566ee08 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -13,6 +13,7 @@ import type { MediaRequestsListResponse, RequestDiscoveryResponse, RequestDiscoverySection, + RequestFeatureStatus, RequestIntegration, RequestIntegrationOptions, RequestIntegrationsResponse, @@ -63,6 +64,14 @@ export function useRequestDiscovery() { }); } +export function useRequestFeatureStatus() { + return useQuery({ + queryKey: requestKeys.status(), + queryFn: () => api("/requests/status"), + staleTime: REQUESTS_STALE_TIME, + }); +} + export function useRequestDiscoverySection(section: string, page = 1) { return useQuery({ queryKey: requestKeys.discoverySection(section, page), @@ -269,6 +278,7 @@ export function useUpdateRequestSettings() { onSuccess: () => { toast.success("Request settings saved"); queryClient.invalidateQueries({ queryKey: adminKeys.requestSettings() }); + queryClient.invalidateQueries({ queryKey: requestKeys.status() }); invalidateRequestSurfaces(queryClient); }, onError: (err) => { From a05a0d26a2d600cd897fabc28c97bf58a8e242fe Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 12:11:56 -0400 Subject: [PATCH 010/201] 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 41dbcaa828c463e8ce164fa905c16e23106c8d02 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 12:49:38 -0400 Subject: [PATCH 011/201] fix(catalog): gate search overview-only matches behind title FTS - Always apply stats CTE + CROSS JOIN so single-word queries no longer flood results with description-only hits - Require overview_rank >= 0.15 for overview-only fallback rows - Switch title gate from contiguous LIKE to title_rank > 0 so reordered-token title matches aren't demoted --- internal/catalog/item_repo.go | 102 ++++++++++++++------------ internal/catalog/item_repo_test.go | 69 ++++++++++++++--- internal/catalog/window_count_test.go | 69 +++++++++-------- 3 files changed, 149 insertions(+), 91 deletions(-) diff --git a/internal/catalog/item_repo.go b/internal/catalog/item_repo.go index c2ebcb23..9788a91c 100644 --- a/internal/catalog/item_repo.go +++ b/internal/catalog/item_repo.go @@ -35,6 +35,16 @@ func NewItemRepository(pool *pgxpool.Pool) *ItemRepository { return &ItemRepository{pool: pool} } +// overviewMatchFloor is the minimum ts_rank_cd score an overview-only match +// must achieve to be returned when no title FTS match exists in the candidate +// set. The overview tsvector is built without setweight(), so PostgreSQL's +// default D weight (0.1) applies: a single occurrence of a single-term query +// scores ~0.1. A floor of 0.15 effectively requires more than one occurrence +// (or a tightly clustered match for multi-term queries), which keeps niche +// description-only searches viable while suppressing the long tail of +// incidental one-mention hits that flooded results before. +const overviewMatchFloor = 0.15 + // itemColumns is the list of columns returned by all SELECT queries on media_items. const itemColumns = `content_id, type, title, sort_title, default_metadata_language, original_title, year, genres, content_rating, runtime, overview, tagline, @@ -773,17 +783,19 @@ func (r *ItemRepository) Search(ctx context.Context, query string, itemTypes []s // buildSearchSQL assembles the unified search query, returning the SQL string // and bound args (or empty string when the input parses to no searchable text). // -// The query always uses a single `WITH scored AS (...)` CTE that aggregates -// per-content_id ranking signals. The final SELECT off the CTE selects -// itemColumns plus COUNT(*) OVER () AS total_count, ordered by relevance and -// paged via LIMIT/OFFSET. When the parsed text spans multiple words, a strict -// title-match path is enabled: a `stats` CTE is computed off `scored`, and the -// final SELECT cross-joins it with a WHERE clause that suppresses non-matching -// rows when at least one row has a strong title match. Because the window -// count runs in the final SELECT (after the cross-joined WHERE), the total -// reflects the post-filter count automatically. +// The query uses two CTEs: `scored` aggregates per-content_id ranking signals +// (title_rank, overview_rank, phrase_rank, plus exact/contiguous/year match +// flags), and `stats` derives a single has_title_match boolean over the +// scored set. The final SELECT CROSS JOINs stats and applies a WHERE that +// keeps every row whose title FTS rank is positive, plus overview-only rows +// when no title match exists in the candidate set AND the overview rank +// clears overviewMatchFloor. This suppresses single-word body-only matches +// for queries like "obsession" without harming queries where the term truly +// only appears in descriptions (those still surface as the fallback bucket). +// COUNT(*) OVER () in the final SELECT means the returned total reflects +// the post-filter row count automatically. // -// Argument order is intentionally fixed across both paths: +// Argument order is intentionally fixed: // // $1 searchText (always) // itemType placeholders, allowed/disabled libraries, MaxContentRating @@ -791,10 +803,6 @@ func (r *ItemRepository) Search(ctx context.Context, query string, itemTypes []s // parsed.Year (or NULL) // parsed.Phrase // limit, offset -// -// The single ExactTitleHint binding is reused by both the strict-only -// contiguous_title_match LIKE filter inside the CTE and the exact_title_match -// equality predicate used for ranking. func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit, offset int, filter AccessFilter) (dataSQL, countSQL string, args []any) { parsed := parseSearchQuery(query) searchText := parsed.Text @@ -804,9 +812,6 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit, if searchText == "" { return "", "", nil } - normalizedSearchText := normalizeTitleForComparison(searchText) - strictTitleFilter := len(strings.Fields(normalizedSearchText)) > 1 - var conditions []string args = []any{searchText} argIdx := 2 @@ -873,9 +878,9 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit, whereClause := "WHERE " + strings.Join(conditions, " AND ") // Bind ExactTitleHint exactly once. The same arg index is referenced by - // both contiguous_title_match (used as a ranking signal in all paths and - // as the strict-title CROSS JOIN filter when strictTitleFilter is true) - // and exact_title_match (used as a ranking signal in all paths). + // both contiguous_title_match and exact_title_match (used as ranking + // signals in the ORDER BY). The post-CTE WHERE itself gates on title_rank + // > 0 (true title FTS match), not on contiguous_title_match. exactIdx := argIdx args = append(args, parsed.ExactTitleHint) argIdx++ @@ -957,39 +962,44 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit, // COUNT(*) OVER () runs after the GROUP BY in the scored CTE collapses // duplicates from the library JOIN, so the window count preserves the // COUNT(DISTINCT mi.content_id) semantics of the prior 2-query path. - // In strict-title mode, placing the window count in the final SELECT - // (after the cross-joined WHERE filter) means the total reflects the + // The stats CTE + CROSS JOIN below means the window count reflects the // post-filter row count automatically. // + // The stats CTE gates on title_rank > 0 (true title FTS match) rather + // than contiguous_title_match (LIKE substring), so reordered-token title + // queries like "order law" → "Law and Order" aren't wrongly demoted to + // the overview-fallback bucket. The post-CTE WHERE then keeps every title + // FTS hit, and admits overview-only rows only when no title hit exists + // anywhere in the candidate set AND the overview rank clears + // overviewMatchFloor. This suppresses single-occurrence body matches + // (which score at PostgreSQL's default D weight of 0.1) for common + // single-word queries like "obsession" that would otherwise flood + // results with description-only hits. + // // countSQL is a count-only sibling that omits LIMIT/OFFSET/ORDER BY. It is // invoked only as a fallback when the data SELECT returns an empty page // past offset 0 — COUNT(*) OVER () emits no rows in that case so total // would otherwise default to 0. - if strictTitleFilter { - statsCTE := ` - , stats AS ( - SELECT MAX(contiguous_title_match) AS has_strong_title_match - FROM scored - )` - dataSQL = scoredCTE + statsCTE + fmt.Sprintf(` - SELECT %s, COUNT(*) OVER () AS total_count + statsCTE := ` + , stats AS ( + SELECT MAX(CASE WHEN title_rank > 0 THEN 1 ELSE 0 END) AS has_title_match FROM scored - CROSS JOIN stats - WHERE COALESCE(stats.has_strong_title_match, 0) = 0 OR scored.contiguous_title_match = 1 - ORDER BY exact_title_match DESC, contiguous_title_match DESC, year_match DESC, phrase_rank DESC, title_rank DESC, overview_rank DESC, LOWER(title) ASC, content_id ASC - LIMIT $%d OFFSET $%d`, itemColumns, argIdx, argIdx+1) - countSQL = scoredCTE + statsCTE + ` - SELECT COUNT(*) FROM scored - CROSS JOIN stats - WHERE COALESCE(stats.has_strong_title_match, 0) = 0 OR scored.contiguous_title_match = 1` - } else { - dataSQL = scoredCTE + fmt.Sprintf(` - SELECT %s, COUNT(*) OVER () AS total_count - FROM scored - ORDER BY exact_title_match DESC, contiguous_title_match DESC, year_match DESC, phrase_rank DESC, title_rank DESC, overview_rank DESC, LOWER(title) ASC, content_id ASC - LIMIT $%d OFFSET $%d`, itemColumns, argIdx, argIdx+1) - countSQL = scoredCTE + `SELECT COUNT(*) FROM scored` - } + )` + // postFilter is the FROM + CROSS JOIN + WHERE shared by both dataSQL and + // countSQL. Keeping it in one string ensures the empty-page fallback count + // can never drift from the data query's filter. + postFilter := fmt.Sprintf(`FROM scored + CROSS JOIN stats + WHERE scored.title_rank > 0 + OR (COALESCE(stats.has_title_match, 0) = 0 AND scored.overview_rank >= %g)`, overviewMatchFloor) + dataSQL = scoredCTE + statsCTE + fmt.Sprintf(` + SELECT %s, COUNT(*) OVER () AS total_count + %s + ORDER BY exact_title_match DESC, contiguous_title_match DESC, year_match DESC, phrase_rank DESC, title_rank DESC, overview_rank DESC, LOWER(title) ASC, content_id ASC + LIMIT $%d OFFSET $%d`, itemColumns, postFilter, argIdx, argIdx+1) + countSQL = scoredCTE + statsCTE + fmt.Sprintf(` + SELECT COUNT(*) + %s`, postFilter) args = append(args, limit, offset) return dataSQL, countSQL, args } diff --git a/internal/catalog/item_repo_test.go b/internal/catalog/item_repo_test.go index b8c396ad..064bb116 100644 --- a/internal/catalog/item_repo_test.go +++ b/internal/catalog/item_repo_test.go @@ -1,6 +1,7 @@ package catalog import ( + "fmt" "strings" "testing" ) @@ -238,25 +239,73 @@ func TestItemRepo_Search_UsesWindowCount(t *testing.T) { } } -// TestItemRepo_Search_StrictTitleFilter_UsesWindowCount asserts the unified -// query also applies to the multi-word "strict title filter" path: the stats -// CTE is computed off the scored CTE, and the window count still runs on the -// final filtered result so the total reflects the strict-title CROSS JOIN -// filter rather than the broader pre-filter set. -func TestItemRepo_Search_StrictTitleFilter_UsesWindowCount(t *testing.T) { +// TestItemRepo_Search_TitleGate_UsesWindowCount asserts the unified query +// pairs the scored CTE with a stats CTE that derives has_title_match, and +// that the window count runs on the final filtered result so the total +// reflects the title-gate CROSS JOIN filter rather than the broader +// pre-filter set. +func TestItemRepo_Search_TitleGate_UsesWindowCount(t *testing.T) { repo := &ItemRepository{} sql, _, _ := repo.buildSearchSQL("the matrix reloaded", []string{"movie"}, 20, 0, AccessFilter{}) if !strings.Contains(sql, "COUNT(*) OVER ()") { - t.Fatalf("expected COUNT(*) OVER () in strict-title path; got %s", sql) + t.Fatalf("expected COUNT(*) OVER () in title-gate path; got %s", sql) } if strings.Count(sql, "WITH scored AS") != 1 { t.Fatalf("expected exactly one scored CTE; got %s", sql) } if !strings.Contains(sql, "stats AS") { - t.Fatalf("expected stats CTE for strict-title filtering; got %s", sql) + t.Fatalf("expected stats CTE; got %s", sql) } - if !strings.Contains(sql, "has_strong_title_match") { - t.Fatalf("expected strict-title filter predicate; got %s", sql) + if !strings.Contains(sql, "has_title_match") { + t.Fatalf("expected has_title_match predicate; got %s", sql) + } +} + +// TestItemRepo_Search_SingleWordEnablesTitleGate pins the bug fix for the +// "obsession returns 2000 results" report: even single-word queries must +// route through the stats CTE + CROSS JOIN, so overview-only matches are +// suppressed whenever any title match exists. Prior to this, single-word +// queries skipped the stats CTE entirely and returned every row where the +// search term appeared in the description. +func TestItemRepo_Search_SingleWordEnablesTitleGate(t *testing.T) { + repo := &ItemRepository{} + sql, _, _ := repo.buildSearchSQL("obsession", []string{"movie"}, 20, 0, AccessFilter{}) + if !strings.Contains(sql, "stats AS") { + t.Fatalf("expected single-word query to include the stats CTE; got %s", sql) + } + if !strings.Contains(sql, "CROSS JOIN stats") { + t.Fatalf("expected single-word query to CROSS JOIN stats; got %s", sql) + } + if !strings.Contains(sql, "scored.title_rank > 0") { + t.Fatalf("expected title gate to use title_rank > 0; got %s", sql) + } +} + +// TestItemRepo_Search_AppliesOverviewRankFloor pins that the overview-only +// fallback arm is gated by overviewMatchFloor, so weak single-occurrence +// description matches do not pass through when no title match exists. The +// floor literal is derived from the constant so the test stays in sync if +// the threshold is retuned. +func TestItemRepo_Search_AppliesOverviewRankFloor(t *testing.T) { + repo := &ItemRepository{} + dataSQL, countSQL, _ := repo.buildSearchSQL("obsession", []string{"movie"}, 20, 0, AccessFilter{}) + want := fmt.Sprintf("scored.overview_rank >= %g", overviewMatchFloor) + if !strings.Contains(dataSQL, want) { + t.Fatalf("expected %q in dataSQL; got %s", want, dataSQL) + } + if !strings.Contains(countSQL, want) { + t.Fatalf("expected %q in countSQL too (must mirror dataSQL); got %s", want, countSQL) + } +} + +// TestItemRepo_Search_EmptyQueryReturnsEmpty pins the early-return contract +// when input parses to no searchable text. Downstream callers rely on +// (dataSQL == "") to short-circuit without binding any args. +func TestItemRepo_Search_EmptyQueryReturnsEmpty(t *testing.T) { + repo := &ItemRepository{} + dataSQL, countSQL, args := repo.buildSearchSQL(" ", nil, 20, 0, AccessFilter{}) + if dataSQL != "" || countSQL != "" || args != nil { + t.Fatalf("expected (\"\", \"\", nil) for whitespace-only query; got (%q, %q, %v)", dataSQL, countSQL, args) } } diff --git a/internal/catalog/window_count_test.go b/internal/catalog/window_count_test.go index 4eaba97f..5f5c5f39 100644 --- a/internal/catalog/window_count_test.go +++ b/internal/catalog/window_count_test.go @@ -272,43 +272,42 @@ func TestBuildBrowseFavoritesPlan_CountSQL_OmitsLimitOffsetOrderBy(t *testing.T) // TestItemRepo_Search_CountSQL_OmitsLimitOffsetOrderBy pins the same // empty-page fallback contract for the Search path. The count sibling must -// preserve the strict-title CROSS JOIN filter so the recovered total -// reflects the post-filter row count (matching COUNT(*) OVER () semantics). +// preserve the title-gate CROSS JOIN filter so the recovered total reflects +// the post-filter row count (matching COUNT(*) OVER () semantics on the +// data SELECT). Single-word and multi-word queries share one SQL shape. func TestItemRepo_Search_CountSQL_OmitsLimitOffsetOrderBy(t *testing.T) { repo := &ItemRepository{} - // Single-word path: count from scored CTE. - _, countSQL, _ := repo.buildSearchSQL("avatar", []string{"movie"}, 20, 0, AccessFilter{}) - if !strings.Contains(countSQL, "WITH scored AS") { - t.Fatalf("countSQL must include scored CTE; got:\n%s", countSQL) - } - if !strings.Contains(countSQL, "SELECT COUNT(*) FROM scored") { - t.Fatalf("expected SELECT COUNT(*) FROM scored; got:\n%s", countSQL) - } - if strings.Contains(countSQL, "LIMIT ") { - t.Fatalf("countSQL must omit LIMIT; got:\n%s", countSQL) - } - if strings.Contains(countSQL, "OFFSET ") { - t.Fatalf("countSQL must omit OFFSET; got:\n%s", countSQL) - } - if strings.Contains(countSQL, "ORDER BY") { - t.Fatalf("countSQL must omit ORDER BY; got:\n%s", countSQL) - } - if strings.Contains(countSQL, "COUNT(*) OVER ()") { - t.Fatalf("countSQL must use plain COUNT(*); got:\n%s", countSQL) - } - - // Strict-title path: count must apply the same CROSS JOIN stats filter so - // the recovered total reflects post-filter row count (not the broader - // pre-filter set). Otherwise the fallback would over-count. - _, strictCountSQL, _ := repo.buildSearchSQL("the matrix reloaded", []string{"movie"}, 20, 0, AccessFilter{}) - if !strings.Contains(strictCountSQL, "stats AS") { - t.Fatalf("strict-title countSQL must include stats CTE; got:\n%s", strictCountSQL) - } - if !strings.Contains(strictCountSQL, "CROSS JOIN stats") { - t.Fatalf("strict-title countSQL must CROSS JOIN stats so the recovered total reflects the post-filter set; got:\n%s", strictCountSQL) - } - if !strings.Contains(strictCountSQL, "has_strong_title_match") { - t.Fatalf("strict-title countSQL must apply the strict-title predicate; got:\n%s", strictCountSQL) + for _, query := range []string{"avatar", "the matrix reloaded"} { + t.Run(query, func(t *testing.T) { + _, countSQL, _ := repo.buildSearchSQL(query, []string{"movie"}, 20, 0, AccessFilter{}) + if !strings.Contains(countSQL, "WITH scored AS") { + t.Fatalf("countSQL must include scored CTE; got:\n%s", countSQL) + } + if !strings.Contains(countSQL, "stats AS") { + t.Fatalf("countSQL must include stats CTE; got:\n%s", countSQL) + } + if !strings.Contains(countSQL, "CROSS JOIN stats") { + t.Fatalf("countSQL must CROSS JOIN stats so the recovered total reflects the post-filter set; got:\n%s", countSQL) + } + if !strings.Contains(countSQL, "has_title_match") { + t.Fatalf("countSQL must apply the title-gate predicate; got:\n%s", countSQL) + } + if !strings.Contains(countSQL, "SELECT COUNT(*)") { + t.Fatalf("expected SELECT COUNT(*); got:\n%s", countSQL) + } + if strings.Contains(countSQL, "LIMIT ") { + t.Fatalf("countSQL must omit LIMIT; got:\n%s", countSQL) + } + if strings.Contains(countSQL, "OFFSET ") { + t.Fatalf("countSQL must omit OFFSET; got:\n%s", countSQL) + } + if strings.Contains(countSQL, "ORDER BY") { + t.Fatalf("countSQL must omit ORDER BY; got:\n%s", countSQL) + } + if strings.Contains(countSQL, "COUNT(*) OVER ()") { + t.Fatalf("countSQL must use plain COUNT(*); got:\n%s", countSQL) + } + }) } } From c9a0fcbe6430f8f82c1379b331126bda4f2519ab Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 13:03:56 -0400 Subject: [PATCH 012/201] ci(docker): build image on push to main via self-hosted runner - Trigger Docker image builds on pushes to main instead of nightly cron - Run on self-hosted Linux runner - Drop the `nightly` tag --- .github/workflows/docker.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4b2fc132..d94c3fdd 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,9 +1,9 @@ -name: Nightly Docker Image +name: Docker Image on: - schedule: - # 03:00 America/New_York during daylight saving time. - - cron: "0 7 * * *" + push: + branches: + - main workflow_dispatch: concurrency: @@ -19,7 +19,7 @@ env: jobs: build: - runs-on: ubuntu-latest + runs-on: [self-hosted, linux] permissions: contents: read packages: write @@ -93,7 +93,6 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=raw,value=nightly,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}} type=sha,prefix=,format=short From 2ed2e013b45c6a4f074ea2d92751115bffb05eb0 Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Mon, 25 May 2026 13:53:03 -0400 Subject: [PATCH 013/201] 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 014/201] 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 015/201] 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 016/201] 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 017/201] 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 018/201] 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 019/201] 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 020/201] 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 021/201] 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 022/201] 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 026/201] 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 027/201] 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 028/201] 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 029/201] 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 044/201] 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 045/201] 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 046/201] 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 047/201] 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 051/201] 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 052/201] 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 054/201] 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 055/201] 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 056/201] 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 057/201] 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{ From e4f3f6d5f77deee6f23f93d55b9a5344e9ae507e Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 10:11:58 -0400 Subject: [PATCH 058/201] Optimize episode added_at sorting --- internal/catalog/episode_catalog_source.go | 2 + internal/catalog/episode_library_repo.go | 19 ++- internal/catalog/name_prefix_sql_test.go | 27 ++++- internal/catalog/query_builder.go | 24 +++- internal/catalog/query_builder_test.go | 57 ++++++++- internal/catalog/query_executor.go | 111 +++++++----------- internal/catalog/query_executor_test.go | 58 +++++++++ internal/catalog/window_count_test.go | 53 ++++----- .../141_episode_title_sort_index.down.sql | 1 + .../141_episode_title_sort_index.up.sql | 7 ++ 10 files changed, 250 insertions(+), 109 deletions(-) create mode 100644 migrations/141_episode_title_sort_index.down.sql create mode 100644 migrations/141_episode_title_sort_index.up.sql diff --git a/internal/catalog/episode_catalog_source.go b/internal/catalog/episode_catalog_source.go index 3988672c..56b19e3a 100644 --- a/internal/catalog/episode_catalog_source.go +++ b/internal/catalog/episode_catalog_source.go @@ -12,6 +12,7 @@ const episodeCatalogSelectBody = `( 'episode'::text AS type, COALESCE(NULLIF(BTRIM(e.title), ''), 'Episode ' || e.episode_number::text) AS title, COALESCE(NULLIF(BTRIM(e.title), ''), 'Episode ' || e.episode_number::text) AS sort_title, + LOWER(COALESCE(NULLIF(BTRIM(e.title), ''), 'Episode ' || e.episode_number::text)) AS sort_key, COALESCE(NULLIF(BTRIM(e.default_metadata_language), ''), COALESCE(si.default_metadata_language, '')) AS default_metadata_language, ''::text AS original_title, COALESCE(si.year, EXTRACT(YEAR FROM e.air_date)::integer, 0) AS year, @@ -45,6 +46,7 @@ const episodeCatalogSelectBody = `( NULL::text AS last_air_date, e.air_date AS last_air_date_at, si.air_time, + COALESCE(si.show_status, '') AS show_status, si.matched_at, si.last_refreshed, si.refresh_failures, diff --git a/internal/catalog/episode_library_repo.go b/internal/catalog/episode_library_repo.go index 8b4e4d2a..f07500e7 100644 --- a/internal/catalog/episode_library_repo.go +++ b/internal/catalog/episode_library_repo.go @@ -17,9 +17,24 @@ func NewEpisodeLibraryRepository(pool *pgxpool.Pool) *EpisodeLibraryRepository { return &EpisodeLibraryRepository{pool: pool} } -// ReconcileFolderMembership removes episode memberships for episodes that no longer -// have any present files in the given folder. +// ReconcileFolderMembership restores missing episode memberships and removes +// memberships for episodes that no longer have any present files in the given +// folder. The returned count is the number of removed stale memberships. func (r *EpisodeLibraryRepository) ReconcileFolderMembership(ctx context.Context, folderID int) (int, error) { + if _, err := r.pool.Exec(ctx, ` + INSERT INTO episode_libraries (episode_id, media_folder_id, first_seen_at) + SELECT mf.episode_id, mf.media_folder_id, MIN(mf.created_at) + FROM media_files mf + JOIN episodes e ON e.content_id = mf.episode_id + WHERE mf.media_folder_id = $1 + AND mf.missing_since IS NULL + AND mf.episode_id IS NOT NULL + GROUP BY mf.episode_id, mf.media_folder_id + ON CONFLICT (episode_id, media_folder_id) DO NOTHING + `, folderID); err != nil { + return 0, fmt.Errorf("restoring episode library membership: %w", err) + } + tag, err := r.pool.Exec(ctx, ` DELETE FROM episode_libraries el WHERE el.media_folder_id = $1 diff --git a/internal/catalog/name_prefix_sql_test.go b/internal/catalog/name_prefix_sql_test.go index 629b8b6d..9f30bffa 100644 --- a/internal/catalog/name_prefix_sql_test.go +++ b/internal/catalog/name_prefix_sql_test.go @@ -23,7 +23,7 @@ func TestQueryExecutor_NamePrefix_PushedIntoWHERE(t *testing.T) { } // First arm: sort-key expression matching idx_media_items_sort_key. - if !strings.Contains(sql, "LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title),''), mi.title)) LIKE") { + if !strings.Contains(sql, "LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title), ''), mi.title)) LIKE") { t.Fatalf("expected sort-key LIKE arm matching idx_media_items_sort_key; got %q", sql) } // Second arm: LOWER(title) matching idx_media_items_search_exact_title; @@ -53,6 +53,31 @@ func TestQueryExecutor_NamePrefix_PushedIntoWHERE(t *testing.T) { } } +func TestQueryExecutor_NamePrefix_UsesEpisodeSortKeyForEpisodeScope(t *testing.T) { + exec := &QueryExecutor{} + access := AccessFilter{NamePrefix: "Pilot"} + + sql, args, err := exec.buildPreviewPageSQL( + QueryDefinition{MediaScope: "episode", LibraryIDs: []int{2}}, + access, + 20, + 0, + true, + ) + if err != nil { + t.Fatalf("buildPreviewPageSQL returned error: %v", err) + } + if !strings.Contains(sql, "mi.sort_key LIKE") { + t.Fatalf("expected episode prefix to use projected sort_key; got %q", sql) + } + if strings.Contains(sql, "BTRIM(mi.sort_title)") { + t.Fatalf("episode prefix should not recompute sort_title expression; got %q", sql) + } + if len(args) < 2 || args[1] != "pilot%" { + t.Fatalf("expected episode library args followed by prefix arg; got %v", args) + } +} + // TestBrowseFilters_NamePrefix_BothArmsSargable asserts that the dual-column // LIKE in BrowseRepository's WHERE clause uses an expression that matches // idx_media_items_sort_key (migration 102) on the first arm and diff --git a/internal/catalog/query_builder.go b/internal/catalog/query_builder.go index 2126aecd..f56a222f 100644 --- a/internal/catalog/query_builder.go +++ b/internal/catalog/query_builder.go @@ -234,10 +234,10 @@ func (qb *QueryBuilder) BuildSortPlan(sortConfig QuerySort) (QuerySortPlan, erro plan.OrderBy = qb.orderByExpr(qb.releaseDateSortExpr(), dir, true, titleExpr) return plan, nil case "added_at": - expr, joins, args := qb.addedAtSortPlan() + expr, joins, args, nullsLast := qb.addedAtSortPlan() plan.Joins = joins plan.Args = args - plan.OrderBy = qb.orderByExpr(expr, dir, len(joins) > 0, titleExpr) + plan.OrderBy = qb.orderByExpr(expr, dir, nullsLast, titleExpr) return plan, nil case "content_rating": rankExpr := qb.contentRatingRankExpr() @@ -1016,6 +1016,9 @@ func (qb *QueryBuilder) buildTimestampComparisonClause(column, op string, value } func (qb *QueryBuilder) normalizedTitleExpr() string { + if isEpisodeCatalogScope(qb.mediaScope) { + return fmt.Sprintf("%s.sort_key", qb.alias) + } return fmt.Sprintf( "LOWER(COALESCE(NULLIF(BTRIM(%s.sort_title), ''), %s.title))", qb.alias, @@ -1039,13 +1042,22 @@ func (qb *QueryBuilder) orderByExpr(expr, dir string, nullsLast bool, titleExpr return clause } -func (qb *QueryBuilder) addedAtSortPlan() (string, []string, []any) { +func (qb *QueryBuilder) addedAtSortPlan() (string, []string, []any, bool) { if len(qb.libraryIDs) == 0 { - return fmt.Sprintf("%s.created_at", qb.alias), nil, nil + return fmt.Sprintf("%s.created_at", qb.alias), nil, nil, false } placeholders, args := qb.consumeIntArgs(qb.libraryIDs) if isEpisodeCatalogScope(qb.mediaScope) { + if len(qb.libraryIDs) == 1 { + joinSQL := fmt.Sprintf( + `JOIN episode_libraries sort_added ON sort_added.episode_id = %s.content_id AND sort_added.media_folder_id = %s`, + qb.alias, + placeholders[0], + ) + return "sort_added.first_seen_at", []string{joinSQL}, args, false + } + joinSQL := fmt.Sprintf( `LEFT JOIN ( SELECT el.episode_id AS content_id, MIN(el.first_seen_at) AS added_at @@ -1056,7 +1068,7 @@ func (qb *QueryBuilder) addedAtSortPlan() (string, []string, []any) { strings.Join(placeholders, ", "), qb.alias, ) - return "sort_added.added_at", []string{joinSQL}, args + return "sort_added.added_at", []string{joinSQL}, args, true } joinSQL := fmt.Sprintf( @@ -1069,7 +1081,7 @@ func (qb *QueryBuilder) addedAtSortPlan() (string, []string, []any) { strings.Join(placeholders, ", "), qb.libraryContentExpr(), ) - return "sort_added.added_at", []string{joinSQL}, args + return "sort_added.added_at", []string{joinSQL}, args, true } func (qb *QueryBuilder) contentRatingRankExpr() string { diff --git a/internal/catalog/query_builder_test.go b/internal/catalog/query_builder_test.go index dd493de0..41400c14 100644 --- a/internal/catalog/query_builder_test.go +++ b/internal/catalog/query_builder_test.go @@ -74,6 +74,27 @@ func TestBuildSortClause_ReleaseDateUsesEpisodeAirDateForEpisodeScope(t *testing } } +func TestBuildSortClause_TitleUsesEpisodeSortKeyForEpisodeScope(t *testing.T) { + clause, args, err := NewQueryBuilder("mi"). + WithMediaScope("episode"). + BuildSortClause(QuerySort{ + Field: "title", + Order: "asc", + }) + if err != nil { + t.Fatalf("BuildSortClause returned error: %v", err) + } + if len(args) != 0 { + t.Fatalf("expected no args, got %v", args) + } + if !strings.Contains(clause, "ORDER BY mi.sort_key ASC, mi.content_id ASC") { + t.Fatalf("expected episode title sort to use indexed sort_key, got %q", clause) + } + if strings.Contains(clause, "BTRIM(mi.sort_title)") { + t.Fatalf("episode title sort should not recompute sort_title expression, got %q", clause) + } +} + func TestBuildSortClause_AddedAtUsesScopedFirstSeenAt(t *testing.T) { plan, err := NewQueryBuilder("mi"). WithLibraryScope([]int{3, 7}). @@ -201,15 +222,43 @@ func TestBuildSortPlan_AddedAtUsesEpisodeLibraryMembershipForEpisodeScope(t *tes if len(plan.Joins) != 1 { t.Fatalf("expected one join, got %v", plan.Joins) } - if !strings.Contains(plan.Joins[0], "FROM episode_libraries el") { + if !strings.Contains(plan.Joins[0], "JOIN episode_libraries sort_added") { t.Fatalf("expected episode added_at join to use episode_libraries, got %q", plan.Joins[0]) } - if !strings.Contains(plan.Joins[0], "GROUP BY el.episode_id") { - t.Fatalf("expected episode added_at join to group by episode_id, got %q", plan.Joins[0]) + if !strings.Contains(plan.Joins[0], "sort_added.media_folder_id = $1") { + t.Fatalf("expected direct episode added_at join to bind one library, got %q", plan.Joins[0]) } - if !strings.Contains(plan.Joins[0], "sort_added.content_id = mi.content_id") { + if !strings.Contains(plan.Joins[0], "sort_added.episode_id = mi.content_id") { t.Fatalf("expected episode added_at join to match episode content_id, got %q", plan.Joins[0]) } + if !strings.Contains(plan.OrderBy, "ORDER BY sort_added.first_seen_at DESC, mi.sort_key ASC, mi.content_id ASC") { + t.Fatalf("expected single-library episode added_at sort to use first_seen_at without NULLS LAST, got %q", plan.OrderBy) + } +} + +func TestBuildSortPlan_AddedAtAggregatesEpisodeLibrariesForMultiLibraryScope(t *testing.T) { + plan, err := NewQueryBuilder("mi"). + WithMediaScope("episode"). + WithLibraryScope([]int{6, 7}). + BuildSortPlan(QuerySort{Field: "added_at", Order: "desc"}) + if err != nil { + t.Fatalf("BuildSortPlan returned error: %v", err) + } + if len(plan.Args) != 2 || plan.Args[0] != 6 || plan.Args[1] != 7 { + t.Fatalf("expected scoped library args [6 7], got %v", plan.Args) + } + if len(plan.Joins) != 1 { + t.Fatalf("expected one join, got %v", plan.Joins) + } + if !strings.Contains(plan.Joins[0], "FROM episode_libraries el") { + t.Fatalf("expected episode added_at aggregate to use episode_libraries, got %q", plan.Joins[0]) + } + if !strings.Contains(plan.Joins[0], "GROUP BY el.episode_id") { + t.Fatalf("expected multi-library episode added_at join to group by episode_id, got %q", plan.Joins[0]) + } + if !strings.Contains(plan.OrderBy, "sort_added.added_at DESC NULLS LAST") { + t.Fatalf("expected multi-library episode added_at sort to keep NULLS LAST, got %q", plan.OrderBy) + } } func TestBuildSortPlan_FileSortUsesEpisodeIDsForEpisodeScope(t *testing.T) { diff --git a/internal/catalog/query_executor.go b/internal/catalog/query_executor.go index 6c0ea0a0..21317399 100644 --- a/internal/catalog/query_executor.go +++ b/internal/catalog/query_executor.go @@ -47,7 +47,7 @@ func (e *QueryExecutor) PreviewPage( return nil, 0, false, err } - pagedSQL, pagedArgs := build.pagedSQL(includeTotal) + pagedSQL, pagedArgs := build.pagedSQL(false) rows, err := e.Pool.Query(ctx, pagedSQL, pagedArgs...) if err != nil { return nil, 0, false, fmt.Errorf("querying preview items: %w", err) @@ -58,14 +58,15 @@ func (e *QueryExecutor) PreviewPage( items []*models.MediaItem total int ) - if includeTotal { - items, total, err = scanItemsWithTotal(rows) - } else { - items, err = scanItems(rows) - } + items, err = scanItems(rows) if err != nil { return nil, 0, false, err } + hasMore := false + if len(items) > build.limit { + hasMore = true + items = items[:build.limit] + } // The preview path uses itemColumns which scans CreatedAt but not // AddedAt (set only by browse queries via MIN(mil.first_seen_at)). // Fall back to CreatedAt so the API response includes added_at. @@ -76,32 +77,15 @@ func (e *QueryExecutor) PreviewPage( } } - hasMore := false if includeTotal { - // COUNT(*) OVER () emits no rows when the data SELECT is empty, so - // total stays 0 even when the broader result set has matching rows - // (e.g. OFFSET past the last page). Re-query the count to give - // callers the real total. Skip when offset == 0 because in that - // case an empty page genuinely means total = 0. - // - // Use build.offset (normalized in buildPreviewPagePlan) rather than - // the raw offset parameter — a caller-supplied negative offset is - // floored to 0 in the plan, and the SQL uses the plan's value, so - // the fallback condition and HasMore must match. - if len(items) == 0 && build.offset > 0 { - countSQL, countArgs := build.countSQL() - if err := e.Pool.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil { - return nil, 0, false, fmt.Errorf("count fallback for empty page: %w", err) - } + countSQL, countArgs := build.countSQL() + if err := e.Pool.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil { + return nil, 0, false, fmt.Errorf("counting preview items: %w", err) } hasMore = total > build.offset+len(items) return items, total, hasMore, nil } - if len(items) > build.limit { - hasMore = true - items = items[:build.limit] - } return items, 0, hasMore, nil } @@ -121,6 +105,9 @@ type previewPagePlan struct { // fromClausePaged is the FROM clause for the paged query (includes any // sort-plan joins). fromClausePaged string + // fromClauseCount is the FROM clause for exact totals. It includes filter + // joins, but intentionally excludes sort-only joins. + fromClauseCount string whereClause string args []any orderBy string @@ -131,41 +118,33 @@ type previewPagePlan struct { } // countSQL renders a count-only query that returns the total number of rows -// matching the plan's WHERE clause, ignoring LIMIT/OFFSET/ORDER BY. Used as -// a fallback when pagedSQL(true) returned an empty page past offset 0: -// COUNT(*) OVER () emits no rows when the data SELECT is empty, so the -// caller would otherwise see total=0 even when the broader result set has -// matching rows. Wraps the inner query in `SELECT COUNT(*) FROM (...) sub` -// so any GROUP BY in the inner query is preserved (we count groups, matching -// what COUNT(*) OVER () would compute). +// matching the plan's WHERE clause, ignoring LIMIT/OFFSET/ORDER BY. PreviewPage +// uses it when callers need an exact total; keeping the count separate lets the +// data SELECT use top-N/index plans instead of forcing COUNT(*) OVER () across +// every matching row. Wraps the inner query in `SELECT COUNT(*) FROM (...) sub` +// so any GROUP BY in the inner query is preserved. // -// Bind cteArgs + args + sortArgs in the same order as pagedSQL. fromClausePaged -// embeds sort-plan join clauses (ORDER BY needs them to project the join -// columns), and those clauses reference $N placeholders for sortArgs; omitting -// sortArgs here would break sorts that need bound join args (added_at, -// progress, date_viewed, plays, resolution, bitrate). LIMIT/OFFSET are -// intentionally dropped — count is over the full filtered set. +// Bind cteArgs + args only. Sort-only joins and their args are intentionally +// excluded because ordering does not affect the filtered row count. func (p previewPagePlan) countSQL() (string, []any) { args := append([]any{}, p.cteArgs...) args = append(args, p.args...) - args = append(args, p.sortArgs...) withClause := "" if len(p.ctes) > 0 { withClause = "WITH " + strings.Join(p.ctes, ",\n") + "\n" } sql := fmt.Sprintf( "%sSELECT COUNT(*) FROM (SELECT 1 %s %s) sub", - withClause, p.fromClausePaged, p.whereClause, + withClause, p.fromClauseCount, p.whereClause, ) return sql, args } // pagedSQL renders the final paged SELECT and returns it together with the -// fully-bound arg list. When includeTotal is true the SELECT list is appended -// with COUNT(*) OVER () AS total_count so the caller can read the total from -// the first scanned row in a single round trip. When includeTotal is false we -// ask the database for one extra row to detect more pages without an exact -// count. +// fully-bound arg list. When includeTotal is false we ask the database for one +// extra row to detect more pages without an exact count. Exact totals are +// handled by countSQL instead of COUNT(*) OVER () so the page query can stop +// after the requested rows. func (p previewPagePlan) pagedSQL(includeTotal bool) (string, []any) { queryLimit := p.limit if !includeTotal { @@ -182,9 +161,6 @@ func (p previewPagePlan) pagedSQL(includeTotal bool) (string, []any) { args = append(args, p.offset) } selectList := qualifiedListItemColumns("mi") - if includeTotal { - selectList += ", COUNT(*) OVER () AS total_count" - } withClause := "" if len(p.ctes) > 0 { withClause = "WITH " + strings.Join(p.ctes, ",\n") + "\n" @@ -257,7 +233,7 @@ func (e *QueryExecutor) buildPreviewPagePlan( builder := NewQueryBuilder("mi"). WithArgIdx(len(baseArgs)+1). WithUserScope(access.UserID, access.ProfileID). - WithMediaScope(def.MediaScope). + WithMediaScope(effectiveScope). WithLibraryScope(libraryIDs) filterWhere, filterArgs, err := builder.Build(def) if err != nil { @@ -314,16 +290,15 @@ func (e *QueryExecutor) buildPreviewPagePlan( } if prefix := strings.TrimSpace(access.NamePrefix); prefix != "" { - // Dual-column OR matching browse.go and favorites_browse.go: items - // where a curated sort_title differs from title (e.g. title="The Office", - // sort_title="Office, The") would be silently lost on prefix="the" if - // we only checked the COALESCE'd sort-key expression. First arm uses - // the idx_media_items_sort_key expression (migration 102); second arm - // uses idx_media_items_search_exact_title on LOWER(title) (migration 001). - // Both arms are sargable; the planner can BitmapOr them. + // Dual-column OR matching browse.go and favorites_browse.go: items where + // a curated sort_title differs from title (e.g. title="The Office", + // sort_title="Office, The") would be silently lost on prefix="the" if we + // only checked the COALESCE'd sort-key expression. The first arm uses the + // scope-specific sort key; the second arm keeps literal title prefixes. + prefixSortExpr := builder.normalizedTitleExpr() conditions = append(conditions, fmt.Sprintf( - "(LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title),''), mi.title)) LIKE $%d ESCAPE '\\' OR LOWER(mi.title) LIKE $%d ESCAPE '\\')", - argIdx, argIdx, + "(%s LIKE $%d ESCAPE '\\' OR LOWER(mi.title) LIKE $%d ESCAPE '\\')", + prefixSortExpr, argIdx, argIdx, )) args = append(args, escapePrefixForLike(prefix)+"%") argIdx++ @@ -334,6 +309,7 @@ func (e *QueryExecutor) buildPreviewPagePlan( whereClause = "WHERE " + strings.Join(conditions, " AND ") } fromClauseBase := "FROM " + baseRelation + fromClauseCount := fromClauseBase if limit <= 0 { limit = 20 @@ -363,9 +339,11 @@ func (e *QueryExecutor) buildPreviewPagePlan( ctes = []string{UserHistoryCTESQL(1)} fromClausePaged = rebindSQLPlaceholders(fromClausePaged, cteShift) + fromClauseCount = rebindSQLPlaceholders(fromClauseCount, cteShift) whereClause = rebindSQLPlaceholders(whereClause, cteShift) sortPlan.OrderBy = rebindSQLPlaceholders(sortPlan.OrderBy, cteShift) fromClausePaged += " LEFT JOIN user_last_watched uhist ON uhist.media_item_id = mi.content_id" + fromClauseCount += " LEFT JOIN user_last_watched uhist ON uhist.media_item_id = mi.content_id" limitArgIdx += cteShift } @@ -373,6 +351,7 @@ func (e *QueryExecutor) buildPreviewPagePlan( ctes: ctes, cteArgs: cteArgs, fromClausePaged: fromClausePaged, + fromClauseCount: fromClauseCount, whereClause: whereClause, args: args, orderBy: sortPlan.OrderBy, @@ -385,8 +364,9 @@ func (e *QueryExecutor) buildPreviewPagePlan( // buildPreviewPageSQL is a test-friendly facade over buildPreviewPagePlan that // returns the rendered paged SELECT plus the bound args. It performs no I/O. -// When includeTotal is true the emitted SELECT carries COUNT(*) OVER () as a -// total_count column so PreviewPage can avoid a separate count query. +// includeTotal only controls whether the page query fetches exactly limit rows +// or an extra row for has-more detection; exact totals are rendered separately +// by previewPagePlan.countSQL. func (e *QueryExecutor) buildPreviewPageSQL( def QueryDefinition, access AccessFilter, @@ -413,11 +393,10 @@ func escapePrefixForLike(s string) string { } // buildLibraryScopeJoin returns a WHERE clause that scopes the outer query -// to items whose membership in media_item_libraries (or episode_libraries for -// the episode catalog scope) matches the allow/deny lists. The clause is an -// EXISTS / NOT EXISTS semi-join that uses the (content_id, media_folder_id) -// PRIMARY KEY index directly without fanning out for items present in -// multiple libraries — Audit Pattern D (2026-05-01 §3 Pattern D). The prior +// to items whose library membership matches the allow/deny lists. The clause +// is an EXISTS / NOT EXISTS semi-join that uses membership indexes +// directly without fanning out for items present in multiple libraries — Audit +// Pattern D (2026-05-01 §3 Pattern D). The prior // shape wrapped the join in a SELECT DISTINCT subquery to defuse that // fanout; the DISTINCT was load-bearing because the PK is on the (content, // folder) PAIR, not on content alone. EXISTS is the canonical non-fanout diff --git a/internal/catalog/query_executor_test.go b/internal/catalog/query_executor_test.go index 128fdaeb..98881851 100644 --- a/internal/catalog/query_executor_test.go +++ b/internal/catalog/query_executor_test.go @@ -167,6 +167,64 @@ func TestEpisodeCatalogBaseRelationForLibraries_UsesEpisodeLibraries(t *testing. } } +func TestEpisodeCatalogProjectionIncludesSharedCatalogColumns(t *testing.T) { + sql, _, err := (&QueryExecutor{}).buildPreviewPageSQL( + QueryDefinition{ + MediaScope: "episode", + LibraryIDs: []int{2}, + Sort: QuerySort{Field: "title", Order: "asc"}, + }, + AccessFilter{}, + 20, + 0, + true, + ) + if err != nil { + t.Fatalf("buildPreviewPageSQL error: %v", err) + } + if !strings.Contains(sql, "COALESCE(si.show_status, '') AS show_status") { + t.Fatalf("expected episode projection to include show_status, got %s", sql) + } + if !strings.Contains(sql, "mi.show_status") { + t.Fatalf("expected outer catalog select to reference show_status, got %s", sql) + } + if !strings.Contains(sql, "LOWER(COALESCE(NULLIF(BTRIM(e.title), ''), 'Episode ' || e.episode_number::text)) AS sort_key") { + t.Fatalf("expected episode projection to include sort_key, got %s", sql) + } + if !strings.Contains(sql, "ORDER BY mi.sort_key ASC, mi.content_id ASC") { + t.Fatalf("expected episode title sort to use sort_key, got %s", sql) + } +} + +func TestEpisodeCatalogSingleLibraryAddedAtUsesDirectMembershipJoin(t *testing.T) { + sql, _, err := (&QueryExecutor{}).buildPreviewPageSQL( + QueryDefinition{ + MediaScope: "episode", + LibraryIDs: []int{2}, + Sort: QuerySort{Field: "added_at", Order: "desc"}, + }, + AccessFilter{}, + 20, + 0, + true, + ) + if err != nil { + t.Fatalf("buildPreviewPageSQL error: %v", err) + } + if !strings.Contains(sql, "JOIN episode_libraries sort_added") { + t.Fatalf("expected direct episode_libraries join for single-library added_at sort, got %s", sql) + } + if !strings.Contains(sql, "sort_added.media_folder_id = $2") { + t.Fatalf("expected direct added_at join to bind the single library, got %s", sql) + } + if strings.Contains(sql, "GROUP BY el.episode_id") { + t.Fatalf("single-library added_at sort should avoid aggregate membership join, got %s", sql) + } + if !strings.Contains(sql, "ORDER BY sort_added.first_seen_at DESC, mi.sort_key ASC, mi.content_id ASC") { + t.Fatalf("expected added_at order to use first_seen_at without NULLS LAST, got %s", sql) + } +} + func TestRebindSQLPlaceholders(t *testing.T) { got := rebindSQLPlaceholders("mi.created_at <= $1 AND mi.year >= $2", 3) want := "mi.created_at <= $4 AND mi.year >= $5" diff --git a/internal/catalog/window_count_test.go b/internal/catalog/window_count_test.go index 5f5c5f39..3ac01402 100644 --- a/internal/catalog/window_count_test.go +++ b/internal/catalog/window_count_test.go @@ -12,17 +12,18 @@ import ( // for use in arg-count assertions across countSQL tests. var placeholderRE = regexp.MustCompile(`\$(\d+)`) -// TestQueryExecutor_PreviewPage_UsesWindowCount asserts that buildPreviewPageSQL -// emits a single-pass paged SELECT that includes COUNT(*) OVER () so PreviewPage -// no longer needs a separate count query when includeTotal is true. -func TestQueryExecutor_PreviewPage_UsesWindowCount(t *testing.T) { +// TestQueryExecutor_PreviewPage_ExactTotalOmitsWindowCount asserts that +// buildPreviewPageSQL keeps the data SELECT free of COUNT(*) OVER (). PreviewPage +// runs the exact count separately so large ordered catalogs can use top-N/index +// plans for the page fetch. +func TestQueryExecutor_PreviewPage_ExactTotalOmitsWindowCount(t *testing.T) { exec := &QueryExecutor{Scope: "movie", BaseRelationSQL: "media_items mi"} sql, _, err := exec.buildPreviewPageSQL(QueryDefinition{}, AccessFilter{}, 20, 0, true /* includeTotal */) if err != nil { t.Fatalf("buildPreviewPageSQL error: %v", err) } - if !strings.Contains(sql, "COUNT(*) OVER ()") { - t.Fatalf("expected COUNT(*) OVER () for single-pass count; got:\n%s", sql) + if strings.Contains(sql, "COUNT(*) OVER ()") { + t.Fatalf("PreviewPage exact totals must omit COUNT(*) OVER (); got:\n%s", sql) } } @@ -74,16 +75,11 @@ func TestBrowseRepository_browse_SkipTotal_OmitsWindowCount(t *testing.T) { } } -// TestQueryExecutor_PreviewPage_CountSQL_BindsSortJoinArgs pins that -// previewPagePlan.countSQL binds the sort-plan join args (sortArgs) — not -// just cteArgs+args. fromClausePaged embeds sort-plan join clauses -// (added_at, progress, date_viewed, plays, resolution, bitrate all need -// LIBRARY-id-bound joins), so omitting sortArgs would leave bound -// placeholders inside the FROM clause unfilled and Postgres would error -// out with "missing argument" at the count fallback path. -// -// Regression guard for the post-perf-overhaul code review (macroscope High). -func TestQueryExecutor_PreviewPage_CountSQL_BindsSortJoinArgs(t *testing.T) { +// TestQueryExecutor_PreviewPage_CountSQL_OmitsSortOnlyJoins pins that exact +// totals do not carry ORDER BY-only joins. The page query still needs those +// joins for sorts such as added_at, but the count query only needs the base +// relation plus filter joins. +func TestQueryExecutor_PreviewPage_CountSQL_OmitsSortOnlyJoins(t *testing.T) { exec := &QueryExecutor{Scope: "movie", BaseRelationSQL: "media_items mi"} plan, err := exec.buildPreviewPagePlan( QueryDefinition{ @@ -102,15 +98,15 @@ func TestQueryExecutor_PreviewPage_CountSQL_BindsSortJoinArgs(t *testing.T) { sql, args := plan.countSQL() - // The sort-plan LEFT JOIN must be embedded in the count SQL — that's the - // reason sortArgs binding matters at all. - if !strings.Contains(sql, "sort_added") { - t.Fatalf("countSQL must include addedAtSortPlan's LEFT JOIN; got:\n%s", sql) + if strings.Contains(sql, "sort_added") { + t.Fatalf("countSQL must omit addedAtSortPlan's LEFT JOIN; got:\n%s", sql) + } + if len(args) != 1 { + t.Fatalf("countSQL must omit sortArgs and bind only the library-scope arg; got %v", args) } - // Verify args length covers every $N placeholder in the SQL. If we - // dropped sortArgs, the highest $N would exceed len(args) and Postgres - // would fail with "missing argument". + // Verify args still cover every $N placeholder after dropping sort-only + // joins and sortArgs. maxIdx := 0 for _, m := range placeholderRE.FindAllStringSubmatch(sql, -1) { idx, _ := strconv.Atoi(m[1]) @@ -122,23 +118,20 @@ func TestQueryExecutor_PreviewPage_CountSQL_BindsSortJoinArgs(t *testing.T) { t.Fatalf("expected at least one $N placeholder in countSQL; got:\n%s", sql) } if len(args) < maxIdx { - t.Fatalf("countSQL references $%d but only %d args bound (sortArgs likely dropped); sql:\n%s\nargs: %v", + t.Fatalf("countSQL references $%d but only %d args bound; sql:\n%s\nargs: %v", maxIdx, len(args), sql, args) } } // TestQueryExecutor_PreviewPage_CountSQL_OmitsLimitOffsetOrderBy pins the -// empty-page fallback SQL shape on previewPagePlan. When pagedSQL(true) -// returns an empty page past offset 0, the executor invokes countSQL() to -// recover the real total — COUNT(*) OVER () would otherwise emit no rows -// and leave the caller seeing total=0 even when broader matches exist. +// exact-total SQL shape on previewPagePlan. PreviewPage invokes countSQL() +// separately instead of making the data SELECT calculate COUNT(*) OVER (). // // The countSQL must: // - omit LIMIT/OFFSET (we want the unpaginated total) // - omit ORDER BY (irrelevant for a count, and may reference unbound args) // - wrap the inner FROM/WHERE in `SELECT COUNT(*) FROM (SELECT 1 ...) sub` -// so any GROUP BY in the inner query counts groups (not rows), matching -// what COUNT(*) OVER () would have computed. +// so any GROUP BY in the inner query counts groups (not rows). func TestQueryExecutor_PreviewPage_CountSQL_OmitsLimitOffsetOrderBy(t *testing.T) { exec := &QueryExecutor{Scope: "movie", BaseRelationSQL: "media_items mi"} plan, err := exec.buildPreviewPagePlan(QueryDefinition{}, AccessFilter{}, 20, 0) diff --git a/migrations/141_episode_title_sort_index.down.sql b/migrations/141_episode_title_sort_index.down.sql new file mode 100644 index 00000000..959a7406 --- /dev/null +++ b/migrations/141_episode_title_sort_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS public.idx_episodes_sort_key_content; diff --git a/migrations/141_episode_title_sort_index.up.sql b/migrations/141_episode_title_sort_index.up.sql new file mode 100644 index 00000000..f249b2f4 --- /dev/null +++ b/migrations/141_episode_title_sort_index.up.sql @@ -0,0 +1,7 @@ +-- Episode library title browse. Matches episodeCatalogSelectBody's sort_key +-- expression so PostgreSQL can satisfy ORDER BY title from the episodes index. +CREATE INDEX IF NOT EXISTS idx_episodes_sort_key_content +ON public.episodes USING btree ( + LOWER(COALESCE(NULLIF(BTRIM(title), ''), 'Episode ' || episode_number::text)), + content_id +); From 886c492c6ac3721a4edaa4f731fab61337c2fd83 Mon Sep 17 00:00:00 2001 From: CoffeeKnyte <67730400+CoffeeKnyte@users.noreply.github.com> Date: Wed, 27 May 2026 15:46:24 +0000 Subject: [PATCH 059/201] fix(libraryingest): treat drainer shutdown cancel as clean stop TV/series full scans (libraries with new or updated items) were recorded as "cancelled" with an empty error message and never completed matching. When the file-walk finishes, the ingest executor waits out a settle window and then calls stopDrainers() to shut down the concurrent match goroutines. That cancels the drainer context while a ProcessBatchByFolderAndPathPrefix call may still be in flight. The drainer treated the resulting context.Canceled as a fatal error: it pushed the error to drainerErrCh and called cancel() on the whole scan context, so scanqueue.process() mapped it to cancelRun(). Large/slow libraries (many series, slow provider lookups) keep a batch in flight continuously, so stopDrainers() almost always landed mid-call and the scan was cancelled; small/fast libraries were usually idle at that instant and completed normally. Treat a cancelled drainer context as a deliberate shutdown: return cleanly without escalating. Genuine external cancellation still reaches the run via the main goroutine's scanCtx checks, so real cancels are not swallowed. Adds a regression test (settle window made injectable) that fails against the old handler with 'concurrent match scope ...: context canceled' and passes with the fix. --- internal/libraryingest/executor.go | 23 ++++++- internal/libraryingest/executor_test.go | 82 +++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 internal/libraryingest/executor_test.go diff --git a/internal/libraryingest/executor.go b/internal/libraryingest/executor.go index ab5b3415..4cc5a0cc 100644 --- a/internal/libraryingest/executor.go +++ b/internal/libraryingest/executor.go @@ -90,6 +90,11 @@ type Executor struct { realtime *notifications.Hub now func() time.Time + // tvDrainSettleWindow overrides scopedTVDrainSettleWindow when > 0. Kept + // injectable so tests can exercise the settle-window shutdown path without + // waiting the full production interval. + tvDrainSettleWindow time.Duration + mu sync.Mutex running []runningClaim } @@ -185,6 +190,16 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode concurrentMatched.Add(int64(processed)) } if err != nil { + // A cancelled drainer context means this is a deliberate + // shutdown (the settle window ended and stopDrainers ran, or + // the whole scan is being torn down), not a real failure. The + // in-flight ProcessBatch call returns context.Canceled here, so + // exit cleanly without escalating. Genuine external + // cancellation still reaches the run via the scanCtx checks in + // the main goroutine, so this does not swallow real cancels. + if drainerCtx.Err() != nil { + return + } select { case drainerErrCh <- fmt.Errorf("concurrent match scope %q: %w", scopePath, err): default: @@ -238,12 +253,16 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode } matchScopes = scanMatchScopes if shouldWaitForTVQueueSettle(folder, scanResult) { + settleWindow := e.tvDrainSettleWindow + if settleWindow <= 0 { + settleWindow = scopedTVDrainSettleWindow + } slog.Info("library ingest: waiting for tv queue settle window", "folder_id", folder.ID, "mode", mode, - "wait", scopedTVDrainSettleWindow, + "wait", settleWindow, ) - timer := time.NewTimer(scopedTVDrainSettleWindow) + timer := time.NewTimer(settleWindow) select { case <-scanCtx.Done(): timer.Stop() diff --git a/internal/libraryingest/executor_test.go b/internal/libraryingest/executor_test.go new file mode 100644 index 00000000..17bcaf02 --- /dev/null +++ b/internal/libraryingest/executor_test.go @@ -0,0 +1,82 @@ +package libraryingest + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scanner" +) + +// settleBlockingMatcher simulates a TV match drainer whose provider lookup is +// still in flight when the settle window expires: the batch call blocks until +// its context is cancelled, then returns context.Canceled — exactly what +// stopDrainers triggers when it cancels the drainer context. +type settleBlockingMatcher struct { + batchCalls atomic.Int64 +} + +func (m *settleBlockingMatcher) ProcessBatchByFolderAndPathPrefix(ctx context.Context, _ int, _ string, _ time.Time) (int, error) { + m.batchCalls.Add(1) + <-ctx.Done() + return 0, ctx.Err() +} + +func (m *settleBlockingMatcher) ProcessAllByFolderAndPathPrefix(context.Context, int, string, time.Time) (int, error) { + return 0, nil +} + +func (m *settleBlockingMatcher) RetryUnmatchedItemsByFolderAndPathPrefix(context.Context, int, string) (int, int, error) { + return 0, 0, nil +} + +// settleStubScanner returns a scan result that triggers the TV settle window +// (a series library with new items) and accepts the post-match finalize call. +type settleStubScanner struct { + result *scanner.ScanResult +} + +func (s *settleStubScanner) ScanFolder(context.Context, *models.MediaFolder) (*scanner.ScanResult, error) { + return s.result, nil +} + +func (s *settleStubScanner) ScanSubtree(context.Context, *models.MediaFolder, string) (*scanner.ScanResult, error) { + return s.result, nil +} + +func (s *settleStubScanner) ScanFile(context.Context, string, *models.MediaFolder) error { + return nil +} + +func (s *settleStubScanner) FinalizeVariantsByPathPrefix(context.Context, *models.MediaFolder, string) error { + return nil +} + +// TestIngestFolderCompletesWhenDrainerCanceledMidBatch is a regression test for +// the settle-window cancellation bug: a TV library full scan was recorded as +// "cancelled" because a drainer batch in flight when stopDrainers() ran returned +// context.Canceled, which the drainer escalated as a fatal scan error. The +// ingest must instead complete normally. +func TestIngestFolderCompletesWhenDrainerCanceledMidBatch(t *testing.T) { + matcher := &settleBlockingMatcher{} + exec := &Executor{ + scanner: &settleStubScanner{result: &scanner.ScanResult{New: 1}}, + matcher: matcher, + now: time.Now, + tvDrainSettleWindow: 50 * time.Millisecond, + } + folder := &models.MediaFolder{ID: 5, Type: "series", Paths: []string{"/tv"}} + + result, err := exec.IngestFolder(context.Background(), folder) + if err != nil { + t.Fatalf("expected ingest to complete, got error: %v", err) + } + if result == nil || result.Skipped { + t.Fatalf("expected a non-skipped result, got %+v", result) + } + if matcher.batchCalls.Load() == 0 { + t.Fatal("drainer never ran a batch; test did not exercise the settle-window shutdown path") + } +} From eeecf6002301e46e6e9e5c1eeb18556e18650642 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 11:57:49 -0400 Subject: [PATCH 060/201] perf(catalog): add episode browse index fast path --- .../2026-05-27-episode-catalog-performance.md | 520 +++++++++ .../episode_catalog_entries_executor.go | 1017 +++++++++++++++++ .../episode_catalog_entries_executor_test.go | 130 +++ internal/catalog/query_executor.go | 22 + .../142_episode_catalog_entries.down.sql | 40 + migrations/142_episode_catalog_entries.up.sql | 647 +++++++++++ scripts/benchmark_episode_catalog.py | 266 +++++ web/src/pages/LibraryBrowse.test.tsx | 2 +- web/src/pages/LibraryBrowse.tsx | 2 +- 9 files changed, 2644 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-27-episode-catalog-performance.md create mode 100644 internal/catalog/episode_catalog_entries_executor.go create mode 100644 internal/catalog/episode_catalog_entries_executor_test.go create mode 100644 migrations/142_episode_catalog_entries.down.sql create mode 100644 migrations/142_episode_catalog_entries.up.sql create mode 100644 scripts/benchmark_episode_catalog.py diff --git a/docs/superpowers/plans/2026-05-27-episode-catalog-performance.md b/docs/superpowers/plans/2026-05-27-episode-catalog-performance.md new file mode 100644 index 00000000..bdb0b7d2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-episode-catalog-performance.md @@ -0,0 +1,520 @@ +# Episode Catalog Performance Plan + +Commands assume the repository root is the cwd. + +> **For agentic workers:** This is a discussion plan, not an execution script. Confirm the architecture decision points before implementing the durable catalog index work. + +## Goal + +Make `/api/v1/catalog?source=query&type=episode&library_id=` fast enough for large series libraries and concurrent users. The first page of common episode-library browse requests should avoid full-library scans, repeated media-file aggregation, and exact counts unless the caller explicitly needs them. + +Target behavior: + +- First-page episode browse returns in less than 800 ms API time for common sorts/filters on a library with about 1 million episodes. +- Hot SQL paths avoid per-request aggregation over `media_files` or all `episode_libraries`. +- Exact totals are not computed by default for expensive query shapes. +- Existing API semantics continue to work for web, Android, Apple, and Jellyfin-compatible clients unless explicitly versioned. + +## Current Findings + +Testing used the large Series library endpoint shape: + +```text +/api/v1/catalog?source=query&type=episode&library_id=2&limit=60&offset=0 +``` + +Library 2 currently has about 767k episode-library rows. The recent fixes improved title and date-added browse, but several sorts and filters still spend too much time in SQL, especially when exact totals are requested. + +### Sort timings + +With exact totals enabled: + +| Sort | API time | +| --- | ---: | +| `title asc` | 0.93s | +| `added_at desc` | 0.73s | +| `release_date desc` | 1.93s | +| `last_air_date desc` | 2.21s | +| `year desc` | 2.28s | +| `content_rating asc` | 1.21s | +| `runtime desc` | 1.97s | +| `rating_imdb desc` | 2.34s | +| `rating_tmdb desc` | 2.12s | +| `resolution desc` | 8.67s | +| `bitrate desc` | 5.53s | +| `progress desc` | 2.43s | +| `date_viewed desc` | 1.98s | +| `plays desc` | 2.06s | + +With `include_total=false`: + +| Sort | API time | +| --- | ---: | +| `title asc` | 0.39s | +| `added_at desc` | 0.29s | +| `release_date desc` | 1.50s | +| `last_air_date desc` | 1.84s | +| `year desc` | 1.45s | +| `runtime desc` | 1.55s | +| `rating_imdb desc` | 1.73s | +| `rating_tmdb desc` | 1.77s | +| `resolution desc` | 5.69s | +| `bitrate desc` | 5.09s | +| `date_viewed desc` | 2.59s | + +The count split helped, but the page query itself is still too slow for many sort families. + +### Filter timings + +With `sort=title&order=asc` and exact totals: + +| Filter | API time | Total | +| --- | ---: | ---: | +| none | 1.07s | 767k | +| `genre=Comedy` | 2.92s | 250k | +| `resolution=1080p` | 3.04s | 474k | +| `subtitle_language=en` | 6.57s | 524k | +| `dolby_vision=true` | 7.63s | 20k | +| `watched=true` | 16.32s | 11k | +| `watched=false` | 13.09s | 756k | +| `last_watched in_last 30d` | timed out at 30s | n/a | + +With `include_total=false`, most of those page queries drop below 1s, except `last_watched in_last 30d`, which still times out. This confirms two independent problems: + +- Exact totals are too expensive to run on every page 0 request. +- Some page plans start from the wrong side of the query and scan the whole library. + +## Root Causes + +1. The generic episode catalog path projects episodes into a `media_items`-shaped subquery and then asks one query builder to handle every sort/filter combination. This keeps code reusable, but it hides cheaper plans from PostgreSQL. + +2. Technical sorts and filters aggregate `media_files` per request: + +```text +media_files -> GROUP BY episode_id -> join all episode candidates -> sort +``` + +For `resolution` and `bitrate`, this means scanning and grouping hundreds of thousands of file rows before returning 60 items. + +3. Personalized sorts and filters left-join small user-state sets onto the entire episode library. For `date_viewed desc`, `plays desc`, and `last_watched`, the database sorts mostly-null rows from the whole library instead of starting with the few watched rows. + +4. Exact counts use the same broad filtered relation as the page query. This is acceptable for small result sets, but expensive for common filters like `watched=false`, `subtitle_language=en`, or genre filters that match hundreds of thousands of rows. + +5. Series-level episode filters and sorts duplicate series metadata across all episodes. A filter like `genre=Comedy` is logically a series filter, but the current episode projection evaluates it at episode scale. + +## Prototype Results + +The following SQL prototypes were tested against the same data shape to validate that the proposed plan is viable. + +### User-state-first plans + +Starting from watched/progress rows, then joining to episode library membership: + +| Query shape | SQL time | +| --- | ---: | +| `last_watched in_last 30d` | about 5 ms | +| `date_viewed desc` first page | about 282 ms | +| `watched=true` title page | about 261 ms | +| `watched=true` exact count | about 80 ms | + +This proves the `last_watched` timeout is a planner/source problem, not an unavoidable data-size problem. + +### Precomputed technical stats + +A temporary per-episode/per-library stats table was built with max resolution rank, max bitrate, HDR/Dolby Vision flags, and audio/subtitle language arrays. Build time was about 22s as a one-time backfill over the dev data; scanner maintenance would keep the permanent table updated incrementally. + +Using that temporary stats table: + +| Query shape | SQL time | +| --- | ---: | +| `subtitle_language=en` filter page | about 24 ms | +| `dolby_vision=true` filter page | about 128 ms | +| `bitrate desc` sort page | about 1 ms | +| `resolution desc` sort page | about 365 ms | + +This validates a durable browse index or stats table for technical fields. + +## Architecture Decision + +There are two viable paths. + +### Option A: Incremental Specialized Plans + +Add specific fast paths for technical stats and user-state filters while keeping the generic query builder as the main executor. + +Pros: + +- Lower implementation cost. +- Smaller schema change. +- Directly fixes the worst outliers: `resolution`, `bitrate`, `subtitle_language`, `dolby_vision`, `last_watched`, `watched`. + +Cons: + +- Leaves several episode metadata sorts around 1.5-2s. +- Keeps exact-count complexity spread through the generic executor. +- Each new slow query shape becomes another special case. + +### Option B: Durable Episode Browse Index + +Create a persistent per-library episode browse index table that stores the sort/filter keys needed to find page IDs quickly, then hydrate only the selected page rows from `episodes` and parent `media_items`. + +This is the recommended target if the server needs to handle hundreds of concurrent users. It turns page selection into indexed top-N scans over a narrow table and avoids repeated joins/aggregates over broad catalog tables. + +Proposed table shape: + +```sql +CREATE TABLE episode_catalog_entries ( + media_folder_id integer NOT NULL, + episode_id text NOT NULL, + series_id text NOT NULL, + sort_key text NOT NULL, + added_at timestamptz NOT NULL, + episode_air_date date, + year integer NOT NULL, + genres text[] NOT NULL, + studios text[] NOT NULL, + networks text[] NOT NULL, + countries text[] NOT NULL, + original_language text NOT NULL, + content_rating text NOT NULL, + content_rating_rank integer NOT NULL, + status text NOT NULL, + runtime integer NOT NULL, + rating_imdb numeric, + rating_tmdb numeric, + max_resolution_rank integer, + max_bitrate integer, + has_hdr boolean NOT NULL DEFAULT false, + has_dolby_vision boolean NOT NULL DEFAULT false, + audio_language_codes text[] NOT NULL DEFAULT '{}', + subtitle_language_codes text[] NOT NULL DEFAULT '{}', + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (media_folder_id, episode_id) +); +``` + +The table should not become a second full metadata store unless measurements prove that hydration is too expensive. The first implementation can use it to pick ordered `episode_id` values, then join only those 60 IDs back to the existing episode projection for response shaping. + +Recommended indexes: + +```sql +CREATE INDEX idx_episode_catalog_entries_title +ON episode_catalog_entries (media_folder_id, sort_key, episode_id); + +CREATE INDEX idx_episode_catalog_entries_added +ON episode_catalog_entries (media_folder_id, added_at DESC, sort_key, episode_id); + +CREATE INDEX idx_episode_catalog_entries_air_date +ON episode_catalog_entries (media_folder_id, episode_air_date DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX idx_episode_catalog_entries_year +ON episode_catalog_entries (media_folder_id, year DESC, sort_key, episode_id); + +CREATE INDEX idx_episode_catalog_entries_runtime +ON episode_catalog_entries (media_folder_id, runtime DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX idx_episode_catalog_entries_imdb +ON episode_catalog_entries (media_folder_id, rating_imdb DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX idx_episode_catalog_entries_tmdb +ON episode_catalog_entries (media_folder_id, rating_tmdb DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX idx_episode_catalog_entries_resolution +ON episode_catalog_entries (media_folder_id, max_resolution_rank DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX idx_episode_catalog_entries_bitrate +ON episode_catalog_entries (media_folder_id, max_bitrate DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX idx_episode_catalog_entries_hdr +ON episode_catalog_entries (media_folder_id, sort_key, episode_id) +WHERE has_hdr; + +CREATE INDEX idx_episode_catalog_entries_dolby_vision +ON episode_catalog_entries (media_folder_id, sort_key, episode_id) +WHERE has_dolby_vision; +``` + +Evaluate either `btree_gin` multi-column GIN or separate GIN indexes for array filters: + +```sql +CREATE INDEX idx_episode_catalog_entries_genres_gin +ON episode_catalog_entries USING gin (genres); + +CREATE INDEX idx_episode_catalog_entries_audio_gin +ON episode_catalog_entries USING gin (audio_language_codes); + +CREATE INDEX idx_episode_catalog_entries_subtitle_gin +ON episode_catalog_entries USING gin (subtitle_language_codes); +``` + +## Query Plan Design + +Introduce a planner layer between `CatalogResolver` and `QueryExecutor`. + +```go +type CatalogPlan struct { + PageSQL string + PageArgs []any + CountSQL string + CountArgs []any + CountMode CatalogCountMode + SnapshotStrategy SnapshotStrategy + PlanName string +} +``` + +The planner chooses one of these sources: + +- `episode_catalog_entries` for normal episode library browse. +- `episode_user_state` CTE/source for watched/date-viewed/plays/last-watched shapes. +- Existing generic `QueryExecutor` fallback for unsupported combinations. + +The page query should use a narrow ID-first CTE: + +```sql +WITH page AS ( + SELECT ece.episode_id, ece.sort_key + FROM episode_catalog_entries ece + WHERE ece.media_folder_id = $1 + ORDER BY ece.sort_key ASC, ece.episode_id ASC + LIMIT $2 OFFSET $3 +) +SELECT ... +FROM page +JOIN episodes e ON e.content_id = page.episode_id +JOIN media_items si ON si.content_id = e.series_id +ORDER BY page.sort_key ASC, page.episode_id ASC; +``` + +Each concrete sort should carry the selected sort keys through the `page` CTE and use the same order in the final hydrated SELECT. Do not compute `row_number()` over the full candidate set just to preserve order; that would reintroduce a broad sort before `LIMIT`. + +For sort/filter shapes that can be satisfied entirely from `episode_catalog_entries`, counts become simple index-backed counts on the narrow table. For expensive or broad counts, the planner should return `total_exact=false` unless exact totals are explicitly requested. + +## User-State Plan + +Personalized sort/filter logic should start from user state when the requested result set is mostly watched/progress rows. + +Fast paths: + +- `last_watched` comparisons and `in_last`. +- `watched=true`. +- `in_progress=true`. +- `favorited=true`. +- `in_watchlist=true`. +- `date_viewed desc` first segment. +- `plays desc` first segment. +- `progress desc` first segment. + +The user-state source can start as request-time CTEs over `user_watch_history`, `user_watch_progress`, `user_favorites`, and `user_watchlist`. If concurrent load tests show those CTEs are still too expensive for heavy users, promote them to a maintained `profile_media_state` aggregate table. + +Special handling: + +- `watched=false` should not scan user state first because the result set is usually almost the whole library. Use the episode browse index with an anti-join against the small watched set, and compute exact count as `library_count - watched_count` when possible. +- `date_viewed desc` with deep offsets eventually reaches the unviewed segment. First implement the watched segment fast path and fall back only when the requested offset exceeds the watched count. +- `last_watched lt/lte` includes never-watched rows because the current SQL uses `-infinity`. Keep this behavior, but route `gt/gte/between/in_last` through user-state-first plans. + +## Count Strategy + +Exact totals are the largest remaining source of avoidable database load. The UI already understands `total_exact=false` and can estimate virtualized height. + +Plan: + +1. Change `LibraryBrowse` to call `useCatalogWindow(..., includeTotal: false)` for the first page unless a specific UI state truly needs an exact count. +2. Add backend count modes: + - `none`: return `total_exact=false` and one extra row for `has_more`. + - `fast`: exact count from a narrow indexed table or small user-state source. + - `cached`: count reused from a query hash and invalidated by scanner/user-state writes. + - `exact`: full exact count, only when requested. +3. For plain episode library counts, use `episode_catalog_entries` or `episode_libraries` directly. +4. For watched filters: + - `watched=true`: count from user-state source joined to library membership. + - `watched=false`: `library_total - watched_true_count` when the filter set permits it. +5. For technical filters, count from `episode_catalog_entries`. + +Do not run broad exact counts by default under web browse traffic. That path does not scale to hundreds of users. + +## Implementation Phases + +### Phase 0: Observability and Benchmark Harness + +- Add structured slow-query logging around catalog page and count execution: + - `source` + - `media_scope` + - `library_count` + - `sort` + - `filter_count` + - `include_total` + - `plan_name` + - page SQL duration + - count SQL duration +- Add a local benchmark helper under `scripts/` that exercises the sort/filter matrix without embedding credentials. +- Add a small `EXPLAIN (ANALYZE, BUFFERS)` note template for comparing plans. + +Verification: + +```bash +go test ./internal/catalog -count=1 +``` + +### Phase 1: Stop Exact Counts by Default in Library Browse + +- Change `web/src/pages/LibraryBrowse.tsx` to pass `includeTotal: false`. +- Keep `CatalogFiltersPanel` result count display compatible with estimated totals or suppress exact count text when `total_exact=false`. +- Confirm `ItemGrid` still virtualizes correctly using the existing estimated-total logic in `useCatalogWindow`. + +Verification: + +```bash +cd web && pnpm run lint +``` + +### Phase 2: Durable Episode Browse Index + +- Add migrations for `episode_catalog_entries`. +- Backfill from: + - `episode_libraries` + - `episodes` + - parent series rows in `media_items` + - active `media_files` +- Add a repository/service that can refresh entries for: + - one episode + - one series + - one library + - one changed media file +- Wire refresh calls into scanner and metadata writes where episode visibility or sort/filter keys change. +- Keep the existing `episode_libraries` table as the source of truth for membership; the new table is a maintained read model. + +Verification: + +```bash +go test ./internal/catalog ./internal/scanner -count=1 +``` + +### Phase 3: Episode Catalog Planner + +- Add a planner that recognizes episode library browse requests and emits ID-first page SQL against `episode_catalog_entries`. +- Keep the existing `QueryExecutor` as fallback. +- Support these first: + - no filters, all common sorts + - `genre`, `year`, `content_rating`, `status` + - `resolution`, `bitrate`, `audio_language`, `subtitle_language`, `hdr`, `dolby_vision` +- Add query-builder tests that assert the selected plan name and SQL shape for each supported sort/filter family. + +Verification: + +```bash +go test ./internal/catalog -count=1 +``` + +### Phase 4: User-State Planner + +- Add user-state-first plans for `last_watched`, `watched=true`, `in_progress=true`, `date_viewed desc`, `plays desc`, and `progress desc`. +- Add `watched=false` fast count using complement logic where safe. +- Preserve current hidden-history behavior. +- Preserve current semantics for never-watched rows on `last_watched lt/lte`. + +Verification: + +```bash +go test ./internal/catalog ./internal/userstore -count=1 +``` + +### Phase 5: Load Testing and Tuning + +- Deploy to dev. +- Re-run the sort/filter matrix with exact totals disabled and enabled. +- Run concurrent load against the high-traffic shapes: + - `title asc` + - `added_at desc` + - `release_date desc` + - `resolution desc` + - `bitrate desc` + - `subtitle_language=en` + - `dolby_vision=true` + - `date_viewed desc` + - `last_watched in_last 30d` +- Use PostgreSQL query plans and slow-query logs to tune indexes before considering the work complete. + +Target load result: + +- 50 concurrent catalog requests: p95 under 1s for indexed paths. +- No request over 5s for supported sort/filter shapes. +- No supported first-page request performs a full `media_files` aggregate. + +## Testing Matrix + +The benchmark harness should cover every executable sort and filter family. + +Sorts: + +- `title` +- `added_at` +- `release_date` +- `last_air_date` +- `year` +- `content_rating` +- `runtime` +- `rating_imdb` +- `rating_tmdb` +- `rating_rt_critic` +- `rating_rt_audience` +- `resolution` +- `bitrate` +- `progress` +- `date_viewed` +- `plays` + +Filters: + +- `type` +- `genre` +- `year` +- `rating_imdb` +- `studio` +- `network` +- `country` +- `original_language` +- `content_rating` +- `added_at` +- `release_date` +- `status` +- `actor` +- `director` +- `writer` +- `producer` +- `watched` +- `favorited` +- `in_watchlist` +- `in_progress` +- `last_watched` +- `resolution` +- `hdr` +- `dolby_vision` +- `bitrate` +- `audio_language` +- `subtitle_language` + +For each case, capture: + +- API time with exact totals. +- API time with `include_total=false`. +- page SQL time. +- count SQL time. +- rows scanned/aggregated from `EXPLAIN`. +- whether the planner used `episode_catalog_entries`, user state, or fallback. + +## Risks and Open Questions + +- The browse index is a read model. The source of truth stays in `episode_libraries`, `episodes`, `media_items`, and `media_files`, so refresh paths must be reliable and observable. +- Scanner and metadata updates can touch large series. Batch refreshes should be chunked and idempotent. +- Multi-library browse needs clear semantics for `added_at` and technical stats. The current single-library Series Library case should be optimized first. +- Exact snapshot semantics may conflict with cached counts. Page snapshots should remain stable; counts can be marked non-exact unless they come from the same snapshot-safe path. +- API response changes around count modes may require Android and Apple follow-up. Keeping the existing `total_exact=false` behavior avoids most client churn. +- Person filters may need fallback initially unless episode-level people data is available and indexed. +- Array GIN filters should be checked with real plans. If separate GIN indexes do too much post-filtering by library, use `btree_gin` multi-column indexes or add selective partial indexes for common libraries. + +## Recommended Decision + +Proceed with Option B in phases. The prototype timings show that denormalizing technical stats and starting user-state queries from user tables both work. A durable `episode_catalog_entries` read model generalizes those wins to normal episode metadata sorts too, which is the more scalable path for hundreds of users. + +Use Phase 1 as the immediate load reducer, then implement the durable browse index and planner behind the existing catalog API. Keep the generic executor as a fallback until the measured matrix shows the new planner covers the important sort/filter combinations. diff --git a/internal/catalog/episode_catalog_entries_executor.go b/internal/catalog/episode_catalog_entries_executor.go new file mode 100644 index 00000000..54fe74be --- /dev/null +++ b/internal/catalog/episode_catalog_entries_executor.go @@ -0,0 +1,1017 @@ +package catalog + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type episodeCatalogEntryPageRow struct { + episodeID string + addedAt time.Time +} + +type episodeCatalogUserStatePlan struct { + entryDef QueryDefinition + source string + alias string + positiveSource bool + userClauses []string + userArgs []any + sortField string + sortOrder string + sortOnly bool + requiresFullPageGate bool +} + +func (e *QueryExecutor) tryEpisodeCatalogUserStatePreviewPage( + ctx context.Context, + def QueryDefinition, + access AccessFilter, + limit int, + offset int, + includeTotal bool, +) ([]*models.MediaItem, int, bool, bool, error) { + def = def.Normalize() + effectiveScope := e.Scope + if effectiveScope == "" { + effectiveScope = def.MediaScope + } + if !isEpisodeCatalogScope(effectiveScope) { + return nil, 0, false, false, nil + } + if access.UserID == 0 || strings.TrimSpace(access.ProfileID) == "" { + return nil, 0, false, false, nil + } + + plan, ok, err := extractEpisodeCatalogUserStatePlan(def) + if err != nil || !ok { + return nil, 0, false, ok, err + } + if includeTotal && plan.sortOnly { + return nil, 0, false, false, nil + } + + libraryID, empty, ok := singleEpisodeCatalogLibraryID(def, access) + if !ok { + return nil, 0, false, false, nil + } + if empty { + return []*models.MediaItem{}, 0, false, true, nil + } + + if limit <= 0 { + limit = 20 + } + if offset < 0 { + offset = 0 + } + + ctes := []string{episodeCatalogUserStateCTE(plan.source)} + args := []any{access.UserID, access.ProfileID, libraryID} + argIdx := 4 + whereParts := []string{"ece.media_folder_id = $3"} + + if access.AllowedContentIDs != nil { + if len(access.AllowedContentIDs) == 0 { + return []*models.MediaItem{}, 0, false, true, nil + } + whereParts = append(whereParts, fmt.Sprintf("ece.episode_id = ANY($%d)", argIdx)) + args = append(args, access.AllowedContentIDs) + argIdx++ + } + + ApplySectionAccessFilter("ece", access, &whereParts, &args, &argIdx) + + if e.SnapshotAt != nil { + whereParts = append(whereParts, fmt.Sprintf("ece.episode_created_at <= $%d", argIdx)) + args = append(args, *e.SnapshotAt) + argIdx++ + } + + if prefix := strings.TrimSpace(access.NamePrefix); prefix != "" { + whereParts = append(whereParts, fmt.Sprintf( + "(ece.sort_key LIKE $%d ESCAPE '\\' OR LOWER(ece.title) LIKE $%d ESCAPE '\\')", + argIdx, + argIdx, + )) + args = append(args, escapePrefixForLike(prefix)+"%") + argIdx++ + } + + queryWhere, queryArgs, nextArgIdx, ok, err := buildEpisodeCatalogEntryQueryWhere(plan.entryDef, argIdx) + if err != nil || !ok { + return nil, 0, false, ok, err + } + if queryWhere != "" { + whereParts = append(whereParts, queryWhere) + args = append(args, queryArgs...) + argIdx = nextArgIdx + } + + userClauses := rebindUserStateClauses(plan.userClauses, argIdx) + whereParts = append(whereParts, userClauses...) + args = append(args, plan.userArgs...) + argIdx += len(plan.userArgs) + + fromClause := episodeCatalogUserStateFromClause(plan) + orderBy, ok := episodeCatalogUserStateOrderBy(plan) + if !ok { + return nil, 0, false, false, nil + } + whereClause := "WHERE " + strings.Join(whereParts, " AND ") + + pageLimit := limit + 1 + pageArgs := append([]any{}, args...) + limitArgIdx := argIdx + pageArgs = append(pageArgs, pageLimit) + offsetClause := "" + if offset > 0 { + offsetClause = fmt.Sprintf(" OFFSET $%d", limitArgIdx+1) + pageArgs = append(pageArgs, offset) + } + + pageSQL := fmt.Sprintf( + `WITH %s + SELECT ece.episode_id, ece.added_at + %s + %s + %s + LIMIT $%d%s`, + strings.Join(ctes, ",\n"), + fromClause, + whereClause, + orderBy, + limitArgIdx, + offsetClause, + ) + + pageRows, err := e.queryEpisodeCatalogEntryPage(ctx, pageSQL, pageArgs...) + if err != nil { + if episodeCatalogEntriesUnavailable(err) { + return nil, 0, false, false, nil + } + return nil, 0, false, true, err + } + if plan.requiresFullPageGate && len(pageRows) < pageLimit { + return nil, 0, false, false, nil + } + + hasMore := len(pageRows) > limit + if hasMore { + pageRows = pageRows[:limit] + } + + items, err := e.hydrateEpisodeCatalogEntryPage(ctx, pageRows) + if err != nil { + if episodeCatalogEntriesUnavailable(err) { + return nil, 0, false, false, nil + } + return nil, 0, false, true, err + } + + total := 0 + if includeTotal { + countSQL := fmt.Sprintf( + `WITH %s + SELECT COUNT(*) + %s + %s`, + strings.Join(ctes, ",\n"), + fromClause, + whereClause, + ) + if err := e.Pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil { + if episodeCatalogEntriesUnavailable(err) { + return nil, 0, false, false, nil + } + return nil, 0, false, true, fmt.Errorf("counting episode user-state catalog entries: %w", err) + } + hasMore = total > offset+len(items) + } + + return items, total, hasMore, true, nil +} + +func (e *QueryExecutor) tryEpisodeCatalogEntriesPreviewPage( + ctx context.Context, + def QueryDefinition, + access AccessFilter, + limit int, + offset int, + includeTotal bool, +) ([]*models.MediaItem, int, bool, bool, error) { + def = def.Normalize() + effectiveScope := e.Scope + if effectiveScope == "" { + effectiveScope = def.MediaScope + } + if !isEpisodeCatalogScope(effectiveScope) { + return nil, 0, false, false, nil + } + + allowPersonalized := strings.TrimSpace(access.ProfileID) != "" + if err := def.ValidateWithOptions(allowPersonalized, allowPersonalized); err != nil { + return nil, 0, false, true, err + } + + libraryID, empty, ok := singleEpisodeCatalogLibraryID(def, access) + if !ok { + return nil, 0, false, false, nil + } + if empty { + return []*models.MediaItem{}, 0, false, true, nil + } + + if limit <= 0 { + limit = 20 + } + if offset < 0 { + offset = 0 + } + + whereParts := []string{"ece.media_folder_id = $1"} + args := []any{libraryID} + argIdx := 2 + + if access.AllowedContentIDs != nil { + if len(access.AllowedContentIDs) == 0 { + return []*models.MediaItem{}, 0, false, true, nil + } + whereParts = append(whereParts, fmt.Sprintf("ece.episode_id = ANY($%d)", argIdx)) + args = append(args, access.AllowedContentIDs) + argIdx++ + } + + ApplySectionAccessFilter("ece", access, &whereParts, &args, &argIdx) + + if e.SnapshotAt != nil { + whereParts = append(whereParts, fmt.Sprintf("ece.episode_created_at <= $%d", argIdx)) + args = append(args, *e.SnapshotAt) + argIdx++ + } + + if prefix := strings.TrimSpace(access.NamePrefix); prefix != "" { + whereParts = append(whereParts, fmt.Sprintf( + "(ece.sort_key LIKE $%d ESCAPE '\\' OR LOWER(ece.title) LIKE $%d ESCAPE '\\')", + argIdx, + argIdx, + )) + args = append(args, escapePrefixForLike(prefix)+"%") + argIdx++ + } + + queryWhere, queryArgs, nextArgIdx, ok, err := buildEpisodeCatalogEntryQueryWhere(def, argIdx) + if err != nil { + return nil, 0, false, true, err + } + if !ok { + return nil, 0, false, false, nil + } + if queryWhere != "" { + whereParts = append(whereParts, queryWhere) + args = append(args, queryArgs...) + argIdx = nextArgIdx + } + + orderBy, ok := episodeCatalogEntryOrderBy(def.Sort) + if !ok { + return nil, 0, false, false, nil + } + + whereClause := "WHERE " + strings.Join(whereParts, " AND ") + pageLimit := limit + 1 + pageArgs := append([]any{}, args...) + limitArgIdx := argIdx + pageArgs = append(pageArgs, pageLimit) + offsetClause := "" + if offset > 0 { + offsetClause = fmt.Sprintf(" OFFSET $%d", limitArgIdx+1) + pageArgs = append(pageArgs, offset) + } + + pageSQL := fmt.Sprintf( + `SELECT ece.episode_id, ece.added_at + FROM episode_catalog_entries ece + %s + %s + LIMIT $%d%s`, + whereClause, + orderBy, + limitArgIdx, + offsetClause, + ) + + pageRows, err := e.queryEpisodeCatalogEntryPage(ctx, pageSQL, pageArgs...) + if err != nil { + if episodeCatalogEntriesUnavailable(err) { + return nil, 0, false, false, nil + } + return nil, 0, false, true, err + } + + hasMore := len(pageRows) > limit + if hasMore { + pageRows = pageRows[:limit] + } + + items, err := e.hydrateEpisodeCatalogEntryPage(ctx, pageRows) + if err != nil { + if episodeCatalogEntriesUnavailable(err) { + return nil, 0, false, false, nil + } + return nil, 0, false, true, err + } + + total := 0 + if includeTotal { + countSQL := fmt.Sprintf( + `SELECT COUNT(*) + FROM episode_catalog_entries ece + %s`, + whereClause, + ) + if err := e.Pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil { + if episodeCatalogEntriesUnavailable(err) { + return nil, 0, false, false, nil + } + return nil, 0, false, true, fmt.Errorf("counting episode catalog entries: %w", err) + } + hasMore = total > offset+len(items) + } + + return items, total, hasMore, true, nil +} + +func extractEpisodeCatalogUserStatePlan(def QueryDefinition) (episodeCatalogUserStatePlan, bool, error) { + plan := episodeCatalogUserStatePlan{ + entryDef: def, + sortField: NormalizeQuerySort(def.Sort).Field, + sortOrder: NormalizeQuerySort(def.Sort).Order, + } + switch plan.sortField { + case "date_viewed", "plays": + plan.source = "viewed" + plan.alias = "uv" + plan.positiveSource = true + plan.sortOnly = true + plan.requiresFullPageGate = true + case "progress": + plan.source = "progress" + plan.alias = "up" + plan.positiveSource = true + plan.sortOnly = true + plan.requiresFullPageGate = true + } + + if len(def.Groups) == 0 { + if plan.source == "" { + return episodeCatalogUserStatePlan{}, false, nil + } + return plan, true, nil + } + if def.Match != "all" { + return episodeCatalogUserStatePlan{}, false, nil + } + + filteredGroups := make([]QueryGroup, 0, len(def.Groups)) + for _, group := range def.Groups { + if group.Match != "all" { + for _, rule := range group.Rules { + if episodeCatalogUserStateRule(rule) { + return episodeCatalogUserStatePlan{}, false, nil + } + } + filteredGroups = append(filteredGroups, group) + continue + } + + filteredRules := make([]QueryRule, 0, len(group.Rules)) + for _, rule := range group.Rules { + source, alias, positive, clause, args, ok, err := buildEpisodeCatalogUserStateRule(rule) + if err != nil { + return episodeCatalogUserStatePlan{}, true, err + } + if !ok { + filteredRules = append(filteredRules, rule) + continue + } + if plan.source != "" && plan.source != source { + return episodeCatalogUserStatePlan{}, false, nil + } + plan.source = source + plan.alias = alias + plan.positiveSource = positive + plan.userClauses = append(plan.userClauses, clause) + plan.userArgs = append(plan.userArgs, args...) + plan.sortOnly = false + plan.requiresFullPageGate = false + } + if len(filteredRules) > 0 { + group.Rules = filteredRules + filteredGroups = append(filteredGroups, group) + } + } + if plan.source == "" { + return episodeCatalogUserStatePlan{}, false, nil + } + plan.entryDef.Groups = filteredGroups + return plan, true, nil +} + +func episodeCatalogUserStateRule(rule QueryRule) bool { + switch rule.Field { + case "watched", "in_progress", "last_watched": + return true + default: + return false + } +} + +func buildEpisodeCatalogUserStateRule(rule QueryRule) (string, string, bool, string, []any, bool, error) { + switch rule.Field { + case "watched": + value, ok := rule.Value.(bool) + if !ok { + return "", "", false, "", nil, true, fmt.Errorf("watched requires a boolean value") + } + if value { + return "viewed", "uv", true, "uv.episode_id IS NOT NULL", nil, true, nil + } + return "viewed", "uv", false, "uv.episode_id IS NULL", nil, true, nil + case "in_progress": + value, ok := rule.Value.(bool) + if !ok { + return "", "", false, "", nil, true, fmt.Errorf("in_progress requires a boolean value") + } + if value { + return "progress", "up", true, "up.episode_id IS NOT NULL", nil, true, nil + } + return "progress", "up", false, "up.episode_id IS NULL", nil, true, nil + case "last_watched": + switch rule.Op { + case "gt", "gte", "between", "in_last": + default: + return "", "", false, "", nil, false, nil + } + clause, args, err := buildEpisodeCatalogLastWatchedClause(rule) + if err != nil { + return "", "", false, "", nil, true, err + } + return "viewed", "uv", true, clause, args, true, nil + default: + return "", "", false, "", nil, false, nil + } +} + +func buildEpisodeCatalogLastWatchedClause(rule QueryRule) (string, []any, error) { + switch rule.Op { + case "gt", "gte": + operator := map[string]string{"gt": ">", "gte": ">="}[rule.Op] + return "uv.last_watched " + operator + " $%d::timestamptz", []any{rule.Value}, nil + case "between": + values, err := toBetweenValues(rule.Value) + if err != nil { + return "", nil, fmt.Errorf("between requires [min, max] array: %w", err) + } + return "uv.last_watched >= $%d::timestamptz AND uv.last_watched <= $%d::timestamptz", []any{values[0], values[1]}, nil + case "in_last": + duration, ok := rule.Value.(string) + if !ok { + return "", nil, fmt.Errorf("in_last requires a duration string like '30d'") + } + interval, err := parseDuration(duration) + if err != nil { + return "", nil, err + } + return "uv.last_watched >= NOW() - INTERVAL '" + interval + "'", nil, nil + default: + return "", nil, fmt.Errorf("unsupported last_watched operator %q", rule.Op) + } +} + +func rebindUserStateClauses(clauses []string, argIdx int) []string { + if len(clauses) == 0 { + return nil + } + out := make([]string, len(clauses)) + nextArg := argIdx + for i, clause := range clauses { + for strings.Contains(clause, "$%d") { + clause = strings.Replace(clause, "$%d", "$"+strconv.Itoa(nextArg), 1) + nextArg++ + } + out[i] = clause + } + return out +} + +func episodeCatalogUserStateCTE(source string) string { + switch source { + case "progress": + return `user_progress AS ( + SELECT uwp.media_item_id AS episode_id, + uwp.position_seconds::double precision / NULLIF(uwp.duration_seconds, 0) AS progress_ratio + FROM user_watch_progress uwp + LEFT JOIN user_history_hidden_items hhi + ON hhi.user_id = $1 + AND hhi.profile_id = $2 + AND hhi.media_item_id = uwp.media_item_id + WHERE uwp.user_id = $1 + AND uwp.profile_id = $2 + AND uwp.completed = FALSE + AND uwp.position_seconds > 0 + AND COALESCE(uwp.duration_seconds, 0) > 0 + AND (hhi.media_item_id IS NULL OR uwp.updated_at > hhi.hidden_before) + )` + default: + return `user_viewed AS ( + SELECT src.episode_id, + MAX(src.last_watched) AS last_watched, + NULLIF(GREATEST( + COALESCE(MAX(src.history_play_count), 0), + COALESCE(MAX(src.progress_play_count), 0) + ), 0) AS play_count + FROM ( + SELECT uwh.media_item_id AS episode_id, + MAX(uwh.watched_at) AS last_watched, + COUNT(*)::integer AS history_play_count, + NULL::integer AS progress_play_count + FROM user_watch_history uwh + LEFT JOIN user_history_hidden_items hhi + ON hhi.user_id = $1 + AND hhi.profile_id = $2 + AND hhi.media_item_id = uwh.media_item_id + WHERE uwh.user_id = $1 + AND uwh.profile_id = $2 + AND uwh.completed = TRUE + AND (hhi.media_item_id IS NULL OR uwh.watched_at > hhi.hidden_before) + GROUP BY uwh.media_item_id + UNION ALL + SELECT uwp.media_item_id AS episode_id, + uwp.updated_at AS last_watched, + NULL::integer AS history_play_count, + 1 AS progress_play_count + FROM user_watch_progress uwp + LEFT JOIN user_history_hidden_items hhi + ON hhi.user_id = $1 + AND hhi.profile_id = $2 + AND hhi.media_item_id = uwp.media_item_id + WHERE uwp.user_id = $1 + AND uwp.profile_id = $2 + AND uwp.completed = TRUE + AND (hhi.media_item_id IS NULL OR uwp.updated_at > hhi.hidden_before) + ) src + GROUP BY src.episode_id + )` + } +} + +func episodeCatalogUserStateFromClause(plan episodeCatalogUserStatePlan) string { + tableName := "user_viewed" + if plan.source == "progress" { + tableName = "user_progress" + } + if plan.positiveSource { + return fmt.Sprintf( + "FROM %s %s JOIN episode_catalog_entries ece ON ece.episode_id = %s.episode_id", + tableName, + plan.alias, + plan.alias, + ) + } + return fmt.Sprintf( + "FROM episode_catalog_entries ece LEFT JOIN %s %s ON %s.episode_id = ece.episode_id", + tableName, + plan.alias, + plan.alias, + ) +} + +func episodeCatalogUserStateOrderBy(plan episodeCatalogUserStatePlan) (string, bool) { + dir := "DESC" + if plan.sortOrder == "asc" { + dir = "ASC" + } + switch plan.sortField { + case "date_viewed": + return fmt.Sprintf("ORDER BY uv.last_watched %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true + case "plays": + return fmt.Sprintf("ORDER BY uv.play_count %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true + case "progress": + return fmt.Sprintf("ORDER BY up.progress_ratio %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true + default: + return episodeCatalogEntryOrderBy(QuerySort{Field: plan.sortField, Order: plan.sortOrder}) + } +} + +func (e *QueryExecutor) queryEpisodeCatalogEntryPage( + ctx context.Context, + sql string, + args ...any, +) ([]episodeCatalogEntryPageRow, error) { + rows, err := e.Pool.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("querying episode catalog entry page: %w", err) + } + defer rows.Close() + + pageRows := make([]episodeCatalogEntryPageRow, 0) + for rows.Next() { + var row episodeCatalogEntryPageRow + if err := rows.Scan(&row.episodeID, &row.addedAt); err != nil { + return nil, fmt.Errorf("scanning episode catalog entry page: %w", err) + } + pageRows = append(pageRows, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating episode catalog entry page: %w", err) + } + return pageRows, nil +} + +func (e *QueryExecutor) hydrateEpisodeCatalogEntryPage( + ctx context.Context, + pageRows []episodeCatalogEntryPageRow, +) ([]*models.MediaItem, error) { + if len(pageRows) == 0 { + return []*models.MediaItem{}, nil + } + + ids := make([]string, 0, len(pageRows)) + addedAtByID := make(map[string]time.Time, len(pageRows)) + for _, row := range pageRows { + ids = append(ids, row.episodeID) + addedAtByID[row.episodeID] = row.addedAt + } + + relation := fmt.Sprintf(episodeCatalogSelectBody, "e.content_id = ANY($1)") + sql := "SELECT " + qualifiedListItemColumns("mi") + " FROM " + relation + rows, err := e.Pool.Query(ctx, sql, ids) + if err != nil { + return nil, fmt.Errorf("hydrating episode catalog entry page: %w", err) + } + defer rows.Close() + + hydrated, err := scanItems(rows) + if err != nil { + return nil, err + } + + itemsByID := make(map[string]*models.MediaItem, len(hydrated)) + for _, item := range hydrated { + if item == nil { + continue + } + if addedAt, ok := addedAtByID[item.ContentID]; ok { + t := addedAt + item.AddedAt = &t + } else if item.AddedAt == nil && !item.CreatedAt.IsZero() { + t := item.CreatedAt + item.AddedAt = &t + } + itemsByID[item.ContentID] = item + } + + ordered := make([]*models.MediaItem, 0, len(pageRows)) + for _, row := range pageRows { + if item := itemsByID[row.episodeID]; item != nil { + ordered = append(ordered, item) + } + } + return ordered, nil +} + +func singleEpisodeCatalogLibraryID(def QueryDefinition, access AccessFilter) (int, bool, bool) { + libraryIDs := append([]int(nil), def.LibraryIDs...) + if access.AllowedLibraryIDs != nil { + if len(libraryIDs) == 0 { + libraryIDs = append([]int(nil), access.AllowedLibraryIDs...) + } else { + libraryIDs = intersectInts(libraryIDs, access.AllowedLibraryIDs) + } + } + if len(libraryIDs) == 0 { + if access.AllowedLibraryIDs != nil { + return 0, true, true + } + return 0, false, false + } + if len(libraryIDs) != 1 { + return 0, false, false + } + libraryID := libraryIDs[0] + if libraryID <= 0 { + return 0, true, true + } + for _, disabledID := range access.DisabledLibraryIDs { + if disabledID == libraryID { + return 0, true, true + } + } + return libraryID, false, true +} + +func buildEpisodeCatalogEntryQueryWhere(def QueryDefinition, argIdx int) (string, []any, int, bool, error) { + if len(def.Groups) == 0 { + return "", nil, argIdx, true, nil + } + + topJoiner := " AND " + if def.Match == "any" { + topJoiner = " OR " + } + + var args []any + var groupClauses []string + for _, group := range def.Groups { + clause, groupArgs, nextArgIdx, ok, err := buildEpisodeCatalogEntryGroupWhere(group, argIdx) + if err != nil || !ok { + return "", nil, argIdx, ok, err + } + argIdx = nextArgIdx + args = append(args, groupArgs...) + if clause != "" { + groupClauses = append(groupClauses, clause) + } + } + + switch len(groupClauses) { + case 0: + return "", args, argIdx, true, nil + case 1: + return groupClauses[0], args, argIdx, true, nil + default: + wrapped := make([]string, len(groupClauses)) + for i, clause := range groupClauses { + wrapped[i] = "(" + clause + ")" + } + return strings.Join(wrapped, topJoiner), args, argIdx, true, nil + } +} + +func buildEpisodeCatalogEntryGroupWhere(group QueryGroup, argIdx int) (string, []any, int, bool, error) { + if len(group.Rules) == 0 { + return "", nil, argIdx, true, nil + } + if group.Match == "all" && countSameFileTechnicalRules(group.Rules) > 1 { + return "", nil, argIdx, false, nil + } + + joiner := " AND " + if group.Match == "any" { + joiner = " OR " + } + + var args []any + var clauses []string + for _, rule := range group.Rules { + clause, ruleArgs, nextArgIdx, ok, err := buildEpisodeCatalogEntryRuleWhere(rule, argIdx) + if err != nil || !ok { + return "", nil, argIdx, ok, err + } + argIdx = nextArgIdx + args = append(args, ruleArgs...) + if clause != "" { + clauses = append(clauses, clause) + } + } + if len(clauses) == 0 { + return "", args, argIdx, true, nil + } + return strings.Join(clauses, joiner), args, argIdx, true, nil +} + +func buildEpisodeCatalogEntryRuleWhere(rule QueryRule, argIdx int) (string, []any, int, bool, error) { + switch rule.Field { + case "type": + return buildEpisodeCatalogTypeClause(rule, argIdx) + case "genre": + return buildEpisodeCatalogArrayClause("ece.genres", rule, argIdx) + case "studio": + return buildEpisodeCatalogArrayClause("ece.studios", rule, argIdx) + case "network": + return buildEpisodeCatalogArrayClause("ece.networks", rule, argIdx) + case "country": + return buildEpisodeCatalogArrayClause("ece.countries", rule, argIdx) + case "year": + return buildEpisodeCatalogComparisonClause("ece.year", rule, argIdx, "") + case "rating_imdb": + return buildEpisodeCatalogComparisonClause("ece.rating_imdb", rule, argIdx, "") + case "original_language": + return buildEpisodeCatalogEqualityClause("ece.original_language", rule, argIdx) + case "content_rating": + return buildEpisodeCatalogEqualityClause("ece.content_rating", rule, argIdx) + case "added_at": + return buildEpisodeCatalogComparisonClause("ece.added_at", rule, argIdx, "timestamptz") + case "release_date": + return buildEpisodeCatalogComparisonClause("ece.episode_air_date", rule, argIdx, "date") + case "status": + return buildEpisodeCatalogEqualityClause("ece.status", rule, argIdx) + case "resolution": + return buildEpisodeCatalogResolutionClause(rule, argIdx) + case "hdr": + return buildEpisodeCatalogBoolPresenceClause("ece.has_hdr", "ece.has_non_hdr", rule, argIdx) + case "dolby_vision": + return buildEpisodeCatalogBoolPresenceClause("ece.has_dolby_vision", "ece.has_non_dolby_vision", rule, argIdx) + case "bitrate": + return buildEpisodeCatalogBitrateClause(rule, argIdx) + case "audio_language": + return buildEpisodeCatalogLanguageArrayClause("ece.audio_language_codes", rule, argIdx) + case "subtitle_language": + return buildEpisodeCatalogLanguageArrayClause("ece.subtitle_language_codes", rule, argIdx) + case "actor", "director", "writer", "producer", "watched", "favorited", "in_watchlist", "in_progress", "last_watched": + return "", nil, argIdx, false, nil + default: + return "", nil, argIdx, false, nil + } +} + +func buildEpisodeCatalogTypeClause(rule QueryRule, argIdx int) (string, []any, int, bool, error) { + value, ok := rule.Value.(string) + if !ok { + return "", nil, argIdx, true, fmt.Errorf("type requires a string value") + } + isEpisode := strings.EqualFold(strings.TrimSpace(value), "episode") + switch rule.Op { + case "is": + if isEpisode { + return "1 = 1", nil, argIdx, true, nil + } + return "1 = 0", nil, argIdx, true, nil + case "is_not": + if isEpisode { + return "1 = 0", nil, argIdx, true, nil + } + return "1 = 1", nil, argIdx, true, nil + default: + return "", nil, argIdx, true, fmt.Errorf("unsupported type operator %q", rule.Op) + } +} + +func buildEpisodeCatalogArrayClause(column string, rule QueryRule, argIdx int) (string, []any, int, bool, error) { + if rule.Op != "is" && rule.Op != "is_not" && rule.Op != "contains" { + return "", nil, argIdx, true, fmt.Errorf("unsupported array operator %q", rule.Op) + } + clause := fmt.Sprintf("%s @> ARRAY[$%d]::text[]", column, argIdx) + if rule.Op == "is_not" { + clause = "NOT (" + clause + ")" + } + return clause, []any{rule.Value}, argIdx + 1, true, nil +} + +func buildEpisodeCatalogLanguageArrayClause(column string, rule QueryRule, argIdx int) (string, []any, int, bool, error) { + value, ok := rule.Value.(string) + if !ok { + return "", nil, argIdx, true, fmt.Errorf("%s requires a string value", rule.Field) + } + rule.Value = strings.ToLower(strings.TrimSpace(value)) + return buildEpisodeCatalogArrayClause(column, rule, argIdx) +} + +func buildEpisodeCatalogResolutionClause(rule QueryRule, argIdx int) (string, []any, int, bool, error) { + value, ok := rule.Value.(string) + if !ok { + return "", nil, argIdx, true, fmt.Errorf("resolution requires a string value") + } + rule.Value = normalizeResolutionValue(value) + return buildEpisodeCatalogArrayClause("ece.resolution_codes", rule, argIdx) +} + +func buildEpisodeCatalogEqualityClause(column string, rule QueryRule, argIdx int) (string, []any, int, bool, error) { + if rule.Op != "is" && rule.Op != "is_not" { + return "", nil, argIdx, true, fmt.Errorf("unsupported equality operator %q", rule.Op) + } + clause := fmt.Sprintf("%s = $%d", column, argIdx) + if rule.Op == "is_not" { + clause = "NOT (" + clause + ")" + } + return clause, []any{rule.Value}, argIdx + 1, true, nil +} + +func buildEpisodeCatalogComparisonClause(column string, rule QueryRule, argIdx int, cast string) (string, []any, int, bool, error) { + placeholder := func(idx int) string { + if cast == "" { + return "$" + strconv.Itoa(idx) + } + return "$" + strconv.Itoa(idx) + "::" + cast + } + + switch rule.Op { + case "gt", "gte", "lt", "lte": + operator := map[string]string{"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[rule.Op] + return fmt.Sprintf("%s %s %s", column, operator, placeholder(argIdx)), []any{rule.Value}, argIdx + 1, true, nil + case "between": + values, err := toBetweenValues(rule.Value) + if err != nil { + return "", nil, argIdx, true, fmt.Errorf("between requires [min, max] array: %w", err) + } + return fmt.Sprintf("%s >= %s AND %s <= %s", column, placeholder(argIdx), column, placeholder(argIdx+1)), + []any{values[0], values[1]}, argIdx + 2, true, nil + case "is": + return fmt.Sprintf("%s = %s", column, placeholder(argIdx)), []any{rule.Value}, argIdx + 1, true, nil + case "is_not": + return fmt.Sprintf("NOT (%s = %s)", column, placeholder(argIdx)), []any{rule.Value}, argIdx + 1, true, nil + case "in_last": + duration, ok := rule.Value.(string) + if !ok { + return "", nil, argIdx, true, fmt.Errorf("in_last requires a duration string like '30d'") + } + interval, err := parseDuration(duration) + if err != nil { + return "", nil, argIdx, true, err + } + return fmt.Sprintf("%s >= NOW() - INTERVAL '%s'", column, interval), nil, argIdx, true, nil + default: + return "", nil, argIdx, true, fmt.Errorf("unsupported comparison operator %q", rule.Op) + } +} + +func buildEpisodeCatalogBoolPresenceClause(trueColumn, falseColumn string, rule QueryRule, argIdx int) (string, []any, int, bool, error) { + if rule.Op != "is" { + return "", nil, argIdx, true, fmt.Errorf("unsupported boolean operator %q", rule.Op) + } + value, ok := rule.Value.(bool) + if !ok { + return "", nil, argIdx, true, fmt.Errorf("%s requires a boolean value", rule.Field) + } + if value { + return trueColumn, nil, argIdx, true, nil + } + return falseColumn, nil, argIdx, true, nil +} + +func buildEpisodeCatalogBitrateClause(rule QueryRule, argIdx int) (string, []any, int, bool, error) { + switch rule.Op { + case "gt", "gte": + operator := map[string]string{"gt": ">", "gte": ">="}[rule.Op] + return fmt.Sprintf("ece.max_bitrate %s $%d", operator, argIdx), []any{rule.Value}, argIdx + 1, true, nil + case "lt", "lte": + operator := map[string]string{"lt": "<", "lte": "<="}[rule.Op] + return fmt.Sprintf("ece.min_bitrate %s $%d", operator, argIdx), []any{rule.Value}, argIdx + 1, true, nil + case "between": + return "", nil, argIdx, false, nil + default: + return "", nil, argIdx, true, fmt.Errorf("unsupported bitrate operator %q", rule.Op) + } +} + +func episodeCatalogEntryOrderBy(sortConfig QuerySort) (string, bool) { + sortConfig = NormalizeQuerySort(sortConfig) + dir := "DESC" + if sortConfig.Order == "asc" { + dir = "ASC" + } + + switch sortConfig.Field { + case "title": + return fmt.Sprintf("ORDER BY ece.sort_key %s, ece.episode_id ASC", dir), true + case "added_at": + return fmt.Sprintf("ORDER BY ece.added_at %s, ece.sort_key ASC, ece.episode_id ASC", dir), true + case "release_date", "last_air_date": + return fmt.Sprintf("ORDER BY ece.episode_air_date %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true + case "year": + return fmt.Sprintf("ORDER BY ece.year %s, ece.sort_key ASC, ece.episode_id ASC", dir), true + case "content_rating": + return fmt.Sprintf("ORDER BY ece.content_rating_rank %s, ece.content_rating_label %s, ece.sort_key ASC, ece.episode_id ASC", dir, dir), true + case "runtime": + return fmt.Sprintf("ORDER BY ece.runtime %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true + case "rating_imdb": + return fmt.Sprintf("ORDER BY ece.rating_imdb %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true + case "rating_tmdb": + return fmt.Sprintf("ORDER BY ece.rating_tmdb %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true + case "rating_rt_critic", "rating_rt_audience": + return "ORDER BY ece.sort_key ASC, ece.episode_id ASC", true + case "resolution": + return fmt.Sprintf("ORDER BY ece.max_resolution_rank %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true + case "bitrate": + return fmt.Sprintf("ORDER BY ece.max_bitrate %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true + default: + return "", false + } +} + +func countSameFileTechnicalRules(rules []QueryRule) int { + count := 0 + for _, rule := range rules { + if isSameFileTechnicalRule(rule) { + count++ + } + } + return count +} + +func episodeCatalogEntriesUnavailable(err error) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return false + } + return pgErr.Code == "42P01" || pgErr.Code == "42883" +} diff --git a/internal/catalog/episode_catalog_entries_executor_test.go b/internal/catalog/episode_catalog_entries_executor_test.go new file mode 100644 index 00000000..964c7a25 --- /dev/null +++ b/internal/catalog/episode_catalog_entries_executor_test.go @@ -0,0 +1,130 @@ +package catalog + +import ( + "strings" + "testing" +) + +func TestEpisodeCatalogEntryOrderByUsesReadModelColumns(t *testing.T) { + tests := []struct { + name string + sort QuerySort + want []string + }{ + { + name: "resolution", + sort: QuerySort{Field: "resolution", Order: "desc"}, + want: []string{"ece.max_resolution_rank DESC NULLS LAST", "ece.sort_key ASC", "ece.episode_id ASC"}, + }, + { + name: "bitrate", + sort: QuerySort{Field: "bitrate", Order: "desc"}, + want: []string{"ece.max_bitrate DESC NULLS LAST", "ece.sort_key ASC", "ece.episode_id ASC"}, + }, + { + name: "date added", + sort: QuerySort{Field: "added_at", Order: "desc"}, + want: []string{"ece.added_at DESC", "ece.sort_key ASC", "ece.episode_id ASC"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + orderBy, ok := episodeCatalogEntryOrderBy(tt.sort) + if !ok { + t.Fatalf("episodeCatalogEntryOrderBy(%+v) did not produce a plan", tt.sort) + } + for _, want := range tt.want { + if !strings.Contains(orderBy, want) { + t.Fatalf("ORDER BY %q does not contain %q", orderBy, want) + } + } + }) + } +} + +func TestBuildEpisodeCatalogEntryQueryWhereUsesReadModelFilters(t *testing.T) { + def := QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{ + {Field: "genre", Op: "is", Value: "Comedy"}, + {Field: "content_rating", Op: "is", Value: "TV-14"}, + {Field: "year", Op: "between", Value: []any{2015, 2016}}, + }, + }}, + }.Normalize() + + where, args, nextArgIdx, ok, err := buildEpisodeCatalogEntryQueryWhere(def, 2) + if err != nil { + t.Fatalf("buildEpisodeCatalogEntryQueryWhere returned error: %v", err) + } + if !ok { + t.Fatal("buildEpisodeCatalogEntryQueryWhere unexpectedly fell back") + } + for _, want := range []string{ + "ece.genres @> ARRAY[$2]::text[]", + "ece.content_rating = $3", + "ece.year >= $4 AND ece.year <= $5", + } { + if !strings.Contains(where, want) { + t.Fatalf("WHERE %q does not contain %q", where, want) + } + } + if nextArgIdx != 6 { + t.Fatalf("nextArgIdx = %d, want 6", nextArgIdx) + } + if len(args) != 4 { + t.Fatalf("len(args) = %d, want 4", len(args)) + } +} + +func TestEpisodeCatalogEntryQueryFallsBackForSameFileTechnicalAnd(t *testing.T) { + group := QueryGroup{ + Match: "all", + Rules: []QueryRule{ + {Field: "resolution", Op: "is", Value: "2160p"}, + {Field: "audio_language", Op: "is", Value: "en"}, + }, + } + + _, _, _, ok, err := buildEpisodeCatalogEntryGroupWhere(group, 1) + if err != nil { + t.Fatalf("buildEpisodeCatalogEntryGroupWhere returned error: %v", err) + } + if ok { + t.Fatal("same-file technical AND should fall back to the generic media_files EXISTS path") + } +} + +func TestExtractEpisodeCatalogUserStatePlanForLastWatched(t *testing.T) { + def := QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{ + {Field: "last_watched", Op: "in_last", Value: "30d"}, + {Field: "genre", Op: "is", Value: "Comedy"}, + }, + }}, + Sort: QuerySort{Field: "title", Order: "asc"}, + }.Normalize() + + plan, ok, err := extractEpisodeCatalogUserStatePlan(def) + if err != nil { + t.Fatalf("extractEpisodeCatalogUserStatePlan returned error: %v", err) + } + if !ok { + t.Fatal("expected last_watched filter to use a user-state plan") + } + if plan.source != "viewed" || plan.alias != "uv" || !plan.positiveSource { + t.Fatalf("unexpected user-state plan: %+v", plan) + } + if len(plan.userClauses) != 1 || !strings.Contains(plan.userClauses[0], "uv.last_watched") { + t.Fatalf("unexpected user clauses: %#v", plan.userClauses) + } + if len(plan.entryDef.Groups) != 1 || len(plan.entryDef.Groups[0].Rules) != 1 { + t.Fatalf("expected genre rule to remain in entry def, got %+v", plan.entryDef.Groups) + } +} diff --git a/internal/catalog/query_executor.go b/internal/catalog/query_executor.go index 21317399..ce113cde 100644 --- a/internal/catalog/query_executor.go +++ b/internal/catalog/query_executor.go @@ -42,6 +42,28 @@ func (e *QueryExecutor) PreviewPage( return nil, 0, false, fmt.Errorf("query executor requires a database pool") } + if items, total, hasMore, ok, err := e.tryEpisodeCatalogUserStatePreviewPage( + ctx, + def, + access, + limit, + offset, + includeTotal, + ); ok || err != nil { + return items, total, hasMore, err + } + + if items, total, hasMore, ok, err := e.tryEpisodeCatalogEntriesPreviewPage( + ctx, + def, + access, + limit, + offset, + includeTotal, + ); ok || err != nil { + return items, total, hasMore, err + } + build, err := e.buildPreviewPagePlan(def, access, limit, offset) if err != nil { return nil, 0, false, err diff --git a/migrations/142_episode_catalog_entries.down.sql b/migrations/142_episode_catalog_entries.down.sql new file mode 100644 index 00000000..8165f97e --- /dev/null +++ b/migrations/142_episode_catalog_entries.down.sql @@ -0,0 +1,40 @@ +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_series ON public.media_items; +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_episodes ON public.episodes; +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_media_files ON public.media_files; +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_episode_libraries ON public.episode_libraries; + +DROP FUNCTION IF EXISTS public.episode_catalog_entries_series_trigger(); +DROP FUNCTION IF EXISTS public.episode_catalog_entries_episodes_trigger(); +DROP FUNCTION IF EXISTS public.episode_catalog_entries_media_files_trigger(); +DROP FUNCTION IF EXISTS public.episode_catalog_entries_episode_libraries_trigger(); +DROP FUNCTION IF EXISTS public.refresh_episode_catalog_entries_for_series(text); +DROP FUNCTION IF EXISTS public.refresh_episode_catalog_entries_for_episode(text); +DROP FUNCTION IF EXISTS public.refresh_episode_catalog_entry(text, integer); +DROP FUNCTION IF EXISTS public.episode_catalog_resolution_rank(text); +DROP FUNCTION IF EXISTS public.episode_catalog_normalized_resolution(text); +DROP FUNCTION IF EXISTS public.episode_catalog_rating_rank(text); + +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_subtitle_gin; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_audio_gin; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_resolution_codes_gin; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_countries_gin; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_networks_gin; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_studios_gin; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_genres_gin; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_non_dolby_vision; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_dolby_vision; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_non_hdr; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_hdr; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_status; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_bitrate; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_resolution; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_tmdb; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_imdb; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_runtime; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_content_rating; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_year; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_air_date; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_added; +DROP INDEX IF EXISTS public.idx_episode_catalog_entries_title; + +DROP TABLE IF EXISTS public.episode_catalog_entries; diff --git a/migrations/142_episode_catalog_entries.up.sql b/migrations/142_episode_catalog_entries.up.sql new file mode 100644 index 00000000..e601a016 --- /dev/null +++ b/migrations/142_episode_catalog_entries.up.sql @@ -0,0 +1,647 @@ +CREATE TABLE IF NOT EXISTS public.episode_catalog_entries ( + media_folder_id integer NOT NULL, + episode_id text NOT NULL, + series_id text NOT NULL, + sort_key text NOT NULL, + title text NOT NULL, + added_at timestamp with time zone NOT NULL, + episode_air_date date, + year integer NOT NULL, + genres text[] NOT NULL DEFAULT '{}', + studios text[] NOT NULL DEFAULT '{}', + networks text[] NOT NULL DEFAULT '{}', + countries text[] NOT NULL DEFAULT '{}', + original_language text NOT NULL DEFAULT '', + content_rating text NOT NULL DEFAULT '', + content_rating_label text NOT NULL DEFAULT '~~~~', + content_rating_rank integer NOT NULL DEFAULT 2147483647, + status text NOT NULL DEFAULT 'matched', + runtime integer NOT NULL DEFAULT 0, + rating_imdb double precision, + rating_tmdb double precision, + max_resolution_rank integer, + resolution_codes text[] NOT NULL DEFAULT '{}', + max_bitrate integer, + min_bitrate integer, + has_hdr boolean NOT NULL DEFAULT false, + has_non_hdr boolean NOT NULL DEFAULT false, + has_dolby_vision boolean NOT NULL DEFAULT false, + has_non_dolby_vision boolean NOT NULL DEFAULT false, + audio_language_codes text[] NOT NULL DEFAULT '{}', + subtitle_language_codes text[] NOT NULL DEFAULT '{}', + episode_created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT episode_catalog_entries_pkey PRIMARY KEY (media_folder_id, episode_id), + CONSTRAINT episode_catalog_entries_episode_id_fkey FOREIGN KEY (episode_id) REFERENCES public.episodes(content_id) ON DELETE CASCADE, + CONSTRAINT episode_catalog_entries_media_folder_id_fkey FOREIGN KEY (media_folder_id) REFERENCES public.media_folders(id) ON DELETE CASCADE +); + +CREATE TEMP TABLE episode_catalog_entries_migration_clock AS +SELECT transaction_timestamp() AS started_at; + +CREATE OR REPLACE FUNCTION public.episode_catalog_rating_rank(rating text) +RETURNS integer +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT CASE UPPER(NULLIF(BTRIM(rating), '')) + WHEN 'G' THEN 0 + WHEN 'TV-Y' THEN 0 + WHEN 'TV-G' THEN 0 + WHEN 'PG' THEN 1 + WHEN 'TV-Y7' THEN 1 + WHEN 'TV-PG' THEN 1 + WHEN 'PG-13' THEN 2 + WHEN 'TV-14' THEN 2 + WHEN 'R' THEN 3 + WHEN 'NC-17' THEN 3 + WHEN 'TV-MA' THEN 3 + ELSE 2147483647 + END +$$; + +CREATE OR REPLACE FUNCTION public.episode_catalog_normalized_resolution(resolution text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT CASE LOWER(NULLIF(BTRIM(resolution), '')) + WHEN '4k' THEN '2160p' + WHEN 'uhd' THEN '2160p' + ELSE LOWER(NULLIF(BTRIM(resolution), '')) + END +$$; + +CREATE OR REPLACE FUNCTION public.episode_catalog_resolution_rank(resolution text) +RETURNS integer +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT CASE UPPER(public.episode_catalog_normalized_resolution(resolution)) + WHEN '480P' THEN 1 + WHEN '720P' THEN 2 + WHEN '1080P' THEN 3 + WHEN '2160P' THEN 4 + WHEN '4320P' THEN 5 + ELSE NULL + END +$$; + +CREATE OR REPLACE FUNCTION public.refresh_episode_catalog_entry(p_episode_id text, p_media_folder_id integer) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + IF p_episode_id IS NULL OR p_media_folder_id IS NULL THEN + RETURN; + END IF; + + INSERT INTO public.episode_catalog_entries ( + media_folder_id, + episode_id, + series_id, + sort_key, + title, + added_at, + episode_air_date, + year, + genres, + studios, + networks, + countries, + original_language, + content_rating, + content_rating_label, + content_rating_rank, + status, + runtime, + rating_imdb, + rating_tmdb, + max_resolution_rank, + resolution_codes, + max_bitrate, + min_bitrate, + has_hdr, + has_non_hdr, + has_dolby_vision, + has_non_dolby_vision, + audio_language_codes, + subtitle_language_codes, + episode_created_at, + updated_at + ) + SELECT + el.media_folder_id, + e.content_id, + e.series_id, + LOWER(COALESCE(NULLIF(BTRIM(e.title), ''), 'Episode ' || e.episode_number::text)) AS sort_key, + COALESCE(NULLIF(BTRIM(e.title), ''), 'Episode ' || e.episode_number::text) AS title, + el.first_seen_at, + e.air_date, + COALESCE(si.year, EXTRACT(YEAR FROM e.air_date)::integer, 0) AS year, + COALESCE(si.genres, '{}'::text[]) AS genres, + COALESCE(si.studios, '{}'::text[]) AS studios, + COALESCE(si.networks, '{}'::text[]) AS networks, + COALESCE(si.countries, '{}'::text[]) AS countries, + COALESCE(si.original_language, '') AS original_language, + COALESCE(si.content_rating, '') AS content_rating, + LOWER(COALESCE(NULLIF(BTRIM(si.content_rating), ''), '~~~~')) AS content_rating_label, + public.episode_catalog_rating_rank(si.content_rating) AS content_rating_rank, + COALESCE(NULLIF(BTRIM(si.status), ''), 'matched') AS status, + COALESCE(NULLIF(e.runtime, 0), COALESCE(si.runtime, 0)) AS runtime, + e.rating_imdb, + e.rating_tmdb, + stats.max_resolution_rank, + COALESCE(stats.resolution_codes, '{}'::text[]) AS resolution_codes, + stats.max_bitrate, + stats.min_bitrate, + COALESCE(stats.has_hdr, false) AS has_hdr, + COALESCE(stats.has_non_hdr, false) AS has_non_hdr, + COALESCE(stats.has_dolby_vision, false) AS has_dolby_vision, + COALESCE(stats.has_non_dolby_vision, false) AS has_non_dolby_vision, + COALESCE(stats.audio_language_codes, '{}'::text[]) AS audio_language_codes, + COALESCE(stats.subtitle_language_codes, '{}'::text[]) AS subtitle_language_codes, + e.created_at, + NOW() + FROM public.episode_libraries el + JOIN public.episodes e ON e.content_id = el.episode_id + JOIN public.media_items si ON si.content_id = e.series_id + LEFT JOIN LATERAL ( + SELECT + MAX(public.episode_catalog_resolution_rank(mf.resolution)) AS max_resolution_rank, + ARRAY( + SELECT DISTINCT code + FROM public.media_files mf_res + CROSS JOIN LATERAL ( + SELECT public.episode_catalog_normalized_resolution(mf_res.resolution) AS code + ) normalized + WHERE mf_res.episode_id = e.content_id + AND mf_res.media_folder_id = el.media_folder_id + AND mf_res.missing_since IS NULL + AND normalized.code IS NOT NULL + ORDER BY code + ) AS resolution_codes, + MAX(mf.bitrate) FILTER (WHERE mf.bitrate IS NOT NULL AND mf.bitrate > 0) AS max_bitrate, + MIN(mf.bitrate) FILTER (WHERE mf.bitrate IS NOT NULL AND mf.bitrate > 0) AS min_bitrate, + BOOL_OR(COALESCE(mf.hdr, false)) AS has_hdr, + BOOL_OR(NOT COALESCE(mf.hdr, false)) AS has_non_hdr, + BOOL_OR(EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(mf.video_tracks, '[]'::jsonb)) AS vt + WHERE NULLIF(BTRIM(vt->>'dolby_vision'), '') IS NOT NULL + )) AS has_dolby_vision, + BOOL_OR(NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(mf.video_tracks, '[]'::jsonb)) AS vt + WHERE NULLIF(BTRIM(vt->>'dolby_vision'), '') IS NOT NULL + )) AS has_non_dolby_vision, + ARRAY( + SELECT DISTINCT LOWER(NULLIF(BTRIM(lang), '')) + FROM public.media_files mf_audio + CROSS JOIN LATERAL UNNEST(COALESCE(mf_audio.audio_language_codes, '{}'::text[])) AS lang + WHERE mf_audio.episode_id = e.content_id + AND mf_audio.media_folder_id = el.media_folder_id + AND mf_audio.missing_since IS NULL + AND NULLIF(BTRIM(lang), '') IS NOT NULL + ORDER BY LOWER(NULLIF(BTRIM(lang), '')) + ) AS audio_language_codes, + ARRAY( + SELECT DISTINCT lang_code + FROM ( + SELECT LOWER(NULLIF(BTRIM(lang), '')) AS lang_code + FROM public.media_files mf_sub + CROSS JOIN LATERAL UNNEST(COALESCE(mf_sub.subtitle_language_codes, '{}'::text[])) AS lang + WHERE mf_sub.episode_id = e.content_id + AND mf_sub.media_folder_id = el.media_folder_id + AND mf_sub.missing_since IS NULL + UNION + SELECT LOWER(NULLIF(BTRIM(track->>'language'), '')) AS lang_code + FROM public.media_files mf_ext + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(mf_ext.external_subtitles, '[]'::jsonb)) AS track + WHERE mf_ext.episode_id = e.content_id + AND mf_ext.media_folder_id = el.media_folder_id + AND mf_ext.missing_since IS NULL + ) subtitle_codes + WHERE lang_code IS NOT NULL + ORDER BY lang_code + ) AS subtitle_language_codes + FROM public.media_files mf + WHERE mf.episode_id = e.content_id + AND mf.media_folder_id = el.media_folder_id + AND mf.missing_since IS NULL + ) stats ON TRUE + WHERE el.episode_id = p_episode_id + AND el.media_folder_id = p_media_folder_id + ON CONFLICT (media_folder_id, episode_id) DO UPDATE SET + series_id = EXCLUDED.series_id, + sort_key = EXCLUDED.sort_key, + title = EXCLUDED.title, + added_at = EXCLUDED.added_at, + episode_air_date = EXCLUDED.episode_air_date, + year = EXCLUDED.year, + genres = EXCLUDED.genres, + studios = EXCLUDED.studios, + networks = EXCLUDED.networks, + countries = EXCLUDED.countries, + original_language = EXCLUDED.original_language, + content_rating = EXCLUDED.content_rating, + content_rating_label = EXCLUDED.content_rating_label, + content_rating_rank = EXCLUDED.content_rating_rank, + status = EXCLUDED.status, + runtime = EXCLUDED.runtime, + rating_imdb = EXCLUDED.rating_imdb, + rating_tmdb = EXCLUDED.rating_tmdb, + max_resolution_rank = EXCLUDED.max_resolution_rank, + resolution_codes = EXCLUDED.resolution_codes, + max_bitrate = EXCLUDED.max_bitrate, + min_bitrate = EXCLUDED.min_bitrate, + has_hdr = EXCLUDED.has_hdr, + has_non_hdr = EXCLUDED.has_non_hdr, + has_dolby_vision = EXCLUDED.has_dolby_vision, + has_non_dolby_vision = EXCLUDED.has_non_dolby_vision, + audio_language_codes = EXCLUDED.audio_language_codes, + subtitle_language_codes = EXCLUDED.subtitle_language_codes, + episode_created_at = EXCLUDED.episode_created_at, + updated_at = NOW(); + + IF NOT FOUND THEN + DELETE FROM public.episode_catalog_entries + WHERE episode_id = p_episode_id + AND media_folder_id = p_media_folder_id; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION public.refresh_episode_catalog_entries_for_episode(p_episode_id text) +RETURNS void +LANGUAGE sql +AS $$ + SELECT public.refresh_episode_catalog_entry(el.episode_id, el.media_folder_id) + FROM public.episode_libraries el + WHERE el.episode_id = p_episode_id; +$$; + +CREATE OR REPLACE FUNCTION public.refresh_episode_catalog_entries_for_series(p_series_id text) +RETURNS void +LANGUAGE sql +AS $$ + SELECT public.refresh_episode_catalog_entry(el.episode_id, el.media_folder_id) + FROM public.episode_libraries el + JOIN public.episodes e ON e.content_id = el.episode_id + WHERE e.series_id = p_series_id; +$$; + +CREATE OR REPLACE FUNCTION public.episode_catalog_entries_episode_libraries_trigger() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + DELETE FROM public.episode_catalog_entries + WHERE episode_id = OLD.episode_id + AND media_folder_id = OLD.media_folder_id; + RETURN OLD; + END IF; + + PERFORM public.refresh_episode_catalog_entry(NEW.episode_id, NEW.media_folder_id); + IF TG_OP = 'UPDATE' + AND (OLD.episode_id IS DISTINCT FROM NEW.episode_id OR OLD.media_folder_id IS DISTINCT FROM NEW.media_folder_id) THEN + PERFORM public.refresh_episode_catalog_entry(OLD.episode_id, OLD.media_folder_id); + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.episode_catalog_entries_media_files_trigger() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + PERFORM public.refresh_episode_catalog_entry(OLD.episode_id, OLD.media_folder_id); + RETURN OLD; + END IF; + + IF TG_OP = 'UPDATE' THEN + PERFORM public.refresh_episode_catalog_entry(OLD.episode_id, OLD.media_folder_id); + END IF; + + PERFORM public.refresh_episode_catalog_entry(NEW.episode_id, NEW.media_folder_id); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.episode_catalog_entries_episodes_trigger() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + DELETE FROM public.episode_catalog_entries + WHERE episode_id = OLD.content_id; + RETURN OLD; + END IF; + + PERFORM public.refresh_episode_catalog_entries_for_episode(NEW.content_id); + IF TG_OP = 'UPDATE' AND OLD.content_id IS DISTINCT FROM NEW.content_id THEN + DELETE FROM public.episode_catalog_entries + WHERE episode_id = OLD.content_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.episode_catalog_entries_series_trigger() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + DELETE FROM public.episode_catalog_entries + WHERE series_id = OLD.content_id; + RETURN OLD; + END IF; + + IF COALESCE(NEW.type, '') = 'series' THEN + PERFORM public.refresh_episode_catalog_entries_for_series(NEW.content_id); + END IF; + RETURN NEW; +END; +$$; + +WITH active_episode_files AS ( + SELECT + mf.episode_id, + mf.media_folder_id, + public.episode_catalog_normalized_resolution(mf.resolution) AS normalized_resolution, + public.episode_catalog_resolution_rank(mf.resolution) AS resolution_rank, + mf.bitrate, + COALESCE(mf.hdr, false) AS hdr, + EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(mf.video_tracks, '[]'::jsonb)) AS vt + WHERE NULLIF(BTRIM(vt->>'dolby_vision'), '') IS NOT NULL + ) AS has_dolby_vision, + mf.audio_language_codes, + mf.subtitle_language_codes, + mf.external_subtitles + FROM public.media_files mf + WHERE mf.episode_id IS NOT NULL + AND mf.missing_since IS NULL +), +file_stats AS ( + SELECT + episode_id, + media_folder_id, + MAX(resolution_rank) AS max_resolution_rank, + ARRAY_AGG(DISTINCT normalized_resolution ORDER BY normalized_resolution) + FILTER (WHERE normalized_resolution IS NOT NULL) AS resolution_codes, + MAX(bitrate) FILTER (WHERE bitrate IS NOT NULL AND bitrate > 0) AS max_bitrate, + MIN(bitrate) FILTER (WHERE bitrate IS NOT NULL AND bitrate > 0) AS min_bitrate, + BOOL_OR(hdr) AS has_hdr, + BOOL_OR(NOT hdr) AS has_non_hdr, + BOOL_OR(has_dolby_vision) AS has_dolby_vision, + BOOL_OR(NOT has_dolby_vision) AS has_non_dolby_vision + FROM active_episode_files + GROUP BY episode_id, media_folder_id +), +audio_stats AS ( + SELECT + episode_id, + media_folder_id, + ARRAY_AGG(DISTINCT lang_code ORDER BY lang_code) AS audio_language_codes + FROM ( + SELECT + aef.episode_id, + aef.media_folder_id, + LOWER(NULLIF(BTRIM(lang), '')) AS lang_code + FROM active_episode_files aef + CROSS JOIN LATERAL UNNEST(COALESCE(aef.audio_language_codes, '{}'::text[])) AS lang + ) codes + WHERE lang_code IS NOT NULL + GROUP BY episode_id, media_folder_id +), +subtitle_stats AS ( + SELECT + episode_id, + media_folder_id, + ARRAY_AGG(DISTINCT lang_code ORDER BY lang_code) AS subtitle_language_codes + FROM ( + SELECT + aef.episode_id, + aef.media_folder_id, + LOWER(NULLIF(BTRIM(lang), '')) AS lang_code + FROM active_episode_files aef + CROSS JOIN LATERAL UNNEST(COALESCE(aef.subtitle_language_codes, '{}'::text[])) AS lang + UNION + SELECT + aef.episode_id, + aef.media_folder_id, + LOWER(NULLIF(BTRIM(track->>'language'), '')) AS lang_code + FROM active_episode_files aef + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(aef.external_subtitles, '[]'::jsonb)) AS track + ) codes + WHERE lang_code IS NOT NULL + GROUP BY episode_id, media_folder_id +) +INSERT INTO public.episode_catalog_entries ( + media_folder_id, + episode_id, + series_id, + sort_key, + title, + added_at, + episode_air_date, + year, + genres, + studios, + networks, + countries, + original_language, + content_rating, + content_rating_label, + content_rating_rank, + status, + runtime, + rating_imdb, + rating_tmdb, + max_resolution_rank, + resolution_codes, + max_bitrate, + min_bitrate, + has_hdr, + has_non_hdr, + has_dolby_vision, + has_non_dolby_vision, + audio_language_codes, + subtitle_language_codes, + episode_created_at, + updated_at +) +SELECT + el.media_folder_id, + e.content_id, + e.series_id, + LOWER(COALESCE(NULLIF(BTRIM(e.title), ''), 'Episode ' || e.episode_number::text)) AS sort_key, + COALESCE(NULLIF(BTRIM(e.title), ''), 'Episode ' || e.episode_number::text) AS title, + el.first_seen_at, + e.air_date, + COALESCE(si.year, EXTRACT(YEAR FROM e.air_date)::integer, 0) AS year, + COALESCE(si.genres, '{}'::text[]) AS genres, + COALESCE(si.studios, '{}'::text[]) AS studios, + COALESCE(si.networks, '{}'::text[]) AS networks, + COALESCE(si.countries, '{}'::text[]) AS countries, + COALESCE(si.original_language, '') AS original_language, + COALESCE(si.content_rating, '') AS content_rating, + LOWER(COALESCE(NULLIF(BTRIM(si.content_rating), ''), '~~~~')) AS content_rating_label, + public.episode_catalog_rating_rank(si.content_rating) AS content_rating_rank, + COALESCE(NULLIF(BTRIM(si.status), ''), 'matched') AS status, + COALESCE(NULLIF(e.runtime, 0), COALESCE(si.runtime, 0)) AS runtime, + e.rating_imdb, + e.rating_tmdb, + file_stats.max_resolution_rank, + COALESCE(file_stats.resolution_codes, '{}'::text[]) AS resolution_codes, + file_stats.max_bitrate, + file_stats.min_bitrate, + COALESCE(file_stats.has_hdr, false) AS has_hdr, + COALESCE(file_stats.has_non_hdr, false) AS has_non_hdr, + COALESCE(file_stats.has_dolby_vision, false) AS has_dolby_vision, + COALESCE(file_stats.has_non_dolby_vision, false) AS has_non_dolby_vision, + COALESCE(audio_stats.audio_language_codes, '{}'::text[]) AS audio_language_codes, + COALESCE(subtitle_stats.subtitle_language_codes, '{}'::text[]) AS subtitle_language_codes, + e.created_at, + NOW() +FROM public.episode_libraries el +JOIN public.episodes e ON e.content_id = el.episode_id +JOIN public.media_items si ON si.content_id = e.series_id +LEFT JOIN file_stats ON file_stats.episode_id = el.episode_id AND file_stats.media_folder_id = el.media_folder_id +LEFT JOIN audio_stats ON audio_stats.episode_id = el.episode_id AND audio_stats.media_folder_id = el.media_folder_id +LEFT JOIN subtitle_stats ON subtitle_stats.episode_id = el.episode_id AND subtitle_stats.media_folder_id = el.media_folder_id +ON CONFLICT (media_folder_id, episode_id) DO NOTHING; + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_title +ON public.episode_catalog_entries USING btree (media_folder_id, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_added +ON public.episode_catalog_entries USING btree (media_folder_id, added_at DESC, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_air_date +ON public.episode_catalog_entries USING btree (media_folder_id, episode_air_date DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_year +ON public.episode_catalog_entries USING btree (media_folder_id, year DESC, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_content_rating +ON public.episode_catalog_entries USING btree (media_folder_id, content_rating_rank, content_rating_label, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_runtime +ON public.episode_catalog_entries USING btree (media_folder_id, runtime DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_imdb +ON public.episode_catalog_entries USING btree (media_folder_id, rating_imdb DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_tmdb +ON public.episode_catalog_entries USING btree (media_folder_id, rating_tmdb DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_resolution +ON public.episode_catalog_entries USING btree (media_folder_id, max_resolution_rank DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_bitrate +ON public.episode_catalog_entries USING btree (media_folder_id, max_bitrate DESC NULLS LAST, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_status +ON public.episode_catalog_entries USING btree (media_folder_id, status, sort_key, episode_id); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_hdr +ON public.episode_catalog_entries USING btree (media_folder_id, sort_key, episode_id) +WHERE has_hdr; + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_non_hdr +ON public.episode_catalog_entries USING btree (media_folder_id, sort_key, episode_id) +WHERE has_non_hdr; + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_dolby_vision +ON public.episode_catalog_entries USING btree (media_folder_id, sort_key, episode_id) +WHERE has_dolby_vision; + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_non_dolby_vision +ON public.episode_catalog_entries USING btree (media_folder_id, sort_key, episode_id) +WHERE has_non_dolby_vision; + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_genres_gin +ON public.episode_catalog_entries USING gin (genres); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_studios_gin +ON public.episode_catalog_entries USING gin (studios); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_networks_gin +ON public.episode_catalog_entries USING gin (networks); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_countries_gin +ON public.episode_catalog_entries USING gin (countries); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_resolution_codes_gin +ON public.episode_catalog_entries USING gin (resolution_codes); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_audio_gin +ON public.episode_catalog_entries USING gin (audio_language_codes); + +CREATE INDEX IF NOT EXISTS idx_episode_catalog_entries_subtitle_gin +ON public.episode_catalog_entries USING gin (subtitle_language_codes); + +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_episode_libraries ON public.episode_libraries; +CREATE TRIGGER trg_episode_catalog_entries_episode_libraries +AFTER INSERT OR UPDATE OR DELETE ON public.episode_libraries +FOR EACH ROW EXECUTE FUNCTION public.episode_catalog_entries_episode_libraries_trigger(); + +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_media_files ON public.media_files; +CREATE TRIGGER trg_episode_catalog_entries_media_files +AFTER INSERT OR UPDATE OF episode_id, media_folder_id, missing_since, resolution, bitrate, hdr, video_tracks, audio_tracks, subtitle_tracks, external_subtitles, created_at OR DELETE +ON public.media_files +FOR EACH ROW EXECUTE FUNCTION public.episode_catalog_entries_media_files_trigger(); + +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_episodes ON public.episodes; +CREATE TRIGGER trg_episode_catalog_entries_episodes +AFTER INSERT OR UPDATE OF content_id, series_id, title, air_date, runtime, rating_imdb, rating_tmdb, still_path, still_thumbhash, created_at OR DELETE +ON public.episodes +FOR EACH ROW EXECUTE FUNCTION public.episode_catalog_entries_episodes_trigger(); + +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_series ON public.media_items; +CREATE TRIGGER trg_episode_catalog_entries_series +AFTER UPDATE OF content_id, type, year, genres, studios, networks, countries, original_language, content_rating, status, runtime ON public.media_items +FOR EACH ROW +WHEN (OLD.type = 'series' OR NEW.type = 'series') +EXECUTE FUNCTION public.episode_catalog_entries_series_trigger(); + +WITH migration_clock AS ( + SELECT started_at FROM episode_catalog_entries_migration_clock LIMIT 1 +), +changed_entries AS ( + SELECT DISTINCT mf.episode_id, mf.media_folder_id + FROM public.media_files mf, migration_clock mc + WHERE mf.episode_id IS NOT NULL + AND mf.updated_at >= mc.started_at + UNION + SELECT DISTINCT el.episode_id, el.media_folder_id + FROM public.episode_libraries el, migration_clock mc + WHERE el.first_seen_at >= mc.started_at + UNION + SELECT DISTINCT el.episode_id, el.media_folder_id + FROM public.episode_libraries el + JOIN public.episodes e ON e.content_id = el.episode_id + CROSS JOIN migration_clock mc + WHERE e.updated_at >= mc.started_at + UNION + SELECT DISTINCT el.episode_id, el.media_folder_id + FROM public.episode_libraries el + JOIN public.episodes e ON e.content_id = el.episode_id + JOIN public.media_items si ON si.content_id = e.series_id + CROSS JOIN migration_clock mc + WHERE si.updated_at >= mc.started_at + AND si.type = 'series' +) +SELECT COUNT(*) +FROM ( + SELECT public.refresh_episode_catalog_entry(episode_id, media_folder_id) + FROM changed_entries +) refreshed; diff --git a/scripts/benchmark_episode_catalog.py b/scripts/benchmark_episode_catalog.py new file mode 100644 index 00000000..0fca64dc --- /dev/null +++ b/scripts/benchmark_episode_catalog.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Benchmark episode catalog sort/filter shapes without embedding credentials. + +Required environment: + SILO_BASE_URL Example: https://silo.example.com + SILO_BEARER_TOKEN API access token + SILO_PROFILE_ID Profile id used for personalized filters/sorts + +Optional environment: + SILO_LIBRARY_ID Defaults to 2 +""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import statistics +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from typing import Iterable + + +def group_rule(field: str, op: str, value: str) -> dict[str, str]: + return { + "groups[0][match]": "all", + "groups[0][rules][0][field]": field, + "groups[0][rules][0][op]": op, + "groups[0][rules][0][value]": value, + } + + +DEFAULT_SORT_CASES = [ + ("sort:title", {"sort": "title", "order": "asc"}), + ("sort:added_at", {"sort": "added_at", "order": "desc"}), + ("sort:release_date", {"sort": "release_date", "order": "desc"}), + ("sort:last_air_date", {"sort": "last_air_date", "order": "desc"}), + ("sort:year", {"sort": "year", "order": "desc"}), + ("sort:content_rating", {"sort": "content_rating", "order": "asc"}), + ("sort:runtime", {"sort": "runtime", "order": "desc"}), + ("sort:rating_imdb", {"sort": "rating_imdb", "order": "desc"}), + ("sort:rating_tmdb", {"sort": "rating_tmdb", "order": "desc"}), + ("sort:rating_rt_critic", {"sort": "rating_rt_critic", "order": "desc"}), + ("sort:rating_rt_audience", {"sort": "rating_rt_audience", "order": "desc"}), + ("sort:resolution", {"sort": "resolution", "order": "desc"}), + ("sort:bitrate", {"sort": "bitrate", "order": "desc"}), + ("sort:progress", {"sort": "progress", "order": "desc"}), + ("sort:date_viewed", {"sort": "date_viewed", "order": "desc"}), + ("sort:plays", {"sort": "plays", "order": "desc"}), +] + +DEFAULT_FILTER_CASES = [ + ("filter:genre", {"genre": "Comedy", "sort": "title", "order": "asc"}), + ("filter:year", {"year_min": "2020", "year_max": "2024", "sort": "year", "order": "desc"}), + ("filter:content_rating", {"content_rating": "tv-pg", "sort": "title", "order": "asc"}), + ( + "filter:resolution", + group_rule("resolution", "is", "1080p") | {"sort": "title", "order": "asc"}, + ), + ("filter:hdr", group_rule("hdr", "is", "true") | {"sort": "title", "order": "asc"}), + ( + "filter:dolby_vision", + group_rule("dolby_vision", "is", "true") | {"sort": "title", "order": "asc"}, + ), + ( + "filter:bitrate", + group_rule("bitrate", "gte", "8000000") | {"sort": "bitrate", "order": "desc"}, + ), + ( + "filter:audio_language", + group_rule("audio_language", "is", "en") | {"sort": "title", "order": "asc"}, + ), + ( + "filter:subtitle_language", + group_rule("subtitle_language", "is", "en") | {"sort": "title", "order": "asc"}, + ), + ( + "filter:watched_true", + group_rule("watched", "is", "true") | {"sort": "title", "order": "asc"}, + ), + ( + "filter:in_progress_true", + group_rule("in_progress", "is", "true") | {"sort": "progress", "order": "desc"}, + ), + ( + "filter:last_watched_30d", + group_rule("last_watched", "in_last", "30d") | {"sort": "date_viewed", "order": "desc"}, + ), +] + + +@dataclass(frozen=True) +class Config: + base_url: str + token: str + profile_id: str + library_id: str + limit: int + offset: int + timeout: float + + +@dataclass(frozen=True) +class CaseResult: + case: str + include_total: bool + status: int + elapsed_ms: float + total: int | None + total_exact: bool | None + has_more: bool | None + item_count: int | None + error: str + + +def getenv_required(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise SystemExit(f"{name} is required") + return value + + +def build_url(config: Config, params: dict[str, str], include_total: bool) -> str: + query = { + "source": "query", + "type": "episode", + "library_id": config.library_id, + "limit": str(config.limit), + "offset": str(config.offset), + **params, + } + if not include_total: + query["include_total"] = "false" + return urllib.parse.urljoin(config.base_url.rstrip("/") + "/", "api/v1/catalog") + "?" + urllib.parse.urlencode(query) + + +def fetch_case(config: Config, case: str, params: dict[str, str], include_total: bool) -> CaseResult: + url = build_url(config, params, include_total) + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {config.token}", + "X-Profile-Id": config.profile_id, + }, + ) + started = time.perf_counter() + try: + with urllib.request.urlopen(request, timeout=config.timeout) as response: + body = response.read() + elapsed_ms = (time.perf_counter() - started) * 1000 + payload = json.loads(body) + items = payload.get("items") + return CaseResult( + case=case, + include_total=include_total, + status=response.status, + elapsed_ms=elapsed_ms, + total=payload.get("total"), + total_exact=payload.get("total_exact"), + has_more=payload.get("has_more"), + item_count=len(items) if isinstance(items, list) else None, + error="", + ) + except urllib.error.HTTPError as err: + elapsed_ms = (time.perf_counter() - started) * 1000 + return CaseResult(case, include_total, err.code, elapsed_ms, None, None, None, None, err.reason) + except Exception as err: # noqa: BLE001 - benchmark output should capture failures. + elapsed_ms = (time.perf_counter() - started) * 1000 + return CaseResult(case, include_total, 0, elapsed_ms, None, None, None, None, str(err)) + + +def result_rows(results: Iterable[CaseResult]) -> list[dict[str, object]]: + rows = [] + for result in results: + rows.append( + { + "case": result.case, + "include_total": str(result.include_total).lower(), + "status": result.status, + "elapsed_ms": f"{result.elapsed_ms:.1f}", + "total": "" if result.total is None else result.total, + "total_exact": "" if result.total_exact is None else str(result.total_exact).lower(), + "has_more": "" if result.has_more is None else str(result.has_more).lower(), + "item_count": "" if result.item_count is None else result.item_count, + "error": result.error, + } + ) + return rows + + +def summarize(results: list[CaseResult]) -> None: + successful = [result.elapsed_ms for result in results if result.status == 200] + if not successful: + return + print( + f"# successful={len(successful)} p50_ms={statistics.median(successful):.1f} " + f"max_ms={max(successful):.1f}", + file=sys.stderr, + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Benchmark episode catalog sort/filter cases.") + parser.add_argument("--limit", type=int, default=60) + parser.add_argument("--offset", type=int, default=0) + parser.add_argument("--timeout", type=float, default=30.0) + parser.add_argument("--repeat", type=int, default=1) + parser.add_argument("--concurrency", type=int, default=1) + parser.add_argument("--include-exact", action="store_true", help="Also run cases with exact totals enabled.") + args = parser.parse_args() + + config = Config( + base_url=getenv_required("SILO_BASE_URL"), + token=getenv_required("SILO_BEARER_TOKEN"), + profile_id=getenv_required("SILO_PROFILE_ID"), + library_id=os.environ.get("SILO_LIBRARY_ID", "2"), + limit=args.limit, + offset=args.offset, + timeout=args.timeout, + ) + + cases = DEFAULT_SORT_CASES + DEFAULT_FILTER_CASES + include_total_values = [False, True] if args.include_exact else [False] + jobs = [ + (name, params, include_total) + for _ in range(args.repeat) + for include_total in include_total_values + for name, params in cases + ] + + results: list[CaseResult] = [] + with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as executor: + futures = [executor.submit(fetch_case, config, *job) for job in jobs] + for future in as_completed(futures): + results.append(future.result()) + + results.sort(key=lambda row: (row.case, row.include_total, row.elapsed_ms)) + writer = csv.DictWriter( + sys.stdout, + fieldnames=[ + "case", + "include_total", + "status", + "elapsed_ms", + "total", + "total_exact", + "has_more", + "item_count", + "error", + ], + ) + writer.writeheader() + writer.writerows(result_rows(results)) + summarize(results) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/src/pages/LibraryBrowse.test.tsx b/web/src/pages/LibraryBrowse.test.tsx index 8318fdc3..8d2ecac0 100644 --- a/web/src/pages/LibraryBrowse.test.tsx +++ b/web/src/pages/LibraryBrowse.test.tsx @@ -152,7 +152,7 @@ describe("LibraryBrowse", () => { }), }), expect.objectContaining({ - includeTotal: true, + includeTotal: false, }), ); }); diff --git a/web/src/pages/LibraryBrowse.tsx b/web/src/pages/LibraryBrowse.tsx index 752e8779..cabbdb58 100644 --- a/web/src/pages/LibraryBrowse.tsx +++ b/web/src/pages/LibraryBrowse.tsx @@ -106,7 +106,7 @@ export default function LibraryBrowse({ const catalogQuery = useCatalogWindow(state, { limit, - includeTotal: true, + includeTotal: false, visibleRange, }); const totalItems = catalogQuery.data?.totalItems ?? 0; From c6176d6c85dedee871a5dfe582e6b77ba7e394c0 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 12:48:59 -0400 Subject: [PATCH 061/201] fix(catalog): address episode catalog review feedback --- .../2026-05-27-episode-catalog-performance.md | 3 + .../episode_catalog_entries_executor.go | 35 +++++---- .../episode_catalog_entries_executor_test.go | 76 +++++++++++++++++++ .../142_episode_catalog_entries.down.sql | 2 + migrations/142_episode_catalog_entries.up.sql | 45 ++++++++--- scripts/benchmark_episode_catalog.py | 35 ++++++++- web/src/pages/LibraryBrowse.test.tsx | 10 ++- web/src/pages/LibraryBrowse.tsx | 7 -- 8 files changed, 175 insertions(+), 38 deletions(-) diff --git a/docs/superpowers/plans/2026-05-27-episode-catalog-performance.md b/docs/superpowers/plans/2026-05-27-episode-catalog-performance.md index bdb0b7d2..1f62291f 100644 --- a/docs/superpowers/plans/2026-05-27-episode-catalog-performance.md +++ b/docs/superpowers/plans/2026-05-27-episode-catalog-performance.md @@ -512,6 +512,9 @@ For each case, capture: - API response changes around count modes may require Android and Apple follow-up. Keeping the existing `total_exact=false` behavior avoids most client churn. - Person filters may need fallback initially unless episode-level people data is available and indexed. - Array GIN filters should be checked with real plans. If separate GIN indexes do too much post-filtering by library, use `btree_gin` multi-column indexes or add selective partial indexes for common libraries. +- Index maintenance, disk, and write amplification should be tracked explicitly. The btree indexes such as `idx_episode_catalog_entries_title`, `idx_episode_catalog_entries_added`, and `idx_episode_catalog_entries_air_date`, plus GIN indexes such as `idx_episode_catalog_entries_genres_gin`, trade scanner write cost and disk growth for browse latency. Measure `episode_catalog_entries` table and index size before rollout, and watch trigger update latency during large scanner runs. +- Backfill safety needs a production runbook. Treat the `episode_catalog_entries` backfill as idempotent work that can be chunked or retried with low-locking settings, capture a production-sized duration estimate before enabling it broadly, and document rollback to dropping the read model plus its triggers. +- Read-model health should be observable. Alert when `episode_catalog_entries.updated_at` lags source table changes beyond a stale threshold or refresh errors appear, fall back to the generic executor when health checks fail, and provide a remediation command to rebuild stale entries. ## Recommended Decision diff --git a/internal/catalog/episode_catalog_entries_executor.go b/internal/catalog/episode_catalog_entries_executor.go index 54fe74be..930f2b56 100644 --- a/internal/catalog/episode_catalog_entries_executor.go +++ b/internal/catalog/episode_catalog_entries_executor.go @@ -29,6 +29,7 @@ type episodeCatalogUserStatePlan struct { sortOrder string sortOnly bool requiresFullPageGate bool + requireProgressRatio bool } func (e *QueryExecutor) tryEpisodeCatalogUserStatePreviewPage( @@ -74,7 +75,7 @@ func (e *QueryExecutor) tryEpisodeCatalogUserStatePreviewPage( offset = 0 } - ctes := []string{episodeCatalogUserStateCTE(plan.source)} + ctes := []string{episodeCatalogUserStateCTE(plan)} args := []any{access.UserID, access.ProfileID, libraryID} argIdx := 4 whereParts := []string{"ece.media_folder_id = $3"} @@ -375,6 +376,7 @@ func extractEpisodeCatalogUserStatePlan(def QueryDefinition) (episodeCatalogUser if plan.source == "" { return episodeCatalogUserStatePlan{}, false, nil } + plan.requireProgressRatio = plan.source == "progress" && plan.sortOnly return plan, true, nil } if def.Match != "all" { @@ -423,6 +425,7 @@ func extractEpisodeCatalogUserStatePlan(def QueryDefinition) (episodeCatalogUser return episodeCatalogUserStatePlan{}, false, nil } plan.entryDef.Groups = filteredGroups + plan.requireProgressRatio = plan.source == "progress" && plan.sortOnly return plan, true, nil } @@ -513,24 +516,28 @@ func rebindUserStateClauses(clauses []string, argIdx int) []string { return out } -func episodeCatalogUserStateCTE(source string) string { - switch source { +func episodeCatalogUserStateCTE(plan episodeCatalogUserStatePlan) string { + switch plan.source { case "progress": - return `user_progress AS ( - SELECT uwp.media_item_id AS episode_id, - uwp.position_seconds::double precision / NULLIF(uwp.duration_seconds, 0) AS progress_ratio - FROM user_watch_progress uwp + progressRatioGate := "" + if plan.requireProgressRatio { + progressRatioGate = ` + AND COALESCE(uwp.duration_seconds, 0) > 0` + } + return fmt.Sprintf(`user_progress AS ( + SELECT uwp.media_item_id AS episode_id, + uwp.position_seconds::double precision / NULLIF(uwp.duration_seconds, 0) AS progress_ratio + FROM user_watch_progress uwp LEFT JOIN user_history_hidden_items hhi ON hhi.user_id = $1 AND hhi.profile_id = $2 AND hhi.media_item_id = uwp.media_item_id WHERE uwp.user_id = $1 - AND uwp.profile_id = $2 - AND uwp.completed = FALSE - AND uwp.position_seconds > 0 - AND COALESCE(uwp.duration_seconds, 0) > 0 - AND (hhi.media_item_id IS NULL OR uwp.updated_at > hhi.hidden_before) - )` + AND uwp.profile_id = $2 + AND uwp.completed = FALSE + AND uwp.position_seconds > 0%s + AND (hhi.media_item_id IS NULL OR uwp.updated_at > hhi.hidden_before) + )`, progressRatioGate) default: return `user_viewed AS ( SELECT src.episode_id, @@ -810,7 +817,7 @@ func buildEpisodeCatalogEntryRuleWhere(rule QueryRule, argIdx int) (string, []an case "content_rating": return buildEpisodeCatalogEqualityClause("ece.content_rating", rule, argIdx) case "added_at": - return buildEpisodeCatalogComparisonClause("ece.added_at", rule, argIdx, "timestamptz") + return buildEpisodeCatalogComparisonClause("ece.episode_created_at", rule, argIdx, "timestamptz") case "release_date": return buildEpisodeCatalogComparisonClause("ece.episode_air_date", rule, argIdx, "date") case "status": diff --git a/internal/catalog/episode_catalog_entries_executor_test.go b/internal/catalog/episode_catalog_entries_executor_test.go index 964c7a25..f255f7ce 100644 --- a/internal/catalog/episode_catalog_entries_executor_test.go +++ b/internal/catalog/episode_catalog_entries_executor_test.go @@ -80,6 +80,32 @@ func TestBuildEpisodeCatalogEntryQueryWhereUsesReadModelFilters(t *testing.T) { } } +func TestEpisodeCatalogAddedAtFilterUsesEpisodeCreatedAt(t *testing.T) { + def := QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{ + {Field: "added_at", Op: "in_last", Value: "30d"}, + }, + }}, + }.Normalize() + + where, _, _, ok, err := buildEpisodeCatalogEntryQueryWhere(def, 2) + if err != nil { + t.Fatalf("buildEpisodeCatalogEntryQueryWhere returned error: %v", err) + } + if !ok { + t.Fatal("buildEpisodeCatalogEntryQueryWhere unexpectedly fell back") + } + if !strings.Contains(where, "ece.episode_created_at >= NOW() - INTERVAL '30 days'") { + t.Fatalf("expected added_at filter to use episode_created_at, got %q", where) + } + if strings.Contains(where, "ece.added_at") { + t.Fatalf("added_at filter must not use library first_seen_at, got %q", where) + } +} + func TestEpisodeCatalogEntryQueryFallsBackForSameFileTechnicalAnd(t *testing.T) { group := QueryGroup{ Match: "all", @@ -98,6 +124,56 @@ func TestEpisodeCatalogEntryQueryFallsBackForSameFileTechnicalAnd(t *testing.T) } } +func TestEpisodeCatalogInProgressPlanKeepsUnknownDurationRows(t *testing.T) { + def := QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{ + {Field: "in_progress", Op: "is", Value: true}, + }, + }}, + Sort: QuerySort{Field: "title", Order: "asc"}, + }.Normalize() + + plan, ok, err := extractEpisodeCatalogUserStatePlan(def) + if err != nil { + t.Fatalf("extractEpisodeCatalogUserStatePlan returned error: %v", err) + } + if !ok { + t.Fatal("expected in_progress filter to use a user-state plan") + } + sql := episodeCatalogUserStateCTE(plan) + if strings.Contains(sql, "COALESCE(uwp.duration_seconds, 0) > 0") { + t.Fatalf("in_progress filter must keep unknown-duration rows, got CTE:\n%s", sql) + } +} + +func TestEpisodeCatalogProgressSortRequiresProgressRatio(t *testing.T) { + def := QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{ + {Field: "genre", Op: "is", Value: "Comedy"}, + }, + }}, + Sort: QuerySort{Field: "progress", Order: "desc"}, + }.Normalize() + + plan, ok, err := extractEpisodeCatalogUserStatePlan(def) + if err != nil { + t.Fatalf("extractEpisodeCatalogUserStatePlan returned error: %v", err) + } + if !ok { + t.Fatal("expected progress sort to use a user-state plan") + } + sql := episodeCatalogUserStateCTE(plan) + if !strings.Contains(sql, "COALESCE(uwp.duration_seconds, 0) > 0") { + t.Fatalf("progress sort must require a computable ratio before falling back, got CTE:\n%s", sql) + } +} + func TestExtractEpisodeCatalogUserStatePlanForLastWatched(t *testing.T) { def := QueryDefinition{ Match: "all", diff --git a/migrations/142_episode_catalog_entries.down.sql b/migrations/142_episode_catalog_entries.down.sql index 8165f97e..e93f6816 100644 --- a/migrations/142_episode_catalog_entries.down.sql +++ b/migrations/142_episode_catalog_entries.down.sql @@ -1,5 +1,7 @@ DROP TRIGGER IF EXISTS trg_episode_catalog_entries_series ON public.media_items; DROP TRIGGER IF EXISTS trg_episode_catalog_entries_episodes ON public.episodes; +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_media_files_update ON public.media_files; +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_media_files_insert_delete ON public.media_files; DROP TRIGGER IF EXISTS trg_episode_catalog_entries_media_files ON public.media_files; DROP TRIGGER IF EXISTS trg_episode_catalog_entries_episode_libraries ON public.episode_libraries; diff --git a/migrations/142_episode_catalog_entries.up.sql b/migrations/142_episode_catalog_entries.up.sql index e601a016..0020addb 100644 --- a/migrations/142_episode_catalog_entries.up.sql +++ b/migrations/142_episode_catalog_entries.up.sql @@ -183,8 +183,8 @@ BEGIN ) AS resolution_codes, MAX(mf.bitrate) FILTER (WHERE mf.bitrate IS NOT NULL AND mf.bitrate > 0) AS max_bitrate, MIN(mf.bitrate) FILTER (WHERE mf.bitrate IS NOT NULL AND mf.bitrate > 0) AS min_bitrate, - BOOL_OR(COALESCE(mf.hdr, false)) AS has_hdr, - BOOL_OR(NOT COALESCE(mf.hdr, false)) AS has_non_hdr, + BOOL_OR(mf.hdr IS TRUE) AS has_hdr, + BOOL_OR(mf.hdr IS FALSE) AS has_non_hdr, BOOL_OR(EXISTS ( SELECT 1 FROM jsonb_array_elements(COALESCE(mf.video_tracks, '[]'::jsonb)) AS vt @@ -376,7 +376,7 @@ WITH active_episode_files AS ( public.episode_catalog_normalized_resolution(mf.resolution) AS normalized_resolution, public.episode_catalog_resolution_rank(mf.resolution) AS resolution_rank, mf.bitrate, - COALESCE(mf.hdr, false) AS hdr, + mf.hdr, EXISTS ( SELECT 1 FROM jsonb_array_elements(COALESCE(mf.video_tracks, '[]'::jsonb)) AS vt @@ -398,8 +398,8 @@ file_stats AS ( FILTER (WHERE normalized_resolution IS NOT NULL) AS resolution_codes, MAX(bitrate) FILTER (WHERE bitrate IS NOT NULL AND bitrate > 0) AS max_bitrate, MIN(bitrate) FILTER (WHERE bitrate IS NOT NULL AND bitrate > 0) AS min_bitrate, - BOOL_OR(hdr) AS has_hdr, - BOOL_OR(NOT hdr) AS has_non_hdr, + BOOL_OR(hdr IS TRUE) AS has_hdr, + BOOL_OR(hdr IS FALSE) AS has_non_hdr, BOOL_OR(has_dolby_vision) AS has_dolby_vision, BOOL_OR(NOT has_dolby_vision) AS has_non_dolby_vision FROM active_episode_files @@ -595,14 +595,32 @@ AFTER INSERT OR UPDATE OR DELETE ON public.episode_libraries FOR EACH ROW EXECUTE FUNCTION public.episode_catalog_entries_episode_libraries_trigger(); DROP TRIGGER IF EXISTS trg_episode_catalog_entries_media_files ON public.media_files; -CREATE TRIGGER trg_episode_catalog_entries_media_files -AFTER INSERT OR UPDATE OF episode_id, media_folder_id, missing_since, resolution, bitrate, hdr, video_tracks, audio_tracks, subtitle_tracks, external_subtitles, created_at OR DELETE -ON public.media_files +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_media_files_insert_delete ON public.media_files; +DROP TRIGGER IF EXISTS trg_episode_catalog_entries_media_files_update ON public.media_files; +CREATE TRIGGER trg_episode_catalog_entries_media_files_insert_delete +AFTER INSERT OR DELETE ON public.media_files FOR EACH ROW EXECUTE FUNCTION public.episode_catalog_entries_media_files_trigger(); +CREATE TRIGGER trg_episode_catalog_entries_media_files_update +AFTER UPDATE ON public.media_files +FOR EACH ROW +WHEN ( + OLD.episode_id IS DISTINCT FROM NEW.episode_id OR + OLD.media_folder_id IS DISTINCT FROM NEW.media_folder_id OR + OLD.missing_since IS DISTINCT FROM NEW.missing_since OR + OLD.resolution IS DISTINCT FROM NEW.resolution OR + OLD.bitrate IS DISTINCT FROM NEW.bitrate OR + OLD.hdr IS DISTINCT FROM NEW.hdr OR + OLD.video_tracks IS DISTINCT FROM NEW.video_tracks OR + OLD.external_subtitles IS DISTINCT FROM NEW.external_subtitles OR + OLD.audio_language_codes IS DISTINCT FROM NEW.audio_language_codes OR + OLD.subtitle_language_codes IS DISTINCT FROM NEW.subtitle_language_codes +) +EXECUTE FUNCTION public.episode_catalog_entries_media_files_trigger(); + DROP TRIGGER IF EXISTS trg_episode_catalog_entries_episodes ON public.episodes; CREATE TRIGGER trg_episode_catalog_entries_episodes -AFTER INSERT OR UPDATE OF content_id, series_id, title, air_date, runtime, rating_imdb, rating_tmdb, still_path, still_thumbhash, created_at OR DELETE +AFTER INSERT OR UPDATE OF content_id, series_id, title, episode_number, air_date, runtime, rating_imdb, rating_tmdb, still_path, still_thumbhash, created_at OR DELETE ON public.episodes FOR EACH ROW EXECUTE FUNCTION public.episode_catalog_entries_episodes_trigger(); @@ -639,6 +657,15 @@ changed_entries AS ( CROSS JOIN migration_clock mc WHERE si.updated_at >= mc.started_at AND si.type = 'series' + UNION + SELECT DISTINCT ece.episode_id, ece.media_folder_id + FROM public.episode_catalog_entries ece + WHERE NOT EXISTS ( + SELECT 1 + FROM public.episode_libraries el + WHERE el.episode_id = ece.episode_id + AND el.media_folder_id = ece.media_folder_id + ) ) SELECT COUNT(*) FROM ( diff --git a/scripts/benchmark_episode_catalog.py b/scripts/benchmark_episode_catalog.py index 0fca64dc..d6ff54d0 100644 --- a/scripts/benchmark_episode_catalog.py +++ b/scripts/benchmark_episode_catalog.py @@ -126,7 +126,14 @@ def getenv_required(name: str) -> str: return value +def validate_base_url(base_url: str) -> None: + parsed = urllib.parse.urlparse(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("SILO_BASE_URL must be an http or https URL") + + def build_url(config: Config, params: dict[str, str], include_total: bool) -> str: + validate_base_url(config.base_url) query = { "source": "query", "type": "episode", @@ -137,7 +144,8 @@ def build_url(config: Config, params: dict[str, str], include_total: bool) -> st } if not include_total: query["include_total"] = "false" - return urllib.parse.urljoin(config.base_url.rstrip("/") + "/", "api/v1/catalog") + "?" + urllib.parse.urlencode(query) + api_url = urllib.parse.urljoin(config.base_url.rstrip("/") + "/", "api/v1/catalog") + return api_url + "?" + urllib.parse.urlencode(query) def fetch_case(config: Config, case: str, params: dict[str, str], include_total: bool) -> CaseResult: @@ -151,16 +159,31 @@ def fetch_case(config: Config, case: str, params: dict[str, str], include_total: }, ) started = time.perf_counter() + status = 0 try: with urllib.request.urlopen(request, timeout=config.timeout) as response: + status = response.status body = response.read() elapsed_ms = (time.perf_counter() - started) * 1000 - payload = json.loads(body) - items = payload.get("items") + try: + payload = json.loads(body) + items = payload.get("items") + except (json.JSONDecodeError, AttributeError, TypeError) as err: + return CaseResult( + case, + include_total, + status, + elapsed_ms, + None, + None, + None, + None, + str(err), + ) return CaseResult( case=case, include_total=include_total, - status=response.status, + status=status, elapsed_ms=elapsed_ms, total=payload.get("total"), total_exact=payload.get("total_exact"), @@ -225,6 +248,10 @@ def main() -> int: offset=args.offset, timeout=args.timeout, ) + try: + validate_base_url(config.base_url) + except ValueError as err: + raise SystemExit(str(err)) from err cases = DEFAULT_SORT_CASES + DEFAULT_FILTER_CASES include_total_values = [False, True] if args.include_exact else [False] diff --git a/web/src/pages/LibraryBrowse.test.tsx b/web/src/pages/LibraryBrowse.test.tsx index 8d2ecac0..c48a4d58 100644 --- a/web/src/pages/LibraryBrowse.test.tsx +++ b/web/src/pages/LibraryBrowse.test.tsx @@ -69,7 +69,7 @@ describe("LibraryBrowse", () => { expect(mocks.useCatalogWindow).toHaveBeenCalled(); }); - it("shows the filtered item count from the catalog result", () => { + it("does not show the estimated item count as an exact result count", () => { mocks.useCatalogWindow.mockReturnValue({ data: { totalItems: 1234, @@ -95,10 +95,11 @@ describe("LibraryBrowse", () => { />, ); - expect(markup).toContain("1,234 items"); + expect(markup).toContain("Filters"); + expect(markup).not.toContain("1,234 items"); }); - it("shows a loading state for the item count while catalog data loads", () => { + it("does not show an item count loading state when exact totals are skipped", () => { mocks.useCatalogWindow.mockReturnValue({ data: { totalItems: 0, @@ -124,7 +125,8 @@ describe("LibraryBrowse", () => { />, ); - expect(markup).toContain("Loading item count"); + expect(markup).toContain("Filters"); + expect(markup).not.toContain("Loading item count"); expect(markup).not.toContain("0 items"); }); diff --git a/web/src/pages/LibraryBrowse.tsx b/web/src/pages/LibraryBrowse.tsx index cabbdb58..c0cd4316 100644 --- a/web/src/pages/LibraryBrowse.tsx +++ b/web/src/pages/LibraryBrowse.tsx @@ -38,10 +38,6 @@ function getLibrarySortRelevanceScope( return "all"; } -function formatItemCount(count: number): string { - return `${count.toLocaleString()} item${count === 1 ? "" : "s"}`; -} - export default function LibraryBrowse({ libraryId, libraryType, @@ -112,7 +108,6 @@ export default function LibraryBrowse({ const totalItems = catalogQuery.data?.totalItems ?? 0; const pages = catalogQuery.data?.pages ?? new Map(); const isLoading = catalogQuery.isLoading; - const itemCountLabel = formatItemCount(totalItems); return (
      @@ -146,8 +141,6 @@ export default function LibraryBrowse({ allowPersonalizedFilters allowPersonalizedSorts sortRelevanceScope={sortRelevanceScope} - resultCountLabel={itemCountLabel} - resultCountLoading={isLoading} /> Date: Wed, 27 May 2026 13:37:33 -0400 Subject: [PATCH 062/201] Fix settle-window drainer cancellation in library ingest --- internal/libraryingest/executor.go | 25 +++--- internal/libraryingest/executor_test.go | 106 ++++++++++++++++++------ 2 files changed, 95 insertions(+), 36 deletions(-) diff --git a/internal/libraryingest/executor.go b/internal/libraryingest/executor.go index 4cc5a0cc..688e326c 100644 --- a/internal/libraryingest/executor.go +++ b/internal/libraryingest/executor.go @@ -165,7 +165,8 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode CurrentScope: claim.path, }) runStartedAt := time.Now().UTC() - drainerCtx, stopDrainers := context.WithCancel(scanCtx) + drainerStopCtx, stopDrainers := context.WithCancel(context.Background()) + defer stopDrainers() drainerErrCh := make(chan error, 1) var drainerWG sync.WaitGroup if len(matchScopes) > 0 { @@ -180,24 +181,21 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode ticker := time.NewTicker(scopedDrainInterval) defer ticker.Stop() for { - if drainerCtx.Err() != nil { + select { + case <-scanCtx.Done(): return + case <-drainerStopCtx.Done(): + return + default: } started := time.Now() - processed, err := e.matcher.ProcessBatchByFolderAndPathPrefix(drainerCtx, folder.ID, scopePath, runStartedAt) + processed, err := e.matcher.ProcessBatchByFolderAndPathPrefix(scanCtx, folder.ID, scopePath, runStartedAt) concurrentMatchDuration.Add(time.Since(started).Nanoseconds()) if processed > 0 { concurrentMatched.Add(int64(processed)) } if err != nil { - // A cancelled drainer context means this is a deliberate - // shutdown (the settle window ended and stopDrainers ran, or - // the whole scan is being torn down), not a real failure. The - // in-flight ProcessBatch call returns context.Canceled here, so - // exit cleanly without escalating. Genuine external - // cancellation still reaches the run via the scanCtx checks in - // the main goroutine, so this does not swallow real cancels. - if drainerCtx.Err() != nil { + if scanCtx.Err() != nil { return } select { @@ -208,7 +206,9 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode return } select { - case <-drainerCtx.Done(): + case <-scanCtx.Done(): + return + case <-drainerStopCtx.Done(): return case <-ticker.C: } @@ -234,6 +234,7 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode scanStarted := time.Now() scanMatchScopes, scanResult, err := e.scan(scanProgressCtx, folder, mode, claim.path) if err != nil { + cancel() stopDrainers() drainerWG.Wait() if len(matchScopes) > 0 { diff --git a/internal/libraryingest/executor_test.go b/internal/libraryingest/executor_test.go index 17bcaf02..073f2199 100644 --- a/internal/libraryingest/executor_test.go +++ b/internal/libraryingest/executor_test.go @@ -2,6 +2,7 @@ package libraryingest import ( "context" + "sync" "sync/atomic" "testing" "time" @@ -10,25 +11,50 @@ import ( "github.com/Silo-Server/silo-server/internal/scanner" ) -// settleBlockingMatcher simulates a TV match drainer whose provider lookup is -// still in flight when the settle window expires: the batch call blocks until -// its context is cancelled, then returns context.Canceled — exactly what -// stopDrainers triggers when it cancels the drainer context. -type settleBlockingMatcher struct { - batchCalls atomic.Int64 +// settleControlledMatcher simulates a TV match drainer whose provider lookup is +// still in flight when the settle window expires. The batch only returns once +// the test releases it, so the test can catch stopDrainers cancelling the active +// batch context instead of only stopping the drainer loop. +type settleControlledMatcher struct { + batchCalls atomic.Int64 + batchCompleted atomic.Bool + batchCtxCanceled atomic.Bool + processAllBeforeBatch atomic.Bool + batchStarted chan struct{} + releaseBatch chan struct{} + closeBatchStartedOnce sync.Once } -func (m *settleBlockingMatcher) ProcessBatchByFolderAndPathPrefix(ctx context.Context, _ int, _ string, _ time.Time) (int, error) { +func newSettleControlledMatcher() *settleControlledMatcher { + return &settleControlledMatcher{ + batchStarted: make(chan struct{}), + releaseBatch: make(chan struct{}), + } +} + +func (m *settleControlledMatcher) ProcessBatchByFolderAndPathPrefix(ctx context.Context, _ int, _ string, _ time.Time) (int, error) { m.batchCalls.Add(1) - <-ctx.Done() - return 0, ctx.Err() + m.closeBatchStartedOnce.Do(func() { + close(m.batchStarted) + }) + select { + case <-ctx.Done(): + m.batchCtxCanceled.Store(true) + return 0, ctx.Err() + case <-m.releaseBatch: + m.batchCompleted.Store(true) + return 1, nil + } } -func (m *settleBlockingMatcher) ProcessAllByFolderAndPathPrefix(context.Context, int, string, time.Time) (int, error) { +func (m *settleControlledMatcher) ProcessAllByFolderAndPathPrefix(context.Context, int, string, time.Time) (int, error) { + if !m.batchCompleted.Load() { + m.processAllBeforeBatch.Store(true) + } return 0, nil } -func (m *settleBlockingMatcher) RetryUnmatchedItemsByFolderAndPathPrefix(context.Context, int, string) (int, int, error) { +func (m *settleControlledMatcher) RetryUnmatchedItemsByFolderAndPathPrefix(context.Context, int, string) (int, int, error) { return 0, 0, nil } @@ -54,29 +80,61 @@ func (s *settleStubScanner) FinalizeVariantsByPathPrefix(context.Context, *model return nil } -// TestIngestFolderCompletesWhenDrainerCanceledMidBatch is a regression test for -// the settle-window cancellation bug: a TV library full scan was recorded as -// "cancelled" because a drainer batch in flight when stopDrainers() ran returned -// context.Canceled, which the drainer escalated as a fatal scan error. The -// ingest must instead complete normally. -func TestIngestFolderCompletesWhenDrainerCanceledMidBatch(t *testing.T) { - matcher := &settleBlockingMatcher{} +// TestIngestFolderLetsActiveDrainerBatchFinishAfterSettleWindow is a regression +// test for the settle-window cancellation bug: a TV library full scan was +// recorded as "cancelled" because stopDrainers cancelled a drainer batch that +// was already in flight. The active batch must be allowed to finish; otherwise +// rows it already claimed can be excluded from the final scoped matcher by the +// runStartedAt attempt window. +func TestIngestFolderLetsActiveDrainerBatchFinishAfterSettleWindow(t *testing.T) { + const settleWindow = 25 * time.Millisecond + + matcher := newSettleControlledMatcher() exec := &Executor{ scanner: &settleStubScanner{result: &scanner.ScanResult{New: 1}}, matcher: matcher, now: time.Now, - tvDrainSettleWindow: 50 * time.Millisecond, + tvDrainSettleWindow: settleWindow, } folder := &models.MediaFolder{ID: 5, Type: "series", Paths: []string{"/tv"}} - result, err := exec.IngestFolder(context.Background(), folder) - if err != nil { - t.Fatalf("expected ingest to complete, got error: %v", err) + type ingestResult struct { + result *Result + err error } - if result == nil || result.Skipped { - t.Fatalf("expected a non-skipped result, got %+v", result) + done := make(chan ingestResult, 1) + go func() { + result, err := exec.IngestFolder(context.Background(), folder) + done <- ingestResult{result: result, err: err} + }() + + select { + case <-matcher.batchStarted: + case <-time.After(time.Second): + t.Fatal("drainer never started a batch") + } + time.Sleep(2 * settleWindow) + close(matcher.releaseBatch) + + var got ingestResult + select { + case got = <-done: + case <-time.After(time.Second): + t.Fatal("ingest did not complete after releasing the active drainer batch") + } + if got.err != nil { + t.Fatalf("expected ingest to complete, got error: %v", got.err) + } + if got.result == nil || got.result.Skipped { + t.Fatalf("expected a non-skipped result, got %+v", got.result) } if matcher.batchCalls.Load() == 0 { t.Fatal("drainer never ran a batch; test did not exercise the settle-window shutdown path") } + if matcher.batchCtxCanceled.Load() { + t.Fatal("settle-window shutdown cancelled the active drainer batch context") + } + if matcher.processAllBeforeBatch.Load() { + t.Fatal("final scoped matcher ran before the active drainer batch completed") + } } From d19b6c48e95fad6d3e315a3b24e8eecc24dc5dc5 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 13:59:29 -0400 Subject: [PATCH 063/201] fix(catalog): support relative date filters --- internal/catalog/catalog_resolver.go | 107 +++++++++- internal/catalog/catalog_resolver_test.go | 44 +++++ .../episode_catalog_entries_executor.go | 6 +- .../episode_catalog_entries_executor_test.go | 26 +++ internal/catalog/query_builder.go | 183 ++++++++++++++++-- internal/catalog/query_builder_test.go | 55 ++++++ 6 files changed, 406 insertions(+), 15 deletions(-) diff --git a/internal/catalog/catalog_resolver.go b/internal/catalog/catalog_resolver.go index e6f48ecd..fe953b28 100644 --- a/internal/catalog/catalog_resolver.go +++ b/internal/catalog/catalog_resolver.go @@ -927,6 +927,7 @@ var catalogQueryRuleFields = map[string]bool{ "country": true, "original_language": true, "content_rating": true, + "added_at": true, "release_date": true, "status": true, "actor": true, @@ -956,6 +957,7 @@ var catalogPersonalRuleFields = map[string]bool{ "country": true, "original_language": true, "content_rating": true, + "added_at": true, "release_date": true, "status": true, "actor": true, @@ -1713,6 +1715,12 @@ func catalogRuleMatchesItem(item *models.MediaItem, rule QueryRule) bool { return false } return compareCatalogNumeric(*item.RatingIMDB, rule.Op, rule.Value) + case "added_at": + addedAt := item.CreatedAt + if item.AddedAt != nil { + addedAt = *item.AddedAt + } + return compareCatalogTime(addedAt, rule.Op, rule.Value) case "release_date": releaseDate := catalogItemReleaseDate(item) if releaseDate == "" { @@ -1793,11 +1801,24 @@ func compareCatalogStringDate(actual, op string, value any) bool { } switch op { - case "gt", "gte", "lt", "lte", "between", "is", "is_not": + case "gt", "gte", "lt", "lte", "between", "is", "is_not", "in_last": default: return false } + if op == "in_last" { + duration, ok := catalogStringValue(value) + if !ok { + return false + } + spec, err := parseDurationSpec(duration) + if err != nil { + return false + } + cutoff := catalogDateOnly(spec.cutoffTime(time.Now().UTC())) + return !actualTime.Before(cutoff) + } + if op == "between" { values, ok := catalogStringRange(value) if !ok { @@ -1838,6 +1859,90 @@ func compareCatalogStringDate(actual, op string, value any) bool { } } +func compareCatalogTime(actual time.Time, op string, value any) bool { + if actual.IsZero() { + return false + } + + switch op { + case "gt", "gte", "lt", "lte", "between", "in_last": + default: + return false + } + + if op == "in_last" { + duration, ok := catalogStringValue(value) + if !ok { + return false + } + spec, err := parseDurationSpec(duration) + if err != nil { + return false + } + return !actual.Before(spec.cutoffTime(time.Now())) + } + + if op == "between" { + values, ok := catalogStringRange(value) + if !ok { + return false + } + start, ok := catalogTimeValue(values[0]) + if !ok { + return false + } + end, ok := catalogTimeValue(values[1]) + if !ok { + return false + } + return !actual.Before(start) && !actual.After(end) + } + + expected, ok := catalogTimeValue(value) + if !ok { + return false + } + + switch op { + case "gt": + return actual.After(expected) + case "gte": + return actual.After(expected) || actual.Equal(expected) + case "lt": + return actual.Before(expected) + case "lte": + return actual.Before(expected) || actual.Equal(expected) + default: + return false + } +} + +func catalogTimeValue(value any) (time.Time, bool) { + switch v := value.(type) { + case time.Time: + return v, !v.IsZero() + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return time.Time{}, false + } + if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil { + return parsed, true + } + if parsed, err := time.Parse("2006-01-02", trimmed); err == nil { + return parsed, true + } + return time.Time{}, false + default: + return time.Time{}, false + } +} + +func catalogDateOnly(value time.Time) time.Time { + utc := value.UTC() + return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC) +} + func catalogFloat(value any) (float64, bool) { switch v := value.(type) { case int: diff --git a/internal/catalog/catalog_resolver_test.go b/internal/catalog/catalog_resolver_test.go index 21d8356c..f2c3ae33 100644 --- a/internal/catalog/catalog_resolver_test.go +++ b/internal/catalog/catalog_resolver_test.go @@ -39,6 +39,24 @@ func TestValidateCatalogQueryRequest_AllowsEpisodeMediaScope(t *testing.T) { } } +func TestValidateCatalogQueryRequest_AllowsAddedAtFilter(t *testing.T) { + req := CatalogRequest{ + Source: CatalogSourceQuery, + Query: QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{{Field: "added_at", Op: "in_last", Value: "1y"}}, + }}, + Sort: QuerySort{Field: "title", Order: "asc"}, + }, + } + + if err := validateCatalogQueryRequest(req, true); err != nil { + t.Fatalf("expected added_at filter to be accepted, got %v", err) + } +} + func TestValidateCatalogQueryRequest_RejectsPersonalizedSortWithoutProfileScopedSurface(t *testing.T) { req := CatalogRequest{ Source: CatalogSourceQuery, @@ -67,6 +85,32 @@ func TestValidateCatalogQueryRequest_AllowsPersonalizedSortWithProfileScopedSurf } } +func TestCatalogRuleMatchesItem_InLastDateFields(t *testing.T) { + recentRelease := time.Now().UTC().AddDate(0, 0, -10).Format("2006-01-02") + oldRelease := time.Now().UTC().AddDate(-2, 0, 0).Format("2006-01-02") + recentItem := &models.MediaItem{ + CreatedAt: time.Now().AddDate(0, 0, -10), + ReleaseDate: &recentRelease, + } + oldItem := &models.MediaItem{ + CreatedAt: time.Now().AddDate(-2, 0, 0), + ReleaseDate: &oldRelease, + } + + if !catalogRuleMatchesItem(recentItem, QueryRule{Field: "added_at", Op: "in_last", Value: "1y"}) { + t.Fatal("expected recent added_at to match 1y in_last") + } + if catalogRuleMatchesItem(oldItem, QueryRule{Field: "added_at", Op: "in_last", Value: "1y"}) { + t.Fatal("expected old added_at not to match 1y in_last") + } + if !catalogRuleMatchesItem(recentItem, QueryRule{Field: "release_date", Op: "in_last", Value: "1y"}) { + t.Fatal("expected recent release_date to match 1y in_last") + } + if catalogRuleMatchesItem(oldItem, QueryRule{Field: "release_date", Op: "in_last", Value: "1y"}) { + t.Fatal("expected old release_date not to match 1y in_last") + } +} + // concurrentCallBarrier records the maximum number of callers observed // in-flight at the same time. Each call into the stubbed facet fetcher invokes // hit, which: diff --git a/internal/catalog/episode_catalog_entries_executor.go b/internal/catalog/episode_catalog_entries_executor.go index 930f2b56..ede10727 100644 --- a/internal/catalog/episode_catalog_entries_executor.go +++ b/internal/catalog/episode_catalog_entries_executor.go @@ -935,7 +935,11 @@ func buildEpisodeCatalogComparisonClause(column string, rule QueryRule, argIdx i if err != nil { return "", nil, argIdx, true, err } - return fmt.Sprintf("%s >= NOW() - INTERVAL '%s'", column, interval), nil, argIdx, true, nil + cutoff := fmt.Sprintf("NOW() - INTERVAL '%s'", interval) + if cast == "date" { + cutoff = fmt.Sprintf("(CURRENT_DATE - INTERVAL '%s')::date", interval) + } + return fmt.Sprintf("%s >= %s", column, cutoff), nil, argIdx, true, nil default: return "", nil, argIdx, true, fmt.Errorf("unsupported comparison operator %q", rule.Op) } diff --git a/internal/catalog/episode_catalog_entries_executor_test.go b/internal/catalog/episode_catalog_entries_executor_test.go index f255f7ce..b49a500e 100644 --- a/internal/catalog/episode_catalog_entries_executor_test.go +++ b/internal/catalog/episode_catalog_entries_executor_test.go @@ -106,6 +106,32 @@ func TestEpisodeCatalogAddedAtFilterUsesEpisodeCreatedAt(t *testing.T) { } } +func TestEpisodeCatalogReleaseDateInLastUsesDateCutoff(t *testing.T) { + def := QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{ + {Field: "release_date", Op: "in_last", Value: "1y"}, + }, + }}, + }.Normalize() + + where, _, _, ok, err := buildEpisodeCatalogEntryQueryWhere(def, 2) + if err != nil { + t.Fatalf("buildEpisodeCatalogEntryQueryWhere returned error: %v", err) + } + if !ok { + t.Fatal("buildEpisodeCatalogEntryQueryWhere unexpectedly fell back") + } + if !strings.Contains(where, "ece.episode_air_date >= (CURRENT_DATE - INTERVAL '1 years')::date") { + t.Fatalf("expected release_date filter to use date cutoff, got %q", where) + } + if strings.Contains(where, "NOW() - INTERVAL") { + t.Fatalf("release_date filter must not compare a date to NOW(), got %q", where) + } +} + func TestEpisodeCatalogEntryQueryFallsBackForSameFileTechnicalAnd(t *testing.T) { group := QueryGroup{ Match: "all", diff --git a/internal/catalog/query_builder.go b/internal/catalog/query_builder.go index f56a222f..8678d056 100644 --- a/internal/catalog/query_builder.go +++ b/internal/catalog/query_builder.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/models" @@ -365,6 +366,10 @@ func (qb *QueryBuilder) buildRule(rule QueryRule) (string, error) { return qb.buildInProgressClause(rule) case "last_watched": return qb.buildLastWatchedClause(rule) + case "added_at": + return qb.buildAddedAtClause(rule) + case "release_date": + return qb.buildReleaseDateClause(rule) case "resolution": return qb.buildResolutionClause(rule) case "hdr": @@ -940,6 +945,64 @@ func (qb *QueryBuilder) buildLastWatchedClause(rule QueryRule) (string, error) { } } +func (qb *QueryBuilder) buildAddedAtClause(rule QueryRule) (string, error) { + column := qb.addedAtFilterExpr() + + switch rule.Op { + case "gt": + return qb.buildTypedComparisonClause(column, ">", rule.Value, "timestamptz"), nil + case "gte": + return qb.buildTypedComparisonClause(column, ">=", rule.Value, "timestamptz"), nil + case "lt": + return qb.buildTypedComparisonClause(column, "<", rule.Value, "timestamptz"), nil + case "lte": + return qb.buildTypedComparisonClause(column, "<=", rule.Value, "timestamptz"), nil + case "between": + return qb.buildTypedBetweenClause(column, rule.Value, "timestamptz") + case "in_last": + duration, ok := rule.Value.(string) + if !ok { + return "", fmt.Errorf("in_last requires a duration string like '30d'") + } + interval, err := parseDuration(duration) + if err != nil { + return "", err + } + return fmt.Sprintf("%s >= NOW() - INTERVAL '%s'", column, interval), nil + default: + return "", fmt.Errorf("unsupported operator %q for added_at", rule.Op) + } +} + +func (qb *QueryBuilder) buildReleaseDateClause(rule QueryRule) (string, error) { + column := qb.releaseDateFilterExpr() + + switch rule.Op { + case "gt": + return qb.buildTypedComparisonClause(column, ">", rule.Value, "date"), nil + case "gte": + return qb.buildTypedComparisonClause(column, ">=", rule.Value, "date"), nil + case "lt": + return qb.buildTypedComparisonClause(column, "<", rule.Value, "date"), nil + case "lte": + return qb.buildTypedComparisonClause(column, "<=", rule.Value, "date"), nil + case "between": + return qb.buildTypedBetweenClause(column, rule.Value, "date") + case "in_last": + duration, ok := rule.Value.(string) + if !ok { + return "", fmt.Errorf("in_last requires a duration string like '30d'") + } + interval, err := parseDuration(duration) + if err != nil { + return "", err + } + return fmt.Sprintf("%s >= (CURRENT_DATE - INTERVAL '%s')::date", column, interval), nil + default: + return "", fmt.Errorf("unsupported operator %q for release_date", rule.Op) + } +} + func (qb *QueryBuilder) userStateCompletionClause() string { qb.args = append(qb.args, qb.userID, qb.profileID) clause := fmt.Sprintf(`( @@ -1015,6 +1078,34 @@ func (qb *QueryBuilder) buildTimestampComparisonClause(column, op string, value return clause } +func (qb *QueryBuilder) buildTypedComparisonClause(column, op string, value any, cast string) string { + qb.args = append(qb.args, value) + placeholder := fmt.Sprintf("$%d", qb.argIdx) + if cast != "" { + placeholder += "::" + cast + } + clause := fmt.Sprintf("%s %s %s", column, op, placeholder) + qb.argIdx++ + return clause +} + +func (qb *QueryBuilder) buildTypedBetweenClause(column string, value any, cast string) (string, error) { + values, err := toBetweenValues(value) + if err != nil { + return "", fmt.Errorf("between requires [min, max] array: %w", err) + } + qb.args = append(qb.args, values[0], values[1]) + left := fmt.Sprintf("$%d", qb.argIdx) + right := fmt.Sprintf("$%d", qb.argIdx+1) + if cast != "" { + left += "::" + cast + right += "::" + cast + } + clause := fmt.Sprintf("%s >= %s AND %s <= %s", column, left, column, right) + qb.argIdx += 2 + return clause, nil +} + func (qb *QueryBuilder) normalizedTitleExpr() string { if isEpisodeCatalogScope(qb.mediaScope) { return fmt.Sprintf("%s.sort_key", qb.alias) @@ -1033,6 +1124,26 @@ func (qb *QueryBuilder) releaseDateSortExpr() string { return queryColumnSQL(qb.alias, querySortDefs["release_date"].columnSQL) } +func (qb *QueryBuilder) releaseDateFilterExpr() string { + if isEpisodeCatalogScope(qb.mediaScope) { + return fmt.Sprintf("%s.episode_air_date", qb.alias) + } + firstAirDate := fmt.Sprintf("NULLIF(BTRIM(%s.first_air_date), '')", qb.alias) + return fmt.Sprintf( + `COALESCE(%s.release_date, CASE WHEN %s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN %s::date ELSE NULL END)`, + qb.alias, + firstAirDate, + firstAirDate, + ) +} + +func (qb *QueryBuilder) addedAtFilterExpr() string { + if isEpisodeCatalogScope(qb.mediaScope) { + return fmt.Sprintf("%s.episode_created_at", qb.alias) + } + return fmt.Sprintf("%s.created_at", qb.alias) +} + func (qb *QueryBuilder) orderByExpr(expr, dir string, nullsLast bool, titleExpr string) string { clause := fmt.Sprintf("ORDER BY %s %s", expr, dir) if nullsLast { @@ -1349,24 +1460,70 @@ func toBetweenValues(v any) ([2]any, error) { } } +type relativeDuration struct { + amount int + unit byte +} + func parseDuration(s string) (string, error) { - if len(s) < 2 { - return "", fmt.Errorf("invalid duration: %q", s) + spec, err := parseDurationSpec(s) + if err != nil { + return "", err + } + return spec.sqlInterval(), nil +} + +func parseDurationSpec(s string) (relativeDuration, error) { + normalized := strings.ToLower(strings.TrimSpace(s)) + if len(normalized) < 2 { + return relativeDuration{}, fmt.Errorf("invalid duration: %q", s) } - value := s[:len(s)-1] - unit := s[len(s)-1] + value := strings.TrimSpace(normalized[:len(normalized)-1]) + unit := normalized[len(normalized)-1] + amount, err := strconv.Atoi(value) + if err != nil || amount <= 0 { + return relativeDuration{}, fmt.Errorf("invalid duration: %q", s) + } switch unit { - case 'h': - return value + " hours", nil - case 'd': - return value + " days", nil - case 'w': - return value + " weeks", nil - case 'm': - return value + " months", nil + case 'h', 'd', 'w', 'm', 'y': + return relativeDuration{amount: amount, unit: unit}, nil default: - return "", fmt.Errorf("unsupported duration unit %q", unit) + return relativeDuration{}, fmt.Errorf("unsupported duration unit %q", unit) + } +} + +func (d relativeDuration) sqlInterval() string { + switch d.unit { + case 'h': + return fmt.Sprintf("%d hours", d.amount) + case 'd': + return fmt.Sprintf("%d days", d.amount) + case 'w': + return fmt.Sprintf("%d weeks", d.amount) + case 'm': + return fmt.Sprintf("%d months", d.amount) + case 'y': + return fmt.Sprintf("%d years", d.amount) + default: + return "" + } +} + +func (d relativeDuration) cutoffTime(now time.Time) time.Time { + switch d.unit { + case 'h': + return now.Add(-time.Duration(d.amount) * time.Hour) + case 'd': + return now.AddDate(0, 0, -d.amount) + case 'w': + return now.AddDate(0, 0, -7*d.amount) + case 'm': + return now.AddDate(0, -d.amount, 0) + case 'y': + return now.AddDate(-d.amount, 0, 0) + default: + return now } } diff --git a/internal/catalog/query_builder_test.go b/internal/catalog/query_builder_test.go index 41400c14..f6409db4 100644 --- a/internal/catalog/query_builder_test.go +++ b/internal/catalog/query_builder_test.go @@ -135,6 +135,61 @@ func TestBuildSortClause_AddedAtFallsBackToCreatedAtWithoutLibraryScope(t *testi } } +func TestBuild_AddedAtInLastAcceptsYears(t *testing.T) { + clause, args, err := NewQueryBuilder("mi").Build(QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{{Field: "added_at", Op: "in_last", Value: "1y"}}, + }}, + }) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + if len(args) != 0 { + t.Fatalf("expected no args, got %v", args) + } + if !strings.Contains(clause, "mi.created_at >= NOW() - INTERVAL '1 years'") { + t.Fatalf("expected added_at in_last to use created_at and years interval, got %q", clause) + } +} + +func TestBuild_ReleaseDateInLastComparesDateExpression(t *testing.T) { + clause, args, err := NewQueryBuilder("mi").Build(QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{{Field: "release_date", Op: "in_last", Value: "1y"}}, + }}, + }) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + if len(args) != 0 { + t.Fatalf("expected no args, got %v", args) + } + for _, want := range []string{ + "COALESCE(mi.release_date, CASE WHEN NULLIF(BTRIM(mi.first_air_date), '') ~", + ">= (CURRENT_DATE - INTERVAL '1 years')::date", + } { + if !strings.Contains(clause, want) { + t.Fatalf("expected release_date in_last clause to contain %q, got %q", want, clause) + } + } + if strings.Contains(clause, "release_date::text") || strings.Contains(clause, "NOW() - INTERVAL") { + t.Fatalf("release_date in_last must not compare text to timestamp, got %q", clause) + } +} + +func TestParseDurationRejectsMalformedInput(t *testing.T) { + if _, err := parseDuration("1'; SELECT 1; --d"); err == nil { + t.Fatal("expected malformed duration to be rejected") + } + if got, err := parseDuration("2Y"); err != nil || got != "2 years" { + t.Fatalf("parseDuration(2Y) = %q, %v; want 2 years", got, err) + } +} + func TestBuildSortClause_ContentRatingUsesRankedOrdering(t *testing.T) { clause, _, err := NewQueryBuilder("mi").BuildSortClause(QuerySort{ Field: "content_rating", From a070f846af6a3d1cb5617a90e438d0194b91a924 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 14:27:14 -0400 Subject: [PATCH 064/201] fix(collections): cap smart collection results --- internal/api/handlers/collections.go | 30 ++++++- internal/api/handlers/library_collections.go | 16 +++- internal/catalog/catalog_parser.go | 5 +- internal/catalog/catalog_resolver.go | 2 + .../episode_catalog_entries_executor.go | 79 +++++++++++++------ internal/catalog/query_definition.go | 17 ++++ internal/catalog/query_executor.go | 42 ++++++++-- internal/catalog/smart_count_refresher.go | 1 + .../collections/CollectionBuilder.tsx | 79 ++++++++++++++++++- .../collections/smartCollectionLimits.ts | 12 +++ web/src/pages/SmartCollectionWizard.tsx | 6 +- web/src/pages/catalogSearchParams.ts | 5 ++ 12 files changed, 251 insertions(+), 43 deletions(-) create mode 100644 web/src/components/collections/smartCollectionLimits.ts diff --git a/internal/api/handlers/collections.go b/internal/api/handlers/collections.go index cf7d4d57..4adcd85d 100644 --- a/internal/api/handlers/collections.go +++ b/internal/api/handlers/collections.go @@ -231,7 +231,14 @@ func (h *CollectionHandler) HandleCreateCollection(w http.ResponseWriter, r *htt } queryDefinitionJSON := defaultJSON(req.QueryDefinition) - if firstNonEmptyCollection(req.CollectionType, "manual") == "smart" || len(req.QueryDefinition) > 0 { + collectionType := firstNonEmptyCollection(req.CollectionType, "manual") + if collectionType == "smart" { + queryDefinitionJSON, err = normalizeSmartCollectionQueryDefinitionJSON(queryDefinitionJSON, true, true) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") + return + } + } else if len(req.QueryDefinition) > 0 { queryDefinitionJSON, err = normalizeQueryDefinitionJSON(queryDefinitionJSON, true, true) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") @@ -243,7 +250,7 @@ func (h *CollectionHandler) HandleCreateCollection(w http.ResponseWriter, r *htt collection, err := store.CreateCollection(r.Context(), userstore.CreateCollectionInput{ CreatorProfileID: profileID, Name: req.Name, - CollectionType: firstNonEmptyCollection(req.CollectionType, "manual"), + CollectionType: collectionType, IsShared: req.IsShared, AllowedProfileIDs: req.AllowedProfileIDs, QueryDefinition: queryDefinition, @@ -300,7 +307,7 @@ func (h *CollectionHandler) HandleUpdateCollection(w http.ResponseWriter, r *htt IncludeInServerCollections: req.IncludeInServerCollections, } if len(req.QueryDefinition) > 0 { - normalized, err := normalizeQueryDefinitionJSON(req.QueryDefinition, true, true) + normalized, err := normalizeSmartCollectionQueryDefinitionJSON(req.QueryDefinition, true, true) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") return @@ -838,6 +845,23 @@ func normalizeQueryDefinitionJSON(raw []byte, allowPersonalizedSorts, allowPerso return normalized, nil } +func normalizeSmartCollectionQueryDefinitionJSON(raw []byte, allowPersonalizedSorts, allowPersonalizedFields bool) (json.RawMessage, error) { + normalized, err := normalizeQueryDefinitionJSON(raw, allowPersonalizedSorts, allowPersonalizedFields) + if err != nil { + return nil, err + } + var def catalog.QueryDefinition + if err := json.Unmarshal(normalized, &def); err != nil { + return nil, err + } + def = catalog.ApplySmartCollectionItemLimit(def) + out, err := json.Marshal(def) + if err != nil { + return nil, err + } + return out, nil +} + func firstNonEmptyCollection(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { diff --git a/internal/api/handlers/library_collections.go b/internal/api/handlers/library_collections.go index 25d8ce7a..f0c8bba4 100644 --- a/internal/api/handlers/library_collections.go +++ b/internal/api/handlers/library_collections.go @@ -1109,7 +1109,14 @@ func (h *LibraryCollectionHandler) HandleCreateAdminCollection(w http.ResponseWr req.CollectionType = "manual" } queryDefinition := defaultJSON(req.QueryDefinition) - if req.CollectionType == "smart" || len(req.QueryDefinition) > 0 { + if req.CollectionType == "smart" { + var err error + queryDefinition, err = normalizeSmartCollectionQueryDefinitionJSON(queryDefinition, false, false) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") + return + } + } else if len(req.QueryDefinition) > 0 { var err error queryDefinition, err = normalizeQueryDefinitionJSON(queryDefinition, false, false) if err != nil { @@ -1195,7 +1202,11 @@ func (h *LibraryCollectionHandler) HandleUpdateAdminCollection(w http.ResponseWr queryDefinition := req.QueryDefinition if len(req.QueryDefinition) > 0 { var err error - queryDefinition, err = normalizeQueryDefinitionJSON(req.QueryDefinition, false, false) + if req.CollectionType == nil || *req.CollectionType == "smart" { + queryDefinition, err = normalizeSmartCollectionQueryDefinitionJSON(req.QueryDefinition, false, false) + } else { + queryDefinition, err = normalizeQueryDefinitionJSON(req.QueryDefinition, false, false) + } if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition") return @@ -3109,6 +3120,7 @@ func (h *LibraryCollectionHandler) loadLiveCollectionItems(r *http.Request, coll if err := def.ValidateWithOptions(false, false); err != nil { return nil, smartCollectionQueryError{fmt.Errorf("validating smart collection query definition: %w", err)} } + def = catalog.ApplySmartCollectionItemLimit(def) switch { case len(collection.LibraryIDs) > 0: diff --git a/internal/catalog/catalog_parser.go b/internal/catalog/catalog_parser.go index 583bb2c1..a67fa133 100644 --- a/internal/catalog/catalog_parser.go +++ b/internal/catalog/catalog_parser.go @@ -162,6 +162,9 @@ func parseCatalogOverlay(values url.Values) (catalogOverlay, error) { if mediaScope := parseCatalogMediaScope(values.Get("type")); mediaScope != "" { def.MediaScope = mediaScope } + if queryLimit := ParseIntParam(values.Get("query_limit")); queryLimit > 0 { + def.Limit = &queryLimit + } implicitRules := make([]QueryRule, 0, 4) if genre := strings.TrimSpace(values.Get("genre")); genre != "" { @@ -370,7 +373,7 @@ func hasCatalogOverlayParams(values url.Values, ignoreLibraryID bool) bool { continue } return true - case "q", "name_prefix", "match", "type", "genre", "year_min", "year_max", "content_rating", "status", "sort", "order": + case "q", "name_prefix", "match", "type", "genre", "year_min", "year_max", "content_rating", "status", "sort", "order", "query_limit": return true } } diff --git a/internal/catalog/catalog_resolver.go b/internal/catalog/catalog_resolver.go index fe953b28..2465b947 100644 --- a/internal/catalog/catalog_resolver.go +++ b/internal/catalog/catalog_resolver.go @@ -419,6 +419,7 @@ func (r *CatalogResolver) resolveLiveLibraryCollectionSource(ctx context.Context } else if collection.LibraryID > 0 { def.LibraryIDs = intersectCatalogDefinitionLibraries(def.LibraryIDs, []int{collection.LibraryID}) } + def = ApplySmartCollectionItemLimit(def) return r.resolveQuerySource(ctx, CatalogRequest{ Source: CatalogSourceQuery, Query: def, @@ -444,6 +445,7 @@ func (r *CatalogResolver) resolveUserCollectionSource(ctx context.Context, req C if err != nil { return nil, fmt.Errorf("%w: parsing user collection query_definition: %v", ErrInvalidCatalogRequest, err) } + def = ApplySmartCollectionItemLimit(def) return r.resolveQuerySource(ctx, CatalogRequest{ Source: CatalogSourceQuery, Query: def, diff --git a/internal/catalog/episode_catalog_entries_executor.go b/internal/catalog/episode_catalog_entries_executor.go index ede10727..b9a747c2 100644 --- a/internal/catalog/episode_catalog_entries_executor.go +++ b/internal/catalog/episode_catalog_entries_executor.go @@ -68,12 +68,7 @@ func (e *QueryExecutor) tryEpisodeCatalogUserStatePreviewPage( return []*models.MediaItem{}, 0, false, true, nil } - if limit <= 0 { - limit = 20 - } - if offset < 0 { - offset = 0 - } + limit, offset, maxResults := normalizePreviewPageBounds(def, limit, offset) ctes := []string{episodeCatalogUserStateCTE(plan)} args := []any{access.UserID, access.ProfileID, libraryID} @@ -129,7 +124,10 @@ func (e *QueryExecutor) tryEpisodeCatalogUserStatePreviewPage( } whereClause := "WHERE " + strings.Join(whereParts, " AND ") - pageLimit := limit + 1 + pageLimit := limit + if maxResults <= 0 || offset+limit < maxResults { + pageLimit++ + } pageArgs := append([]any{}, args...) limitArgIdx := argIdx pageArgs = append(pageArgs, pageLimit) @@ -180,16 +178,14 @@ func (e *QueryExecutor) tryEpisodeCatalogUserStatePreviewPage( total := 0 if includeTotal { - countSQL := fmt.Sprintf( - `WITH %s - SELECT COUNT(*) - %s - %s`, + countSQL, countArgs := episodeCatalogCountSQL( strings.Join(ctes, ",\n"), fromClause, whereClause, + args, + maxResults, ) - if err := e.Pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil { + if err := e.Pool.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil { if episodeCatalogEntriesUnavailable(err) { return nil, 0, false, false, nil } @@ -231,12 +227,7 @@ func (e *QueryExecutor) tryEpisodeCatalogEntriesPreviewPage( return []*models.MediaItem{}, 0, false, true, nil } - if limit <= 0 { - limit = 20 - } - if offset < 0 { - offset = 0 - } + limit, offset, maxResults := normalizePreviewPageBounds(def, limit, offset) whereParts := []string{"ece.media_folder_id = $1"} args := []any{libraryID} @@ -288,7 +279,10 @@ func (e *QueryExecutor) tryEpisodeCatalogEntriesPreviewPage( } whereClause := "WHERE " + strings.Join(whereParts, " AND ") - pageLimit := limit + 1 + pageLimit := limit + if maxResults <= 0 || offset+limit < maxResults { + pageLimit++ + } pageArgs := append([]any{}, args...) limitArgIdx := argIdx pageArgs = append(pageArgs, pageLimit) @@ -333,13 +327,14 @@ func (e *QueryExecutor) tryEpisodeCatalogEntriesPreviewPage( total := 0 if includeTotal { - countSQL := fmt.Sprintf( - `SELECT COUNT(*) - FROM episode_catalog_entries ece - %s`, + countSQL, countArgs := episodeCatalogCountSQL( + "", + "FROM episode_catalog_entries ece", whereClause, + args, + maxResults, ) - if err := e.Pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil { + if err := e.Pool.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil { if episodeCatalogEntriesUnavailable(err) { return nil, 0, false, false, nil } @@ -351,6 +346,40 @@ func (e *QueryExecutor) tryEpisodeCatalogEntriesPreviewPage( return items, total, hasMore, true, nil } +func episodeCatalogCountSQL( + withCTEs string, + fromClause string, + whereClause string, + args []any, + maxResults int, +) (string, []any) { + countArgs := append([]any{}, args...) + withClause := "" + if strings.TrimSpace(withCTEs) != "" { + withClause = "WITH " + withCTEs + "\n" + } + if maxResults > 0 { + limitArgIdx := len(countArgs) + 1 + countArgs = append(countArgs, maxResults) + return fmt.Sprintf( + `%sSELECT COUNT(*) + FROM (SELECT 1 %s %s LIMIT $%d) sub`, + withClause, + fromClause, + whereClause, + limitArgIdx, + ), countArgs + } + return fmt.Sprintf( + `%sSELECT COUNT(*) + %s + %s`, + withClause, + fromClause, + whereClause, + ), countArgs +} + func extractEpisodeCatalogUserStatePlan(def QueryDefinition) (episodeCatalogUserStatePlan, bool, error) { plan := episodeCatalogUserStatePlan{ entryDef: def, diff --git a/internal/catalog/query_definition.go b/internal/catalog/query_definition.go index 3f15b917..b84b0e46 100644 --- a/internal/catalog/query_definition.go +++ b/internal/catalog/query_definition.go @@ -96,6 +96,23 @@ type QueryDefinition struct { Limit *int `json:"limit,omitempty"` } +const ( + DefaultSmartCollectionItemLimit = 100 + MaxSmartCollectionItemLimit = 500 +) + +func ApplySmartCollectionItemLimit(def QueryDefinition) QueryDefinition { + limit := DefaultSmartCollectionItemLimit + if def.Limit != nil && *def.Limit > 0 { + limit = *def.Limit + } + if limit > MaxSmartCollectionItemLimit { + limit = MaxSmartCollectionItemLimit + } + def.Limit = &limit + return def +} + type QueryGroup struct { Match string `json:"match"` Rules []QueryRule `json:"rules"` diff --git a/internal/catalog/query_executor.go b/internal/catalog/query_executor.go index ce113cde..e4e00ff6 100644 --- a/internal/catalog/query_executor.go +++ b/internal/catalog/query_executor.go @@ -136,6 +136,7 @@ type previewPagePlan struct { sortArgs []any limit int offset int + maxResults int limitArgIdx int } @@ -155,6 +156,15 @@ func (p previewPagePlan) countSQL() (string, []any) { if len(p.ctes) > 0 { withClause = "WITH " + strings.Join(p.ctes, ",\n") + "\n" } + if p.maxResults > 0 { + countLimitArgIdx := len(args) + 1 + args = append(args, p.maxResults) + sql := fmt.Sprintf( + "%sSELECT COUNT(*) FROM (SELECT 1 %s %s LIMIT $%d) sub", + withClause, p.fromClauseCount, p.whereClause, countLimitArgIdx, + ) + return sql, args + } sql := fmt.Sprintf( "%sSELECT COUNT(*) FROM (SELECT 1 %s %s) sub", withClause, p.fromClauseCount, p.whereClause, @@ -169,7 +179,7 @@ func (p previewPagePlan) countSQL() (string, []any) { // after the requested rows. func (p previewPagePlan) pagedSQL(includeTotal bool) (string, []any) { queryLimit := p.limit - if !includeTotal { + if !includeTotal && (p.maxResults <= 0 || p.offset+p.limit < p.maxResults) { queryLimit++ } args := append([]any{}, p.cteArgs...) @@ -333,12 +343,7 @@ func (e *QueryExecutor) buildPreviewPagePlan( fromClauseBase := "FROM " + baseRelation fromClauseCount := fromClauseBase - if limit <= 0 { - limit = 20 - } - if offset < 0 { - offset = 0 - } + limit, offset, maxResults := normalizePreviewPageBounds(def, limit, offset) sortPlan, err := builder.WithArgIdx(argIdx).BuildSortPlan(def.Sort) if err != nil { return previewPagePlan{}, err @@ -380,10 +385,33 @@ func (e *QueryExecutor) buildPreviewPagePlan( sortArgs: sortPlan.Args, limit: limit, offset: offset, + maxResults: maxResults, limitArgIdx: limitArgIdx, }, nil } +func normalizePreviewPageBounds(def QueryDefinition, limit int, offset int) (int, int, int) { + if limit <= 0 { + limit = 20 + } + if offset < 0 { + offset = 0 + } + if def.Limit == nil || *def.Limit <= 0 { + return limit, offset, 0 + } + + maxResults := *def.Limit + remaining := maxResults - offset + if remaining <= 0 { + return 0, offset, maxResults + } + if limit > remaining { + limit = remaining + } + return limit, offset, maxResults +} + // buildPreviewPageSQL is a test-friendly facade over buildPreviewPagePlan that // returns the rendered paged SELECT plus the bound args. It performs no I/O. // includeTotal only controls whether the page query fetches exactly limit rows diff --git a/internal/catalog/smart_count_refresher.go b/internal/catalog/smart_count_refresher.go index 53e3d763..3d3d383a 100644 --- a/internal/catalog/smart_count_refresher.go +++ b/internal/catalog/smart_count_refresher.go @@ -83,6 +83,7 @@ func (r *SmartCountRefresher) refreshOne(ctx context.Context, id string, defJSON if err := json.Unmarshal(defJSON, &def); err != nil { return fmt.Errorf("unmarshal query_definition: %w", err) } + def = ApplySmartCollectionItemLimit(def.Normalize()) queryCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() _, total, err := r.Executor.Preview(queryCtx, def, AccessFilter{}, 1) diff --git a/web/src/components/collections/CollectionBuilder.tsx b/web/src/components/collections/CollectionBuilder.tsx index 104f814d..de0f1092 100644 --- a/web/src/components/collections/CollectionBuilder.tsx +++ b/web/src/components/collections/CollectionBuilder.tsx @@ -30,6 +30,11 @@ import CollectionGuidedRulesEditor from "./CollectionGuidedRulesEditor"; import CollectionOrderingEditor from "./CollectionOrderingEditor"; import CollectionPreviewPane from "./CollectionPreviewPane"; import CollectionRulesEditor from "./CollectionRulesEditor"; +import { + SMART_COLLECTION_DEFAULT_LIMIT, + SMART_COLLECTION_MAX_LIMIT, + withSmartCollectionLimit, +} from "./smartCollectionLimits"; export interface CollectionBuilderValue { title: string; @@ -67,15 +72,19 @@ export function createCollectionBuilderValue( query_definition?: QueryDefinitionInput | null; }, ): CollectionBuilderValue { + const collectionType = overrides?.collection_type ?? "smart"; + const queryDefinition = normalizeQueryDefinition( + overrides?.query_definition ?? createEmptyQueryDefinition(), + ); + return { title: overrides?.title ?? "", description: overrides?.description ?? "", - collection_type: overrides?.collection_type ?? "smart", + collection_type: collectionType, visibility: overrides?.visibility ?? "visible", featured: overrides?.featured ?? false, - query_definition: normalizeQueryDefinition( - overrides?.query_definition ?? createEmptyQueryDefinition(), - ), + query_definition: + collectionType === "smart" ? withSmartCollectionLimit(queryDefinition) : queryDefinition, sort_config: overrides?.sort_config ?? {}, access: overrides?.access ?? { is_shared: false, allowed_profile_ids: [] }, include_in_server_collections: overrides?.include_in_server_collections ?? false, @@ -170,6 +179,10 @@ export default function CollectionBuilder({ onChange({ ...value, collection_type: next as "manual" | "smart", + query_definition: + next === "smart" + ? withSmartCollectionLimit(value.query_definition) + : value.query_definition, }) } disabled={readOnly} @@ -260,6 +273,15 @@ export default function CollectionBuilder({ )}
    +
    +

    Limit

    + onChange({ ...value, query_definition })} + readOnly={readOnly} + /> +
    + {advanced ? (

    Ordering

    @@ -340,6 +362,55 @@ export default function CollectionBuilder({ ); } +export function SmartCollectionLimitField({ + query, + onQueryChange, + readOnly = false, +}: { + query: QueryDefinition; + onQueryChange: (query: QueryDefinition) => void; + readOnly?: boolean; +}) { + function commit(input: HTMLInputElement) { + const parsed = Number.parseInt(input.value, 10); + const limit = + Number.isFinite(parsed) && parsed > 0 + ? Math.min(parsed, SMART_COLLECTION_MAX_LIMIT) + : SMART_COLLECTION_DEFAULT_LIMIT; + input.value = String(limit); + if (query.limit !== limit) { + onQueryChange({ ...query, limit }); + } + } + + return ( +
    +
    + +

    + Caps saved collection results. Use 1-{SMART_COLLECTION_MAX_LIMIT}. +

    +
    + commit(event.currentTarget)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + /> +
    + ); +} + function ToggleRow({ title, description, diff --git a/web/src/components/collections/smartCollectionLimits.ts b/web/src/components/collections/smartCollectionLimits.ts new file mode 100644 index 00000000..5f288704 --- /dev/null +++ b/web/src/components/collections/smartCollectionLimits.ts @@ -0,0 +1,12 @@ +import type { QueryDefinition } from "@/api/types"; + +export const SMART_COLLECTION_DEFAULT_LIMIT = 100; +export const SMART_COLLECTION_MAX_LIMIT = 500; + +export function withSmartCollectionLimit(query: QueryDefinition): QueryDefinition { + const limit = + typeof query.limit === "number" && Number.isFinite(query.limit) && query.limit > 0 + ? Math.min(Math.floor(query.limit), SMART_COLLECTION_MAX_LIMIT) + : SMART_COLLECTION_DEFAULT_LIMIT; + return { ...query, limit }; +} diff --git a/web/src/pages/SmartCollectionWizard.tsx b/web/src/pages/SmartCollectionWizard.tsx index b89c1a64..84092450 100644 --- a/web/src/pages/SmartCollectionWizard.tsx +++ b/web/src/pages/SmartCollectionWizard.tsx @@ -28,8 +28,10 @@ import { } from "@/components/ui/select"; import { createCollectionBuilderValue, + SmartCollectionLimitField, type CollectionBuilderValue, } from "@/components/collections/CollectionBuilder"; +import { withSmartCollectionLimit } from "@/components/collections/smartCollectionLimits"; import CollectionAccessEditor from "@/components/collections/CollectionAccessEditor"; import { useCatalogWindow } from "@/hooks/queries/catalog"; import { @@ -114,7 +116,7 @@ export default function SmartCollectionWizard(wizard: SmartCollectionWizardProps useDocumentTitle(isEdit ? `Edit ${draft.title || "Collection"}` : "New Collection"); const handleQueryDefinitionChange = useCallback((next: QueryDefinition) => { - setDraft((current) => ({ ...current, query_definition: next })); + setDraft((current) => ({ ...current, query_definition: withSmartCollectionLimit(next) })); }, []); const headerTitle = isEdit ? draft.title || "Edit Collection" : "New Collection"; @@ -327,6 +329,8 @@ function Step1FiltersAndPreview({ resultCountLoading={isLoading} /> + + {totalItems === 0 && !isLoading ? (

    No titles match these filters yet.

    diff --git a/web/src/pages/catalogSearchParams.ts b/web/src/pages/catalogSearchParams.ts index ee671d72..68fe4cf8 100644 --- a/web/src/pages/catalogSearchParams.ts +++ b/web/src/pages/catalogSearchParams.ts @@ -130,6 +130,7 @@ export function parseCatalogSearchParams(searchParams: URLSearchParams): Catalog const sort = normalizeQuerySortField(readString(searchParams.get("sort"))); const rawOrder = readString(searchParams.get("order")); + const queryLimit = parsePositiveInt(searchParams.get("query_limit")) ?? undefined; baseState.query_definition = normalizeQueryDefinition({ library_ids: baseState.library_id ? [baseState.library_id] : [], @@ -142,6 +143,7 @@ export function parseCatalogSearchParams(searchParams: URLSearchParams): Catalog order: rawOrder === "asc" || rawOrder === "desc" ? rawOrder : undefined, } : undefined, + limit: queryLimit, }); return baseState; @@ -314,6 +316,9 @@ export function buildCatalogApiSearchParams(state: CatalogSearchState): URLSearc params.set("order", state.query_definition.sort.order); } } + if (state.query_definition.limit != null && state.query_definition.limit > 0) { + params.set("query_limit", String(state.query_definition.limit)); + } state.query_definition.groups.forEach((group, groupIndex) => { params.set(`groups[${groupIndex}][match]`, group.match); From 3bcde929a50f689d3dc7f493634a707f89248a2d Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 14:54:51 -0400 Subject: [PATCH 065/201] fix(library): preserve episode browse url --- web/src/pages/LibraryPage.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index bdaeaf19..18af3d58 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -37,6 +37,10 @@ export default function LibraryPage() { useDocumentTitle(library?.name ?? "Library"); useEffect(() => { + if (!libraryType) { + return; + } + const normalizedSearchParams = updateLibraryPageSearchParams( searchParams, { activeTab, browseType, queryDefinition }, From dc4c20bb0fb6d9b9d7664bbf804063fdf1de696e Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 14:54:58 -0400 Subject: [PATCH 066/201] fix(catalog): use season posters for episode cards --- internal/catalog/episode_catalog_source.go | 5 +++-- internal/sections/fetcher.go | 10 ++++----- .../components/ContinueWatchingCard.test.tsx | 8 +++---- web/src/components/ContinueWatchingCard.tsx | 22 +++++++++++-------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/internal/catalog/episode_catalog_source.go b/internal/catalog/episode_catalog_source.go index 56b19e3a..3bb04af0 100644 --- a/internal/catalog/episode_catalog_source.go +++ b/internal/catalog/episode_catalog_source.go @@ -28,8 +28,8 @@ const episodeCatalogSelectBody = `( COALESCE(e.imdb_id, '') AS imdb_id, COALESCE(e.tmdb_id, '') AS tmdb_id, COALESCE(e.tvdb_id, '') AS tvdb_id, - COALESCE(e.still_path, '') AS poster_path, - COALESCE(e.still_thumbhash, '') AS poster_thumbhash, + COALESCE(NULLIF(s.poster_path, ''), NULLIF(si.poster_path, ''), NULLIF(e.still_path, ''), '') AS poster_path, + COALESCE(NULLIF(s.poster_thumbhash, ''), NULLIF(si.poster_thumbhash, ''), NULLIF(e.still_thumbhash, ''), '') AS poster_thumbhash, COALESCE(si.backdrop_path, '') AS backdrop_path, COALESCE(si.backdrop_thumbhash, '') AS backdrop_thumbhash, COALESCE(si.logo_path, '') AS logo_path, @@ -58,6 +58,7 @@ const episodeCatalogSelectBody = `( e.updated_at FROM episodes e JOIN media_items si ON si.content_id = e.series_id + LEFT JOIN seasons s ON s.content_id = e.season_id WHERE %s ) mi` diff --git a/internal/sections/fetcher.go b/internal/sections/fetcher.go index 01dbb759..9a771d7e 100644 --- a/internal/sections/fetcher.go +++ b/internal/sections/fetcher.go @@ -2153,7 +2153,7 @@ func (f *Fetcher) fetchEpisodeTargetsByContentIDs(ctx context.Context, contentID effectiveLibraryIDs := effectiveFetchLibraryIDs(libraryIDs, filter) - fromClause := "episodes e JOIN media_items si ON e.series_id = si.content_id" + fromClause := "episodes e JOIN media_items si ON e.series_id = si.content_id LEFT JOIN seasons s ON s.content_id = e.season_id" if libraryID != nil || effectiveLibraryIDs != nil { fromClause += " JOIN media_item_libraries mil ON si.content_id = mil.content_id" } @@ -2187,16 +2187,16 @@ func (f *Fetcher) fetchEpisodeTargetsByContentIDs(ctx context.Context, contentID e.overview, e.runtime, e.rating_imdb, - e.still_path, - e.still_thumbhash, + COALESCE(NULLIF(s.poster_path, ''), NULLIF(si.poster_path, ''), NULLIF(e.still_path, ''), '') AS poster_path, + COALESCE(NULLIF(s.poster_thumbhash, ''), NULLIF(si.poster_thumbhash, ''), NULLIF(e.still_thumbhash, ''), '') AS poster_thumbhash, e.season_number, e.episode_number, e.air_date, si.title, si.genres, si.content_rating, - si.backdrop_path, - si.backdrop_thumbhash, + COALESCE(NULLIF(e.still_path, ''), NULLIF(si.backdrop_path, ''), '') AS backdrop_path, + COALESCE(NULLIF(e.still_thumbhash, ''), NULLIF(si.backdrop_thumbhash, ''), '') AS backdrop_thumbhash, si.logo_path, si.status FROM %s diff --git a/web/src/components/ContinueWatchingCard.test.tsx b/web/src/components/ContinueWatchingCard.test.tsx index cfa6cd5a..bd4a3f18 100644 --- a/web/src/components/ContinueWatchingCard.test.tsx +++ b/web/src/components/ContinueWatchingCard.test.tsx @@ -11,7 +11,7 @@ vi.mock("@/playback/watchPlaybackContext", () => ({ })); describe("ContinueWatchingCard", () => { - it("prefers the poster image for episodes (poster_url is the horizontal still)", () => { + it("prefers the backdrop image for section episodes (backdrop_url is the horizontal still)", () => { const queryClient = new QueryClient(); const markup = renderToStaticMarkup( @@ -34,7 +34,7 @@ describe("ContinueWatchingCard", () => { position_seconds: 120, duration_seconds: 3600, progress_updated_at: "2026-03-07T00:00:00Z", - poster_url: "/episode-poster.jpg", + poster_url: "/season-poster.jpg", poster_thumbhash: "", backdrop_url: "/episode-backdrop.jpg", backdrop_thumbhash: "", @@ -45,8 +45,8 @@ describe("ContinueWatchingCard", () => { , ); - expect(markup).toContain('src="/episode-poster.jpg"'); - expect(markup).not.toContain('src="/episode-backdrop.jpg"'); + expect(markup).toContain('src="/episode-backdrop.jpg"'); + expect(markup).not.toContain('src="/season-poster.jpg"'); }); it("prefers the backdrop image for movies (poster_url is a vertical poster)", () => { diff --git a/web/src/components/ContinueWatchingCard.tsx b/web/src/components/ContinueWatchingCard.tsx index c73b9cd8..fd7985f9 100644 --- a/web/src/components/ContinueWatchingCard.tsx +++ b/web/src/components/ContinueWatchingCard.tsx @@ -144,19 +144,23 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { ? "w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]" : "w-[260px] shrink-0 sm:w-[315px]"; const imageAspect = isPoster ? "aspect-[2/3]" : "aspect-video"; - // Episodes store the horizontal still in poster_url (see episode_catalog_source.go); - // wide-variant movies/series/seasons need the backdrop for the 16:9 card. - // Poster variant always wants the vertical poster. + const isSectionEpisode = "sectionItem" in props && props.sectionItem?.type === "episode"; + // Section episode payloads use poster_url for vertical season/series artwork + // and backdrop_url for the episode still used by wide cards. const imagePrimary = isPoster ? card.posterUrl - : card.type === "episode" - ? card.posterUrl - : card.backdropUrl; + : isSectionEpisode + ? card.backdropUrl + : card.type === "episode" + ? card.posterUrl + : card.backdropUrl; const imageFallback = isPoster ? card.backdropUrl - : card.type === "episode" - ? card.backdropUrl - : card.posterUrl; + : isSectionEpisode + ? card.posterUrl + : card.type === "episode" + ? card.backdropUrl + : card.posterUrl; const imageSrc = imagePrimary || imageFallback; return ( From 71ca2012c533c8b04cf23e12085cabd4be1896b1 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 15:48:00 -0400 Subject: [PATCH 067/201] feat(sections): show episode context in cards --- internal/api/handlers/libraries.go | 2 +- internal/api/handlers/sections.go | 49 ++++++++- internal/api/handlers/sections_test.go | 113 +++++++++++++++++++- internal/sections/defaults.go | 79 ++++++++++---- internal/sections/defaults_test.go | 35 +++--- internal/sections/generated.go | 109 +++++++++++++++++-- internal/sections/generated_test.go | 45 ++++++++ internal/sections/repo.go | 30 +++--- web/src/components/ItemCard.test.tsx | 2 +- web/src/components/ItemCard.tsx | 25 ++--- web/src/components/SectionItemCard.test.tsx | 29 +++++ web/src/components/SectionItemCard.tsx | 17 ++- web/src/lib/episodeCardLabels.ts | 36 +++++++ 13 files changed, 485 insertions(+), 86 deletions(-) create mode 100644 web/src/lib/episodeCardLabels.ts diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index 7d5c7acb..fa3a4784 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -549,7 +549,7 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil { slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr) } - if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name); seedErr != nil { + if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil { slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr) } } diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go index 206eedc6..5ae1cb9e 100644 --- a/internal/api/handlers/sections.go +++ b/internal/api/handlers/sections.go @@ -26,6 +26,7 @@ type SectionHandler struct { repo *sections.Repository fetcher *sections.Fetcher previewFetcher sectionPreviewFetcher // set to fetcher at construction; separate for test injection + episodeFetcher sectionEpisodeFetcher FolderRepo *catalog.FolderRepository EpisodeRepo *catalog.EpisodeRepository StoreProvider userstore.UserStoreProvider @@ -37,7 +38,11 @@ type SectionHandler struct { // NewSectionHandler creates a new SectionHandler. func NewSectionHandler(repo *sections.Repository, fetcher *sections.Fetcher) *SectionHandler { - return &SectionHandler{repo: repo, fetcher: fetcher, previewFetcher: fetcher} + return &SectionHandler{repo: repo, fetcher: fetcher, previewFetcher: fetcher, episodeFetcher: fetcher} +} + +type sectionEpisodeFetcher interface { + FetchEpisodesByContentIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) ([]*models.MediaItem, map[string]sections.SectionItemMeta, error) } func (h *SectionHandler) defaultHomeSections(ctx context.Context) ([]*sections.PageSection, error) { @@ -1169,6 +1174,7 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect } userStates := h.listSectionItemUserStates(r, allItems) imageURLs := h.resolveSectionItemImageURLs(r.Context(), withItems) + episodeMeta := h.listSectionEpisodeItemMeta(r.Context(), withItems, requestAccessFilter(r)) for _, s := range withItems { items := make([]sectionItemResponse, 0, len(s.Items)) for _, item := range s.Items { @@ -1178,6 +1184,11 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect meta = &value } } + if meta == nil { + if value, ok := episodeMeta[item.ContentID]; ok { + meta = &value + } + } imageKey := sectionItemImageKey{sectionID: s.ID, contentID: item.ContentID} items = append(items, h.toSectionItemResponse(s.SectionType, item, meta, overlaySummaries[item.ContentID], userStates[item.ContentID], imageURLs[imageKey])) } @@ -1196,6 +1207,42 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect return resp } +func (h *SectionHandler) listSectionEpisodeItemMeta(ctx context.Context, withItems []sections.SectionWithItems, filter catalog.AccessFilter) map[string]sections.SectionItemMeta { + if h == nil || h.episodeFetcher == nil { + return map[string]sections.SectionItemMeta{} + } + + ids := make([]string, 0) + seen := make(map[string]struct{}) + for _, section := range withItems { + for _, item := range section.Items { + if item == nil || item.Type != "episode" || strings.TrimSpace(item.ContentID) == "" { + continue + } + if section.ItemMeta != nil { + if _, ok := section.ItemMeta[item.ContentID]; ok { + continue + } + } + if _, ok := seen[item.ContentID]; ok { + continue + } + seen[item.ContentID] = struct{}{} + ids = append(ids, item.ContentID) + } + } + if len(ids) == 0 { + return map[string]sections.SectionItemMeta{} + } + + _, meta, err := h.episodeFetcher.FetchEpisodesByContentIDs(ctx, ids, filter) + if err != nil { + slog.Warn("loading section episode metadata", "error", err) + return map[string]sections.SectionItemMeta{} + } + return meta +} + func (h *SectionHandler) resolveSectionItemImageURLs(ctx context.Context, withItems []sections.SectionWithItems) map[sectionItemImageKey]sectionItemImageURLs { result := make(map[sectionItemImageKey]sectionItemImageURLs) if h.DetailSvc == nil { diff --git a/internal/api/handlers/sections_test.go b/internal/api/handlers/sections_test.go index f7b06e07..a46f5d2b 100644 --- a/internal/api/handlers/sections_test.go +++ b/internal/api/handlers/sections_test.go @@ -17,6 +17,16 @@ import ( "github.com/Silo-Server/silo-server/internal/userstore" ) +type stubSectionEpisodeFetcher struct { + calls int + meta map[string]sections.SectionItemMeta +} + +func (s *stubSectionEpisodeFetcher) FetchEpisodesByContentIDs(_ context.Context, _ []string, _ catalog.AccessFilter) ([]*models.MediaItem, map[string]sections.SectionItemMeta, error) { + s.calls++ + return nil, s.meta, nil +} + func TestSectionBackdropPathUsesExpectedVariants(t *testing.T) { tests := []struct { name string @@ -65,6 +75,101 @@ func TestSectionBackdropPathUsesExpectedVariants(t *testing.T) { } } +func TestBuildSectionsResponseEnrichesEpisodeMetadata(t *testing.T) { + seasonNumber := 1 + episodeNumber := 1 + seriesID := "series-1" + fetcher := &stubSectionEpisodeFetcher{ + meta: map[string]sections.SectionItemMeta{ + "episode-1": { + SeriesID: &seriesID, + SeriesTitle: "American Dad!", + SeasonNumber: &seasonNumber, + EpisodeNumber: &episodeNumber, + }, + }, + } + h := &SectionHandler{episodeFetcher: fetcher} + withItems := []sections.SectionWithItems{ + { + ResolvedSection: sections.ResolvedSection{ID: "released", SectionType: sections.SectionCustomFilter, Title: "Released"}, + Items: []*models.MediaItem{{ + ContentID: "episode-1", + Type: "episode", + Title: "Dumbston Checks In", + Status: "matched", + }}, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/sections", nil) + resp := h.buildSectionsResponse(req, withItems) + + if fetcher.calls != 1 { + t.Fatalf("episode metadata fetch calls = %d, want 1", fetcher.calls) + } + item := resp.Sections[0].Items[0] + if item.SeriesTitle != "American Dad!" { + t.Fatalf("series title = %q, want %q", item.SeriesTitle, "American Dad!") + } + if item.SeasonNumber == nil || *item.SeasonNumber != 1 { + t.Fatalf("season number = %v, want 1", item.SeasonNumber) + } + if item.EpisodeNumber == nil || *item.EpisodeNumber != 1 { + t.Fatalf("episode number = %v, want 1", item.EpisodeNumber) + } +} + +func TestBuildSectionsResponseKeepsExistingEpisodeMeta(t *testing.T) { + seasonNumber := 2 + episodeNumber := 6 + seriesID := "series-existing" + fetcher := &stubSectionEpisodeFetcher{ + meta: map[string]sections.SectionItemMeta{ + "episode-1": { + SeriesTitle: "Fetched Series", + }, + }, + } + h := &SectionHandler{episodeFetcher: fetcher} + withItems := []sections.SectionWithItems{ + { + ResolvedSection: sections.ResolvedSection{ID: "next", SectionType: sections.SectionNextUp, Title: "Next"}, + Items: []*models.MediaItem{{ + ContentID: "episode-1", + Type: "episode", + Title: "Episode 6", + Status: "matched", + }}, + ItemMeta: map[string]sections.SectionItemMeta{ + "episode-1": { + SeriesID: &seriesID, + SeriesTitle: "Only Child", + SeasonNumber: &seasonNumber, + EpisodeNumber: &episodeNumber, + }, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/sections", nil) + resp := h.buildSectionsResponse(req, withItems) + + if fetcher.calls != 0 { + t.Fatalf("episode metadata fetch calls = %d, want 0", fetcher.calls) + } + item := resp.Sections[0].Items[0] + if item.SeriesTitle != "Only Child" { + t.Fatalf("series title = %q, want %q", item.SeriesTitle, "Only Child") + } + if item.SeasonNumber == nil || *item.SeasonNumber != 2 { + t.Fatalf("season number = %v, want 2", item.SeasonNumber) + } + if item.EpisodeNumber == nil || *item.EpisodeNumber != 6 { + t.Fatalf("episode number = %v, want 6", item.EpisodeNumber) + } +} + type countingSectionImageResolver struct { batchCalls int singleCalls int @@ -199,8 +304,8 @@ func TestDropEmptySeasonalSectionsHandlesNilAndEmpty(t *testing.T) { func TestLibraryDefaultSectionsUsesFolderType(t *testing.T) { got := libraryDefaultSections(&models.MediaFolder{Type: "series"}, 12) - if len(got) != 7 { - t.Fatalf("expected 7 series default sections, got %d", len(got)) + if len(got) != 6 { + t.Fatalf("expected 6 series default sections, got %d", len(got)) } if got[1].Title != "Recently Added TV" { t.Fatalf("section 1 title = %q, want %q", got[1].Title, "Recently Added TV") @@ -208,8 +313,8 @@ func TestLibraryDefaultSectionsUsesFolderType(t *testing.T) { if got[2].Title != "Recently Released Episodes" { t.Fatalf("section 2 title = %q, want %q", got[2].Title, "Recently Released Episodes") } - if got[5].SectionType != sections.SectionRecommendedForYou { - t.Fatalf("section 5 type = %q, want %q", got[5].SectionType, sections.SectionRecommendedForYou) + if got[4].SectionType != sections.SectionRecommendedForYou { + t.Fatalf("section 4 type = %q, want %q", got[4].SectionType, sections.SectionRecommendedForYou) } } diff --git a/internal/sections/defaults.go b/internal/sections/defaults.go index cbd9a64b..f201bb3a 100644 --- a/internal/sections/defaults.go +++ b/internal/sections/defaults.go @@ -21,16 +21,17 @@ func DefaultHomeSections(libraries []*models.MediaFolder) []*PageSection { if library == nil { continue } - for _, sectionType := range []SectionType{SectionRecentlyAdded, SectionRecentlyReleased} { + for _, section := range generatedHomeLibraryRecentDefaults(library.ID, library.Name, library.Type) { + section.Position = position result = append(result, &PageSection{ - ID: fmt.Sprintf("default-home-%s-library-%d", sectionType, library.ID), - Scope: "home", - Position: position, - SectionType: sectionType, - Title: GeneratedHomeLibraryRecentTitle(sectionType, library.Name), - ItemLimit: 20, - Config: GeneratedHomeLibraryRecentConfig(library.ID), - Enabled: true, + ID: generatedHomeLibraryRecentID(section, library.ID), + Scope: section.Scope, + Position: section.Position, + SectionType: section.SectionType, + Title: section.Title, + ItemLimit: section.ItemLimit, + Config: section.Config, + Enabled: section.Enabled, }) position++ } @@ -62,6 +63,50 @@ func DefaultHomeSections(libraries []*models.MediaFolder) []*PageSection { return result } +func generatedHomeLibraryRecentID(section *PageSection, libraryID int) string { + kind := generatedHomeLibraryRecentKindForSection(section) + if kind == generatedHomeLibraryRecentKindReleasedEpisodes { + return fmt.Sprintf("default-home-%s-library-%d", kind, libraryID) + } + return fmt.Sprintf("default-home-%s-library-%d", section.SectionType, libraryID) +} + +func generatedHomeLibraryRecentDefaults(libraryID int, libraryName, libraryType string) []*PageSection { + sections := []*PageSection{ + { + Scope: "home", + SectionType: SectionRecentlyAdded, + Title: GeneratedHomeLibraryRecentTitle(SectionRecentlyAdded, libraryName), + ItemLimit: 20, + Config: GeneratedHomeLibraryRecentConfig(libraryID), + Enabled: true, + }, + } + + switch libraryType { + case "series": + sections = append(sections, &PageSection{ + Scope: "home", + SectionType: SectionCustomFilter, + Title: fmt.Sprintf("Recently Released Episodes in %s", libraryName), + ItemLimit: 20, + Config: GeneratedHomeLibraryRecentEpisodesConfig(libraryID), + Enabled: true, + }) + default: + sections = append(sections, &PageSection{ + Scope: "home", + SectionType: SectionRecentlyReleased, + Title: GeneratedHomeLibraryRecentTitle(SectionRecentlyReleased, libraryName), + ItemLimit: 20, + Config: GeneratedHomeLibraryRecentConfig(libraryID), + Enabled: true, + }) + } + + return sections +} + func defaultQueryConfig(def catalog.QueryDefinition) json.RawMessage { config, err := json.Marshal(def.Normalize()) if err != nil { @@ -97,15 +142,6 @@ func defaultRecentEpisodesConfig() json.RawMessage { }) } -func defaultRecentShowsConfig() json.RawMessage { - return defaultQueryConfig(catalog.QueryDefinition{ - MediaScope: "series", - Match: "all", - Groups: []catalog.QueryGroup{}, - Sort: catalog.QuerySort{Field: "last_air_date", Order: "desc"}, - }) -} - // DefaultLibrarySectionsForType returns the canonical default sections for a // library type. These are used when seeding new libraries and restoring // library defaults. @@ -127,10 +163,9 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe {ID: "default-continue-watching", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Watching", ItemLimit: 20, Config: emptyCfg, Enabled: true}, {ID: "default-recently-added-tv", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added TV", ItemLimit: 20, Config: defaultMediaScopeConfig("series"), Enabled: true}, {ID: "default-recently-released-episodes", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionCustomFilter, Title: "Recently Released Episodes", ItemLimit: 20, Config: defaultRecentEpisodesConfig(), Enabled: true}, - {ID: "default-recently-released-tv-shows", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionCustomFilter, Title: "Recently Released TV Shows", ItemLimit: 20, Config: defaultRecentShowsConfig(), Enabled: true}, - {ID: "default-top-rated-tv", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionCustomFilter, Title: "Top Rated TV", ItemLimit: 20, Config: defaultTopRatedConfig("series"), Enabled: true}, - {ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 5, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true}, - {ID: "default-random-tv", Scope: "library", LibraryID: libraryID, Position: 6, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("series"), Enabled: true}, + {ID: "default-top-rated-tv", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionCustomFilter, Title: "Top Rated TV", ItemLimit: 20, Config: defaultTopRatedConfig("series"), Enabled: true}, + {ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true}, + {ID: "default-random-tv", Scope: "library", LibraryID: libraryID, Position: 5, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("series"), Enabled: true}, } default: return DefaultLibrarySections(libraryID) diff --git a/internal/sections/defaults_test.go b/internal/sections/defaults_test.go index 4212c2cc..fab39c17 100644 --- a/internal/sections/defaults_test.go +++ b/internal/sections/defaults_test.go @@ -21,8 +21,8 @@ func TestDefaultHomeSectionsWithoutLibraries(t *testing.T) { func TestDefaultHomeSectionsWithLibraries(t *testing.T) { libraries := []*models.MediaFolder{ - {ID: 7, Name: "Movies", SortOrder: 1}, - {ID: 9, Name: "Shows", SortOrder: 2}, + {ID: 7, Name: "Movies", Type: "movies", SortOrder: 1}, + {ID: 9, Name: "Shows", Type: "series", SortOrder: 2}, } got := DefaultHomeSections(libraries) @@ -43,7 +43,7 @@ func TestDefaultHomeSectionsWithLibraries(t *testing.T) { {index: 1, id: "default-home-recently_added-library-7", sectionType: SectionRecentlyAdded, title: "Recently Added in Movies", position: 1, libraryID: 7}, {index: 2, id: "default-home-recently_released-library-7", sectionType: SectionRecentlyReleased, title: "Recently Released in Movies", position: 2, libraryID: 7}, {index: 3, id: "default-home-recently_added-library-9", sectionType: SectionRecentlyAdded, title: "Recently Added in Shows", position: 3, libraryID: 9}, - {index: 4, id: "default-home-recently_released-library-9", sectionType: SectionRecentlyReleased, title: "Recently Released in Shows", position: 4, libraryID: 9}, + {index: 4, id: "default-home-recently_released_episodes-library-9", sectionType: SectionCustomFilter, title: "Recently Released Episodes in Shows", position: 4, libraryID: 9}, } for _, tt := range tests { @@ -71,6 +71,14 @@ func TestDefaultHomeSectionsWithLibraries(t *testing.T) { t.Fatalf("section %d config library id = %d, want %d", tt.index, libraryID, tt.libraryID) } } + + assertQueryDefinition(t, got[4].Config, catalog.QueryDefinition{ + LibraryIDs: []int{9}, + MediaScope: "episode", + Match: "all", + Groups: []catalog.QueryGroup{}, + Sort: catalog.QuerySort{Field: "release_date", Order: "desc"}, + }) } func TestDefaultLibrarySectionsForTypeMovies(t *testing.T) { @@ -140,8 +148,8 @@ func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) { libraryID := 17 got := DefaultLibrarySectionsForType(&libraryID, "series") - if len(got) != 7 { - t.Fatalf("expected 7 series default sections, got %d", len(got)) + if len(got) != 6 { + t.Fatalf("expected 6 series default sections, got %d", len(got)) } tests := []struct { @@ -154,10 +162,9 @@ func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) { {index: 0, id: "default-continue-watching", sectionType: SectionContinueWatching, title: "Continue Watching", position: 0}, {index: 1, id: "default-recently-added-tv", sectionType: SectionRecentlyAdded, title: "Recently Added TV", position: 1}, {index: 2, id: "default-recently-released-episodes", sectionType: SectionCustomFilter, title: "Recently Released Episodes", position: 2}, - {index: 3, id: "default-recently-released-tv-shows", sectionType: SectionCustomFilter, title: "Recently Released TV Shows", position: 3}, - {index: 4, id: "default-top-rated-tv", sectionType: SectionCustomFilter, title: "Top Rated TV", position: 4}, - {index: 5, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 5}, - {index: 6, id: "default-random-tv", sectionType: SectionRandom, title: "Random Picks", position: 6}, + {index: 3, id: "default-top-rated-tv", sectionType: SectionCustomFilter, title: "Top Rated TV", position: 3}, + {index: 4, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 4}, + {index: 5, id: "default-random-tv", sectionType: SectionRandom, title: "Random Picks", position: 5}, } for _, tt := range tests { @@ -192,19 +199,13 @@ func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) { Sort: catalog.QuerySort{Field: "release_date", Order: "desc"}, }) assertQueryDefinition(t, got[3].Config, catalog.QueryDefinition{ - MediaScope: "series", - Match: "all", - Groups: []catalog.QueryGroup{}, - Sort: catalog.QuerySort{Field: "last_air_date", Order: "desc"}, - }) - assertQueryDefinition(t, got[4].Config, catalog.QueryDefinition{ MediaScope: "series", Match: "all", Groups: []catalog.QueryGroup{}, Sort: catalog.QuerySort{Field: "rating_imdb", Order: "desc"}, }) - assertEmptyJSON(t, got[5].Config) - assertQueryDefinition(t, got[6].Config, catalog.QueryDefinition{ + assertEmptyJSON(t, got[4].Config) + assertQueryDefinition(t, got[5].Config, catalog.QueryDefinition{ MediaScope: "series", Match: "all", Groups: []catalog.QueryGroup{}, diff --git a/internal/sections/generated.go b/internal/sections/generated.go index 0ee8c5d3..4eff2e79 100644 --- a/internal/sections/generated.go +++ b/internal/sections/generated.go @@ -4,13 +4,23 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" ) const GeneratedHomeLibraryRecentSource = "home_library_recent" +const ( + generatedHomeLibraryRecentKindAdded = "recently_added" + generatedHomeLibraryRecentKindReleased = "recently_released" + generatedHomeLibraryRecentKindReleasedEpisodes = "recently_released_episodes" +) + type generatedHomeLibraryRecentConfig struct { - FilterLibraryID *int `json:"filter_library_id"` - GeneratedSource string `json:"generated_source"` + FilterLibraryID *int `json:"filter_library_id"` + GeneratedLibraryID *int `json:"generated_library_id"` + GeneratedKind string `json:"generated_kind"` + GeneratedSource string `json:"generated_source"` } func GeneratedHomeLibraryRecentConfig(libraryID int) json.RawMessage { @@ -24,6 +34,30 @@ func GeneratedHomeLibraryRecentConfig(libraryID int) json.RawMessage { return config } +func GeneratedHomeLibraryRecentEpisodesConfig(libraryID int) json.RawMessage { + config, err := json.Marshal(struct { + catalog.QueryDefinition + GeneratedLibraryID int `json:"generated_library_id"` + GeneratedKind string `json:"generated_kind"` + GeneratedSource string `json:"generated_source"` + }{ + QueryDefinition: catalog.QueryDefinition{ + LibraryIDs: []int{libraryID}, + MediaScope: "episode", + Match: "all", + Groups: []catalog.QueryGroup{}, + Sort: catalog.QuerySort{Field: "release_date", Order: "desc"}, + }.Normalize(), + GeneratedLibraryID: libraryID, + GeneratedKind: generatedHomeLibraryRecentKindReleasedEpisodes, + GeneratedSource: GeneratedHomeLibraryRecentSource, + }) + if err != nil { + return json.RawMessage(`{}`) + } + return config +} + func GeneratedHomeLibraryRecentTitle(sectionType SectionType, libraryName string) string { switch sectionType { case SectionRecentlyAdded: @@ -35,28 +69,74 @@ func GeneratedHomeLibraryRecentTitle(sectionType SectionType, libraryName string } } +func generatedHomeLibraryRecentTitle(kind string, sectionType SectionType, libraryName string) string { + switch kind { + case generatedHomeLibraryRecentKindReleasedEpisodes: + return fmt.Sprintf("Recently Released Episodes in %s", libraryName) + case generatedHomeLibraryRecentKindAdded: + return fmt.Sprintf("Recently Added in %s", libraryName) + case generatedHomeLibraryRecentKindReleased: + return fmt.Sprintf("Recently Released in %s", libraryName) + default: + return GeneratedHomeLibraryRecentTitle(sectionType, libraryName) + } +} + +func generatedHomeLibraryRecentKindForSection(s *PageSection) string { + if s == nil { + return "" + } + _, kind, _ := parseGeneratedHomeLibraryRecentConfig(s.Config) + if kind != "" { + return kind + } + switch s.SectionType { + case SectionRecentlyAdded: + return generatedHomeLibraryRecentKindAdded + case SectionRecentlyReleased: + return generatedHomeLibraryRecentKindReleased + default: + return "" + } +} + func ParseGeneratedHomeLibraryRecentConfig(config json.RawMessage) (int, bool) { + id, _, ok := parseGeneratedHomeLibraryRecentConfig(config) + return id, ok +} + +func parseGeneratedHomeLibraryRecentConfig(config json.RawMessage) (int, string, bool) { var cfg generatedHomeLibraryRecentConfig if len(config) == 0 { - return 0, false + return 0, "", false } if err := json.Unmarshal(config, &cfg); err != nil { - return 0, false + return 0, "", false } - if cfg.GeneratedSource != GeneratedHomeLibraryRecentSource || cfg.FilterLibraryID == nil || *cfg.FilterLibraryID <= 0 { - return 0, false + if cfg.GeneratedSource != GeneratedHomeLibraryRecentSource { + return 0, "", false } - return *cfg.FilterLibraryID, true + libraryID := cfg.GeneratedLibraryID + if libraryID == nil { + libraryID = cfg.FilterLibraryID + } + if libraryID == nil || *libraryID <= 0 { + return 0, "", false + } + return *libraryID, cfg.GeneratedKind, true } func IsGeneratedHomeLibraryRecentSection(s *PageSection, libraryID int) bool { if s == nil || s.Scope != "home" || s.LibraryID != nil { return false } - if s.SectionType != SectionRecentlyAdded && s.SectionType != SectionRecentlyReleased { + id, _, ok := parseGeneratedHomeLibraryRecentConfig(s.Config) + if !ok { + return false + } + if s.SectionType != SectionRecentlyAdded && s.SectionType != SectionRecentlyReleased && s.SectionType != SectionCustomFilter { return false } - id, ok := ParseGeneratedHomeLibraryRecentConfig(s.Config) return ok && id == libraryID } @@ -64,6 +144,15 @@ func ShouldSyncGeneratedHomeLibraryRecentTitle(s *PageSection, oldLibraryName st if s == nil { return false } - expected := GeneratedHomeLibraryRecentTitle(s.SectionType, oldLibraryName) + kind := generatedHomeLibraryRecentKindForSection(s) + expected := generatedHomeLibraryRecentTitle(kind, s.SectionType, oldLibraryName) return strings.TrimSpace(s.Title) == expected } + +func GeneratedHomeLibraryRecentSyncedTitle(s *PageSection, libraryName string) string { + if s == nil { + return "" + } + kind := generatedHomeLibraryRecentKindForSection(s) + return generatedHomeLibraryRecentTitle(kind, s.SectionType, libraryName) +} diff --git a/internal/sections/generated_test.go b/internal/sections/generated_test.go index 692dae9a..f0011853 100644 --- a/internal/sections/generated_test.go +++ b/internal/sections/generated_test.go @@ -14,6 +14,30 @@ func TestParseGeneratedHomeLibraryRecentConfig(t *testing.T) { } } +func TestParseGeneratedHomeLibraryRecentEpisodesConfig(t *testing.T) { + libraryID, ok := ParseGeneratedHomeLibraryRecentConfig(GeneratedHomeLibraryRecentEpisodesConfig(42)) + if !ok { + t.Fatalf("expected config to parse") + } + if libraryID != 42 { + t.Fatalf("library id = %d, want 42", libraryID) + } + + def, err := ParseQueryDefinition(GeneratedHomeLibraryRecentEpisodesConfig(42)) + if err != nil { + t.Fatalf("ParseQueryDefinition() error = %v", err) + } + if def.MediaScope != "episode" { + t.Fatalf("media_scope = %q, want episode", def.MediaScope) + } + if def.Sort.Field != "release_date" || def.Sort.Order != "desc" { + t.Fatalf("sort = %#v, want release_date desc", def.Sort) + } + if len(def.LibraryIDs) != 1 || def.LibraryIDs[0] != 42 { + t.Fatalf("library_ids = %v, want [42]", def.LibraryIDs) + } +} + func TestShouldSyncGeneratedHomeLibraryRecentTitle(t *testing.T) { section := &PageSection{ Scope: "home", @@ -30,3 +54,24 @@ func TestShouldSyncGeneratedHomeLibraryRecentTitle(t *testing.T) { t.Fatalf("did not expect custom title to sync") } } + +func TestShouldSyncGeneratedHomeLibraryRecentEpisodesTitle(t *testing.T) { + section := &PageSection{ + Scope: "home", + SectionType: SectionCustomFilter, + Title: "Recently Released Episodes in Shows", + Config: GeneratedHomeLibraryRecentEpisodesConfig(3), + } + if !ShouldSyncGeneratedHomeLibraryRecentTitle(section, "Shows") { + t.Fatalf("expected generated episode title to sync") + } + + if got := GeneratedHomeLibraryRecentSyncedTitle(section, "TV"); got != "Recently Released Episodes in TV" { + t.Fatalf("synced title = %q, want %q", got, "Recently Released Episodes in TV") + } + + section.Title = "Staff Picks" + if ShouldSyncGeneratedHomeLibraryRecentTitle(section, "Shows") { + t.Fatalf("did not expect custom title to sync") + } +} diff --git a/internal/sections/repo.go b/internal/sections/repo.go index 57464d6b..7fb4ca19 100644 --- a/internal/sections/repo.go +++ b/internal/sections/repo.go @@ -383,15 +383,17 @@ func (r *Repository) nextHomePosition(ctx context.Context) (int, error) { return maxPosition + 1, nil } -func (r *Repository) CreateGeneratedHomeLibraryRecentSections(ctx context.Context, libraryID int, libraryName string) ([]*PageSection, error) { +func (r *Repository) CreateGeneratedHomeLibraryRecentSections(ctx context.Context, libraryID int, libraryName, libraryType string) ([]*PageSection, error) { existing, err := r.listGeneratedHomeLibraryRecentSections(ctx, libraryID) if err != nil { return nil, fmt.Errorf("listing generated home sections: %w", err) } - existingByType := make(map[SectionType]*PageSection, len(existing)) + existingByKind := make(map[string]*PageSection, len(existing)) for _, section := range existing { - existingByType[section.SectionType] = section + if kind := generatedHomeLibraryRecentKindForSection(section); kind != "" { + existingByKind[kind] = section + } } position, err := r.nextHomePosition(ctx) @@ -400,19 +402,17 @@ func (r *Repository) CreateGeneratedHomeLibraryRecentSections(ctx context.Contex } created := make([]*PageSection, 0, 2) - for _, sectionType := range []SectionType{SectionRecentlyAdded, SectionRecentlyReleased} { - if _, ok := existingByType[sectionType]; ok { + for _, section := range generatedHomeLibraryRecentDefaults(libraryID, libraryName, libraryType) { + kind := generatedHomeLibraryRecentKindForSection(section) + if kind == generatedHomeLibraryRecentKindReleasedEpisodes { + if _, ok := existingByKind[generatedHomeLibraryRecentKindReleased]; ok { + continue + } + } + if _, ok := existingByKind[kind]; ok { continue } - section := &PageSection{ - Scope: "home", - Position: position, - SectionType: sectionType, - Title: GeneratedHomeLibraryRecentTitle(sectionType, libraryName), - ItemLimit: 20, - Config: GeneratedHomeLibraryRecentConfig(libraryID), - Enabled: true, - } + section.Position = position createdSection, createErr := r.Create(ctx, section) if createErr != nil { return nil, fmt.Errorf("creating generated home section %q: %w", section.Title, createErr) @@ -434,7 +434,7 @@ func (r *Repository) SyncGeneratedHomeLibraryRecentTitles(ctx context.Context, l if !ShouldSyncGeneratedHomeLibraryRecentTitle(section, oldLibraryName) { continue } - section.Title = GeneratedHomeLibraryRecentTitle(section.SectionType, newLibraryName) + section.Title = GeneratedHomeLibraryRecentSyncedTitle(section, newLibraryName) if err := r.Update(ctx, section); err != nil { return fmt.Errorf("updating generated home section %s: %w", section.ID, err) } diff --git a/web/src/components/ItemCard.test.tsx b/web/src/components/ItemCard.test.tsx index 639a01b5..c52e5dfc 100644 --- a/web/src/components/ItemCard.test.tsx +++ b/web/src/components/ItemCard.test.tsx @@ -75,7 +75,7 @@ describe("ItemCard SortMeta", () => { }); expect(markup).toContain("The Last of Us"); - expect(markup).toContain("S1 E1"); + expect(markup).toContain("S01E01"); expect(markup).toContain("When You're Lost in the Darkness"); }); }); diff --git a/web/src/components/ItemCard.tsx b/web/src/components/ItemCard.tsx index 5c4de0e9..4fc6cba5 100644 --- a/web/src/components/ItemCard.tsx +++ b/web/src/components/ItemCard.tsx @@ -7,20 +7,12 @@ import { timeAgo } from "@/lib/timeAgo"; import MediaItemMenu from "@/components/MediaItemMenu"; import CardOverlays from "@/components/overlays/CardOverlays"; import { overlayDataFromBrowseItem, type CardOverlayPrefs } from "@/lib/overlays"; +import { buildEpisodeCardLabels } from "@/lib/episodeCardLabels"; function SortMeta({ item, sortField }: { item: BrowseItem; sortField?: string }) { - if ( - item.type === "episode" && - item.series_title && - item.season_number != null && - item.episode_number != null - ) { - return ( - <> - S{item.season_number} E{item.episode_number} - {item.title ? ` • ${item.title}` : ""} - - ); + const episodeLabels = buildEpisodeCardLabels(item); + if (episodeLabels) { + return <>{episodeLabels.episodeCode}; } const defaultLabel = [item.year || "", item.type === "series" ? "Series" : ""] @@ -90,8 +82,8 @@ export default function ItemCard({ const [loaded, setLoaded] = useState(false); const thumbhashUrl = item.poster_thumbhash ? decodeThumbhash(item.poster_thumbhash) : ""; const itemHref = `/item/${item.content_id}${libraryId ? `?libraryId=${libraryId}` : ""}`; - const displayTitle = - item.type === "episode" && item.series_title ? item.series_title : item.title; + const episodeLabels = buildEpisodeCardLabels(item); + const displayTitle = episodeLabels ? episodeLabels.seriesTitle : item.title; return (
    @@ -176,6 +168,11 @@ export default function ItemCard({
    {displayTitle}
    + {episodeLabels?.episodeTitle ? ( +
    + {episodeLabels.episodeTitle} +
    + ) : null}
    diff --git a/web/src/components/SectionItemCard.test.tsx b/web/src/components/SectionItemCard.test.tsx index 2317c352..250a6af2 100644 --- a/web/src/components/SectionItemCard.test.tsx +++ b/web/src/components/SectionItemCard.test.tsx @@ -21,6 +21,35 @@ vi.mock("@/hooks/useOverlayPrefs", () => ({ })); describe("SectionItemCard", () => { + it("renders episode cards with series title, episode title, and episode code", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("American Dad!"); + expect(markup).toContain("Dumbston Checks In"); + expect(markup).toContain("S22E10"); + }); + it("renders premiere metadata when an upcoming event is present", () => { const markup = renderToStaticMarkup( @@ -91,7 +93,9 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp />
    -
    {item.title}
    +
    + {episodeLabels ? episodeLabels.seriesTitle : item.title} +
    {upcomingEvent ? ( <> {subtitle && ( @@ -104,6 +108,17 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp {airTimeLabel && {airTimeLabel}}
    + ) : episodeLabels ? ( + <> + {episodeLabels.episodeTitle ? ( +
    + {episodeLabels.episodeTitle} +
    + ) : null} +
    + {episodeLabels.episodeCode} +
    + ) : (
    {item.year ? `${item.year}` : ""} {item.type === "series" ? "Series" : ""} diff --git a/web/src/lib/episodeCardLabels.ts b/web/src/lib/episodeCardLabels.ts new file mode 100644 index 00000000..4e27ee50 --- /dev/null +++ b/web/src/lib/episodeCardLabels.ts @@ -0,0 +1,36 @@ +interface EpisodeCardLabelItem { + type?: string; + title?: string; + series_title?: string; + season_number?: number | null; + episode_number?: number | null; +} + +export interface EpisodeCardLabels { + seriesTitle: string; + episodeTitle?: string; + episodeCode: string; +} + +function formatEpisodeCode(seasonNumber: number, episodeNumber: number): string { + return `S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}`; +} + +export function buildEpisodeCardLabels(item: EpisodeCardLabelItem): EpisodeCardLabels | null { + if ( + item.type !== "episode" || + item.season_number == null || + item.episode_number == null || + !item.title + ) { + return null; + } + + const hasSeriesTitle = Boolean(item.series_title && item.series_title !== item.title); + + return { + seriesTitle: hasSeriesTitle ? item.series_title! : item.title, + episodeTitle: hasSeriesTitle ? item.title : undefined, + episodeCode: formatEpisodeCode(item.season_number, item.episode_number), + }; +} From 26370616013b4eebffcd7dbb6900383512f26481 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 21:32:18 -0400 Subject: [PATCH 068/201] feat(calendar): show local episode airtimes --- internal/api/handlers/admin.go | 11 +- internal/api/handlers/calendar.go | 87 +++++++++++-- internal/api/handlers/calendar_test.go | 56 +++++++++ internal/catalog/air_schedule.go | 114 ++++++++++++++++++ internal/catalog/air_schedule_test.go | 71 +++++++++++ internal/catalog/calendar_repo.go | 15 +-- internal/catalog/detail.go | 2 + internal/catalog/item_repo.go | 21 ++-- internal/catalog/update.go | 1 + internal/catalogseed/export_stream.go | 4 + internal/catalogseed/service.go | 40 +++--- internal/catalogseed/types.go | 2 + internal/metadata/air_time_test.go | 44 ++++++- internal/metadata/merge.go | 10 +- internal/metadata/service.go | 9 ++ internal/metadata/types.go | 2 + internal/models/media.go | 1 + .../162_media_items_air_timezone.down.sql | 1 + .../162_media_items_air_timezone.up.sql | 1 + web/src/api/types.ts | 4 + web/src/components/EditMetadataDialog.tsx | 35 +++++- .../components/calendar/CalendarEventCard.tsx | 2 +- web/src/hooks/queries/calendar.test.ts | 11 +- web/src/hooks/queries/calendar.ts | 12 +- web/src/hooks/queries/items.ts | 1 + web/src/hooks/queries/keys.ts | 4 +- web/src/lib/upcomingEventPresentation.test.ts | 22 ++++ web/src/lib/upcomingEventPresentation.ts | 12 +- 28 files changed, 535 insertions(+), 60 deletions(-) create mode 100644 internal/catalog/air_schedule.go create mode 100644 internal/catalog/air_schedule_test.go create mode 100644 migrations/162_media_items_air_timezone.down.sql create mode 100644 migrations/162_media_items_air_timezone.up.sql create mode 100644 web/src/lib/upcomingEventPresentation.test.ts diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index e22d1970..473e0dd2 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -966,6 +966,7 @@ type UpdateItemMetadataRequest struct { FirstAirDate *string `json:"first_air_date"` LastAirDate *string `json:"last_air_date"` AirTime *string `json:"air_time"` + AirTimezone *string `json:"air_timezone"` AirDate *string `json:"air_date"` Status *string `json:"status"` RatingIMDB *float64 `json:"rating_imdb"` @@ -993,6 +994,14 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } + if req.AirTimezone != nil { + trimmed := strings.TrimSpace(*req.AirTimezone) + req.AirTimezone = &trimmed + if !catalog.ValidateAirTimezone(trimmed) { + writeError(w, http.StatusBadRequest, "bad_request", "air_timezone must be a valid IANA timezone") + return + } + } upd := catalog.MetadataUpdate{ Title: req.Title, SortTitle: req.SortTitle, OriginalTitle: req.OriginalTitle, @@ -1000,7 +1009,7 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R Year: req.Year, Runtime: req.Runtime, Genres: req.Genres, Studios: req.Studios, Networks: req.Networks, Countries: req.Countries, ReleaseDate: req.ReleaseDate, FirstAirDate: req.FirstAirDate, LastAirDate: req.LastAirDate, - AirTime: req.AirTime, + AirTime: req.AirTime, AirTimezone: req.AirTimezone, AirDate: req.AirDate, Status: req.Status, RatingIMDB: req.RatingIMDB, RatingTMDB: req.RatingTMDB, RatingRTCritic: req.RatingRTCritic, RatingRTAudience: req.RatingRTAudience, diff --git a/internal/api/handlers/calendar.go b/internal/api/handlers/calendar.go index 47419ce7..3812c668 100644 --- a/internal/api/handlers/calendar.go +++ b/internal/api/handlers/calendar.go @@ -3,6 +3,7 @@ package handlers import ( "context" "net/http" + "sort" "strconv" "time" @@ -36,6 +37,9 @@ type calendarEventResponse struct { EpisodeNumber *int `json:"episode_number,omitempty"` AirDate string `json:"air_date"` AirTime *string `json:"air_time,omitempty"` + AirAt *string `json:"air_at,omitempty"` + AirTimezone *string `json:"air_timezone,omitempty"` + LocalAirDate string `json:"local_air_date"` PosterURL string `json:"poster_url,omitempty"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` Badges []string `json:"badges"` @@ -91,9 +95,11 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque return } + viewerLocation := catalog.CalendarLocation(q.Get("timezone")) + cf := catalog.CalendarFilter{ - Start: start, - End: end, + Start: start.AddDate(0, 0, -2), + End: end.AddDate(0, 0, 2), Filter: filter, AllowedLibraryIDs: af.AllowedLibraryIDs, DisabledLibraryIDs: af.DisabledLibraryIDs, @@ -118,11 +124,11 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque } // Group events by date and build response. - days := groupEventsByDate(events, r, h.detailSvc) + days := groupEventsByDate(events, r, h.detailSvc, start, end, viewerLocation) writeJSON(w, http.StatusOK, calendarResponse{Events: days}) } -func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService) []calendarDayResponse { +func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService, start, end time.Time, viewerLocation *time.Location) []calendarDayResponse { if len(events) == 0 { return []calendarDayResponse{} } @@ -144,17 +150,75 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv posterURLs = detailSvc.PresignImageURLs(r.Context(), posterPaths, "poster", "small") } + type preparedCalendarEvent struct { + event catalog.CalendarEvent + localDate string + sourceDate string + airAt *time.Time + airAtString *string + } + + prepared := make([]preparedCalendarEvent, 0, len(events)) + for _, ev := range events { + airAt := catalog.CalendarEventAirAt(ev.AirDate, ev.AirTime, ev.AirTimezone) + localDateTime := ev.AirDate + if airAt != nil { + localDateTime = airAt.In(viewerLocation) + } + localDate := localDateTime.Format("2006-01-02") + if localDate < start.Format("2006-01-02") || localDate > end.Format("2006-01-02") { + continue + } + var airAtString *string + if airAt != nil { + formatted := airAt.Format(time.RFC3339) + airAtString = &formatted + } + prepared = append(prepared, preparedCalendarEvent{ + event: ev, + localDate: localDate, + sourceDate: ev.AirDate.Format("2006-01-02"), + airAt: airAt, + airAtString: airAtString, + }) + } + if len(prepared) == 0 { + return []calendarDayResponse{} + } + + sort.SliceStable(prepared, func(i, j int) bool { + left, right := prepared[i], prepared[j] + if left.localDate != right.localDate { + return left.localDate < right.localDate + } + if left.airAt != nil && right.airAt != nil && !left.airAt.Equal(*right.airAt) { + return left.airAt.Before(*right.airAt) + } + if (left.airAt != nil) != (right.airAt != nil) { + return left.airAt != nil + } + if left.event.Title != right.event.Title { + return left.event.Title < right.event.Title + } + if left.event.SeasonNumber != nil && right.event.SeasonNumber != nil && *left.event.SeasonNumber != *right.event.SeasonNumber { + return *left.event.SeasonNumber < *right.event.SeasonNumber + } + if left.event.EpisodeNumber != nil && right.event.EpisodeNumber != nil && *left.event.EpisodeNumber != *right.event.EpisodeNumber { + return *left.event.EpisodeNumber < *right.event.EpisodeNumber + } + return left.event.ContentID < right.event.ContentID + }) + var days []calendarDayResponse var currentDay *calendarDayResponse - for _, ev := range events { - dateStr := ev.AirDate.Format("2006-01-02") - - if currentDay == nil || currentDay.Date != dateStr { + for _, item := range prepared { + ev := item.event + if currentDay == nil || currentDay.Date != item.localDate { if currentDay != nil { days = append(days, *currentDay) } - currentDay = &calendarDayResponse{Date: dateStr} + currentDay = &calendarDayResponse{Date: item.localDate} } badges := buildBadges(ev) @@ -167,8 +231,11 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv SeriesID: ev.SeriesID, SeasonNumber: ev.SeasonNumber, EpisodeNumber: ev.EpisodeNumber, - AirDate: dateStr, + AirDate: item.sourceDate, AirTime: ev.AirTime, + AirAt: item.airAtString, + AirTimezone: ev.AirTimezone, + LocalAirDate: item.localDate, PosterURL: posterURLs[ev.PosterPath], PosterThumbhash: ev.PosterThumbhash, Badges: badges, diff --git a/internal/api/handlers/calendar_test.go b/internal/api/handlers/calendar_test.go index e9813b31..f9d2acd8 100644 --- a/internal/api/handlers/calendar_test.go +++ b/internal/api/handlers/calendar_test.go @@ -110,6 +110,62 @@ func TestHandleGetCalendar_ReturnsEmptyEvents(t *testing.T) { } } +func TestHandleGetCalendar_ExpandsRepoRangeAndGroupsByViewerLocalDate(t *testing.T) { + t.Parallel() + + airTime := "00:30" + airTimezone := "Asia/Tokyo" + repo := &stubCalendarRepo{ + events: []catalog.CalendarEvent{ + { + ContentID: "episode-1", + Type: "episode", + Title: "Series", + SeriesID: ptrString("series-1"), + AirDate: time.Date(2026, time.January, 2, 0, 0, 0, 0, time.UTC), + AirTime: &airTime, + AirTimezone: &airTimezone, + }, + }, + } + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-01-01&end=2026-01-01&timezone=America/New_York", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := repo.last.Start.Format("2006-01-02"); got != "2025-12-30" { + t.Fatalf("repo start = %s, want expanded 2025-12-30", got) + } + if got := repo.last.End.Format("2006-01-02"); got != "2026-01-03" { + t.Fatalf("repo end = %s, want expanded 2026-01-03", got) + } + + var resp calendarResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Events) != 1 { + t.Fatalf("days len = %d, want 1", len(resp.Events)) + } + if resp.Events[0].Date != "2026-01-01" { + t.Fatalf("day = %q, want viewer-local 2026-01-01", resp.Events[0].Date) + } + item := resp.Events[0].Items[0] + if item.AirDate != "2026-01-02" { + t.Fatalf("air_date = %q, want source date 2026-01-02", item.AirDate) + } + if item.LocalAirDate != "2026-01-01" { + t.Fatalf("local_air_date = %q, want 2026-01-01", item.LocalAirDate) + } + if item.AirAt == nil || *item.AirAt != "2026-01-01T15:30:00Z" { + t.Fatalf("air_at = %v, want 2026-01-01T15:30:00Z", item.AirAt) + } +} + func TestHandleGetCalendar_GroupsEventsAndBatchResolvesCardPosters(t *testing.T) { t.Parallel() diff --git a/internal/catalog/air_schedule.go b/internal/catalog/air_schedule.go new file mode 100644 index 00000000..b4a9bade --- /dev/null +++ b/internal/catalog/air_schedule.go @@ -0,0 +1,114 @@ +package catalog + +import ( + "strings" + "time" +) + +var networkAirTimezones = map[string]string{ + "abc": "America/New_York", + "cbs": "America/New_York", + "nbc": "America/New_York", + "fox": "America/New_York", + "the cw": "America/New_York", + "cw": "America/New_York", + "hbo": "America/New_York", + "showtime": "America/New_York", + "fx": "America/New_York", + "amc": "America/New_York", + "bbc": "Europe/London", + "bbc one": "Europe/London", + "bbc two": "Europe/London", + "itv": "Europe/London", + "channel 4": "Europe/London", +} + +var countryAirTimezones = map[string]string{ + "france": "Europe/Paris", + "japan": "Asia/Tokyo", + "south korea": "Asia/Seoul", + "korea": "Asia/Seoul", + "united kingdom": "Europe/London", + "uk": "Europe/London", +} + +// ValidateAirTimezone reports whether tz is empty or a valid IANA timezone. +func ValidateAirTimezone(tz string) bool { + tz = strings.TrimSpace(tz) + if tz == "" { + return true + } + _, err := time.LoadLocation(tz) + return err == nil +} + +// InferAirTimezone returns a conservative source airing timezone for series metadata. +func InferAirTimezone(networks, countries []string) string { + for _, network := range networks { + if tz := networkAirTimezones[normalizeScheduleLookupKey(network)]; tz != "" { + return tz + } + } + for _, country := range countries { + if tz := countryAirTimezones[normalizeScheduleLookupKey(country)]; tz != "" { + return tz + } + } + return "" +} + +// CalendarEventAirAt combines a source date, source wall-clock time, and source timezone. +func CalendarEventAirAt(airDate time.Time, airTime, airTimezone *string) *time.Time { + if airTime == nil || strings.TrimSpace(*airTime) == "" { + return nil + } + if airTimezone == nil || strings.TrimSpace(*airTimezone) == "" { + return nil + } + loc, err := time.LoadLocation(strings.TrimSpace(*airTimezone)) + if err != nil { + return nil + } + parsed, ok := parseAirTime(*airTime) + if !ok { + return nil + } + local := time.Date( + airDate.Year(), + airDate.Month(), + airDate.Day(), + parsed.Hour(), + parsed.Minute(), + parsed.Second(), + 0, + loc, + ) + utc := local.UTC() + return &utc +} + +func CalendarLocation(name string) *time.Location { + if strings.TrimSpace(name) == "" { + return time.UTC + } + loc, err := time.LoadLocation(strings.TrimSpace(name)) + if err != nil { + return time.UTC + } + return loc +} + +func parseAirTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + for _, layout := range []string{"15:04:05", "15:04"} { + parsed, err := time.Parse(layout, value) + if err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +func normalizeScheduleLookupKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} diff --git a/internal/catalog/air_schedule_test.go b/internal/catalog/air_schedule_test.go new file mode 100644 index 00000000..65fa139b --- /dev/null +++ b/internal/catalog/air_schedule_test.go @@ -0,0 +1,71 @@ +package catalog + +import ( + "testing" + "time" +) + +func TestInferAirTimezone_UsesNetworkBeforeCountry(t *testing.T) { + t.Parallel() + + got := InferAirTimezone([]string{"BBC One"}, []string{"Japan"}) + if got != "Europe/London" { + t.Fatalf("timezone = %q, want Europe/London", got) + } +} + +func TestInferAirTimezone_UsesSingleTimezoneCountry(t *testing.T) { + t.Parallel() + + got := InferAirTimezone(nil, []string{"South Korea"}) + if got != "Asia/Seoul" { + t.Fatalf("timezone = %q, want Asia/Seoul", got) + } +} + +func TestValidateAirTimezone(t *testing.T) { + t.Parallel() + + if !ValidateAirTimezone("America/New_York") { + t.Fatal("expected America/New_York to be valid") + } + if ValidateAirTimezone("Eastern") { + t.Fatal("expected Eastern to be invalid") + } +} + +func TestCalendarEventAirAt_ConvertsSourceTimezoneToUTC(t *testing.T) { + t.Parallel() + + airDate := time.Date(2026, time.May, 28, 0, 0, 0, 0, time.UTC) + airTime := "23:30" + airTimezone := "Asia/Tokyo" + + got := CalendarEventAirAt(airDate, &airTime, &airTimezone) + if got == nil { + t.Fatal("expected air_at") + } + + want := time.Date(2026, time.May, 28, 14, 30, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("air_at = %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339)) + } +} + +func TestCalendarEventAirAt_UsesDSTForSourceDate(t *testing.T) { + t.Parallel() + + airTime := "20:00" + airTimezone := "America/New_York" + winter := CalendarEventAirAt(time.Date(2026, time.January, 15, 0, 0, 0, 0, time.UTC), &airTime, &airTimezone) + summer := CalendarEventAirAt(time.Date(2026, time.July, 15, 0, 0, 0, 0, time.UTC), &airTime, &airTimezone) + if winter == nil || summer == nil { + t.Fatal("expected air_at values") + } + if winter.Hour() != 1 { + t.Fatalf("winter UTC hour = %d, want 1", winter.Hour()) + } + if summer.Hour() != 0 { + t.Fatalf("summer UTC hour = %d, want 0", summer.Hour()) + } +} diff --git a/internal/catalog/calendar_repo.go b/internal/catalog/calendar_repo.go index 2247920a..46dd7069 100644 --- a/internal/catalog/calendar_repo.go +++ b/internal/catalog/calendar_repo.go @@ -21,6 +21,7 @@ type CalendarEvent struct { EpisodeNumber *int AirDate time.Time AirTime *string + AirTimezone *string PosterPath string PosterThumbhash string IsPremiere bool @@ -70,7 +71,7 @@ func (r *CalendarRepository) ListEvents(ctx context.Context, f CalendarFilter) ( var seasonNum, episodeNum *int if err := rows.Scan( &ev.ContentID, &ev.Type, &ev.Title, &episodeTitle, &seriesID, - &seasonNum, &episodeNum, &ev.AirDate, &ev.AirTime, + &seasonNum, &episodeNum, &ev.AirDate, &ev.AirTime, &ev.AirTimezone, &ev.PosterPath, &ev.PosterThumbhash, &ev.IsPremiere, &ev.IsFinale, ); err != nil { @@ -126,7 +127,7 @@ func (r *CalendarRepository) buildListEventsQuery(f CalendarFilter) (string, []a WHERE e.episode_number = 1 AND e.air_date IS NOT NULL ) SELECT content_id, type, title, episode_title, series_id, - season_number, episode_number, air_date, air_time, + season_number, episode_number, air_date, air_time, air_timezone, poster_path, poster_thumbhash, is_premiere, is_finale FROM ( @@ -150,7 +151,7 @@ func (r *CalendarRepository) buildMovieBranch(startArg, endArg int, f CalendarFi return fmt.Sprintf(`SELECT mi.content_id, 'movie'::text AS type, mi.title, NULL::text AS episode_title, NULL::text AS series_id, NULL::int AS season_number, NULL::int AS episode_number, - mi.release_date AS air_date, NULL::text AS air_time, + mi.release_date AS air_date, NULL::text AS air_time, NULL::text AS air_timezone, mi.poster_path, mi.poster_thumbhash, FALSE AS is_premiere, FALSE AS is_finale FROM media_items mi @@ -168,7 +169,7 @@ func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg int, f Ca return fmt.Sprintf(`SELECT e.content_id, e.series_id, e.season_number, e.episode_number, e.title AS episode_title, e.air_date, - mi.title AS title, mi.air_time, + mi.title AS title, mi.air_time, mi.air_timezone, mi.poster_path, mi.poster_thumbhash FROM episodes e JOIN media_items mi ON mi.content_id = e.series_id @@ -179,7 +180,7 @@ func (r *CalendarRepository) buildEpisodeBranch() string { return `SELECT fe.content_id, 'episode'::text AS type, fe.title, fe.episode_title, fe.series_id, fe.season_number, fe.episode_number, - fe.air_date, fe.air_time, + fe.air_date, fe.air_time, fe.air_timezone, fe.poster_path, fe.poster_thumbhash, (fe.episode_number = 1) AS is_premiere, (fe.episode_number = sf.max_episode_number) AS is_finale @@ -197,7 +198,7 @@ func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg int, f Cal r.appendPersonalFilterClause("s.series_id", f, &conditions, args, argIdx) return fmt.Sprintf(`SELECT s.content_id, s.series_id, s.season_number, - s.title AS episode_title, s.air_date, mi.title AS title, mi.air_time, + s.title AS episode_title, s.air_date, mi.title AS title, mi.air_time, mi.air_timezone, COALESCE(NULLIF(s.poster_path, ''), mi.poster_path) AS poster_path, COALESCE(NULLIF(s.poster_thumbhash, ''), mi.poster_thumbhash) AS poster_thumbhash FROM seasons s @@ -209,7 +210,7 @@ func (r *CalendarRepository) buildSeasonBranch() string { return `SELECT fs.content_id, 'season_premiere'::text AS type, fs.title, fs.episode_title, fs.series_id, fs.season_number, NULL::int AS episode_number, - fs.air_date, fs.air_time, + fs.air_date, fs.air_time, fs.air_timezone, fs.poster_path, fs.poster_thumbhash, TRUE AS is_premiere, FALSE AS is_finale FROM filtered_seasons fs diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index 99661385..c4f3c49d 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -94,6 +94,7 @@ type ItemDetail struct { LastAirDate *string `json:"last_air_date,omitempty"` ReleaseDate *string `json:"release_date,omitempty"` AirTime *string `json:"air_time,omitempty"` + AirTimezone *string `json:"air_timezone,omitempty"` ShowStatus string `json:"show_status,omitempty"` // Presigned image URLs. @@ -806,6 +807,7 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M LastAirDate: item.LastAirDate, ReleaseDate: item.ReleaseDate, AirTime: item.AirTime, + AirTimezone: item.AirTimezone, ShowStatus: item.ShowStatus, PosterThumbhash: item.PosterThumbhash, BackdropThumbhash: item.BackdropThumbhash, diff --git a/internal/catalog/item_repo.go b/internal/catalog/item_repo.go index 9788a91c..2b917837 100644 --- a/internal/catalog/item_repo.go +++ b/internal/catalog/item_repo.go @@ -52,7 +52,7 @@ const itemColumns = `content_id, type, title, sort_title, default_metadata_langu imdb_id, tmdb_id, tvdb_id, poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path, metadata_s3_path, metadata_etag, season_count, - studios, networks, countries, keywords, original_language, release_date::text, first_air_date, last_air_date, air_time, + studios, networks, countries, keywords, original_language, release_date::text, first_air_date, last_air_date, air_time, air_timezone, show_status, matched_at, last_refreshed, refresh_failures, episode_metadata_incomplete, episode_metadata_last_checked_at, locked_fields, status, created_at, updated_at` @@ -65,7 +65,7 @@ func qualifiedItemColumns(alias string) string { "imdb_id", "tmdb_id", "tvdb_id", "poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path", "metadata_s3_path", "metadata_etag", "season_count", - "studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", + "studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone", "show_status", "matched_at", "last_refreshed", "refresh_failures", "episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at", @@ -85,7 +85,7 @@ func qualifiedListItemColumns(alias string) string { "imdb_id", "tmdb_id", "tvdb_id", "poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path", "metadata_s3_path", "metadata_etag", "season_count", - "studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", + "studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone", "show_status", "matched_at", "last_refreshed", "refresh_failures", "episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at", @@ -141,6 +141,7 @@ func scanItem(row pgx.Row) (*models.MediaItem, error) { &item.FirstAirDate, &item.LastAirDate, &item.AirTime, + &item.AirTimezone, &item.ShowStatus, &item.MatchedAt, &item.LastRefreshed, @@ -203,6 +204,7 @@ func scanItems(rows pgx.Rows) ([]*models.MediaItem, error) { &item.FirstAirDate, &item.LastAirDate, &item.AirTime, + &item.AirTimezone, &item.ShowStatus, &item.MatchedAt, &item.LastRefreshed, @@ -326,7 +328,7 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo imdb_id, tmdb_id, tvdb_id, poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path, metadata_s3_path, metadata_etag, season_count, - studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, + studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone, show_status, matched_at, last_refreshed, refresh_failures, episode_metadata_incomplete, episode_metadata_last_checked_at, status @@ -337,10 +339,10 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, - $28, $29, $30, $31, $32, $33, $34, $35, $36, - $37, - $38, $39, $40, - $41, $42, $43 + $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, + $38, + $39, $40, $41, + $42, $43, $44 ) ON CONFLICT (content_id) DO UPDATE SET type = EXCLUDED.type, @@ -378,6 +380,7 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo first_air_date = EXCLUDED.first_air_date, last_air_date = EXCLUDED.last_air_date, air_time = EXCLUDED.air_time, + air_timezone = EXCLUDED.air_timezone, show_status = EXCLUDED.show_status, matched_at = EXCLUDED.matched_at, last_refreshed = EXCLUDED.last_refreshed, @@ -424,6 +427,7 @@ func (r *ItemRepository) upsert(ctx context.Context, execer itemExecer, item *mo item.FirstAirDate, item.LastAirDate, item.AirTime, + item.AirTimezone, item.ShowStatus, item.MatchedAt, item.LastRefreshed, @@ -1285,6 +1289,7 @@ func (r *ItemRepository) UpdateMetadata(ctx context.Context, contentID string, u addString("first_air_date", upd.FirstAirDate) addString("last_air_date", upd.LastAirDate) addString("air_time", upd.AirTime) + addString("air_timezone", upd.AirTimezone) addString("status", upd.Status) addString("show_status", upd.ShowStatus) addString("imdb_id", upd.ImdbID) diff --git a/internal/catalog/update.go b/internal/catalog/update.go index 46cea8d6..b0057327 100644 --- a/internal/catalog/update.go +++ b/internal/catalog/update.go @@ -28,6 +28,7 @@ type MetadataUpdate struct { FirstAirDate *string LastAirDate *string AirTime *string + AirTimezone *string AirDate *string Status *string ShowStatus *string diff --git a/internal/catalogseed/export_stream.go b/internal/catalogseed/export_stream.go index f02734b3..7a4a9e88 100644 --- a/internal/catalogseed/export_stream.go +++ b/internal/catalogseed/export_stream.go @@ -372,6 +372,8 @@ func (s *Service) streamItemRecords(ctx context.Context, folderIDs []int, fn fun mi.release_date::text, mi.first_air_date, mi.last_air_date, + mi.air_time, + mi.air_timezone, mi.matched_at, mi.last_refreshed, COALESCE(mi.refresh_failures, 0), @@ -426,6 +428,8 @@ func (s *Service) streamItemRecords(ctx context.Context, folderIDs []int, fn fun &record.ReleaseDate, &record.FirstAirDate, &record.LastAirDate, + &record.AirTime, + &record.AirTimezone, &record.MatchedAt, &record.LastRefreshed, &record.RefreshFailures, diff --git a/internal/catalogseed/service.go b/internal/catalogseed/service.go index 0206895f..e3838dad 100644 --- a/internal/catalogseed/service.go +++ b/internal/catalogseed/service.go @@ -166,7 +166,7 @@ func (s *Service) ImportWithProgress(ctx context.Context, data []byte, opts Impo item.ImdbID, item.TmdbID, item.TvdbID, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.MetadataS3Path, item.MetadataEtag, item.SeasonCount, - studios, networks, countries, keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, + studios, networks, countries, keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, item.AirTime, item.AirTimezone, item.MatchedAt, item.LastRefreshed, item.RefreshFailures, item.LockedFields, item.Status, item.CreatedAt, item.UpdatedAt, }) @@ -179,7 +179,7 @@ func (s *Service) ImportWithProgress(ctx context.Context, data []byte, opts Impo "imdb_id", "tmdb_id", "tvdb_id", "poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path", "metadata_s3_path", "metadata_etag", "season_count", - "studios", "networks", "countries", "keywords", "original_language", "release_date", "first_air_date", "last_air_date", + "studios", "networks", "countries", "keywords", "original_language", "release_date", "first_air_date", "last_air_date", "air_time", "air_timezone", "matched_at", "last_refreshed", "refresh_failures", "locked_fields", "status", "created_at", "updated_at", }, @@ -1044,7 +1044,7 @@ func bulkInsertItems(ctx context.Context, tx pgx.Tx, items []ItemRecord, onBatch item.ImdbID, item.TmdbID, item.TvdbID, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.MetadataS3Path, item.MetadataEtag, item.SeasonCount, - item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, + item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, item.AirTime, item.AirTimezone, item.MatchedAt, item.LastRefreshed, item.RefreshFailures, item.LockedFields, item.Status, item.CreatedAt, item.UpdatedAt, }) @@ -1058,12 +1058,12 @@ func bulkInsertItems(ctx context.Context, tx pgx.Tx, items []ItemRecord, onBatch imdb_id, tmdb_id, tvdb_id, poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path, metadata_s3_path, metadata_etag, season_count, - studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, + studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone, matched_at, last_refreshed, refresh_failures, locked_fields, status, created_at, updated_at ) VALUES `, rows, - 41, + 43, nil, "", onBatch, @@ -1904,13 +1904,13 @@ func batchImportItems(ctx context.Context, tx pgx.Tx, items []ItemRecord, mode C item.ImdbID, item.TmdbID, item.TvdbID, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.MetadataS3Path, item.MetadataEtag, item.SeasonCount, - studios, networks, countries, keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, + studios, networks, countries, keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, item.AirTime, item.AirTimezone, item.MatchedAt, item.LastRefreshed, item.RefreshFailures, item.LockedFields, item.Status, item.CreatedAt, item.UpdatedAt, }) } - const colCount = 41 + const colCount = 43 prefix := ` INSERT INTO media_items ( content_id, type, title, sort_title, original_title, year, genres, @@ -1919,7 +1919,7 @@ func batchImportItems(ctx context.Context, tx pgx.Tx, items []ItemRecord, mode C imdb_id, tmdb_id, tvdb_id, poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path, metadata_s3_path, metadata_etag, season_count, - studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, + studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone, matched_at, last_refreshed, refresh_failures, locked_fields, status, created_at, updated_at ) VALUES ` @@ -1963,6 +1963,8 @@ func batchImportItems(ctx context.Context, tx pgx.Tx, items []ItemRecord, mode C release_date = EXCLUDED.release_date, first_air_date = EXCLUDED.first_air_date, last_air_date = EXCLUDED.last_air_date, + air_time = EXCLUDED.air_time, + air_timezone = EXCLUDED.air_timezone, matched_at = EXCLUDED.matched_at, last_refreshed = EXCLUDED.last_refreshed, refresh_failures = EXCLUDED.refresh_failures, @@ -2055,7 +2057,7 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo imdb_id, tmdb_id, tvdb_id, poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path, metadata_s3_path, metadata_etag, season_count, - studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, + studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone, matched_at, last_refreshed, refresh_failures, locked_fields, status, created_at, updated_at ) VALUES ( @@ -2065,9 +2067,9 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, - $27, $28, $29, $30, $31, $32, $33, $34, - $35, $36, $37, $38, $39, - $40, $41 + $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, + $37, $38, $39, $40, $41, + $42, $43 ) ON CONFLICT (content_id) DO NOTHING`, item.ContentID, item.Type, item.Title, item.SortTitle, item.OriginalTitle, item.Year, item.Genres, @@ -2076,7 +2078,7 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo item.ImdbID, item.TmdbID, item.TvdbID, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.MetadataS3Path, item.MetadataEtag, item.SeasonCount, - item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, + item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, item.AirTime, item.AirTimezone, item.MatchedAt, item.LastRefreshed, item.RefreshFailures, item.LockedFields, item.Status, item.CreatedAt, item.UpdatedAt, ) @@ -2094,7 +2096,7 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo imdb_id, tmdb_id, tvdb_id, poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path, metadata_s3_path, metadata_etag, season_count, - studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, + studios, networks, countries, keywords, original_language, release_date, first_air_date, last_air_date, air_time, air_timezone, matched_at, last_refreshed, refresh_failures, locked_fields, status, created_at, updated_at ) VALUES ( @@ -2104,9 +2106,9 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, - $27, $28, $29, $30, $31, $32, $33, $34, - $35, $36, $37, $38, $39, - $40, $41 + $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, + $37, $38, $39, $40, $41, + $42, $43 ) ON CONFLICT (content_id) DO UPDATE SET type = EXCLUDED.type, @@ -2142,6 +2144,8 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo release_date = EXCLUDED.release_date, first_air_date = EXCLUDED.first_air_date, last_air_date = EXCLUDED.last_air_date, + air_time = EXCLUDED.air_time, + air_timezone = EXCLUDED.air_timezone, matched_at = EXCLUDED.matched_at, last_refreshed = EXCLUDED.last_refreshed, refresh_failures = EXCLUDED.refresh_failures, @@ -2155,7 +2159,7 @@ func importItem(ctx context.Context, tx pgx.Tx, item ItemRecord, mode ConflictMo item.ImdbID, item.TmdbID, item.TvdbID, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.MetadataS3Path, item.MetadataEtag, item.SeasonCount, - item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, + item.Studios, item.Networks, item.Countries, item.Keywords, item.OriginalLanguage, item.ReleaseDate, item.FirstAirDate, item.LastAirDate, item.AirTime, item.AirTimezone, item.MatchedAt, item.LastRefreshed, item.RefreshFailures, item.LockedFields, item.Status, item.CreatedAt, item.UpdatedAt, ).Scan(&created) diff --git a/internal/catalogseed/types.go b/internal/catalogseed/types.go index d44b2a96..ff899a56 100644 --- a/internal/catalogseed/types.go +++ b/internal/catalogseed/types.go @@ -70,6 +70,8 @@ type ItemRecord struct { ReleaseDate *string `json:"release_date,omitempty"` FirstAirDate *string `json:"first_air_date,omitempty"` LastAirDate *string `json:"last_air_date,omitempty"` + AirTime *string `json:"air_time,omitempty"` + AirTimezone *string `json:"air_timezone,omitempty"` MatchedAt *time.Time `json:"matched_at,omitempty"` LastRefreshed *time.Time `json:"last_refreshed,omitempty"` RefreshFailures int `json:"refresh_failures"` diff --git a/internal/metadata/air_time_test.go b/internal/metadata/air_time_test.go index 89332a54..5348f0d8 100644 --- a/internal/metadata/air_time_test.go +++ b/internal/metadata/air_time_test.go @@ -49,18 +49,40 @@ func TestMetadataResultToItem_CarriesAirTime(t *testing.T) { } } +func TestMetadataResultToItem_InfersAirTimezone(t *testing.T) { + result := &MetadataResult{ + HasMetadata: true, + Title: "Series", + Networks: []string{"BBC One"}, + } + setMetadataAirTime(t, result, "20:00") + + item := metadataResultToItem(result, "series") + if item.AirTimezone == nil { + t.Fatal("expected item air_timezone to be inferred") + } + if got := *item.AirTimezone; got != "Europe/London" { + t.Fatalf("expected inferred air_timezone Europe/London, got %q", got) + } +} + func TestItemToMetadataResult_CarriesAirTime(t *testing.T) { airTime := "20:00" + airTimezone := "America/New_York" result := itemToMetadataResult(&models.MediaItem{ - ContentID: "series-1", - Type: "series", - Title: "Series", - AirTime: &airTime, + ContentID: "series-1", + Type: "series", + Title: "Series", + AirTime: &airTime, + AirTimezone: &airTimezone, }) if got := getMetadataAirTime(t, result); got != airTime { t.Fatalf("expected metadata air_time %q, got %q", airTime, got) } + if result.AirTimezone != airTimezone { + t.Fatalf("expected metadata air_timezone %q, got %q", airTimezone, result.AirTimezone) + } } func TestMergeMetadata_CarriesAirTime(t *testing.T) { @@ -75,6 +97,20 @@ func TestMergeMetadata_CarriesAirTime(t *testing.T) { } } +func TestMergeMetadata_RespectsAirScheduleLock(t *testing.T) { + source := &MetadataResult{AirTime: "20:00", AirTimezone: "Europe/London"} + target := &MetadataResult{AirTime: "21:00", AirTimezone: "America/New_York"} + + MergeMetadata(source, target, []MetadataField{FieldAirSchedule}, MergeReplaceUnlocked) + + if target.AirTime != "21:00" { + t.Fatalf("expected locked air_time to remain 21:00, got %q", target.AirTime) + } + if target.AirTimezone != "America/New_York" { + t.Fatalf("expected locked air_timezone to remain America/New_York, got %q", target.AirTimezone) + } +} + func TestMergeGlobalMetadata_CarriesAirTime(t *testing.T) { source := &MetadataResult{} target := &MetadataResult{} diff --git a/internal/metadata/merge.go b/internal/metadata/merge.go index f33c0f0b..fbeb65bc 100644 --- a/internal/metadata/merge.go +++ b/internal/metadata/merge.go @@ -49,7 +49,10 @@ func MergeMetadata(source, target *MetadataResult, locked []MetadataField, mode mergeInt(&target.SeasonCount, source.SeasonCount, mode) mergeScalar(&target.FirstAirDate, source.FirstAirDate, mode) mergeScalar(&target.LastAirDate, source.LastAirDate, mode) - mergeScalar(&target.AirTime, source.AirTime, mode) + if !isLocked(FieldAirSchedule) { + mergeScalar(&target.AirTime, source.AirTime, mode) + mergeScalar(&target.AirTimezone, source.AirTimezone, mode) + } // Genres follow provider priority during FillEmpty instead of unioning tags // from later providers, which can create noisy hybrid classifications. @@ -114,7 +117,10 @@ func MergeGlobalMetadata(source, target *MetadataResult, locked []MetadataField, mergeInt(&target.SeasonCount, source.SeasonCount, mode) mergeScalar(&target.FirstAirDate, source.FirstAirDate, mode) mergeScalar(&target.LastAirDate, source.LastAirDate, mode) - mergeScalar(&target.AirTime, source.AirTime, mode) + if !isLocked(FieldAirSchedule) { + mergeScalar(&target.AirTime, source.AirTime, mode) + mergeScalar(&target.AirTimezone, source.AirTimezone, mode) + } if !isLocked(FieldGenres) { mergePrioritizedStringSlice(&target.Genres, source.Genres, mode) diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 33c963a7..f90fb81a 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -4746,6 +4746,9 @@ func itemToMetadataResult(item *models.MediaItem) *MetadataResult { if item.AirTime != nil { result.AirTime = *item.AirTime } + if item.AirTimezone != nil { + result.AirTimezone = *item.AirTimezone + } if item.ReleaseDate != nil { result.ReleaseDate = *item.ReleaseDate } @@ -4810,6 +4813,12 @@ func metadataResultToItem(r *MetadataResult, contentType string) *models.MediaIt if r.AirTime != "" { item.AirTime = &r.AirTime } + if r.AirTime != "" && r.AirTimezone == "" { + r.AirTimezone = catalog.InferAirTimezone(r.Networks, r.Countries) + } + if r.AirTimezone != "" { + item.AirTimezone = &r.AirTimezone + } if r.ReleaseDate != "" { item.ReleaseDate = &r.ReleaseDate } diff --git a/internal/metadata/types.go b/internal/metadata/types.go index b40f4f69..cb7e433b 100644 --- a/internal/metadata/types.go +++ b/internal/metadata/types.go @@ -39,6 +39,7 @@ const ( FieldTags FieldContentRating FieldImages + FieldAirSchedule ) // RefreshPriority controls queue ordering. @@ -178,6 +179,7 @@ type MetadataResult struct { FirstAirDate string LastAirDate string AirTime string + AirTimezone string } // Ratings holds ratings from multiple sources. diff --git a/internal/models/media.go b/internal/models/media.go index 54c64a82..30459504 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -314,6 +314,7 @@ type MediaItem struct { FirstAirDate *string // ISO date (series only), nullable LastAirDate *string // ISO date (series only), nullable AirTime *string // Series broadcast time (e.g. "20:00"), nullable + AirTimezone *string // Series broadcast timezone (IANA name, e.g. "America/New_York"), nullable ShowStatus string // Series lifecycle: "returning", "ended", "cancelled", "in_production", or "" if unknown (series only) People []ItemPerson MatchedAt *time.Time diff --git a/migrations/162_media_items_air_timezone.down.sql b/migrations/162_media_items_air_timezone.down.sql new file mode 100644 index 00000000..be2d1a6e --- /dev/null +++ b/migrations/162_media_items_air_timezone.down.sql @@ -0,0 +1 @@ +ALTER TABLE media_items DROP COLUMN IF EXISTS air_timezone; diff --git a/migrations/162_media_items_air_timezone.up.sql b/migrations/162_media_items_air_timezone.up.sql new file mode 100644 index 00000000..af9cf2aa --- /dev/null +++ b/migrations/162_media_items_air_timezone.up.sql @@ -0,0 +1 @@ +ALTER TABLE media_items ADD COLUMN air_timezone text; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 55c3a06e..e81ba3b0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -831,6 +831,7 @@ export interface ItemDetail { first_air_date: string | null; last_air_date: string | null; air_time?: string | null; + air_timezone?: string | null; // Presigned image URLs. poster_url: string; @@ -2505,6 +2506,9 @@ export interface SectionItemUpcomingEvent { type: "movie" | "episode" | "season_premiere"; air_date: string; air_time?: string; + air_at?: string | null; + air_timezone?: string | null; + local_air_date?: string; episode_title?: string | null; season_number?: number | null; episode_number?: number | null; diff --git a/web/src/components/EditMetadataDialog.tsx b/web/src/components/EditMetadataDialog.tsx index 1574470c..9d54429d 100644 --- a/web/src/components/EditMetadataDialog.tsx +++ b/web/src/components/EditMetadataDialog.tsx @@ -24,6 +24,19 @@ const FIELD_STUDIOS = 3; const FIELD_RATING = 6; const FIELD_RUNTIME = 7; const FIELD_CONTENT_RATING = 9; +const FIELD_AIR_SCHEDULE = 11; + +const AIR_TIMEZONES = [ + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "Europe/London", + "Europe/Paris", + "Asia/Tokyo", + "Asia/Seoul", + "Australia/Sydney", +]; type Section = "general" | "dates" | "tags" | "ids" | "images"; @@ -52,6 +65,8 @@ const FIELD_LOCK_MAP: Record = { rating_rt_audience: FIELD_RATING, runtime: FIELD_RUNTIME, content_rating: FIELD_CONTENT_RATING, + air_time: FIELD_AIR_SCHEDULE, + air_timezone: FIELD_AIR_SCHEDULE, }; interface EditMetadataDialogProps { @@ -78,6 +93,7 @@ function initFormState(item: ItemDetail) { first_air_date: item.first_air_date ?? "", last_air_date: item.last_air_date ?? "", air_time: item.air_time ?? "", + air_timezone: item.air_timezone ?? "", air_date: item.air_date ?? "", status: item.status ?? "", rating_imdb: item.rating_imdb, @@ -168,6 +184,8 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet if (form.last_air_date !== originalForm.last_air_date) data.last_air_date = form.last_air_date || null; if (form.air_time !== originalForm.air_time) data.air_time = form.air_time || null; + if (form.air_timezone !== originalForm.air_timezone) + data.air_timezone = form.air_timezone || null; if (form.air_date !== originalForm.air_date) data.air_date = form.air_date || null; if (form.status !== originalForm.status) data.status = form.status; if (form.rating_imdb !== originalForm.rating_imdb) data.rating_imdb = form.rating_imdb; @@ -432,7 +450,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet )} {item.type === "series" && ( -
    +
    setField("last_air_date", e.target.value)} /> - + setField("air_time", e.target.value)} /> + + setField("air_timezone", e.target.value)} + /> + + {AIR_TIMEZONES.map((timezone) => ( + +
    )} diff --git a/web/src/components/calendar/CalendarEventCard.tsx b/web/src/components/calendar/CalendarEventCard.tsx index d505b811..4f670b8d 100644 --- a/web/src/components/calendar/CalendarEventCard.tsx +++ b/web/src/components/calendar/CalendarEventCard.tsx @@ -19,7 +19,7 @@ export default function CalendarEventCard({ event }: { event: CalendarEvent }) { : `/item/${event.series_id ?? event.content_id}`; const subtitle = formatUpcomingSubtitle(event); - const airTime = formatUpcomingTime(event.air_time); + const airTime = formatUpcomingTime(event.air_time, event.air_at); return (
    diff --git a/web/src/hooks/queries/calendar.test.ts b/web/src/hooks/queries/calendar.test.ts index 75a0ddf7..7cccfd4e 100644 --- a/web/src/hooks/queries/calendar.test.ts +++ b/web/src/hooks/queries/calendar.test.ts @@ -14,6 +14,9 @@ vi.mock("@/api/client", () => ({ import { useCalendarWeek } from "./calendar"; describe("useCalendarWeek", () => { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const encodedTimezone = encodeURIComponent(timezone); + beforeEach(() => { mockUseQuery.mockReset(); mockApi.mockReset(); @@ -29,11 +32,13 @@ describe("useCalendarWeek", () => { expect(mockUseQuery).toHaveBeenCalledWith( expect.objectContaining({ - queryKey: ["calendar", "week", "2026-04-06", "all", "all"], + queryKey: ["calendar", "week", "2026-04-06", "all", "all", timezone], staleTime: 10 * 60 * 1000, }), ); - expect(mockApi).toHaveBeenCalledWith("/calendar?start=2026-04-06&end=2026-04-12&filter=all"); + expect(mockApi).toHaveBeenCalledWith( + `/calendar?start=2026-04-06&end=2026-04-12&filter=all&timezone=${encodedTimezone}`, + ); }); it("includes the selected library in the request", async () => { @@ -43,7 +48,7 @@ describe("useCalendarWeek", () => { await queryOptions.queryFn(); expect(mockApi).toHaveBeenCalledWith( - "/calendar?start=2026-04-06&end=2026-04-12&filter=favorites&library_id=7", + `/calendar?start=2026-04-06&end=2026-04-12&filter=favorites&timezone=${encodedTimezone}&library_id=7`, ); }); }); diff --git a/web/src/hooks/queries/calendar.ts b/web/src/hooks/queries/calendar.ts index 0277eed5..ccbd1104 100644 --- a/web/src/hooks/queries/calendar.ts +++ b/web/src/hooks/queries/calendar.ts @@ -13,6 +13,9 @@ export interface CalendarEvent { episode_number?: number; air_date: string; air_time?: string; + air_at?: string | null; + air_timezone?: string | null; + local_air_date: string; poster_url?: string; poster_thumbhash?: string; badges: string[]; @@ -28,13 +31,16 @@ interface CalendarResponse { } export function useCalendarWeek(weekStart: string, params: { filter: string; libraryId?: number }) { + const timezone = getViewerTimezone(); + return useQuery({ - queryKey: calendarKeys.week(weekStart, params.filter, params.libraryId), + queryKey: calendarKeys.week(weekStart, params.filter, params.libraryId, timezone), queryFn: () => { const sp = new URLSearchParams({ start: weekStart, end: addDays(weekStart, 6), filter: params.filter, + timezone, }); if (params.libraryId) sp.set("library_id", String(params.libraryId)); return api(`/calendar?${sp}`).then((d) => d.events ?? []); @@ -42,3 +48,7 @@ export function useCalendarWeek(weekStart: string, params: { filter: string; lib staleTime: 10 * 60 * 1000, }); } + +function getViewerTimezone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; +} diff --git a/web/src/hooks/queries/items.ts b/web/src/hooks/queries/items.ts index cbbb257a..aaa4d070 100644 --- a/web/src/hooks/queries/items.ts +++ b/web/src/hooks/queries/items.ts @@ -207,6 +207,7 @@ export interface UpdateItemMetadataRequest { first_air_date?: string | null; last_air_date?: string | null; air_time?: string | null; + air_timezone?: string | null; air_date?: string | null; status?: string; rating_imdb?: number | null; diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index b0e8272c..be61fbef 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -291,8 +291,8 @@ export const recKeys = { export const calendarKeys = { all: ["calendar"] as const, - week: (weekStart: string, filter: string, libraryId?: number) => - ["calendar", "week", weekStart, filter, libraryId ?? "all"] as const, + week: (weekStart: string, filter: string, libraryId?: number, timezone?: string) => + ["calendar", "week", weekStart, filter, libraryId ?? "all", timezone ?? "UTC"] as const, }; export const downloadKeys = { diff --git a/web/src/lib/upcomingEventPresentation.test.ts b/web/src/lib/upcomingEventPresentation.test.ts new file mode 100644 index 00000000..7c73345f --- /dev/null +++ b/web/src/lib/upcomingEventPresentation.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { formatUpcomingTime } from "./upcomingEventPresentation"; + +describe("formatUpcomingTime", () => { + it("formats air_at before falling back to source air_time", () => { + const expected = new Date("2026-01-01T15:30:00Z").toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + + expect(formatUpcomingTime("00:30", "2026-01-01T15:30:00Z")).toBe(expected); + }); + + it("falls back to source air_time when air_at is missing", () => { + const expected = new Date("2000-01-01T20:00").toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + + expect(formatUpcomingTime("20:00", null)).toBe(expected); + }); +}); diff --git a/web/src/lib/upcomingEventPresentation.ts b/web/src/lib/upcomingEventPresentation.ts index b3987aaa..416e5a32 100644 --- a/web/src/lib/upcomingEventPresentation.ts +++ b/web/src/lib/upcomingEventPresentation.ts @@ -2,6 +2,7 @@ interface UpcomingPresentationEvent { type: "movie" | "episode" | "season_premiere"; air_date: string; air_time?: string | null; + air_at?: string | null; episode_title?: string | null; season_number?: number | null; episode_number?: number | null; @@ -61,7 +62,16 @@ export function formatUpcomingDate(airDate: string): string { }); } -export function formatUpcomingTime(airTime?: string | null): string | null { +export function formatUpcomingTime(airTime?: string | null, airAt?: string | null): string | null { + if (airAt) { + const date = new Date(airAt); + if (!Number.isNaN(date.getTime())) { + return date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + } + } if (!airTime) { return null; } From 5bda20740cf06ef44abbf610ce4b5e629b959140 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 21:40:07 -0400 Subject: [PATCH 069/201] Enable DRI passthrough in docker compose Co-authored-by: Codex --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 7bcf5f71..f995e672 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,8 @@ services: env_file: - path: .env required: false + devices: + - /dev/dri:/dev/dri # Optional distributed-mode example. Most single-host installs should # just use the integrated service above. From 590f61a550f009ffa3b51c4cf6b22a8685c52c98 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 23:40:59 +0200 Subject: [PATCH 070/201] feat(ui): paginate the Ambiguous Roots table Match the sibling tables on the Admin Libraries page (Troubleshooting/unmatched): use the existing usePagination hook + PaginationBar (10/page, auto-hidden when <=10 rows), render pag.rows, and reset to page 0 when the library selector or search filter changes. --- web/src/pages/AdminLibraries.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/web/src/pages/AdminLibraries.tsx b/web/src/pages/AdminLibraries.tsx index 243ef9a3..354014a7 100644 --- a/web/src/pages/AdminLibraries.tsx +++ b/web/src/pages/AdminLibraries.tsx @@ -1150,6 +1150,8 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) { ); }, [roots, search]); + const pag = usePagination(filteredRoots); + if (libraries.length === 0) { return null; } @@ -1177,7 +1179,10 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) { value={ effectiveSelectedLibraryId != null ? String(effectiveSelectedLibraryId) : undefined } - onValueChange={(value) => setSelectedLibraryId(Number.parseInt(value, 10))} + onValueChange={(value) => { + setSelectedLibraryId(Number.parseInt(value, 10)); + pag.setPage(0); + }} > @@ -1195,7 +1200,10 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) { setSearch(e.target.value)} + onChange={(e) => { + setSearch(e.target.value); + pag.setPage(0); + }} className="h-8 pl-8 text-xs" />
    @@ -1220,7 +1228,7 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) { ) : ( - filteredRoots.map((root) => ( + pag.rows.map((root) => (
    @@ -1262,6 +1270,7 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
    +
    {editingRoot ? ( From 0bb1030ca2679de2a2e62b343f4f02892303d32a Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 04:04:34 +0200 Subject: [PATCH 071/201] feat(admin): enlarge match-candidate posters + hover-to-enlarge Unmatched-item match dialog rendered candidate posters at 44x64px, too small to identify a film. Bump to 64x96 (2:3) and add a portaled Tooltip hover preview (192x288) using the existing poster image, so operators can tell candidates apart. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/components/MatchItemDialog.tsx | 29 ++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/web/src/components/MatchItemDialog.tsx b/web/src/components/MatchItemDialog.tsx index 4bc1d839..f83bf702 100644 --- a/web/src/components/MatchItemDialog.tsx +++ b/web/src/components/MatchItemDialog.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import type { FileVersion, ItemDetail, MatchCandidate } from "@/api/types"; import MediaLocations from "@/components/MediaLocations"; import { useSearchItemMatchCandidates, useApplyItemMatch } from "@/hooks/queries/items"; @@ -170,6 +171,7 @@ export default function MatchItemDialog({ item, open, onOpenChange }: MatchItemD {candidates.length > 0 && (
    +
    {candidates.map((candidate, index) => { const candidateKey = Object.entries(candidate.provider_ids) @@ -189,13 +191,27 @@ export default function MatchItemDialog({ item, open, onOpenChange }: MatchItemD data-testid="match-candidate" > {candidate.image_url ? ( - + + + + + + {candidate.title} + + ) : ( -
    +
    )}
    {candidate.title}
    @@ -219,6 +235,7 @@ export default function MatchItemDialog({ item, open, onOpenChange }: MatchItemD ); })}
    +
    )} From 49efc3846c590e31eeefca42b859971526b859a1 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 04:16:49 +0200 Subject: [PATCH 072/201] fix(admin): search unmatched items across the whole table, not just the page The unmatched-items search filtered only the current page's rows client-side. Push the query server-side: HandleListUnmatchedItems takes an optional 'q' param and filters title/library/type/status with parameterized ILIKE across all rows, paginating the filtered set. Frontend hook takes a debounced search, resets to page 1 on change, keeps the section mounted while searching. Also fixes stale test mocks that returned the pre-pagination array shape instead of {items,total}. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/handlers/libraries.go | 49 ++++++++++++++++++++---- web/src/hooks/queries/admin/libraries.ts | 9 +++-- web/src/hooks/queries/keys.ts | 4 +- web/src/pages/AdminLibraries.test.tsx | 27 +++++++------ web/src/pages/AdminLibraries.tsx | 36 ++++++++--------- 5 files changed, 83 insertions(+), 42 deletions(-) diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index fa3a4784..7a296735 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -2058,16 +2058,49 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http } } + // Optional case-insensitive search across title, library name, type, and + // status. Applied server-side so it spans the whole table, not just the + // current page. The folder name comes from the lateral join, so both the + // count and list queries carry the join (and the filter) when searching. + search := strings.TrimSpace(q.Get("q")) + filter := "" + filterArgs := []any{} + if search != "" { + filterArgs = append(filterArgs, "%"+search+"%") + filter = ` AND (mi.title ILIKE $1 OR mi.type ILIKE $1 OR mi.status ILIKE $1 OR COALESCE(lib.folder_name, '') ILIKE $1)` + } + + // Only pay for the lateral folder lookup when the search filter actually + // references lib.folder_name; the no-search count is the hot path on every + // admin-page load and shouldn't carry an unnecessary join over the whole + // unmatched set. + countSQL := ` + SELECT COUNT(*) + FROM media_items mi + WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')` + if search != "" { + countSQL = ` + SELECT COUNT(*) + FROM media_items mi + LEFT JOIN LATERAL ( + SELECT f.name AS folder_name + FROM media_item_libraries mil + JOIN media_folders f ON f.id = mil.media_folder_id + WHERE mil.content_id = mi.content_id + LIMIT 1 + ) lib ON true + WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')` + filter + } + var total int - if err := h.pool.QueryRow(r.Context(), - `SELECT COUNT(*) FROM media_items WHERE status IN ('unmatched', 'pending', 'ambiguous')`, - ).Scan(&total); err != nil { + if err := h.pool.QueryRow(r.Context(), countSQL, filterArgs...).Scan(&total); err != nil { slog.Error("counting unmatched items", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unmatched items") return } - rows, err := h.pool.Query(r.Context(), ` + listArgs := append(append([]any{}, filterArgs...), limit, offset) + listSQL := fmt.Sprintf(` SELECT mi.content_id, mi.title, mi.year, mi.type, mi.status, COALESCE(lib.folder_id, 0), COALESCE(lib.folder_name, '') @@ -2079,10 +2112,12 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http WHERE mil.content_id = mi.content_id LIMIT 1 ) lib ON true - WHERE mi.status IN ('unmatched', 'pending', 'ambiguous') + WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s ORDER BY mi.title ASC, mi.content_id ASC - LIMIT $1 OFFSET $2 - `, limit, offset) + LIMIT $%d OFFSET $%d + `, filter, len(filterArgs)+1, len(filterArgs)+2) + + rows, err := h.pool.Query(r.Context(), listSQL, listArgs...) if err != nil { slog.Error("listing unmatched items", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched items") diff --git a/web/src/hooks/queries/admin/libraries.ts b/web/src/hooks/queries/admin/libraries.ts index 680b8cdd..f7b9c250 100644 --- a/web/src/hooks/queries/admin/libraries.ts +++ b/web/src/hooks/queries/admin/libraries.ts @@ -510,13 +510,16 @@ export function useRefreshLibraryMetadata() { const UNMATCHED_PAGE_SIZE = 10; -export function useUnmatchedLibraryItems(page = 0) { +export function useUnmatchedLibraryItems(page = 0, search = "") { const offset = page * UNMATCHED_PAGE_SIZE; + const trimmed = search.trim(); return useQuery({ - queryKey: adminKeys.unmatchedItems(page), + queryKey: adminKeys.unmatchedItems(page, trimmed), queryFn: () => api( - `/libraries/unmatched-items?limit=${UNMATCHED_PAGE_SIZE}&offset=${offset}`, + `/libraries/unmatched-items?limit=${UNMATCHED_PAGE_SIZE}&offset=${offset}${ + trimmed ? `&q=${encodeURIComponent(trimmed)}` : "" + }`, ).then((d) => d ?? { items: [], total: 0 }), staleTime: ADMIN_STALE_TIME, placeholderData: (prev) => prev, diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index b0e8272c..2268b8de 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -386,9 +386,9 @@ export const adminKeys = { pluginRepositories: () => ["admin", "plugins", "repositories"] as const, pluginCatalog: () => ["admin", "plugins", "catalog"] as const, pluginInstallations: () => ["admin", "plugins", "installations"] as const, - unmatchedItems: (page?: number) => + unmatchedItems: (page?: number, search?: string) => page != null - ? (["admin", "libraries", "unmatchedItems", page] as const) + ? (["admin", "libraries", "unmatchedItems", page, search ?? ""] as const) : (["admin", "libraries", "unmatchedItems"] as const), itemImages: (id: string) => ["admin", "items", id, "images"] as const, buildInfo: () => ["admin", "system", "buildInfo"] as const, diff --git a/web/src/pages/AdminLibraries.test.tsx b/web/src/pages/AdminLibraries.test.tsx index 1b818130..6c93cfed 100644 --- a/web/src/pages/AdminLibraries.test.tsx +++ b/web/src/pages/AdminLibraries.test.tsx @@ -114,7 +114,7 @@ describe("AdminLibraries", () => { isLoading: false, }); mocks.useUnmatchedLibraryItems.mockReturnValue({ - data: [], + data: { items: [], total: 0 }, isLoading: false, }); }); @@ -210,17 +210,20 @@ describe("AdminLibraries", () => { it("renders an unmatched items section when unmatched items exist", () => { mocks.useUnmatchedLibraryItems.mockReturnValue({ - data: [ - { - content_id: "movie-99", - title: "Unknown Film", - year: 0, - content_type: "movie", - library_id: 1, - library_name: "Movies", - status: "unmatched", - }, - ], + data: { + items: [ + { + content_id: "movie-99", + title: "Unknown Film", + year: 0, + content_type: "movie", + library_id: 1, + library_name: "Movies", + status: "unmatched", + }, + ], + total: 1, + }, isLoading: false, }); diff --git a/web/src/pages/AdminLibraries.tsx b/web/src/pages/AdminLibraries.tsx index 354014a7..08a5e7f9 100644 --- a/web/src/pages/AdminLibraries.tsx +++ b/web/src/pages/AdminLibraries.tsx @@ -1,5 +1,6 @@ import { Fragment, useState, useEffect, useCallback, useMemo } from "react"; import type { FormEvent } from "react"; +import { useDebounce } from "@/hooks/useDebounce"; import { useEventChannel } from "@/components/realtimeEventsContext"; import type { AdminJob, @@ -1655,27 +1656,26 @@ function SkippedRootsSection({ skippedRoots }: { skippedRoots: LibrarySkippedRoo function UnmatchedItemsSection() { const [page, setPage] = useState(0); const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 250); const [matchItem, setMatchItem] = useState(null); - const { data } = useUnmatchedLibraryItems(page); + const { data } = useUnmatchedLibraryItems(page, debouncedSearch); const total = data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / UNMATCHED_PAGE_SIZE)); const clamped = Math.min(page, totalPages - 1); const rangeStart = total === 0 ? 0 : clamped * UNMATCHED_PAGE_SIZE + 1; const rangeEnd = Math.min((clamped + 1) * UNMATCHED_PAGE_SIZE, total); - const filteredItems = useMemo(() => { - const items = data?.items ?? []; - if (!search) return items; - const q = search.toLowerCase(); - return items.filter( - (item) => - item.title.toLowerCase().includes(q) || - item.library_name.toLowerCase().includes(q) || - item.content_type.toLowerCase().includes(q) || - item.status.toLowerCase().includes(q), - ); - }, [data?.items, search]); + const items = data?.items ?? []; - if (total === 0 && page === 0) return null; + // The search is applied server-side (spans the whole table, not just this + // page); reset to the first page whenever the debounced query changes. + useEffect(() => { + setPage(0); + }, [debouncedSearch]); + + // Hide the section only when there are genuinely no unmatched items and no + // active search — keep it mounted while searching so the box and the + // "no matches" state stay visible even when a query returns nothing. + if (total === 0 && page === 0 && search.trim() === "") return null; return (
    @@ -1698,7 +1698,7 @@ function UnmatchedItemsSection() {
    setSearch(e.target.value)} className="h-8 pl-8 text-xs" @@ -1716,14 +1716,14 @@ function UnmatchedItemsSection() { - {filteredItems.length === 0 ? ( + {items.length === 0 ? ( - No unmatched items on this page match your filter. + No unmatched items match your search. ) : ( - filteredItems.map((u) => ( + items.map((u) => ( Date: Thu, 28 May 2026 04:23:10 +0200 Subject: [PATCH 073/201] style(admin): prettier-format match dialog; reset unmatched page in onChange Run prettier over the Tooltip-wrapped poster JSX, and reset the unmatched-items page in the search input's onChange rather than a useEffect (avoids the react-hooks/set-state-in-effect warning / cascading renders). Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/components/MatchItemDialog.tsx | 124 ++++++++++++------------- web/src/pages/AdminLibraries.tsx | 13 ++- 2 files changed, 68 insertions(+), 69 deletions(-) diff --git a/web/src/components/MatchItemDialog.tsx b/web/src/components/MatchItemDialog.tsx index f83bf702..7abd52e0 100644 --- a/web/src/components/MatchItemDialog.tsx +++ b/web/src/components/MatchItemDialog.tsx @@ -172,69 +172,69 @@ export default function MatchItemDialog({ item, open, onOpenChange }: MatchItemD
    -
    - {candidates.map((candidate, index) => { - const candidateKey = Object.entries(candidate.provider_ids) - .map(([k, v]) => `${k}-${v}`) - .join("_"); - return ( - - ); - })} -
    + + ); + })} +
    )} diff --git a/web/src/pages/AdminLibraries.tsx b/web/src/pages/AdminLibraries.tsx index 08a5e7f9..5097a979 100644 --- a/web/src/pages/AdminLibraries.tsx +++ b/web/src/pages/AdminLibraries.tsx @@ -1666,12 +1666,6 @@ function UnmatchedItemsSection() { const rangeEnd = Math.min((clamped + 1) * UNMATCHED_PAGE_SIZE, total); const items = data?.items ?? []; - // The search is applied server-side (spans the whole table, not just this - // page); reset to the first page whenever the debounced query changes. - useEffect(() => { - setPage(0); - }, [debouncedSearch]); - // Hide the section only when there are genuinely no unmatched items and no // active search — keep it mounted while searching so the box and the // "no matches" state stay visible even when a query returns nothing. @@ -1700,7 +1694,12 @@ function UnmatchedItemsSection() { setSearch(e.target.value)} + onChange={(e) => { + // Search is server-side and spans the whole table; jump back to + // the first page so results start at the top as the query changes. + setSearch(e.target.value); + setPage(0); + }} className="h-8 pl-8 text-xs" />
    From c3c806410f70df65d390de9f5350b1a0a897c8a2 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 15:32:46 +0200 Subject: [PATCH 074/201] test(admin): wire QueryClientProvider + missing hook mocks so the suite runs The AdminLibraries test file failed all 6 tests with 'No QueryClient set' on this branch and at the parent commit -- pre-existing infrastructure gap. With that fixed, several hooks that the page imports (useCancelLibraryScans, useLibraryRoots, useUpsertLibraryRootOverride, useDeleteLibraryRootOverride, useActiveScans) and the UNMATCHED_PAGE_SIZE constant also needed mocking. One stale assertion on the renamed 'Root path' header is updated; the deeper troubleshooting test, which mocked useSkippedLibraryRoots but the section was refactored to useLibraryRoots(_, 'ambiguous'), is skipped with a TODO -- a real rewrite is needed and is out of scope for this MR. 5 of 6 tests now run and pass; the 6th is properly flagged. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/pages/AdminLibraries.test.tsx | 99 +++++++++++++++------------ 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/web/src/pages/AdminLibraries.test.tsx b/web/src/pages/AdminLibraries.test.tsx index 6c93cfed..6250ee8a 100644 --- a/web/src/pages/AdminLibraries.test.tsx +++ b/web/src/pages/AdminLibraries.test.tsx @@ -1,5 +1,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -23,6 +24,11 @@ const mocks = vi.hoisted(() => ({ useDeleteLibraryPoster: vi.fn(), useUnmatchedLibraryItems: vi.fn(), useAdminPlugins: vi.fn(), + useCancelLibraryScans: vi.fn(), + useLibraryRoots: vi.fn(), + useUpsertLibraryRootOverride: vi.fn(), + useDeleteLibraryRootOverride: vi.fn(), + useActiveScans: vi.fn(), })); vi.mock("@/hooks/queries/admin/libraries", () => ({ @@ -45,14 +51,38 @@ vi.mock("@/hooks/queries/admin/libraries", () => ({ useUploadLibraryPoster: (...args: unknown[]) => mocks.useUploadLibraryPoster(...args), useDeleteLibraryPoster: (...args: unknown[]) => mocks.useDeleteLibraryPoster(...args), useUnmatchedLibraryItems: (...args: unknown[]) => mocks.useUnmatchedLibraryItems(...args), + useCancelLibraryScans: (...args: unknown[]) => mocks.useCancelLibraryScans(...args), + useLibraryRoots: (...args: unknown[]) => mocks.useLibraryRoots(...args), + useUpsertLibraryRootOverride: (...args: unknown[]) => mocks.useUpsertLibraryRootOverride(...args), + useDeleteLibraryRootOverride: (...args: unknown[]) => mocks.useDeleteLibraryRootOverride(...args), + UNMATCHED_PAGE_SIZE: 10, })); vi.mock("@/hooks/queries/admin/plugins", () => ({ useAdminPlugins: (...args: unknown[]) => mocks.useAdminPlugins(...args), })); +vi.mock("@/hooks/queries/admin/scans", () => ({ + useActiveScans: (...args: unknown[]) => mocks.useActiveScans(...args), +})); + import AdminLibraries from "./AdminLibraries"; +// renderPage wraps the page in the providers it needs at runtime: a +// QueryClientProvider for the (mocked) TanStack hooks, and a MemoryRouter for +// the s inside AdminLibraries. Without QueryClientProvider, even fully +// mocked useQuery hooks throw "No QueryClient set" during render. +const renderPage = () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup( + + + + + , + ); +}; + describe("AdminLibraries", () => { beforeEach(() => { const mutate = vi.fn(); @@ -117,14 +147,15 @@ describe("AdminLibraries", () => { data: { items: [], total: 0 }, isLoading: false, }); + mocks.useCancelLibraryScans.mockReturnValue(queryState); + mocks.useLibraryRoots.mockReturnValue({ data: [], isLoading: false }); + mocks.useUpsertLibraryRootOverride.mockReturnValue(queryState); + mocks.useDeleteLibraryRootOverride.mockReturnValue(queryState); + mocks.useActiveScans.mockReturnValue({ data: [], isLoading: false }); }); it("uses scan language instead of metadata refresh language on the admin libraries page", () => { - const markup = renderToStaticMarkup( - - - , - ); + const markup = renderPage(); expect(markup).toContain( "Manage library roots and scans. Catalog import/export now lives under Maintenance.", @@ -137,16 +168,20 @@ describe("AdminLibraries", () => { ); }); - it("renders a low-key troubleshooting section only when skipped roots exist", () => { - mocks.useSkippedLibraryRoots.mockReturnValue({ + it("renders the Ambiguous Roots section with a populated row", () => { + mocks.useLibraryRoots.mockReturnValue({ data: [ { library_id: 1, library_name: "Movies", root_path: "/media/movies/Inception (2010)", - reason: "missing_folder_ids", + state: "ambiguous", + inferred_type: "movie", + type_confidence: "low", + title: "Inception", + year: 2010, + observed_file_count: 1, sample_file_path: "/media/movies/Inception (2010)/Inception (2010).mkv", - file_count: 1, first_seen_at: "2026-03-23T20:00:00Z", last_seen_at: "2026-03-23T21:00:00Z", }, @@ -154,29 +189,21 @@ describe("AdminLibraries", () => { isLoading: false, }); - const markup = renderToStaticMarkup( - - - , - ); + const markup = renderPage(); - expect(markup).toContain("Troubleshooting"); - expect(markup).toContain("Root path"); - expect(markup).toContain("Movies"); + expect(markup).toContain("Ambiguous Roots"); + expect(markup).toContain("Inception"); expect(markup).toContain("/media/movies/Inception (2010)"); - expect(markup).toContain("missing_folder_ids"); - expect(markup).toContain("First seen"); - expect(markup).toContain("Last seen"); }); - it("hides the troubleshooting section when no skipped roots exist", () => { - const markup = renderToStaticMarkup( - - - , - ); + it("renders the empty-state inside Ambiguous Roots when no roots exist", () => { + // Default useLibraryRoots mock returns { data: [], isLoading: false }. The + // section itself still renders (it's gated on libraries.length, not on the + // root list), and the table body shows the empty-state copy. + const markup = renderPage(); - expect(markup).not.toContain("Root path"); + expect(markup).toContain("Ambiguous Roots"); + expect(markup).toContain("No ambiguous roots for this library"); }); it("renders Match instead of Re-match for stale IDs", () => { @@ -198,11 +225,7 @@ describe("AdminLibraries", () => { isLoading: false, }); - const markup = renderToStaticMarkup( - - - , - ); + const markup = renderPage(); expect(markup).toContain("Match"); expect(markup).not.toContain("Re-match"); @@ -227,11 +250,7 @@ describe("AdminLibraries", () => { isLoading: false, }); - const markup = renderToStaticMarkup( - - - , - ); + const markup = renderPage(); expect(markup).toContain("Unmatched Items"); expect(markup).toContain("Unknown Film"); @@ -239,11 +258,7 @@ describe("AdminLibraries", () => { }); it("hides unmatched items section when no unmatched items exist", () => { - const markup = renderToStaticMarkup( - - - , - ); + const markup = renderPage(); expect(markup).not.toContain("Unmatched Items"); }); From 74e9be644398eb78f4be9623ce36896cc80db70f Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 11:31:50 -0400 Subject: [PATCH 075/201] fix(admin): search all unmatched item library memberships --- internal/api/handlers/libraries.go | 36 +++++++++++++----------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index 7a296735..8fe0df76 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -2060,37 +2060,33 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http // Optional case-insensitive search across title, library name, type, and // status. Applied server-side so it spans the whole table, not just the - // current page. The folder name comes from the lateral join, so both the - // count and list queries carry the join (and the filter) when searching. + // current page. The displayed folder still comes from the lateral join below, + // but the search predicate checks every membership so multi-library items are + // found when any linked library name matches. search := strings.TrimSpace(q.Get("q")) filter := "" filterArgs := []any{} if search != "" { filterArgs = append(filterArgs, "%"+search+"%") - filter = ` AND (mi.title ILIKE $1 OR mi.type ILIKE $1 OR mi.status ILIKE $1 OR COALESCE(lib.folder_name, '') ILIKE $1)` + filter = ` AND ( + mi.title ILIKE $1 + OR mi.type ILIKE $1 + OR mi.status ILIKE $1 + OR EXISTS ( + SELECT 1 + FROM media_item_libraries search_mil + JOIN media_folders search_f ON search_f.id = search_mil.media_folder_id + WHERE search_mil.content_id = mi.content_id + AND search_f.name ILIKE $1 + ) + )` } - // Only pay for the lateral folder lookup when the search filter actually - // references lib.folder_name; the no-search count is the hot path on every - // admin-page load and shouldn't carry an unnecessary join over the whole - // unmatched set. countSQL := ` SELECT COUNT(*) FROM media_items mi WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')` - if search != "" { - countSQL = ` - SELECT COUNT(*) - FROM media_items mi - LEFT JOIN LATERAL ( - SELECT f.name AS folder_name - FROM media_item_libraries mil - JOIN media_folders f ON f.id = mil.media_folder_id - WHERE mil.content_id = mi.content_id - LIMIT 1 - ) lib ON true - WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')` + filter - } + countSQL += filter var total int if err := h.pool.QueryRow(r.Context(), countSQL, filterArgs...).Scan(&total); err != nil { From f2b6eff29d15bd2304756442dd30302df7aa15fb Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 18:36:58 +0200 Subject: [PATCH 076/201] fix(naming): strip unsubstituted Sonarr tokens ({TvdbId}/{imdb-}) from titles These tokens survived the provider-tag regex ([\w]+ doesn't match braces), polluting parsed titles (e.g. 'A Girl & Her Guard Dog [tvdb-{TvdbId}]') so they could not score-match. Broaden the regex to drop {...} and empty tokens. --- internal/naming/provider_token_test.go | 25 +++++++++++++++++++++++++ internal/naming/root_inference.go | 5 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 internal/naming/provider_token_test.go diff --git a/internal/naming/provider_token_test.go b/internal/naming/provider_token_test.go new file mode 100644 index 00000000..9dde9de8 --- /dev/null +++ b/internal/naming/provider_token_test.go @@ -0,0 +1,25 @@ +package naming + +import "testing" + +func TestStripInferProviderTags_HandlesBrokenSonarrTokens(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"A Girl & Her Guard Dog [tvdb-{TvdbId}]", "A Girl & Her Guard Dog"}, + {"A Raven in the Harem [tvdb-{TvdbId}]", "A Raven in the Harem"}, + {"A Salad Bowl of Eccentrics {imdb-}", "A Salad Bowl of Eccentrics"}, + {"Coronation Street {imdb-}", "Coronation Street"}, + // well-formed tags must still be stripped: + {"Some Show [tvdb-81189]", "Some Show"}, + {"Some Show {tmdb-27205}", "Some Show"}, + // no tag: unchanged: + {"Cowboy Bebop", "Cowboy Bebop"}, + } + for _, c := range cases { + if got := stripInferProviderTags(c.in); got != c.want { + t.Errorf("stripInferProviderTags(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/naming/root_inference.go b/internal/naming/root_inference.go index e0095cdc..f383d725 100644 --- a/internal/naming/root_inference.go +++ b/internal/naming/root_inference.go @@ -18,7 +18,10 @@ var ( inferSeasonDirRe = regexp.MustCompile(`(?i)^Season\s+(\d{1,4})(?:\s.*)?$`) inferNumericSeasonRe = regexp.MustCompile(`^\d{1,4}$`) inferSpecialsDirRe = regexp.MustCompile(`(?i)^(?:specials?|extras?)$`) - inferProviderTagRe = regexp.MustCompile(`\s*[{\[](?:tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-[\w]+[}\]]`) + // Matches a well-formed tag ([tvdb-81189]), an unsubstituted Sonarr token + // ([tvdb-{TvdbId}]), or an empty token ({imdb-}). The id part is either a + // {...} placeholder or zero-or-more word chars. + inferProviderTagRe = regexp.MustCompile(`\s*[{\[](?:tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-(?:\{[^}]*\}|[\w]*)[}\]]`) ) type RootAssignment struct { From 534f7bb9014d11613dd9ec36d266d8dfd2467a26 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 18:43:18 +0200 Subject: [PATCH 077/201] fix(naming): numeric-only titles are not bare provider IDs '86' / '22 7' were parsed as trailing tvdb ids, tripping the trusted-ID gate so the correct title match was rejected. Require a letter in the name before treating a trailing number as a bare id. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/naming/folderid.go | 16 +++++++++++++++ internal/naming/folderid_numeric_test.go | 25 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 internal/naming/folderid_numeric_test.go diff --git a/internal/naming/folderid.go b/internal/naming/folderid.go index af5f7a3f..ade90a1c 100644 --- a/internal/naming/folderid.go +++ b/internal/naming/folderid.go @@ -3,6 +3,7 @@ package naming import ( "regexp" "strings" + "unicode" ) // folderIDPattern matches patterns like [tmdbid-27205], {tmdb-27205}, @@ -66,12 +67,27 @@ func ParseFolderIDs(folderName string, folderType string) *FolderIDHints { return nil } + // A bare trailing number is only an ID when appended to a real title. If the + // name has no letters (e.g. "86", "22 7"), it's a numeric title, not an ID. + if !containsLetter(trimmed) { + return nil + } + if strings.EqualFold(strings.TrimSpace(folderType), "series") { return &FolderIDHints{TvdbID: id} } return &FolderIDHints{TmdbID: id} } +func containsLetter(s string) bool { + for _, r := range s { + if unicode.IsLetter(r) { + return true + } + } + return false +} + func looksLikeYear(value string) bool { return len(value) == 4 && value >= "1800" && value <= "2100" } diff --git a/internal/naming/folderid_numeric_test.go b/internal/naming/folderid_numeric_test.go new file mode 100644 index 00000000..a50a71a9 --- /dev/null +++ b/internal/naming/folderid_numeric_test.go @@ -0,0 +1,25 @@ +package naming + +import "testing" + +func TestParseFolderIDs_NumericTitleIsNotAnID(t *testing.T) { + // Numeric-only anime titles must NOT be parsed as a bare trailing tvdb/tmdb id. + if got := ParseFolderIDs("86", "series"); got != nil { + t.Errorf(`ParseFolderIDs("86","series") = %+v, want nil`, got) + } + if got := ParseFolderIDs("22 7", "series"); got != nil { + t.Errorf(`ParseFolderIDs("22 7","series") = %+v, want nil`, got) + } + + // A real bare trailing id with title text must still be parsed. + got := ParseFolderIDs("Some Show 81189", "series") + if got == nil || got.TvdbID != "81189" { + t.Errorf(`ParseFolderIDs("Some Show 81189","series") = %+v, want TvdbID="81189"`, got) + } + + // Structured tags must still win regardless of letters. + got = ParseFolderIDs("{tmdb-27205}", "movies") + if got == nil || got.TmdbID != "27205" { + t.Errorf(`ParseFolderIDs("{tmdb-27205}","movies") = %+v, want TmdbID="27205"`, got) + } +} From 79222248396d7337bddb70ede36f49d8118dcf8e Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 18:47:02 +0200 Subject: [PATCH 078/201] test(naming): document bare-id trade-off; cover CJK title + movies numeric Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/naming/folderid.go | 3 +++ internal/naming/folderid_numeric_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/naming/folderid.go b/internal/naming/folderid.go index ade90a1c..e9cc064c 100644 --- a/internal/naming/folderid.go +++ b/internal/naming/folderid.go @@ -69,6 +69,9 @@ func ParseFolderIDs(folderName string, folderType string) *FolderIDHints { // A bare trailing number is only an ID when appended to a real title. If the // name has no letters (e.g. "86", "22 7"), it's a numeric title, not an ID. + // Trade-off: a folder named ONLY a bare provider id (e.g. "81189") with no + // title text now returns nil rather than that id — acceptable because + // Sonarr/Radarr never produce bare-number folders. if !containsLetter(trimmed) { return nil } diff --git a/internal/naming/folderid_numeric_test.go b/internal/naming/folderid_numeric_test.go index a50a71a9..5e0e61be 100644 --- a/internal/naming/folderid_numeric_test.go +++ b/internal/naming/folderid_numeric_test.go @@ -22,4 +22,16 @@ func TestParseFolderIDs_NumericTitleIsNotAnID(t *testing.T) { if got == nil || got.TmdbID != "27205" { t.Errorf(`ParseFolderIDs("{tmdb-27205}","movies") = %+v, want TmdbID="27205"`, got) } + + // Non-Latin (CJK) title with a trailing real id must still parse — + // unicode.IsLetter covers kana/kanji, so this is treated as title + id. + got = ParseFolderIDs("進撃の巨人 73743", "series") + if got == nil || got.TvdbID != "73743" { + t.Errorf(`ParseFolderIDs("進撃の巨人 73743","series") = %+v, want TvdbID="73743"`, got) + } + + // Numeric-only title for a movies library is also not an id. + if got := ParseFolderIDs("86", "movies"); got != nil { + t.Errorf(`ParseFolderIDs("86","movies") = %+v, want nil`, got) + } } From e1320c8817dcd5d9c914b3664993c227c325a24a Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 19:59:00 +0200 Subject: [PATCH 079/201] feat(matcher): auto-accept a year-corroborated single distinct show A search that resolves to one distinct show (one candidate, or the same title+year returned once per source as unmerged TVDB/TMDB rows) whose year matches the parsed year is now auto-accepted via the existing top-ranked candidate, even when the fuzzy title score is in the 55-69 band. The 55/70/15 thresholds are unchanged; this only adds a year-gated acceptance for effectively-unique results (recovers lone-correct-result items like 1201 (1993)). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 46 +++++++++++++++ .../metadata/match_candidates_select_test.go | 57 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 internal/metadata/match_candidates_select_test.go diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 4b9acaa3..e13306c9 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -342,6 +342,42 @@ type scoredMatchCandidate struct { score float64 } +// candidatesAreSingleDistinctShow reports whether every scored candidate refers +// to the same show as best — same year, an exact normalized title match, and no +// conflicting provider IDs. This is true when the search effectively returned +// one distinct title, possibly as separate per-source rows (e.g. a TVDB row and +// a TMDB row that weren't merged because each carries only its own provider's +// ID). Candidates that share a canonical provider key but carry different values +// are considered distinct shows and cause the function to return false. +func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCandidate) bool { + // Year==0 means the provider didn't supply a release year, so we cannot + // claim the candidates refer to the *same* show via year-equality. Without + // this guard, two no-year candidates from different providers would satisfy + // the multi-source corroboration arm of the lone-result rule and get + // auto-accepted, which over-accepts ambiguous matches. + if best.Year == 0 { + return false + } + for _, c := range scored { + if c.candidate.Year == 0 || c.candidate.Year != best.Year { + return false + } + if inferTitleSimilarity(best.Title, c.candidate.Title, best.Year) != 1 { + return false + } + // If both candidates carry the same canonical provider key (e.g. both + // have a tmdb ID) but different values, they are different shows. + for _, key := range canonicalCandidateIDKeys { + bv := strings.TrimSpace(best.ProviderIDs[key]) + cv := strings.TrimSpace(c.candidate.ProviderIDs[key]) + if bv != "" && cv != "" && bv != cv { + return false + } + } + } + return true +} + func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) { if len(candidates) == 0 { return nil, false @@ -369,6 +405,16 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) if best.score < 55 { return nil, false } + // A search that resolves to a single distinct show (one candidate, or the + // same title+year returned once per source) whose year matches the parsed + // year is high-confidence even when the fuzzy title score sits in the 55-69 + // band (short/numeric/alternate titles). Accept the top-ranked candidate + // without lowering the score thresholds. + if hints.Year != 0 && best.candidate.Year == hints.Year && + candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) && + candidatesAreSingleDistinctShow(best.candidate, scoredCandidates) { + return &best.candidate, true + } if len(scoredCandidates) == 1 { if best.score < 70 { return nil, false diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go new file mode 100644 index 00000000..df7685e8 --- /dev/null +++ b/internal/metadata/match_candidates_select_test.go @@ -0,0 +1,57 @@ +package metadata + +import "testing" + +func TestSelectInitialMatchCandidate_LoneResultYearMatchBelow70(t *testing.T) { + // Exact title, matching year, NO sources, NO provider IDs => score 45+20 = 65 (<70). + // Old behavior rejected this (single candidate <70); the new rule accepts it. + hints := &MatchHints{Title: "1201", Year: 1993, Type: "movie"} + cands := []MatchCandidate{{Title: "1201", Year: 1993, ContentType: "movie"}} + got, ok := selectInitialMatchCandidate(hints, cands) + if !ok || got == nil || got.Title != "1201" { + t.Fatalf("expected lone year-matching result to be accepted, got ok=%v cand=%+v", ok, got) + } +} + +func TestSelectInitialMatchCandidate_SameShowAcrossTwoSources(t *testing.T) { + // Same title+year returned once per source (TVDB-only and TMDB-only, no shared ID + // so they were NOT merged). Old behavior: tie-break bails -> nil. New: accept best. + hints := &MatchHints{Title: "Blue Lock", Year: 2022, Type: "series"} + cands := []MatchCandidate{ + {Title: "Blue Lock", Year: 2022, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "404404"}}, + {Title: "Blue Lock", Year: 2022, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "120089"}}, + } + got, ok := selectInitialMatchCandidate(hints, cands) + if !ok || got == nil { + t.Fatalf("expected same-show-across-sources to be accepted, got ok=%v cand=%+v", ok, got) + } +} + +func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *testing.T) { + // Exact title but year mismatch, one source => score 45+12 = 57 (in [55,70), no year bonus). + // Year does NOT corroborate, so the new rule must NOT fire; single candidate <70 => reject. + hints := &MatchHints{Title: "1201", Year: 1993, Type: "movie"} + cands := []MatchCandidate{{Title: "1201", Year: 1990, ContentType: "movie", Sources: []string{"tmdb"}}} + if got, ok := selectInitialMatchCandidate(hints, cands); ok { + t.Fatalf("expected year-mismatch lone result to be rejected, got cand=%+v", got) + } +} + +func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) { + // Two DIFFERENT shows (different titles, similar scores => gap=0 => tie-break path). + // candidatesAreSingleDistinctShow returns false (titles differ), so the new rule + // must NOT fire; falls through to the existing tie-break which returns nil because + // DetailScore is 0. Guards against over-accepting distinct results. + // + // "The Show Special" and "The Show Extra" both score 38 against hint "The Show" / + // 2010 (0-similarity title match, 1 source, 1 provider ID), gap = 0 < 15 => + // tie-break; inferTitleSimilarity between the two candidates is 0 => not same show. + hints := &MatchHints{Title: "The Show", Year: 2010, Type: "movie"} + cands := []MatchCandidate{ + {Title: "The Show Special", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "1"}}, + {Title: "The Show Extra", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "2"}}, + } + if _, ok := selectInitialMatchCandidate(hints, cands); ok { + t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule") + } +} From f3cd1e6ab58fd3ce427c5ece803c04c4ef453174 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 20:10:55 +0200 Subject: [PATCH 080/201] test(matcher): exercise the single-distinct-show guard properly + conflicting-ID case; doc notes Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 5 +++ .../metadata/match_candidates_select_test.go | 39 ++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index e13306c9..10650de8 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -349,6 +349,8 @@ type scoredMatchCandidate struct { // a TMDB row that weren't merged because each carries only its own provider's // ID). Candidates that share a canonical provider key but carry different values // are considered distinct shows and cause the function to return false. +// Candidates with Year == 0 are treated as year-mismatched (a provider that +// omitted the year yields false here) — conservative by design. func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCandidate) bool { // Year==0 means the provider didn't supply a release year, so we cannot // claim the candidates refer to the *same* show via year-equality. Without @@ -410,6 +412,9 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) // year is high-confidence even when the fuzzy title score sits in the 55-69 // band (short/numeric/alternate titles). Accept the top-ranked candidate // without lowering the score thresholds. + // Residual risk: two different shows with an identical title+year and no + // provider IDs would both pass; accepted as low-risk given the title+year+type + // corroboration. if hints.Year != 0 && best.candidate.Year == hints.Year && candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) && candidatesAreSingleDistinctShow(best.candidate, scoredCandidates) { diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index df7685e8..ed2af379 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -38,20 +38,39 @@ func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *test } func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) { - // Two DIFFERENT shows (different titles, similar scores => gap=0 => tie-break path). - // candidatesAreSingleDistinctShow returns false (titles differ), so the new rule - // must NOT fire; falls through to the existing tie-break which returns nil because - // DetailScore is 0. Guards against over-accepting distinct results. + // Two genuinely different shows that BOTH score >=55 (so the new rule IS evaluated, + // not short-circuited by the <55 floor): candidatesAreSingleDistinctShow must return + // false (titles differ), so the new rule does NOT fire and behavior falls through to + // the existing tie-break (which returns nil here because DetailScore is 0). Guards + // against over-accepting distinct results. // - // "The Show Special" and "The Show Extra" both score 38 against hint "The Show" / - // 2010 (0-similarity title match, 1 source, 1 provider ID), gap = 0 < 15 => - // tie-break; inferTitleSimilarity between the two candidates is 0 => not same show. - hints := &MatchHints{Title: "The Show", Year: 2010, Type: "movie"} + // Each candidate shares 7 tokens with the hint plus one distinct trailing word, so + // each is coherent with the hint (Jaccard 7/8 = 0.875 >= 0.85 => sim 0.8 => +28) and + // scores 28 + 20(year) + 24(2 sources) + 5 + 1(1 id) = 78 (>=55, reaches the guard). + // The two candidates differ from each other (Jaccard 7/9 = 0.78 < 0.85 => sim 0), so + // candidatesAreSingleDistinctShow returns false. Equal scores => gap 0 < 15 => tie-break. + hints := &MatchHints{Title: "The Real History of the World War", Year: 2010, Type: "series"} cands := []MatchCandidate{ - {Title: "The Show Special", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "1"}}, - {Title: "The Show Extra", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "2"}}, + {Title: "The Real History of the World War Europe", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "1"}}, + {Title: "The Real History of the World War Pacific", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "2"}}, } if _, ok := selectInitialMatchCandidate(hints, cands); ok { t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule") } } + +func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testing.T) { + // Same title+year but different tmdb IDs => two distinct shows; must NOT auto-accept. + // Each scores 65 (45 exact title + 20 year) + 5 + 1(richness) = ... actually + // 45+20+5+1 = 71 (no sources, one provider ID), well above the 55 floor, so the new + // branch is reached. candidatesAreSingleDistinctShow must return false because the two + // candidates carry the same canonical provider key (tmdb) with conflicting values. + hints := &MatchHints{Title: "Alpha", Year: 2022, Type: "movie"} + cands := []MatchCandidate{ + {Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "111"}}, + {Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "222"}}, + } + if _, ok := selectInitialMatchCandidate(hints, cands); ok { + t.Fatal("conflicting tmdb IDs must not be auto-accepted") + } +} From 747eef2c6b8b733094d443dbe58c277496157eaa Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 21:01:28 +0200 Subject: [PATCH 081/201] fix(matcher): tolerate concurrent-merge ErrItemNotFound in series episode-link ensure A scan drainer and the background MatchWorker can process the same folder concurrently. When a provider-ID merge moves a series' episodes to the survivor and deletes the source, an in-flight ensureSeriesEpisodeLinks(sourceID) hits catalog.ErrItemNotFound and was failing the whole scan. The episodes are already reattached, so this is benign: log and continue (matching the lenient call sites) instead of failing. Genuine errors still abort. --- internal/metadata/worker.go | 32 +++- internal/metadata/worker_test.go | 274 +++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 6 deletions(-) diff --git a/internal/metadata/worker.go b/internal/metadata/worker.go index 72785f9b..bec12554 100644 --- a/internal/metadata/worker.go +++ b/internal/metadata/worker.go @@ -2,6 +2,7 @@ package metadata import ( "context" + "errors" "fmt" "log/slog" "path/filepath" @@ -12,6 +13,7 @@ import ( "sync/atomic" "time" + "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/models" ) @@ -837,10 +839,21 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo } } if err := w.service.ensureSeriesEpisodeLinks(ctx, representative.ContentID); err != nil { - if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil { - return 0, updateErr + if errors.Is(err, catalog.ErrItemNotFound) { + // The series item was concurrently merged into another (provider-ID + // dedup moves its seasons+episodes to the survivor, then deletes the + // source row). The episodes are already reattached, so there is nothing + // to link here — benign; finish normally instead of failing the batch. + slog.Info("metadata: series item gone during episode-link ensure (likely concurrent merge); skipping", + "content_id", representative.ContentID, + "folder_id", job.MediaFolderID, + "observed_root_path", job.ObservedRootPath) + } else { + if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil { + return 0, updateErr + } + return 0, fmt.Errorf("ensuring series episode links for %s: %w", representative.ContentID, err) } - return 0, fmt.Errorf("ensuring series episode links for %s: %w", representative.ContentID, err) } } if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil { @@ -933,10 +946,17 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo } if strings.TrimSpace(finalContentID) != "" { if err := w.service.ensureSeriesEpisodeLinks(ctx, finalContentID); err != nil { - if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil { - return 0, updateErr + if errors.Is(err, catalog.ErrItemNotFound) { + slog.Info("metadata: series item gone during episode-link ensure (likely concurrent merge); skipping", + "content_id", finalContentID, + "folder_id", job.MediaFolderID, + "observed_root_path", job.ObservedRootPath) + } else { + if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil { + return 0, updateErr + } + return 0, fmt.Errorf("ensuring series episode links for %s: %w", finalContentID, err) } - return 0, fmt.Errorf("ensuring series episode links for %s: %w", finalContentID, err) } if _, ok := w.service.confirmedOwnershipItem(ctx, finalContentID); ok { w.service.claimConfirmedSeriesRootOwnership(ctx, job.MediaFolderID, job.ObservedRootPath, finalContentID, groupFiles) diff --git a/internal/metadata/worker_test.go b/internal/metadata/worker_test.go index 867d3f44..0ecb4d15 100644 --- a/internal/metadata/worker_test.go +++ b/internal/metadata/worker_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/models" ) @@ -1200,3 +1201,276 @@ func TestWorkerProcessBatchByFolderAndPathPrefix_MovieQueueClaimsOnlyOncePerScan t.Fatal("expected queue error to be recorded") } } + +// --------------------------------------------------------------------------- +// Tests for concurrent-merge ErrItemNotFound tolerance (hotfix 2026-05-27) +// --------------------------------------------------------------------------- + +// TestProcessSeriesRoot_Site1_ErrItemNotFoundIsToleratedNotFailed verifies that +// when ensureSeriesEpisodeLinks returns catalog.ErrItemNotFound at Site 1 +// (the "all files already linked" fast path), the batch succeeds instead of +// failing. This covers the concurrent-merge case where the source series row +// was deleted after its episodes were moved to the survivor. +func TestProcessSeriesRoot_Site1_ErrItemNotFoundIsToleratedNotFailed(t *testing.T) { + h := newTestHarness() + ctx := context.Background() + + // Need a series-type folder so queueUsageForFolder enables the series queue. + h.service.folderRepo = &fakeWorkerFolderRepo{ + folders: map[int]*models.MediaFolder{ + 10: {ID: 10, Type: "series", Enabled: true}, + }, + } + + // Pre-populate the item as "matched" so reusableQueuedMovieSkeleton + // returns false (matched is not skeleton-like), bypassing the re-process + // block and falling through directly to ensureSeriesEpisodeLinks. + const contentID = "series-gone-after-merge" + if err := h.itemRepo.Upsert(ctx, &models.MediaItem{ + ContentID: contentID, + Status: "matched", + Title: "Example Show", + Type: "series", + Studios: []string{}, + Networks: []string{}, + Countries: []string{}, + Genres: []string{}, + }); err != nil { + t.Fatalf("upsert item: %v", err) + } + + // All files are already linked — hasUnlinkedGroupFile returns false. + file := &models.MediaFile{ + ID: 1, + MediaFolderID: 10, + FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv", + ObservedRootPath: "/media/shows/Example Show", + GroupKeyVersion: 1, + ContentGroupKey: "v1|series|example_show|2024", + ContentID: contentID, + } + h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file) + h.fileRepo.contentIDs[file.ID] = contentID + + // Hook ensureSeriesEpisodeLinks to simulate the source row being gone. + h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error { + return fmt.Errorf("loading series item: %w", catalog.ErrItemNotFound) + } + + queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{ + MediaFolderID: 10, + ObservedRootPath: "/media/shows/Example Show", + SampleFilePath: file.FilePath, + ObservedFileCount: 1, + }) + + worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0) + worker.SetSeriesRootClaimer(queueRepo, true) + processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{}) + if err != nil { + t.Fatalf("expected no error for ErrItemNotFound (benign concurrent merge), got: %v", err) + } + if processed != 1 { + t.Fatalf("processed = %d, want 1", processed) + } + // Queue row must be deleted on normal completion. + if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; !ok { + t.Fatal("expected queue row to be deleted on normal completion") + } + // No error must have been recorded in the queue. + if queueRepo.errors["10:/media/shows/Example Show"] != "" { + t.Fatalf("expected no queue error, got %q", queueRepo.errors["10:/media/shows/Example Show"]) + } +} + +// TestProcessSeriesRoot_Site1_NonNotFoundErrorStillFails verifies that a +// genuine (non-ErrItemNotFound) error from ensureSeriesEpisodeLinks at Site 1 +// still fails the batch — the fix must not swallow real errors. +func TestProcessSeriesRoot_Site1_NonNotFoundErrorStillFails(t *testing.T) { + h := newTestHarness() + ctx := context.Background() + + h.service.folderRepo = &fakeWorkerFolderRepo{ + folders: map[int]*models.MediaFolder{ + 10: {ID: 10, Type: "series", Enabled: true}, + }, + } + + const contentID = "series-link-failure" + if err := h.itemRepo.Upsert(ctx, &models.MediaItem{ + ContentID: contentID, + Status: "matched", + Title: "Example Show", + Type: "series", + Studios: []string{}, + Networks: []string{}, + Countries: []string{}, + Genres: []string{}, + }); err != nil { + t.Fatalf("upsert item: %v", err) + } + + file := &models.MediaFile{ + ID: 1, + MediaFolderID: 10, + FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv", + ObservedRootPath: "/media/shows/Example Show", + GroupKeyVersion: 1, + ContentGroupKey: "v1|series|example_show|2024", + ContentID: contentID, + } + h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file) + h.fileRepo.contentIDs[file.ID] = contentID + + // Return a genuine (non-not-found) error. + linkErr := errors.New("database connection reset") + h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error { + return linkErr + } + + queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{ + MediaFolderID: 10, + ObservedRootPath: "/media/shows/Example Show", + SampleFilePath: file.FilePath, + ObservedFileCount: 1, + }) + + worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0) + worker.SetSeriesRootClaimer(queueRepo, true) + _, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{}) + if err == nil { + t.Fatal("expected error for genuine link failure, got nil") + } + if !strings.Contains(err.Error(), "ensuring series episode links") { + t.Fatalf("unexpected error message: %v", err) + } +} + +// TestProcessSeriesRoot_Site2_ErrItemNotFoundIsToleratedNotFailed verifies that +// when ensureSeriesEpisodeLinks returns catalog.ErrItemNotFound at Site 2 +// (the new-skeleton / enrichment path), the batch succeeds. +func TestProcessSeriesRoot_Site2_ErrItemNotFoundIsToleratedNotFailed(t *testing.T) { + h := newTestHarness() + ctx := context.Background() + + h.service.folderRepo = &fakeWorkerFolderRepo{ + folders: map[int]*models.MediaFolder{ + 10: {ID: 10, Type: "series", Enabled: true}, + }, + } + + // File has no ContentID yet → hasUnlinkedGroupFile returns true → + // proceeds through createOrFindSkeleton + enrichment path (Site 2). + file := &models.MediaFile{ + ID: 1, + MediaFolderID: 10, + FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv", + ObservedRootPath: "/media/shows/Example Show", + GroupKeyVersion: 1, + ContentGroupKey: "v1|series|example_show|2024", + BaseTitle: "Example Show", + BaseType: "series", + } + h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file) + + const skeletonID = "skeleton-series-id" + + h.service.hooks.createOrFindSkeleton = func(_ context.Context, _ *models.MediaFile, _ int) (*skeletonResult, error) { + return &skeletonResult{ + ContentID: skeletonID, + IsNew: true, + ItemStatus: "pending", + Type: "series", + }, nil + } + h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) { + return &ProcessResult{Updated: true}, nil + } + // Simulate the concurrent-merge: ensureSeriesEpisodeLinks finds the source gone. + h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error { + return fmt.Errorf("loading series item: %w", catalog.ErrItemNotFound) + } + + queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{ + MediaFolderID: 10, + ObservedRootPath: "/media/shows/Example Show", + SampleFilePath: file.FilePath, + ObservedFileCount: 1, + }) + + worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0) + worker.SetSeriesRootClaimer(queueRepo, true) + processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{}) + if err != nil { + t.Fatalf("expected no error for ErrItemNotFound (benign concurrent merge), got: %v", err) + } + if processed != 1 { + t.Fatalf("processed = %d, want 1", processed) + } + if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; !ok { + t.Fatal("expected queue row to be deleted on normal completion") + } + if queueRepo.errors["10:/media/shows/Example Show"] != "" { + t.Fatalf("expected no queue error, got %q", queueRepo.errors["10:/media/shows/Example Show"]) + } +} + +// TestProcessSeriesRoot_Site2_NonNotFoundErrorStillFails verifies that a genuine +// error from ensureSeriesEpisodeLinks at Site 2 still fails the batch. +func TestProcessSeriesRoot_Site2_NonNotFoundErrorStillFails(t *testing.T) { + h := newTestHarness() + ctx := context.Background() + + h.service.folderRepo = &fakeWorkerFolderRepo{ + folders: map[int]*models.MediaFolder{ + 10: {ID: 10, Type: "series", Enabled: true}, + }, + } + + file := &models.MediaFile{ + ID: 1, + MediaFolderID: 10, + FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv", + ObservedRootPath: "/media/shows/Example Show", + GroupKeyVersion: 1, + ContentGroupKey: "v1|series|example_show|2024", + BaseTitle: "Example Show", + BaseType: "series", + } + h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file) + + const skeletonID = "skeleton-series-id-2" + + h.service.hooks.createOrFindSkeleton = func(_ context.Context, _ *models.MediaFile, _ int) (*skeletonResult, error) { + return &skeletonResult{ + ContentID: skeletonID, + IsNew: true, + ItemStatus: "pending", + Type: "series", + }, nil + } + h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) { + return &ProcessResult{Updated: true}, nil + } + linkErr := errors.New("db timeout") + h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error { + return linkErr + } + + queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{ + MediaFolderID: 10, + ObservedRootPath: "/media/shows/Example Show", + SampleFilePath: file.FilePath, + ObservedFileCount: 1, + }) + + worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0) + worker.SetSeriesRootClaimer(queueRepo, true) + _, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{}) + if err == nil { + t.Fatal("expected error for genuine link failure at site 2, got nil") + } + if !strings.Contains(err.Error(), "ensuring series episode links") { + t.Fatalf("unexpected error message: %v", err) + } +} From 24f516077c9df97132dd1c950cfc7ab0d1a0f396 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 22:47:08 +0200 Subject: [PATCH 082/201] diag(matcher): debug-log per-candidate match scores Adds a DEBUG-gated log in selectInitialMatchCandidate printing each scored candidate (title/year/type/sources/provider_ids/score) against the hint, so operators can see why an item did or didn't auto-match. Zero-cost when debug logging is off; no change to matching behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 53 ++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 10650de8..e6ad3646 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -2,6 +2,8 @@ package metadata import ( "context" + "fmt" + "log/slog" "math" "sort" "strconv" @@ -360,6 +362,17 @@ func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCa if best.Year == 0 { return false } + // Track the first non-empty value seen per canonical provider key across + // best AND every scored candidate. If any key ends up with more than one + // distinct value, the tie group spans multiple shows — including the case + // where `best` lacks a key but two non-best candidates carry conflicting + // values for it. + seenIDs := make(map[string]string, len(canonicalCandidateIDKeys)) + for _, key := range canonicalCandidateIDKeys { + if v := strings.TrimSpace(best.ProviderIDs[key]); v != "" { + seenIDs[key] = v + } + } for _, c := range scored { if c.candidate.Year == 0 || c.candidate.Year != best.Year { return false @@ -367,13 +380,17 @@ func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCa if inferTitleSimilarity(best.Title, c.candidate.Title, best.Year) != 1 { return false } - // If both candidates carry the same canonical provider key (e.g. both - // have a tmdb ID) but different values, they are different shows. for _, key := range canonicalCandidateIDKeys { - bv := strings.TrimSpace(best.ProviderIDs[key]) cv := strings.TrimSpace(c.candidate.ProviderIDs[key]) - if bv != "" && cv != "" && bv != cv { - return false + if cv == "" { + continue + } + if existing, ok := seenIDs[key]; ok { + if existing != cv { + return false + } + } else { + seenIDs[key] = cv } } } @@ -396,6 +413,32 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) return scoredCandidates[i].score > scoredCandidates[j].score }) + if slog.Default().Enabled(context.Background(), slog.LevelDebug) { + // hints can be nil on some callers (scoreMatchCandidate tolerates it). + // Guard the debug log so DEBUG-enabled runs don't panic on a nil deref. + hintTitle, hintType := "", "" + hintYear := 0 + if hints != nil { + hintTitle = hints.Title + hintYear = hints.Year + hintType = hints.Type + } + for rank, sc := range scoredCandidates { + slog.Debug("match candidate scoring", + "hint_title", hintTitle, + "hint_year", hintYear, + "hint_type", hintType, + "rank", rank, + "candidate_title", sc.candidate.Title, + "candidate_year", sc.candidate.Year, + "candidate_type", sc.candidate.ContentType, + "sources", strings.Join(sc.candidate.Sources, ","), + "provider_ids", fmt.Sprintf("%v", sc.candidate.ProviderIDs), + "score", sc.score, + ) + } + } + best := scoredCandidates[0] if trustedHintIDsPresent(hints) { if candidateMatchesTrustedIDs(hints, best.candidate) { From b5e4213e819dee951d276bb7811b2458defe0a4d Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 23:14:33 +0200 Subject: [PATCH 083/201] feat(matcher): resolve cross-source ties by library provider priority Accept a year-corroborated single distinct show when the TOP tie-group (within 15 pts of best) is one show across providers, ignoring low-score noise below it, and pick the winner by the library's metadata-provider chain order (providerPriority, highest-first; falls back to top-scored). Recovers items like '100 Days Wild' that are returned identically by TVDB and TMDB. Thresholds (55/70/15) unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 52 +++++++++++++++++-- .../metadata/match_candidates_select_test.go | 48 ++++++++++++++--- internal/metadata/match_candidates_test.go | 2 + internal/metadata/service.go | 6 ++- 4 files changed, 96 insertions(+), 12 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index e6ad3646..335f1e4b 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -397,7 +397,40 @@ func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCa return true } -func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) { +// topTieGroup returns the highest-scored candidate plus every candidate within +// the 15-point tie window of it — i.e. the set of candidates that are not +// clearly beaten. Assumes scored is sorted descending by score. +func topTieGroup(scored []scoredMatchCandidate) []scoredMatchCandidate { + if len(scored) == 0 { + return nil + } + group := []scoredMatchCandidate{scored[0]} + for _, c := range scored[1:] { + if scored[0].score-c.score < 15 { + group = append(group, c) + } + } + return group +} + +// pickByProviderPriority returns the group candidate whose Sources include the +// highest-priority provider (providerPriority is ordered highest-first, e.g. the +// library's chain order). Falls back to the top-scored candidate when there is no +// priority info or no source matches. +func pickByProviderPriority(group []scoredMatchCandidate, providerPriority []string) *MatchCandidate { + for _, prov := range providerPriority { + for i := range group { + for _, s := range group[i].candidate.Sources { + if strings.EqualFold(s, prov) { + return &group[i].candidate + } + } + } + } + return &group[0].candidate +} + +func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, providerPriority []string) (*MatchCandidate, bool) { if len(candidates) == 0 { return nil, false } @@ -455,13 +488,22 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) // year is high-confidence even when the fuzzy title score sits in the 55-69 // band (short/numeric/alternate titles). Accept the top-ranked candidate // without lowering the score thresholds. + // We check only the TOP tie-group (candidates within 15 pts of best) so that + // low-score noise from unrelated shows below the group does not veto a clear + // cross-source agreement. When the top group is one distinct show, pick the + // winner by the library's metadata-provider priority (falls back to top-scored). // Residual risk: two different shows with an identical title+year and no // provider IDs would both pass; accepted as low-risk given the title+year+type // corroboration. if hints.Year != 0 && best.candidate.Year == hints.Year && - candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) && - candidatesAreSingleDistinctShow(best.candidate, scoredCandidates) { - return &best.candidate, true + candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) { + topGroup := topTieGroup(scoredCandidates) + if candidatesAreSingleDistinctShow(best.candidate, topGroup) { + // One distinct show, possibly returned by several providers and clearly + // ahead of any different show below. Accept it, choosing the winner by + // the library's metadata-provider priority (falls back to top-scored). + return pickByProviderPriority(topGroup, providerPriority), true + } } if len(scoredCandidates) == 1 { if best.score < 70 { @@ -553,7 +595,7 @@ func selectRefreshMatchCandidate(existing *models.MediaItem, candidates []MatchC TvdbID: existing.TvdbID, ImdbID: existing.ImdbID, } - return selectInitialMatchCandidate(hints, candidates) + return selectInitialMatchCandidate(hints, candidates, nil) } func trustedHintIDsPresent(hints *MatchHints) bool { diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index ed2af379..6af943d6 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -1,13 +1,15 @@ package metadata -import "testing" +import ( + "testing" +) func TestSelectInitialMatchCandidate_LoneResultYearMatchBelow70(t *testing.T) { // Exact title, matching year, NO sources, NO provider IDs => score 45+20 = 65 (<70). // Old behavior rejected this (single candidate <70); the new rule accepts it. hints := &MatchHints{Title: "1201", Year: 1993, Type: "movie"} cands := []MatchCandidate{{Title: "1201", Year: 1993, ContentType: "movie"}} - got, ok := selectInitialMatchCandidate(hints, cands) + got, ok := selectInitialMatchCandidate(hints, cands, nil) if !ok || got == nil || got.Title != "1201" { t.Fatalf("expected lone year-matching result to be accepted, got ok=%v cand=%+v", ok, got) } @@ -21,7 +23,7 @@ func TestSelectInitialMatchCandidate_SameShowAcrossTwoSources(t *testing.T) { {Title: "Blue Lock", Year: 2022, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "404404"}}, {Title: "Blue Lock", Year: 2022, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "120089"}}, } - got, ok := selectInitialMatchCandidate(hints, cands) + got, ok := selectInitialMatchCandidate(hints, cands, nil) if !ok || got == nil { t.Fatalf("expected same-show-across-sources to be accepted, got ok=%v cand=%+v", ok, got) } @@ -32,7 +34,7 @@ func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *test // Year does NOT corroborate, so the new rule must NOT fire; single candidate <70 => reject. hints := &MatchHints{Title: "1201", Year: 1993, Type: "movie"} cands := []MatchCandidate{{Title: "1201", Year: 1990, ContentType: "movie", Sources: []string{"tmdb"}}} - if got, ok := selectInitialMatchCandidate(hints, cands); ok { + if got, ok := selectInitialMatchCandidate(hints, cands, nil); ok { t.Fatalf("expected year-mismatch lone result to be rejected, got cand=%+v", got) } } @@ -54,7 +56,7 @@ func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) { {Title: "The Real History of the World War Europe", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "1"}}, {Title: "The Real History of the World War Pacific", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "2"}}, } - if _, ok := selectInitialMatchCandidate(hints, cands); ok { + if _, ok := selectInitialMatchCandidate(hints, cands, nil); ok { t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule") } } @@ -70,7 +72,41 @@ func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testin {Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "111"}}, {Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "222"}}, } - if _, ok := selectInitialMatchCandidate(hints, cands); ok { + if _, ok := selectInitialMatchCandidate(hints, cands, nil); ok { t.Fatal("conflicting tmdb IDs must not be auto-accepted") } } + +func TestSelectInitialMatchCandidate_CrossSourceTieResolvedByProviderPriority(t *testing.T) { + // "100 Days Wild" (2020, series, no shared IDs): TVDB and TMDB each return the correct + // show (score 83 each: 45 exact title + 20 year + 12 source + 5 has IDs + 1 richness). + // Two noise candidates (unrelated title/year) score 18 — well outside the 15-pt tie + // window of 83, so topTieGroup contains only the two correct candidates. + // candidatesAreSingleDistinctShow passes (same title/year, no conflicting IDs), + // and pickByProviderPriority selects the winner by the library's chain order. + hints := &MatchHints{Title: "100 Days Wild", Year: 2020, Type: "series"} + cands := []MatchCandidate{ + {Title: "100 Days Wild", Year: 2020, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "386908"}}, + {Title: "100 Days Wild", Year: 2020, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "109476"}}, + {Title: "Some Other Show", Year: 2026, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "476741"}}, + {Title: "Live to 100", Year: 2023, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "437829"}}, + } + + // tvdb ranked first in provider chain -> tvdb candidate wins + got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb", "tmdb"}) + if !ok || got == nil || got.ProviderIDs["tvdb"] != "386908" { + t.Fatalf("expected tvdb winner (386908), got ok=%v cand=%+v", ok, got) + } + + // tmdb ranked first in provider chain -> tmdb candidate wins + got, ok = selectInitialMatchCandidate(hints, cands, []string{"tmdb", "tvdb"}) + if !ok || got == nil || got.ProviderIDs["tmdb"] != "109476" { + t.Fatalf("expected tmdb winner (109476), got ok=%v cand=%+v", ok, got) + } + + // nil priority -> still accepts (fallback to top-scored, i.e. first in sorted order) + got, ok = selectInitialMatchCandidate(hints, cands, nil) + if !ok || got == nil { + t.Fatalf("expected nil-priority to still accept a match, got ok=%v cand=%+v", ok, got) + } +} diff --git a/internal/metadata/match_candidates_test.go b/internal/metadata/match_candidates_test.go index acb5c37e..ce4a1300 100644 --- a/internal/metadata/match_candidates_test.go +++ b/internal/metadata/match_candidates_test.go @@ -508,6 +508,7 @@ func TestSelectInitialMatchCandidate_UsesProviderOrderForExactCrossProviderTie(t Sources: []string{"tmdb"}, }, }, + nil, ) if !ok || winner == nil { t.Fatal("expected exact cross-provider tie to use provider order") @@ -540,6 +541,7 @@ func TestSelectInitialMatchCandidate_ProviderOrderTieRequiresExactTitleYear(t *t Sources: []string{"imdb", "metadb", "tmdb", "xattr"}, }, }, + nil, ) if ok || winner != nil { t.Fatal("expected non-equivalent cross-provider tie to remain unmatched") diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 33c963a7..db4d5f0a 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -902,7 +902,11 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques } candidates := NormalizeCandidates(allResults, contentType) - if winner, ok := selectInitialMatchCandidate(req.Hints, candidates); ok && winner != nil { + providerPriority := make([]string, 0, len(itemChain)) + for _, p := range itemChain { + providerPriority = append(providerPriority, p.Slug()) + } + if winner, ok := selectInitialMatchCandidate(req.Hints, candidates, providerPriority); ok && winner != nil { for k, v := range winner.ProviderIDs { if v != "" { accumulatedIDs[k] = v From c89d31d0dbc7694f562d85f68ee2a407225b1f3f Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 01:59:35 +0200 Subject: [PATCH 084/201] feat(matcher): accept cross-source-corroborated ties without a hint year When the top tie-group is one distinct show returned by 2+ distinct providers (candidatesAreSingleDistinctShow already verifies matching title+year), accept it even if the hint has no parsed year (year-less folders like '100 Deeds for Eddie McDowd'). Multi-source agreement substitutes for the year guard; lone single-source no-year results stay subject to the single-candidate >=70 gate. Thresholds unchanged. --- internal/metadata/match_candidates.go | 33 ++++++++++++--- .../metadata/match_candidates_select_test.go | 42 +++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 335f1e4b..2b0d68fd 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -430,6 +430,22 @@ func pickByProviderPriority(group []scoredMatchCandidate, providerPriority []str return &group[0].candidate } +// distinctSourceCount returns how many distinct providers (case-insensitive +// Sources values) appear across the candidate group — i.e. how many independent +// providers returned this show. +func distinctSourceCount(group []scoredMatchCandidate) int { + seen := make(map[string]struct{}) + for _, c := range group { + for _, s := range c.candidate.Sources { + s = strings.ToLower(strings.TrimSpace(s)) + if s != "" { + seen[s] = struct{}{} + } + } + } + return len(seen) +} + func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, providerPriority []string) (*MatchCandidate, bool) { if len(candidates) == 0 { return nil, false @@ -495,14 +511,19 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, // Residual risk: two different shows with an identical title+year and no // provider IDs would both pass; accepted as low-risk given the title+year+type // corroboration. - if hints.Year != 0 && best.candidate.Year == hints.Year && - candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) { + if candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) { topGroup := topTieGroup(scoredCandidates) if candidatesAreSingleDistinctShow(best.candidate, topGroup) { - // One distinct show, possibly returned by several providers and clearly - // ahead of any different show below. Accept it, choosing the winner by - // the library's metadata-provider priority (falls back to top-scored). - return pickByProviderPriority(topGroup, providerPriority), true + yearCorroborated := hints.Year != 0 && best.candidate.Year == hints.Year + // Cross-source agreement (the same title+year returned by 2+ distinct + // providers, which candidatesAreSingleDistinctShow already verified) is + // strong independent corroboration — it stands in for a missing hint year + // (folders without a "(YYYY)"). A lone single-source no-year result is NOT + // accepted here and stays subject to the single-candidate >=70 gate. + multiSourceCorroborated := distinctSourceCount(topGroup) >= 2 + if yearCorroborated || multiSourceCorroborated { + return pickByProviderPriority(topGroup, providerPriority), true + } } } if len(scoredCandidates) == 1 { diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index 6af943d6..49e23858 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -77,6 +77,48 @@ func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testin } } +func TestSelectInitialMatchCandidate_CrossSourceNoHintYearAcceptedByMultiSource(t *testing.T) { + // Hint has NO year (0). Both providers return the same show (year 1999), tied. + // Multi-source agreement substitutes for the missing hint year. + hints := &MatchHints{Title: "100 Deeds for Eddie McDowd", Year: 0, Type: "series"} + cands := []MatchCandidate{ + {Title: "100 Deeds for Eddie McDowd", Year: 1999, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "72450"}}, + {Title: "100 Deeds for Eddie McDowd", Year: 1999, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "6518"}}, + } + got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb", "tmdb"}) + if !ok || got == nil || got.ProviderIDs["tvdb"] != "72450" { + t.Fatalf("expected tvdb winner via multi-source corroboration, got ok=%v cand=%+v", ok, got) + } +} + +func TestSelectInitialMatchCandidate_CrossSourceNoCandidateYearNotAccepted(t *testing.T) { + // Both providers return the same title but neither carries a release year. + // Year-equality between two 0-years is meaningless, so + // candidatesAreSingleDistinctShow must reject and the multi-source + // corroboration arm must NOT fire — otherwise two no-year cross-source + // results would auto-accept, over-accepting ambiguous matches. + hints := &MatchHints{Title: "Untitled Show", Year: 0, Type: "series"} + cands := []MatchCandidate{ + {Title: "Untitled Show", Year: 0, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "111"}}, + {Title: "Untitled Show", Year: 0, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "222"}}, + } + if got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb", "tmdb"}); ok { + t.Fatalf("no-year cross-source candidates must not be auto-accepted, got %+v", got) + } +} + +func TestSelectInitialMatchCandidate_LoneNoYearSingleSourceNotAccepted(t *testing.T) { + // Single candidate, no hint year, single source: no year corroboration AND only + // one source -> must NOT auto-accept (falls to the single-candidate >=70 gate). + hints := &MatchHints{Title: "Some Obscure Show", Year: 0, Type: "series"} + cands := []MatchCandidate{ + {Title: "Some Obscure Show", Year: 1999, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "999"}}, + } + if got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb"}); ok { + t.Fatalf("lone no-year single-source result must not be auto-accepted, got %+v", got) + } +} + func TestSelectInitialMatchCandidate_CrossSourceTieResolvedByProviderPriority(t *testing.T) { // "100 Days Wild" (2020, series, no shared IDs): TVDB and TMDB each return the correct // show (score 83 each: 45 exact title + 20 year + 12 source + 5 has IDs + 1 richness). From 6c071e7248583523e3cabf06765da2f2a4fa2012 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 02:51:13 +0200 Subject: [PATCH 085/201] diag(matcher): debug-log provider search query + per-provider result counts Adds DEBUG logs in the ModeInitialMatch search path: each provider's result count for the query, and the assembled raw/candidate totals. Lets us see when a provider search returns zero ('no metadata found') vs a scoring/tie issue. Zero behavior change. --- internal/metadata/service.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/metadata/service.go b/internal/metadata/service.go index db4d5f0a..4b699f87 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -886,6 +886,12 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques "provider", p.Slug(), "error", err) continue } + slog.Debug("metadata: provider search result", + "provider", p.Slug(), + "query_title", searchQuery.Title, + "query_year", searchQuery.Year, + "result_count", len(results), + ) for _, result := range results { if searchResultConflictsWithTrustedIDs(accumulatedIDs, result.ProviderIDs) { slog.Warn("metadata: skipping conflicting search result", @@ -902,6 +908,12 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques } candidates := NormalizeCandidates(allResults, contentType) + slog.Debug("metadata: search candidates assembled", + "query_title", searchQuery.Title, + "query_year", searchQuery.Year, + "raw_results", len(allResults), + "candidates", len(candidates), + ) providerPriority := make([]string, 0, len(itemChain)) for _, p := range itemChain { providerPriority = append(providerPriority, p.Slug()) From 17d156a7ca9891e2f1b2cae04baefb884acdf9c8 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 03:18:03 +0200 Subject: [PATCH 086/201] fix(naming): parse bare bracketed IMDb IDs ([tt10011226]/{tt...}) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folders tagged with a bare IMDb id in brackets (Plex/Kodi style, e.g. '17 Blocks (2021) [tt10011226]') had the id silently dropped — folderIDPattern needs an 'imdb-' prefix and trailingImdbIDPattern needs an un-bracketed trailing tt-id. Recognize bracketed bare tt-ids so these items get the trusted-ID match path instead of falling to title+year scoring. --- internal/naming/folderid.go | 9 +++++++++ internal/naming/folderid_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 internal/naming/folderid_test.go diff --git a/internal/naming/folderid.go b/internal/naming/folderid.go index e9cc064c..473308ba 100644 --- a/internal/naming/folderid.go +++ b/internal/naming/folderid.go @@ -13,6 +13,11 @@ var folderIDPattern = regexp.MustCompile(`[{\[](tmdb|tmdbid|imdb|imdbid|tvdb|tvd var trailingImdbIDPattern = regexp.MustCompile(`(?i)(?:^|\s)(tt\d{7,8})$`) var trailingNumericIDPattern = regexp.MustCompile(`(?:^|\s)(\d+)$`) +// bracketedBareImdbPattern matches a bare IMDb id wrapped in brackets without a +// provider prefix, e.g. [tt10011226] or {tt0095016} (Plex/Kodi-style tags). A +// tt-prefixed number is unambiguously IMDb. +var bracketedBareImdbPattern = regexp.MustCompile(`(?i)[{\[](tt\d{7,8})[}\]]`) + // ParseStructuredFolderIDs extracts only explicit structured provider IDs from // a folder or file name, such as {tmdb-27205} or [imdbid-tt1375666}. It does // not consider trailing bare IDs or folderType-based heuristics. @@ -52,6 +57,10 @@ func ParseFolderIDs(folderName string, folderType string) *FolderIDHints { return hints } + if m := bracketedBareImdbPattern.FindStringSubmatch(folderName); m != nil { + return &FolderIDHints{ImdbID: strings.ToLower(m[1])} + } + trimmed := strings.TrimSpace(folderName) if m := trailingImdbIDPattern.FindStringSubmatch(trimmed); m != nil { return &FolderIDHints{ImdbID: strings.ToLower(m[1])} diff --git a/internal/naming/folderid_test.go b/internal/naming/folderid_test.go new file mode 100644 index 00000000..471e9417 --- /dev/null +++ b/internal/naming/folderid_test.go @@ -0,0 +1,29 @@ +package naming + +import "testing" + +func TestParseFolderIDs_BracketedBareImdb(t *testing.T) { + cases := []struct { + name, folder, wantImdb string + }{ + {"square brackets", "17 Blocks (2021) [tt10011226]", "tt10011226"}, + {"curly braces", "Some Show {tt1234567}", "tt1234567"}, + {"8-digit tt", "Movie (2024) [tt12345678]", "tt12345678"}, + } + for _, c := range cases { + got := ParseFolderIDs(c.folder, "movie") + if got == nil || got.ImdbID != c.wantImdb { + t.Errorf("%s: ParseFolderIDs(%q) = %+v, want ImdbID=%q", c.name, c.folder, got, c.wantImdb) + } + } + // must NOT change existing behavior: + if got := ParseFolderIDs("Movie [imdb-tt1375666]", "movie"); got == nil || got.ImdbID != "tt1375666" { + t.Errorf("structured imdb regressed: %+v", got) + } + if got := ParseFolderIDs("Some Movie (2020) [BB]", "movie"); got != nil { + t.Errorf("non-tt bracket tag must not parse as id, got %+v", got) + } + if got := ParseFolderIDs("17 Blocks (2021)", "movie"); got != nil { + t.Errorf("no-id folder must return nil, got %+v", got) + } +} From 416fba8dab5cd9cc98a22545b58a5ea04d82c8d2 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 04:11:17 +0200 Subject: [PATCH 087/201] feat(metadata): match sole exact-title candidate despite year off by <=2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folder years routinely differ from provider release years by a year or two (festival vs wide release, regional dates), zeroing the year bonus and leaving a lone exact-title candidate at 63-68 — just under the single-candidate >=70 gate (e.g. Dead Reckoning 1947 vs 1946, 17 Blocks 2021 vs 2019, Stasi FC). Add title corroboration to the existing lone-result rule: a sole distinct show whose normalized title exactly matches and whose year is within +/-2 is accepted. The 55 floor still rejects low-similarity titles (e.g. Hotel Transylvania Puppy! vs Puppy!). No 55/70/15 threshold change. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 20 +++++- internal/metadata/match_candidates_test.go | 81 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 2b0d68fd..d6cec77d 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -446,6 +446,14 @@ func distinctSourceCount(group []scoredMatchCandidate) int { return len(seen) } +// absYearDelta returns the absolute difference between two release years. +func absYearDelta(a, b int) int { + if a > b { + return a - b + } + return b - a +} + func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, providerPriority []string) (*MatchCandidate, bool) { if len(candidates) == 0 { return nil, false @@ -521,7 +529,17 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, // (folders without a "(YYYY)"). A lone single-source no-year result is NOT // accepted here and stays subject to the single-candidate >=70 gate. multiSourceCorroborated := distinctSourceCount(topGroup) >= 2 - if yearCorroborated || multiSourceCorroborated { + // An exact normalized-title match on a sole distinct show is strong + // corroboration on its own, even when the folder year is off by a year + // or two (festival vs wide-release date, regional release) — e.g. + // "Dead Reckoning (1947)" vs TMDB's 1946, "17 Blocks (2021)" vs 2019. + // Bounded to ±2 years so same-title remakes decades apart still require + // a year or multi-source match. Uses the same normalizer as title scoring + // so "exact" here means a perfect title-similarity component. + titleCorroborated := hints.Year != 0 && best.candidate.Year != 0 && + absYearDelta(best.candidate.Year, hints.Year) <= 2 && + normalizeTitleForScoring(best.candidate.Title) == normalizeTitleForScoring(hints.Title) + if yearCorroborated || multiSourceCorroborated || titleCorroborated { return pickByProviderPriority(topGroup, providerPriority), true } } diff --git a/internal/metadata/match_candidates_test.go b/internal/metadata/match_candidates_test.go index ce4a1300..2e206dfa 100644 --- a/internal/metadata/match_candidates_test.go +++ b/internal/metadata/match_candidates_test.go @@ -25,12 +25,85 @@ func TestSelectInitialMatchCandidate_IgnoresLocalContentIDForTrustedSelection(t Sources: []string{"tmdb"}, }, }, + nil, ) if !ok || winner == nil { t.Fatal("expected local content_id not to force trusted-ID matching") } } +func TestSelectInitialMatchCandidate_SoleExactTitleYearOffByTwoMatches(t *testing.T) { + t.Parallel() + + // Sole distinct candidate, exact title, year off by 2 (e.g. "Stasi FC (2023)" + // vs TMDB's 2025). Scores in the 55-69 band — below the single-candidate >=70 + // gate — but the exact title on a lone result should now match via title + // corroboration without lowering any threshold. + winner, ok := selectInitialMatchCandidate( + &MatchHints{Title: "Stasi FC", Year: 2023, Type: "movie"}, + []MatchCandidate{ + { + Title: "Stasi FC", + Year: 2025, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "111"}, + Sources: []string{"tmdb"}, + }, + }, + nil, + ) + if !ok || winner == nil || winner.ProviderIDs["tmdb"] != "111" { + t.Fatalf("expected sole exact-title year-off-by-2 candidate to match, got ok=%v winner=%+v", ok, winner) + } +} + +func TestSelectInitialMatchCandidate_SoleExactTitleYearOffByThreeRejected(t *testing.T) { + t.Parallel() + + // A 3-year gap exceeds the ±2 bound: a same-title film three years apart is + // not corroborated and stays subject to the single-candidate >=70 gate. + winner, ok := selectInitialMatchCandidate( + &MatchHints{Title: "Stasi FC", Year: 2023, Type: "movie"}, + []MatchCandidate{ + { + Title: "Stasi FC", + Year: 2026, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "111"}, + Sources: []string{"tmdb"}, + }, + }, + nil, + ) + if ok || winner != nil { + t.Fatalf("expected year-off-by-3 sole candidate to be rejected, got ok=%v winner=%+v", ok, winner) + } +} + +func TestSelectInitialMatchCandidate_SoleDifferentTitleExactYearStillFloored(t *testing.T) { + t.Parallel() + + // "Hotel Transylvania Puppy!" vs TMDB's "Puppy!" (same year) scores below the + // 55 floor on title similarity, so it must stay rejected — title corroboration + // must not rescue a low-similarity title just because the year matches. + winner, ok := selectInitialMatchCandidate( + &MatchHints{Title: "Hotel Transylvania Puppy!", Year: 2017, Type: "movie"}, + []MatchCandidate{ + { + Title: "Puppy!", + Year: 2017, + ContentType: "movie", + ProviderIDs: map[string]string{"tmdb": "222"}, + Sources: []string{"tmdb"}, + }, + }, + nil, + ) + if ok || winner != nil { + t.Fatalf("expected low-similarity sole candidate to stay rejected, got ok=%v winner=%+v", ok, winner) + } +} + func TestSuppressTitleYearFallbackForTrustedIDs_IgnoresMetadb(t *testing.T) { t.Parallel() @@ -377,6 +450,7 @@ func TestSelectInitialMatchCandidate_AcceptsSinglePunctuationEquivalentCandidate Sources: []string{"tmdb"}, }, }, + nil, ) if !ok || winner == nil { t.Fatalf("expected lone punctuation-equivalent candidate to be accepted") @@ -411,6 +485,7 @@ func TestSelectInitialMatchCandidate_AcceptsProviderTitleWithRepeatedYear(t *tes Sources: []string{"tmdb"}, }, }, + nil, ) if !ok || winner == nil { t.Fatal("expected provider title with repeated release year to be accepted") @@ -445,6 +520,7 @@ func TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie(t *t DetailScore: 46, }, }, + nil, ) if !ok || winner == nil { t.Fatal("expected richer duplicate TMDB candidate to be accepted") @@ -479,6 +555,7 @@ func TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap(t DetailScore: 34, }, }, + nil, ) if ok || winner != nil { t.Fatal("expected duplicate tie without clear detail gap to remain unmatched") @@ -573,6 +650,7 @@ func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie DetailScore: 80, }, }, + nil, ) if ok || winner != nil { t.Fatal("expected richer different-title candidate to be rejected") @@ -602,6 +680,7 @@ func TestSelectInitialMatchCandidate_DetailScoreRequiresDatedDuplicateCandidates DetailScore: 46, }, }, + nil, ) if ok || winner != nil { t.Fatal("expected duplicate detail tie-breaker to reject candidates without matching years") @@ -633,6 +712,7 @@ func TestSelectInitialMatchCandidate_DetailScoreRequiresHintCompatibleType(t *te DetailScore: 46, }, }, + nil, ) if ok || winner != nil { t.Fatal("expected duplicate detail tie-breaker to reject candidates with hint-incompatible type") @@ -655,6 +735,7 @@ func TestSelectInitialMatchCandidate_RejectsWeakSingleCandidate(t *testing.T) { Sources: []string{"tmdb"}, }, }, + nil, ) if ok || winner != nil { t.Fatalf("expected weak lone candidate to be rejected") From 5d6be3ad4b6a8af62afc6047421056948f278196 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 16:55:39 +0200 Subject: [PATCH 088/201] chore(lint): gofmt single-space alignment in root_inference.go var block When inferProviderTagRe was broadened to handle unsubstituted Sonarr token placeholders ({TvdbId}/{imdb-}), the regex grew long enough that gofmt prefers single-space rather than column-aligned spacing across the var block. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/naming/root_inference.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/naming/root_inference.go b/internal/naming/root_inference.go index f383d725..f5db7672 100644 --- a/internal/naming/root_inference.go +++ b/internal/naming/root_inference.go @@ -21,7 +21,7 @@ var ( // Matches a well-formed tag ([tvdb-81189]), an unsubstituted Sonarr token // ([tvdb-{TvdbId}]), or an empty token ({imdb-}). The id part is either a // {...} placeholder or zero-or-more word chars. - inferProviderTagRe = regexp.MustCompile(`\s*[{\[](?:tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-(?:\{[^}]*\}|[\w]*)[}\]]`) + inferProviderTagRe = regexp.MustCompile(`\s*[{\[](?:tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-(?:\{[^}]*\}|[\w]*)[}\]]`) ) type RootAssignment struct { From 3f49271f636fc0dcea50fc11f65ac92f17714012 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 13:09:28 -0400 Subject: [PATCH 089/201] fix(metadata): tighten matcher and bare IMDb parsing --- internal/metadata/match_candidates.go | 6 +++++- .../metadata/match_candidates_select_test.go | 20 +++++++++++++++++++ internal/naming/folderid.go | 16 ++++++--------- internal/naming/folderid_test.go | 17 ++++++++++++++++ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index d6cec77d..345b2c0c 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -539,7 +539,11 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, titleCorroborated := hints.Year != 0 && best.candidate.Year != 0 && absYearDelta(best.candidate.Year, hints.Year) <= 2 && normalizeTitleForScoring(best.candidate.Title) == normalizeTitleForScoring(hints.Title) - if yearCorroborated || multiSourceCorroborated || titleCorroborated { + // Year or source-count corroboration is only meaningful when the winning + // candidate is at least title-coherent with the scanner hint. Otherwise a + // high source/provider score can auto-accept an unrelated same-year result. + hintTitleCoherent := inferTitleSimilarity(hints.Title, best.candidate.Title, hints.Year) > 0 + if (hintTitleCoherent && (yearCorroborated || multiSourceCorroborated)) || titleCorroborated { return pickByProviderPriority(topGroup, providerPriority), true } } diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index 49e23858..699c94e4 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -39,6 +39,26 @@ func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *test } } +func TestSelectInitialMatchCandidate_CorroborationRequiresHintTitleCoherence(t *testing.T) { + // Same-year, multi-source candidate scores above the 55 floor via provider + // evidence, but the title is not coherent with the scanner hint. The lone + // result rule must not rescue it just because the year/source evidence is + // strong enough to reach the corroboration branch. + hints := &MatchHints{Title: "Hotel Transylvania Puppy!", Year: 2017, Type: "movie"} + cands := []MatchCandidate{ + { + Title: "Puppy!", + Year: 2017, + ContentType: "movie", + Sources: []string{"tmdb", "tvdb", "imdb"}, + ProviderIDs: map[string]string{"tmdb": "222"}, + }, + } + if got, ok := selectInitialMatchCandidate(hints, cands, []string{"tmdb", "tvdb", "imdb"}); ok { + t.Fatalf("unrelated same-year candidate must not be auto-accepted, got %+v", got) + } +} + func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) { // Two genuinely different shows that BOTH score >=55 (so the new rule IS evaluated, // not short-circuited by the <55 floor): candidatesAreSingleDistinctShow must return diff --git a/internal/naming/folderid.go b/internal/naming/folderid.go index 473308ba..d977648a 100644 --- a/internal/naming/folderid.go +++ b/internal/naming/folderid.go @@ -18,15 +18,11 @@ var trailingNumericIDPattern = regexp.MustCompile(`(?:^|\s)(\d+)$`) // tt-prefixed number is unambiguously IMDb. var bracketedBareImdbPattern = regexp.MustCompile(`(?i)[{\[](tt\d{7,8})[}\]]`) -// ParseStructuredFolderIDs extracts only explicit structured provider IDs from -// a folder or file name, such as {tmdb-27205} or [imdbid-tt1375666}. It does +// ParseStructuredFolderIDs extracts only explicit provider IDs from a folder or +// file name, such as {tmdb-27205}, [imdbid-tt1375666], or [tt1375666]. It does // not consider trailing bare IDs or folderType-based heuristics. func ParseStructuredFolderIDs(name string) *FolderIDHints { matches := folderIDPattern.FindAllStringSubmatch(name, -1) - if len(matches) == 0 { - return nil - } - hints := &FolderIDHints{} for _, m := range matches { provider := strings.ToLower(m[1]) @@ -42,6 +38,10 @@ func ParseStructuredFolderIDs(name string) *FolderIDHints { } } + if m := bracketedBareImdbPattern.FindStringSubmatch(name); m != nil && hints.ImdbID == "" { + hints.ImdbID = strings.ToLower(m[1]) + } + if hints.TmdbID == "" && hints.ImdbID == "" && hints.TvdbID == "" { return nil } @@ -57,10 +57,6 @@ func ParseFolderIDs(folderName string, folderType string) *FolderIDHints { return hints } - if m := bracketedBareImdbPattern.FindStringSubmatch(folderName); m != nil { - return &FolderIDHints{ImdbID: strings.ToLower(m[1])} - } - trimmed := strings.TrimSpace(folderName) if m := trailingImdbIDPattern.FindStringSubmatch(trimmed); m != nil { return &FolderIDHints{ImdbID: strings.ToLower(m[1])} diff --git a/internal/naming/folderid_test.go b/internal/naming/folderid_test.go index 471e9417..45960787 100644 --- a/internal/naming/folderid_test.go +++ b/internal/naming/folderid_test.go @@ -27,3 +27,20 @@ func TestParseFolderIDs_BracketedBareImdb(t *testing.T) { t.Errorf("no-id folder must return nil, got %+v", got) } } + +func TestParseStructuredFolderIDs_BracketedBareImdb(t *testing.T) { + got := ParseStructuredFolderIDs("17 Blocks (2021) [tt10011226]") + if got == nil || got.ImdbID != "tt10011226" { + t.Fatalf("ParseStructuredFolderIDs bare imdb = %+v, want ImdbID tt10011226", got) + } + + got = ParseStructuredFolderIDs("Some Movie {tmdb-12345} [tt7654321]") + if got == nil || got.TmdbID != "12345" || got.ImdbID != "tt7654321" { + t.Fatalf("ParseStructuredFolderIDs mixed ids = %+v, want tmdb+imdb", got) + } + + got = ParseStructuredFolderIDs("Some Movie [imdb-tt1375666] [tt7654321]") + if got == nil || got.ImdbID != "tt1375666" { + t.Fatalf("structured imdb should win over bare imdb, got %+v", got) + } +} From 9272203d8c4b98ea97025b9ab331de610d1ebcb8 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 14:51:47 -0400 Subject: [PATCH 090/201] docs: add resilient library deletion design spec Batched, deadlock-retrying rewrite of delete_library to replace the single multi-minute transaction that deadlocks on large libraries. Co-Authored-By: Claude Opus 4.7 --- ...-05-28-library-delete-resilience-design.md | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-library-delete-resilience-design.md diff --git a/docs/superpowers/specs/2026-05-28-library-delete-resilience-design.md b/docs/superpowers/specs/2026-05-28-library-delete-resilience-design.md new file mode 100644 index 00000000..1bf40e61 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-library-delete-resilience-design.md @@ -0,0 +1,143 @@ +# Resilient Library Deletion Design + +## Goal + +Make `delete_library` survive concurrent database writers and large libraries. Today it deletes an entire library inside a single multi-minute transaction; on a large library this deadlocks against ordinary background writes and rolls back, making zero progress. Rewrite the deletion to run in small, autocommitted batches with per-batch deadlock retry, so it completes reliably, holds only short locks, and resumes cleanly if interrupted. + +## Background + +A `delete_library` admin job repeatedly failed with: + +``` +deleting orphaned items: ERROR: deadlock detected (SQLSTATE 40P01) +``` + +Diagnosis on a library of ~249K items / ~339K media files: + +- The whole deletion runs in one transaction in `FolderRepository.DeleteWithStats` (`internal/catalog/folder_repo.go`): collect orphan IDs, drop the folder (CASCADE), then `DELETE FROM media_items WHERE content_id = ANY($1)`. +- `media_items` cascades to 15 child tables, so a bulk delete holds a very large lock set for minutes. +- The Postgres deadlock log shows the cycle: the delete cascade into `media_item_provider_ids` versus a concurrent `INSERT INTO media_item_provider_ids` from a routine background task. Each ~7-minute attempt was guaranteed to overlap an ordinary write and lose. +- Because it is one transaction, every failure rolls back completely: no progress, not resumable. + +The fix targets the deletion mechanism only. Coordinating or pausing background writers is explicitly out of scope (see Out of Scope) — a batched, retrying delete is robust against *any* concurrent writer, not just today's tasks. + +## Approach + +Replace the single transaction with a sequence of phases. Each phase deletes in bounded batches, and each batch is one autocommit statement wrapped in a deadlock-retry helper. Short lock windows make deadlocks rare; when one occurs, only that small batch retries. + +Scope of the rewrite: + +- Rewrite `FolderRepository.DeleteWithStats` in `internal/catalog/folder_repo.go`. +- Add an unexported `retryOnDeadlock` helper in the `catalog` package. +- `FolderRepository.Delete` (thin wrapper), the `DeleteFolderStats` struct, and the `delete_library` executor in `internal/adminjob/library_delete.go` keep their current signatures and behavior. S3 image-cleanup queueing and generated-home-section cleanup are untouched. + +## Deletion phases + +`DeleteWithStats(ctx, id, progress)` performs, in order: + +### Phase 0 — preflight (reads only) + +- Load the folder name; return `ErrFolderNotFound` if the row is gone. +- `COUNT` media files (`WHERE media_folder_id = $1`), item links (`WHERE media_folder_id = $1`), and orphans, to populate the result payload and the progress denominator. + +### Phase 1 — orphaned `media_items` (batched loop) + +Repeat until no rows are returned: + +```sql +SELECT mil.content_id +FROM media_item_libraries mil +WHERE mil.media_folder_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM media_item_libraries other + WHERE other.content_id = mil.content_id + AND other.media_folder_id <> $1 + ) +LIMIT $2 -- orphanDeleteBatch +``` + +For each returned batch: collect S3 image dirs for those IDs (existing `collectImageDirs`, accumulated into a deduped set), then + +```sql +DELETE FROM media_items WHERE content_id = ANY($1) +``` + +The FK cascade removes each item's `media_item_libraries` (including this folder's), `media_item_provider_ids`, episodes, seasons, embeddings, and the rest of the 15 child tables. Deleting orphan items first also clears their junction rows, so the next iteration's detection query naturally advances. Orphan detection must run before Phase 3 touches shared junctions, so the "only in this folder" test stays valid. + +### Phase 2 — folder `media_files` (batched loop) + +`media_files` is folder-tied (`media_folder_id` FK, `ON DELETE CASCADE`), not item-tied, so it is deleted by folder. Repeat until a batch deletes fewer than the batch size: + +```sql +DELETE FROM media_files +WHERE id IN ( + SELECT id FROM media_files WHERE media_folder_id = $1 LIMIT $2 -- folderChildDeleteBatch +) +``` + +### Phase 3 — remaining shared-item junctions (batched loop) + +After Phase 1, the only `media_item_libraries` rows left for this folder belong to items that also live in other folders. Delete them in batches (dropping the membership only, never the shared item): + +```sql +DELETE FROM media_item_libraries +WHERE ctid IN ( + SELECT ctid FROM media_item_libraries WHERE media_folder_id = $1 LIMIT $2 -- folderChildDeleteBatch +) +``` + +### Phase 4 — folder row + +```sql +DELETE FROM media_folders WHERE id = $1 +``` + +Now lightweight. Tolerates 0 rows affected (a resumed re-run may already have removed it) — it does not re-raise `ErrFolderNotFound` here. The folder row is deleted **last**, so a partial deletion leaves the library present-but-disabled and a re-run continues. + +## Deadlock-retry helper + +New unexported helper in the `catalog` package: + +```go +func retryOnDeadlock(ctx context.Context, op func() error) error +``` + +- Retries when the error is a `*pgconn.PgError` with code `40P01` (deadlock) or `40001` (serialization failure). +- Up to 5 attempts, exponential backoff starting at 50ms, doubling each retry. +- Honors `ctx` cancellation between attempts (returns `ctx.Err()`). +- Any non-retryable error returns immediately; the final retryable error is returned after the cap. + +Every batch statement runs through `retryOnDeadlock`. It has a single caller today, so it stays package-local; it can be promoted to a shared DB utility if a second caller appears. + +## Batch sizes + +Named constants in `internal/catalog/folder_repo.go`, tunable: + +- `orphanDeleteBatch = 1000` — small, because each `media_items` row cascades across 15 tables. +- `folderChildDeleteBatch = 5000` — for the lighter folder-scoped `media_files` and junction deletes. + +## Transaction model, stats, progress + +- No surrounding transaction. Each batch is a single `pool.Exec` (its own implicit transaction), mirroring the existing batched-cleanup pattern in `internal/opslog/cleanup.go`. This is the core change that eliminates the multi-minute lock window. +- Stats: `MediaFiles` and `MediaItemLinks` come from the Phase 0 counts; `OrphanedItems` accumulates across Phase 1 batches; `OrphanedImageDirs` is the accumulated deduped dir set. Image-dir volume for the affected workloads is modest; the deduped set is the bounded-memory point of note. +- Progress: the callback reports orphan items processed against the Phase 0 orphan total, plus phase-transition messages. The admin-job runner's existing 10s heartbeat keeps the job alive across the drain. + +## Error handling & resumability + +- Because batches autocommit, a failure or cancellation persists partial progress. There is no rollback of hundreds of thousands of rows and no long lock hold. +- The job is resumable by re-running it: the folder still exists (deleted last), and Phase 1 re-detects whatever orphans remain. +- New items inserted concurrently during deletion are picked up by the loop's re-detection. + +## Testing + +The `catalog` package uses pure-Go unit tests; there is no Postgres integration harness, so tests avoid a live database. + +- **`retryOnDeadlock` unit test:** a fake `op` returns a `*pgconn.PgError{Code: "40P01"}` N times then `nil` — assert it retries, respects the attempt cap, backs off, and returns immediately on a non-retryable error; a separate case asserts `ctx` cancellation stops retries. +- **Batch-loop unit test:** extract the "loop until a batch is under the batch size" iteration behind a small injected exec function so the looping/chunking logic is testable without Postgres. +- **Integration verification on dev:** deploy and re-trigger the deletion against the real stuck library; confirm it drains to completion via application logs and database row counts. This is the integration test, given the absence of a catalog DB harness. (Commands assume the repository root is the cwd and follow the deployment-debugging runbook.) + +## Out of scope + +- Pausing or coordinating background writers (e.g., having metadata/scan tasks skip disabled or pending-deletion libraries). The batched, retrying delete is robust against any concurrent writer; writer coordination is a separate potential improvement. +- Changing the admin-job runner, the executor's image-cleanup queueing, or home-section cleanup. +- Schema or FK changes. From 1ab4e1f26909b85e7a8af29a6757b139e6a4be5d Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 15:03:17 -0400 Subject: [PATCH 091/201] docs: add implementation plan for resilient library deletion Co-Authored-By: Claude Opus 4.7 --- .../2026-05-28-library-delete-resilience.md | 724 ++++++++++++++++++ 1 file changed, 724 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-library-delete-resilience.md diff --git a/docs/superpowers/plans/2026-05-28-library-delete-resilience.md b/docs/superpowers/plans/2026-05-28-library-delete-resilience.md new file mode 100644 index 00000000..e8211bd2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-library-delete-resilience.md @@ -0,0 +1,724 @@ +# Resilient Library Deletion 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:** Rewrite `delete_library` so it deletes a library in small, autocommitted, deadlock-retrying batches instead of one multi-minute transaction that deadlocks and rolls back on large libraries. + +**Architecture:** `FolderRepository.DeleteWithStats` becomes a phased, batched operation (orphan items → folder media_files → remaining shared junctions → folder row). Each batch is one autocommit statement wrapped in a `retryOnDeadlock` helper, so locks are held briefly and the job is resumable. Two small helpers (`retryOnDeadlock`, `deleteInBatches`) are unit-tested in pure Go; the full path is verified against the live dev database. + +**Tech Stack:** Go, pgx v5 (`pgxpool`, `pgconn`), PostgreSQL. Spec: `docs/superpowers/specs/2026-05-28-library-delete-resilience-design.md`. + +All commands assume the repository root is the cwd. + +--- + +## File Structure + +- **Modify** `internal/catalog/folder_repo.go` + - Add constants `orphanDeleteBatch`, `folderChildDeleteBatch`. + - Add package vars `deadlockMaxAttempts`, `deadlockBaseBackoff` and func `retryOnDeadlock`. + - Add func `deleteInBatches`. + - Add interface `rowQuerier`; change `collectImageDirs` and `filterUnreferencedImageDirs` to take `rowQuerier` instead of `pgx.Tx`; extract `collectRawImageDirs`. + - Rewrite `DeleteWithStats`; add helpers `collectOrphanBatch`, `dirSetToSlice`. + - Leave `collectOrphanIDs` and `Delete` unchanged. +- **Create** `internal/catalog/folder_delete_test.go` — pure-Go unit tests for `retryOnDeadlock` and `deleteInBatches`. +- **Do not modify** `internal/catalog/library_repo.go` — its `collectImageDirs`/`collectOrphanIDs` calls keep compiling because `pgx.Tx` satisfies `rowQuerier`. (Verified by build.) +- **Unchanged behavior** in `internal/adminjob/library_delete.go` (executor), `internal/adminjob/runner.go` (heartbeat). + +--- + +## Task 1: Deadlock-retry helper + +**Files:** +- Modify: `internal/catalog/folder_repo.go` +- Test: `internal/catalog/folder_delete_test.go` (create) + +- [ ] **Step 1: Write the failing tests** + +Create `internal/catalog/folder_delete_test.go`: + +```go +package catalog + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgconn" +) + +// withFastDeadlockRetry shrinks retry timing/attempts for tests and restores +// the originals on cleanup. Tests using it must not call t.Parallel(). +func withFastDeadlockRetry(t *testing.T, maxAttempts int) { + t.Helper() + oldMax, oldBackoff := deadlockMaxAttempts, deadlockBaseBackoff + deadlockMaxAttempts = maxAttempts + deadlockBaseBackoff = time.Millisecond + t.Cleanup(func() { + deadlockMaxAttempts = oldMax + deadlockBaseBackoff = oldBackoff + }) +} + +func TestRetryOnDeadlockRetriesThenSucceeds(t *testing.T) { + withFastDeadlockRetry(t, 5) + calls := 0 + err := retryOnDeadlock(context.Background(), func() error { + calls++ + if calls < 3 { + return &pgconn.PgError{Code: "40P01"} + } + return nil + }) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if calls != 3 { + t.Fatalf("expected 3 calls, got %d", calls) + } +} + +func TestRetryOnDeadlockReturnsNonRetryableImmediately(t *testing.T) { + withFastDeadlockRetry(t, 5) + sentinel := errors.New("boom") + calls := 0 + err := retryOnDeadlock(context.Background(), func() error { + calls++ + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel, got %v", err) + } + if calls != 1 { + t.Fatalf("expected 1 call, got %d", calls) + } +} + +func TestRetryOnDeadlockGivesUpAfterMaxAttempts(t *testing.T) { + withFastDeadlockRetry(t, 4) + calls := 0 + err := retryOnDeadlock(context.Background(), func() error { + calls++ + return &pgconn.PgError{Code: "40P01"} + }) + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Code != "40P01" { + t.Fatalf("expected 40P01 pg error, got %v", err) + } + if calls != 4 { + t.Fatalf("expected 4 calls, got %d", calls) + } +} + +func TestRetryOnDeadlockStopsOnCanceledContext(t *testing.T) { + withFastDeadlockRetry(t, 5) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + calls := 0 + err := retryOnDeadlock(ctx, func() error { + calls++ + return &pgconn.PgError{Code: "40P01"} + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + if calls != 1 { + t.Fatalf("expected 1 call before cancel, got %d", calls) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/catalog/ -run TestRetryOnDeadlock -v` +Expected: compile failure / FAIL — `undefined: retryOnDeadlock`, `deadlockMaxAttempts`, `deadlockBaseBackoff`. + +- [ ] **Step 3: Implement the helper** + +In `internal/catalog/folder_repo.go`, add near the top of the file (after the `import` block, before the sentinel errors var is fine): + +```go +// Retry parameters for transient serialization/deadlock failures. They are +// package vars (not consts) only so tests can shrink them; production code +// never mutates them. +var ( + deadlockMaxAttempts = 5 + deadlockBaseBackoff = 50 * time.Millisecond +) + +// retryOnDeadlock runs op, retrying when Postgres reports a deadlock (40P01) or +// serialization failure (40001), with exponential backoff. It returns +// immediately for any other error, and honors context cancellation between +// attempts. +func retryOnDeadlock(ctx context.Context, op func() error) error { + backoff := deadlockBaseBackoff + for attempt := 1; ; attempt++ { + err := op() + if err == nil { + return nil + } + var pgErr *pgconn.PgError + if attempt < deadlockMaxAttempts && errors.As(err, &pgErr) && + (pgErr.Code == "40P01" || pgErr.Code == "40001") { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + backoff *= 2 + continue + } + return err + } +} +``` + +(`context`, `errors`, `time`, and `github.com/jackc/pgx/v5/pgconn` are already imported in this file.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/catalog/ -run TestRetryOnDeadlock -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Format and commit** + +```bash +gofmt -w internal/catalog/folder_repo.go internal/catalog/folder_delete_test.go +git add internal/catalog/folder_repo.go internal/catalog/folder_delete_test.go +git commit -m "feat(catalog): add deadlock-retry helper for batched deletes" +``` + +--- + +## Task 2: Batched-delete loop helper + +**Files:** +- Modify: `internal/catalog/folder_repo.go` +- Test: `internal/catalog/folder_delete_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/catalog/folder_delete_test.go`: + +```go +func TestDeleteInBatchesLoopsUntilUnderBatchSize(t *testing.T) { + withFastDeadlockRetry(t, 5) + counts := []int64{5, 5, 2} + idx := 0 + total, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) { + n := counts[idx] + idx++ + return n, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if total != 12 { + t.Fatalf("expected total 12, got %d", total) + } + if idx != 3 { + t.Fatalf("expected 3 batches, got %d", idx) + } +} + +func TestDeleteInBatchesStopsImmediatelyWhenFirstBatchUnderSize(t *testing.T) { + withFastDeadlockRetry(t, 5) + calls := 0 + total, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) { + calls++ + return 0, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if total != 0 || calls != 1 { + t.Fatalf("expected total 0 and 1 call, got total=%d calls=%d", total, calls) + } +} + +func TestDeleteInBatchesReturnsError(t *testing.T) { + withFastDeadlockRetry(t, 5) + sentinel := errors.New("delete failed") + _, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) { + return 0, sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel, got %v", err) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/catalog/ -run TestDeleteInBatches -v` +Expected: FAIL — `undefined: deleteInBatches`. + +- [ ] **Step 3: Implement the helper** + +In `internal/catalog/folder_repo.go`, add after `retryOnDeadlock`: + +```go +// deleteInBatches repeatedly runs deleteBatch (each a single autocommit +// statement) until a batch removes fewer than batchSize rows. Each batch is +// retried on deadlock. It returns the total number of rows deleted. +func deleteInBatches( + ctx context.Context, + batchSize int, + deleteBatch func(ctx context.Context) (int64, error), +) (int64, error) { + var total int64 + for { + var affected int64 + if err := retryOnDeadlock(ctx, func() error { + n, e := deleteBatch(ctx) + affected = n + return e + }); err != nil { + return total, err + } + total += affected + if affected < int64(batchSize) { + return total, nil + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/catalog/ -run TestDeleteInBatches -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Format and commit** + +```bash +gofmt -w internal/catalog/folder_repo.go internal/catalog/folder_delete_test.go +git add internal/catalog/folder_repo.go internal/catalog/folder_delete_test.go +git commit -m "feat(catalog): add deleteInBatches loop helper" +``` + +--- + +## Task 3: Generalize image-dir helpers to a querier interface + +This lets the image-dir helpers run on either a `*pgxpool.Pool` (new batched path) or a `pgx.Tx` (existing `library_repo.go` path), and splits raw collection from filtering so the batched path can collect cheaply per batch and filter once. + +**Files:** +- Modify: `internal/catalog/folder_repo.go:547-635` (the `collectImageDirs` / `filterUnreferencedImageDirs` block) + +- [ ] **Step 1: Add the `rowQuerier` interface** + +In `internal/catalog/folder_repo.go`, add (near the other type declarations, e.g. just above `collectOrphanIDs`): + +```go +// rowQuerier is satisfied by both *pgxpool.Pool and pgx.Tx, letting read +// helpers run inside or outside an explicit transaction. +type rowQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) +} +``` + +- [ ] **Step 2: Split `collectImageDirs` into raw collection + filtering** + +Replace the existing `collectImageDirs` function (currently `internal/catalog/folder_repo.go:549-584`) with these two functions: + +```go +// collectImageDirs returns S3 directory prefixes for images belonging to the +// given content IDs that are not still referenced by other surviving content. +func collectImageDirs(ctx context.Context, q rowQuerier, contentIDs []string) ([]string, error) { + dirs, err := collectRawImageDirs(ctx, q, contentIDs) + if err != nil { + return nil, err + } + return filterUnreferencedImageDirs(ctx, q, dirs, contentIDs) +} + +// collectRawImageDirs returns the deduped S3 directory prefixes referenced by +// the given content IDs (items, their seasons, and their episodes), without +// filtering out dirs still used by other content. +func collectRawImageDirs(ctx context.Context, q rowQuerier, contentIDs []string) ([]string, error) { + imgRows, err := q.Query(ctx, ` + SELECT poster_path, backdrop_path, logo_path FROM media_items WHERE content_id = ANY($1) + UNION ALL + SELECT poster_path, '', '' FROM seasons WHERE series_id = ANY($1) + UNION ALL + SELECT still_path, '', '' FROM episodes WHERE series_id = ANY($1) + `, contentIDs) + if err != nil { + return nil, fmt.Errorf("collecting image paths: %w", err) + } + defer imgRows.Close() + dirSet := make(map[string]struct{}) + for imgRows.Next() { + var p1, p2, p3 string + if err := imgRows.Scan(&p1, &p2, &p3); err != nil { + return nil, fmt.Errorf("scanning image path: %w", err) + } + for _, p := range []string{p1, p2, p3} { + if p != "" && !strings.Contains(p, "://") { + if dir := pathDir(p); dir != "" { + dirSet[dir] = struct{}{} + } + } + } + } + if err := imgRows.Err(); err != nil { + return nil, fmt.Errorf("iterating image paths: %w", err) + } + dirs := make([]string, 0, len(dirSet)) + for dir := range dirSet { + dirs = append(dirs, dir) + } + return dirs, nil +} +``` + +- [ ] **Step 3: Change `filterUnreferencedImageDirs` to take `rowQuerier`** + +In `internal/catalog/folder_repo.go`, change the signature (currently `internal/catalog/folder_repo.go:586`) from: + +```go +func filterUnreferencedImageDirs(ctx context.Context, tx pgx.Tx, dirs, deletingContentIDs []string) ([]string, error) { +``` + +to: + +```go +func filterUnreferencedImageDirs(ctx context.Context, q rowQuerier, dirs, deletingContentIDs []string) ([]string, error) { +``` + +and change the single `tx.Query(ctx, ...)` call inside it to `q.Query(ctx, ...)`. Leave the SQL and the rest of the body unchanged. + +- [ ] **Step 4: Verify the build (proves `library_repo.go` still compiles with `pgx.Tx`)** + +Run: `go build ./... && go vet ./internal/catalog/` +Expected: no errors. (`library_repo.go` passes a `pgx.Tx` to `collectImageDirs`, which now accepts `rowQuerier`; `pgx.Tx` satisfies it.) + +- [ ] **Step 5: Run the catalog tests** + +Run: `go test ./internal/catalog/...` +Expected: PASS (existing tests plus Tasks 1–2 helpers). + +- [ ] **Step 6: Format and commit** + +```bash +gofmt -w internal/catalog/folder_repo.go +git add internal/catalog/folder_repo.go +git commit -m "refactor(catalog): make image-dir helpers querier-agnostic" +``` + +--- + +## Task 4: Rewrite `DeleteWithStats` as a batched, phased operation + +**Files:** +- Modify: `internal/catalog/folder_repo.go:415-511` (the `DeleteWithStats` body) and add two helpers + two constants. + +- [ ] **Step 1: Add the batch-size constants** + +In `internal/catalog/folder_repo.go`, add near the retry vars from Task 1: + +```go +const ( + // orphanDeleteBatch is small because each media_items row cascades across + // ~15 child tables. + orphanDeleteBatch = 1000 + // folderChildDeleteBatch covers the lighter folder-scoped media_files and + // junction deletes. + folderChildDeleteBatch = 5000 +) +``` + +- [ ] **Step 2: Replace the `DeleteWithStats` body** + +Replace the entire existing `DeleteWithStats` function (`internal/catalog/folder_repo.go:415-511`) with: + +```go +func (r *FolderRepository) DeleteWithStats( + ctx context.Context, + id int, + progress func(current, total int, message string), +) (*DeleteFolderStats, error) { + stats := &DeleteFolderStats{} + + // Phase 0: preflight reads (no long-lived transaction). + if err := r.pool.QueryRow(ctx, `SELECT name FROM media_folders WHERE id = $1`, id).Scan(&stats.LibraryName); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrFolderNotFound + } + return nil, fmt.Errorf("loading folder before delete: %w", err) + } + if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM media_files WHERE media_folder_id = $1`, id).Scan(&stats.MediaFiles); err != nil { + return nil, fmt.Errorf("counting media files: %w", err) + } + if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM media_item_libraries WHERE media_folder_id = $1`, id).Scan(&stats.MediaItemLinks); err != nil { + return nil, fmt.Errorf("counting media item links: %w", err) + } + var orphanTotal int + if err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM media_item_libraries mil + WHERE mil.media_folder_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM media_item_libraries other + WHERE other.content_id = mil.content_id + AND other.media_folder_id <> $1 + )`, id).Scan(&orphanTotal); err != nil { + return nil, fmt.Errorf("counting orphaned items: %w", err) + } + + // Phase 1: delete orphaned media_items in detect-then-delete batches. + // Cascade removes their junctions, provider IDs, episodes, seasons, etc. + if progress != nil { + progress(0, orphanTotal, "Deleting orphaned items") + } + rawDirs := make(map[string]struct{}) + for { + ids, err := r.collectOrphanBatch(ctx, id, orphanDeleteBatch) + if err != nil { + return nil, err + } + if len(ids) == 0 { + break + } + dirs, err := collectRawImageDirs(ctx, r.pool, ids) + if err != nil { + return nil, err + } + for _, d := range dirs { + rawDirs[d] = struct{}{} + } + if err := retryOnDeadlock(ctx, func() error { + _, e := r.pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids) + return e + }); err != nil { + return nil, fmt.Errorf("deleting orphaned items: %w", err) + } + stats.OrphanedItems += len(ids) + if progress != nil { + progress(stats.OrphanedItems, orphanTotal, "Deleting orphaned items") + } + } + + // Filter accumulated image dirs once, now that orphans are gone, against + // any surviving content. Empty deleting-set means "exclude nothing". + if len(rawDirs) > 0 { + filtered, err := filterUnreferencedImageDirs(ctx, r.pool, dirSetToSlice(rawDirs), []string{}) + if err != nil { + return nil, err + } + stats.OrphanedImageDirs = filtered + } + + // Phase 2: delete this folder's media_files (folder-tied, not item-tied). + if progress != nil { + progress(orphanTotal, orphanTotal, "Deleting media files") + } + if _, err := deleteInBatches(ctx, folderChildDeleteBatch, func(ctx context.Context) (int64, error) { + tag, e := r.pool.Exec(ctx, ` + DELETE FROM media_files + WHERE id IN ( + SELECT id FROM media_files WHERE media_folder_id = $1 LIMIT $2 + )`, id, folderChildDeleteBatch) + if e != nil { + return 0, e + } + return tag.RowsAffected(), nil + }); err != nil { + return nil, fmt.Errorf("deleting media files: %w", err) + } + + // Phase 3: delete remaining folder memberships (shared items kept; only the + // membership in this folder is removed). + if progress != nil { + progress(orphanTotal, orphanTotal, "Removing library memberships") + } + if _, err := deleteInBatches(ctx, folderChildDeleteBatch, func(ctx context.Context) (int64, error) { + tag, e := r.pool.Exec(ctx, ` + DELETE FROM media_item_libraries + WHERE ctid IN ( + SELECT ctid FROM media_item_libraries WHERE media_folder_id = $1 LIMIT $2 + )`, id, folderChildDeleteBatch) + if e != nil { + return 0, e + } + return tag.RowsAffected(), nil + }); err != nil { + return nil, fmt.Errorf("deleting media item links: %w", err) + } + + // Phase 4: delete the now-lightweight folder row. Tolerate 0 rows so a + // resumed run that already removed it still succeeds. + if err := retryOnDeadlock(ctx, func() error { + _, e := r.pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, id) + return e + }); err != nil { + return nil, fmt.Errorf("deleting folder: %w", err) + } + + if progress != nil { + progress(orphanTotal, orphanTotal, "Library deletion completed") + } + return stats, nil +} + +// collectOrphanBatch returns up to limit content IDs whose only library +// membership is the given folder. +func (r *FolderRepository) collectOrphanBatch(ctx context.Context, folderID, limit int) ([]string, error) { + rows, err := r.pool.Query(ctx, ` + SELECT mil.content_id + FROM media_item_libraries mil + WHERE mil.media_folder_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM media_item_libraries other + WHERE other.content_id = mil.content_id + AND other.media_folder_id <> $1 + ) + LIMIT $2`, folderID, limit) + if err != nil { + return nil, fmt.Errorf("finding orphaned items: %w", err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var contentID string + if err := rows.Scan(&contentID); err != nil { + return nil, fmt.Errorf("scanning orphan content_id: %w", err) + } + ids = append(ids, contentID) + } + return ids, rows.Err() +} + +// dirSetToSlice returns the keys of set as a slice (nil if empty). +func dirSetToSlice(set map[string]struct{}) []string { + if len(set) == 0 { + return nil + } + dirs := make([]string, 0, len(set)) + for d := range set { + dirs = append(dirs, d) + } + return dirs +} +``` + +Note: this removes the old single-transaction body (`tx, err := r.pool.Begin(...)`, the inline orphan-collection loop, the `collectImageDirs` call, the CASCADE folder drop, and `tx.Commit`). `collectOrphanIDs` and `Delete` are untouched. `collectImageDirs` remains (still used by `library_repo.go`). + +- [ ] **Step 3: Verify the build and vet** + +Run: `go build ./... && go vet ./internal/catalog/` +Expected: no errors. If `go vet` flags an unused function, confirm it is genuinely unused before removing — `collectImageDirs`, `collectOrphanIDs`, and `pathDir` must all remain (still referenced). + +- [ ] **Step 4: Run the full catalog + adminjob test suites** + +Run: `go test ./internal/catalog/... ./internal/adminjob/...` +Expected: PASS. (No DB-backed test exercises `DeleteWithStats`; the helper unit tests and existing tests must stay green.) + +- [ ] **Step 5: Lint** + +Run: `golangci-lint run ./internal/catalog/...` +Expected: no findings. (If `golangci-lint` is unavailable, run `gofmt -l internal/catalog/` and ensure it prints nothing.) + +- [ ] **Step 6: Format and commit** + +```bash +gofmt -w internal/catalog/folder_repo.go +git add internal/catalog/folder_repo.go +git commit -m "fix(catalog): delete libraries in deadlock-retrying batches + +Replaces the single multi-minute delete transaction with phased, batched +autocommit deletes (orphan items, media files, memberships, folder row), +each retried on deadlock. Holds only short locks, survives concurrent +writers, and is resumable on failure." +``` + +--- + +## Task 5: Verify on the dev server + +This is the integration test (no catalog DB harness exists). Follow the deployment-debugging runbook. The Audiobooks library (folder id 8) is currently stuck with ~249K items / ~339K files. + +- [ ] **Step 1: Deploy the branch to dev** + +Run: `make dev-deploy` +Expected: builds `silo:dev-local`, recreates the `silo` service. Then confirm health: +`ssh "$DEV_HOST" 'curl -s http://localhost:8090/api/v1/ready'` + +- [ ] **Step 2: Capture the starting counts** + +Run (psql via the dev compose `postgres` service, user/db `continuum`): + +```sql +SELECT + (SELECT COUNT(*) FROM media_files WHERE media_folder_id = 8) AS files, + (SELECT COUNT(*) FROM media_item_libraries WHERE media_folder_id = 8) AS links, + (SELECT EXISTS (SELECT 1 FROM media_folders WHERE id = 8)) AS folder_exists; +``` + +Expected: non-zero files/links, `folder_exists = t`. + +- [ ] **Step 3: Trigger the deletion** + +Re-trigger the library deletion through the normal path (admin UI "delete library" for Audiobooks, or the admin delete endpoint). Confirm a new job is queued: + +```sql +SELECT id, job_type, status, requested_at +FROM admin_jobs WHERE job_type = 'delete_library' +ORDER BY requested_at DESC LIMIT 3; +``` + +- [ ] **Step 4: Monitor to completion** + +Watch the job and the draining counts (re-run periodically): + +```sql +SELECT id, status, progress_current, progress_total, error_message, heartbeat_at +FROM admin_jobs WHERE job_type = 'delete_library' +ORDER BY requested_at DESC LIMIT 1; +``` + +```sql +SELECT COUNT(*) FROM media_item_libraries WHERE media_folder_id = 8; +``` + +Also confirm deadlock retries (if any) are recovered rather than fatal: +`ssh "$DEV_HOST" "$DEV_COMPOSE_CMD logs postgres 2>&1 | grep -i deadlock | tail -20"` +Expected: the job reaches `status = completed`; counts trend to 0; any deadlocks are transient (the job does not fail). + +- [ ] **Step 5: Confirm full removal** + +```sql +SELECT + (SELECT COUNT(*) FROM media_files WHERE media_folder_id = 8) AS files, + (SELECT COUNT(*) FROM media_item_libraries WHERE media_folder_id = 8) AS links, + (SELECT EXISTS (SELECT 1 FROM media_folders WHERE id = 8)) AS folder_exists; +``` + +Expected: `files = 0`, `links = 0`, `folder_exists = f`. + +- [ ] **Step 6: Report** + +Summarize: starting counts, time to completion, number of batches/retries observed, and final state. No commit (verification only). + +--- + +## Self-Review + +**Spec coverage:** +- Phased batched deletion (Phases 0–4) → Task 4. ✔ +- `retryOnDeadlock` (40P01/40001, 5 attempts, backoff, ctx-aware) → Task 1. ✔ +- Batch sizes `orphanDeleteBatch=1000`, `folderChildDeleteBatch=5000` → Task 4 Step 1. ✔ +- Autocommit per batch / no long transaction → Task 4 Step 2 (uses `r.pool.Exec`, no `Begin`). ✔ +- Stats + progress + heartbeat reliance → Task 4 Step 2; runner heartbeat unchanged. ✔ +- Resumability (folder row last, tolerate 0 rows) → Task 4 Phase 4. ✔ +- Unit tests for retry + batch loop → Tasks 1–2. ✔ +- `collectImageDirs` shared with `library_repo.go` preserved → Task 3 (interface widening), Task 4 Step 3 build check. ✔ +- Dev integration verification → Task 5. ✔ +- Out of scope (writer coordination, runner/executor changes, schema changes) → not present in any task. ✔ + +**Placeholder scan:** No TBD/TODO; every code step has complete code; every command has expected output. ✔ + +**Type consistency:** `retryOnDeadlock(ctx, func() error) error`, `deleteInBatches(ctx, int, func(ctx) (int64,error)) (int64,error)`, `rowQuerier{ Query(ctx,string,...any)(pgx.Rows,error) }`, `collectRawImageDirs`/`collectImageDirs`/`filterUnreferencedImageDirs(ctx, rowQuerier, ...)`, `collectOrphanBatch(ctx,int,int)([]string,error)`, `dirSetToSlice(map[string]struct{})[]string`, consts `orphanDeleteBatch`/`folderChildDeleteBatch`, vars `deadlockMaxAttempts`/`deadlockBaseBackoff` — names used consistently across tasks. ✔ From e159f0b4ba797fc3b6d8bb88db6329360376be0f Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 15:06:33 -0400 Subject: [PATCH 092/201] feat(catalog): add deadlock-retry helper for batched deletes --- internal/catalog/folder_delete_test.go | 90 ++++++++++++++++++++++++++ internal/catalog/folder_repo.go | 34 ++++++++++ 2 files changed, 124 insertions(+) create mode 100644 internal/catalog/folder_delete_test.go diff --git a/internal/catalog/folder_delete_test.go b/internal/catalog/folder_delete_test.go new file mode 100644 index 00000000..5bf1a88d --- /dev/null +++ b/internal/catalog/folder_delete_test.go @@ -0,0 +1,90 @@ +package catalog + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgconn" +) + +// withFastDeadlockRetry shrinks retry timing/attempts for tests and restores +// the originals on cleanup. Tests using it must not call t.Parallel(). +func withFastDeadlockRetry(t *testing.T, maxAttempts int) { + t.Helper() + oldMax, oldBackoff := deadlockMaxAttempts, deadlockBaseBackoff + deadlockMaxAttempts = maxAttempts + deadlockBaseBackoff = time.Millisecond + t.Cleanup(func() { + deadlockMaxAttempts = oldMax + deadlockBaseBackoff = oldBackoff + }) +} + +func TestRetryOnDeadlockRetriesThenSucceeds(t *testing.T) { + withFastDeadlockRetry(t, 5) + calls := 0 + err := retryOnDeadlock(context.Background(), func() error { + calls++ + if calls < 3 { + return &pgconn.PgError{Code: "40P01"} + } + return nil + }) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if calls != 3 { + t.Fatalf("expected 3 calls, got %d", calls) + } +} + +func TestRetryOnDeadlockReturnsNonRetryableImmediately(t *testing.T) { + withFastDeadlockRetry(t, 5) + sentinel := errors.New("boom") + calls := 0 + err := retryOnDeadlock(context.Background(), func() error { + calls++ + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel, got %v", err) + } + if calls != 1 { + t.Fatalf("expected 1 call, got %d", calls) + } +} + +func TestRetryOnDeadlockGivesUpAfterMaxAttempts(t *testing.T) { + withFastDeadlockRetry(t, 4) + calls := 0 + err := retryOnDeadlock(context.Background(), func() error { + calls++ + return &pgconn.PgError{Code: "40P01"} + }) + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Code != "40P01" { + t.Fatalf("expected 40P01 pg error, got %v", err) + } + if calls != 4 { + t.Fatalf("expected 4 calls, got %d", calls) + } +} + +func TestRetryOnDeadlockStopsOnCanceledContext(t *testing.T) { + withFastDeadlockRetry(t, 5) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + calls := 0 + err := retryOnDeadlock(ctx, func() error { + calls++ + return &pgconn.PgError{Code: "40P01"} + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + if calls != 1 { + t.Fatalf("expected 1 call before cancel, got %d", calls) + } +} diff --git a/internal/catalog/folder_repo.go b/internal/catalog/folder_repo.go index 1a40bca7..fcf9859a 100644 --- a/internal/catalog/folder_repo.go +++ b/internal/catalog/folder_repo.go @@ -14,6 +14,40 @@ import ( "github.com/Silo-Server/silo-server/internal/models" ) +// Retry parameters for transient serialization/deadlock failures. They are +// package vars (not consts) only so tests can shrink them; production code +// never mutates them. +var ( + deadlockMaxAttempts = 5 + deadlockBaseBackoff = 50 * time.Millisecond +) + +// retryOnDeadlock runs op, retrying when Postgres reports a deadlock (40P01) or +// serialization failure (40001), with exponential backoff. It returns +// immediately for any other error, and honors context cancellation between +// attempts. +func retryOnDeadlock(ctx context.Context, op func() error) error { + backoff := deadlockBaseBackoff + for attempt := 1; ; attempt++ { + err := op() + if err == nil { + return nil + } + var pgErr *pgconn.PgError + if attempt < deadlockMaxAttempts && errors.As(err, &pgErr) && + (pgErr.Code == "40P01" || pgErr.Code == "40001") { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + backoff *= 2 + continue + } + return err + } +} + // Sentinel errors for folder repository operations. var ( ErrFolderNotFound = errors.New("folder not found") From 485c6e28fd1d21580cf33e36655928b9690c3082 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 15:25:17 -0400 Subject: [PATCH 093/201] test(catalog): clarify cancel-path expectation in retry test --- internal/catalog/folder_delete_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/catalog/folder_delete_test.go b/internal/catalog/folder_delete_test.go index 5bf1a88d..7133ccd9 100644 --- a/internal/catalog/folder_delete_test.go +++ b/internal/catalog/folder_delete_test.go @@ -77,6 +77,8 @@ func TestRetryOnDeadlockStopsOnCanceledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() calls := 0 + // op always runs once at the top of the loop before the canceled ctx is + // observed in the select, so exactly one call is expected. err := retryOnDeadlock(ctx, func() error { calls++ return &pgconn.PgError{Code: "40P01"} From 0be713b30cac03ab1d970c4e95c9eb3ec66fabf8 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 15:26:48 -0400 Subject: [PATCH 094/201] feat(catalog): add deleteInBatches loop helper --- internal/catalog/folder_delete_test.go | 46 ++++++++++++++++++++++++++ internal/catalog/folder_repo.go | 25 ++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/internal/catalog/folder_delete_test.go b/internal/catalog/folder_delete_test.go index 7133ccd9..280a3e01 100644 --- a/internal/catalog/folder_delete_test.go +++ b/internal/catalog/folder_delete_test.go @@ -90,3 +90,49 @@ func TestRetryOnDeadlockStopsOnCanceledContext(t *testing.T) { t.Fatalf("expected 1 call before cancel, got %d", calls) } } + +func TestDeleteInBatchesLoopsUntilUnderBatchSize(t *testing.T) { + withFastDeadlockRetry(t, 5) + counts := []int64{5, 5, 2} + idx := 0 + total, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) { + n := counts[idx] + idx++ + return n, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if total != 12 { + t.Fatalf("expected total 12, got %d", total) + } + if idx != 3 { + t.Fatalf("expected 3 batches, got %d", idx) + } +} + +func TestDeleteInBatchesStopsImmediatelyWhenFirstBatchUnderSize(t *testing.T) { + withFastDeadlockRetry(t, 5) + calls := 0 + total, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) { + calls++ + return 0, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if total != 0 || calls != 1 { + t.Fatalf("expected total 0 and 1 call, got total=%d calls=%d", total, calls) + } +} + +func TestDeleteInBatchesReturnsError(t *testing.T) { + withFastDeadlockRetry(t, 5) + sentinel := errors.New("delete failed") + _, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) { + return 0, sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel, got %v", err) + } +} diff --git a/internal/catalog/folder_repo.go b/internal/catalog/folder_repo.go index fcf9859a..ea4a5e60 100644 --- a/internal/catalog/folder_repo.go +++ b/internal/catalog/folder_repo.go @@ -48,6 +48,31 @@ func retryOnDeadlock(ctx context.Context, op func() error) error { } } +// deleteInBatches repeatedly runs deleteBatch (each a single autocommit +// statement) until a batch removes fewer than batchSize rows. Each batch is +// retried on deadlock. It returns the total number of rows deleted. +func deleteInBatches( + ctx context.Context, + batchSize int, + deleteBatch func(ctx context.Context) (int64, error), +) (int64, error) { + var total int64 + for { + var affected int64 + if err := retryOnDeadlock(ctx, func() error { + n, e := deleteBatch(ctx) + affected = n + return e + }); err != nil { + return total, err + } + total += affected + if affected < int64(batchSize) { + return total, nil + } + } +} + // Sentinel errors for folder repository operations. var ( ErrFolderNotFound = errors.New("folder not found") From 486ecafbb5b3983f2d76a18ad9d30c35b41c4d8b Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 15:29:37 -0400 Subject: [PATCH 095/201] refactor(catalog): make image-dir helpers querier-agnostic Add rowQuerier interface satisfied by both *pgxpool.Pool and pgx.Tx. Split collectImageDirs into collectRawImageDirs (raw collection) and a thin wrapper that filters via filterUnreferencedImageDirs. Both helpers now accept rowQuerier so a later task can call them from pool-level batch deletes without an open transaction. Co-Authored-By: Claude Sonnet 4.6 --- internal/catalog/folder_repo.go | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/internal/catalog/folder_repo.go b/internal/catalog/folder_repo.go index ea4a5e60..17ffc473 100644 --- a/internal/catalog/folder_repo.go +++ b/internal/catalog/folder_repo.go @@ -579,6 +579,12 @@ func pathDir(path string) string { return path[:idx+1] } +// rowQuerier is satisfied by both *pgxpool.Pool and pgx.Tx, letting read +// helpers run inside or outside an explicit transaction. +type rowQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) +} + // collectOrphanIDs returns content IDs from the given set that have no // remaining library memberships. Must be called within a transaction. func collectOrphanIDs(ctx context.Context, tx pgx.Tx, contentIDs []string) ([]string, error) { @@ -603,10 +609,21 @@ func collectOrphanIDs(ctx context.Context, tx pgx.Tx, contentIDs []string) ([]st return ids, rows.Err() } -// collectImageDirs returns S3 directory prefixes for all images belonging to -// the given content IDs (items, seasons, and episodes). -func collectImageDirs(ctx context.Context, tx pgx.Tx, contentIDs []string) ([]string, error) { - imgRows, err := tx.Query(ctx, ` +// collectImageDirs returns S3 directory prefixes for images belonging to the +// given content IDs that are not still referenced by other surviving content. +func collectImageDirs(ctx context.Context, q rowQuerier, contentIDs []string) ([]string, error) { + dirs, err := collectRawImageDirs(ctx, q, contentIDs) + if err != nil { + return nil, err + } + return filterUnreferencedImageDirs(ctx, q, dirs, contentIDs) +} + +// collectRawImageDirs returns the deduped S3 directory prefixes referenced by +// the given content IDs (items, their seasons, and their episodes), without +// filtering out dirs still used by other content. +func collectRawImageDirs(ctx context.Context, q rowQuerier, contentIDs []string) ([]string, error) { + imgRows, err := q.Query(ctx, ` SELECT poster_path, backdrop_path, logo_path FROM media_items WHERE content_id = ANY($1) UNION ALL SELECT poster_path, '', '' FROM seasons WHERE series_id = ANY($1) @@ -634,20 +651,19 @@ func collectImageDirs(ctx context.Context, tx pgx.Tx, contentIDs []string) ([]st if err := imgRows.Err(); err != nil { return nil, fmt.Errorf("iterating image paths: %w", err) } - dirs := make([]string, 0, len(dirSet)) for dir := range dirSet { dirs = append(dirs, dir) } - return filterUnreferencedImageDirs(ctx, tx, dirs, contentIDs) + return dirs, nil } -func filterUnreferencedImageDirs(ctx context.Context, tx pgx.Tx, dirs, deletingContentIDs []string) ([]string, error) { +func filterUnreferencedImageDirs(ctx context.Context, q rowQuerier, dirs, deletingContentIDs []string) ([]string, error) { if len(dirs) == 0 { return nil, nil } - rows, err := tx.Query(ctx, ` + rows, err := q.Query(ctx, ` SELECT candidate.dir FROM unnest($1::text[]) AS candidate(dir) WHERE NOT EXISTS ( From 731235f6a51c8260a411e2d9ce2cfb1898e94969 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 15:35:18 -0400 Subject: [PATCH 096/201] fix(catalog): delete libraries in deadlock-retrying batches Replaces the single multi-minute delete transaction with phased, batched autocommit deletes (orphan items, media files, memberships, folder row), each retried on deadlock. Holds only short locks, survives concurrent writers, and is resumable on failure. --- internal/catalog/folder_repo.go | 203 ++++++++++++++++++++++---------- 1 file changed, 141 insertions(+), 62 deletions(-) diff --git a/internal/catalog/folder_repo.go b/internal/catalog/folder_repo.go index 17ffc473..f6bdd356 100644 --- a/internal/catalog/folder_repo.go +++ b/internal/catalog/folder_repo.go @@ -22,6 +22,15 @@ var ( deadlockBaseBackoff = 50 * time.Millisecond ) +const ( + // orphanDeleteBatch is small because each media_items row cascades across + // ~15 child tables. + orphanDeleteBatch = 1000 + // folderChildDeleteBatch covers the lighter folder-scoped media_files and + // junction deletes. + folderChildDeleteBatch = 5000 +) + // retryOnDeadlock runs op, retrying when Postgres reports a deadlock (40P01) or // serialization failure (40001), with exponential backoff. It returns // immediately for any other error, and honors context cancellation between @@ -476,97 +485,167 @@ func (r *FolderRepository) DeleteWithStats( id int, progress func(current, total int, message string), ) (*DeleteFolderStats, error) { - tx, err := r.pool.Begin(ctx) - if err != nil { - return nil, fmt.Errorf("beginning delete transaction: %w", err) - } - defer func() { _ = tx.Rollback(ctx) }() - stats := &DeleteFolderStats{} - if err := tx.QueryRow(ctx, `SELECT name FROM media_folders WHERE id = $1`, id).Scan(&stats.LibraryName); err != nil { + + // Phase 0: preflight reads (no long-lived transaction). + if err := r.pool.QueryRow(ctx, `SELECT name FROM media_folders WHERE id = $1`, id).Scan(&stats.LibraryName); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrFolderNotFound } return nil, fmt.Errorf("loading folder before delete: %w", err) } - - if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM media_files WHERE media_folder_id = $1`, id).Scan(&stats.MediaFiles); err != nil { + if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM media_files WHERE media_folder_id = $1`, id).Scan(&stats.MediaFiles); err != nil { return nil, fmt.Errorf("counting media files: %w", err) } - if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM media_item_libraries WHERE media_folder_id = $1`, id).Scan(&stats.MediaItemLinks); err != nil { + if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM media_item_libraries WHERE media_folder_id = $1`, id).Scan(&stats.MediaItemLinks); err != nil { return nil, fmt.Errorf("counting media item links: %w", err) } + var orphanTotal int + if err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM media_item_libraries mil + WHERE mil.media_folder_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM media_item_libraries other + WHERE other.content_id = mil.content_id + AND other.media_folder_id <> $1 + )`, id).Scan(&orphanTotal); err != nil { + return nil, fmt.Errorf("counting orphaned items: %w", err) + } - // Collect items that exist ONLY in this library before cascade removes the - // junction rows, so we know which media_items to clean up. - rows, err := tx.Query(ctx, ` + // Phase 1: delete orphaned media_items in detect-then-delete batches. + // Cascade removes their junctions, provider IDs, episodes, seasons, etc. + if progress != nil { + progress(0, orphanTotal, "Deleting orphaned items") + } + rawDirs := make(map[string]struct{}) + for { + ids, err := r.collectOrphanBatch(ctx, id, orphanDeleteBatch) + if err != nil { + return nil, err + } + if len(ids) == 0 { + break + } + dirs, err := collectRawImageDirs(ctx, r.pool, ids) + if err != nil { + return nil, err + } + for _, d := range dirs { + rawDirs[d] = struct{}{} + } + if err := retryOnDeadlock(ctx, func() error { + _, e := r.pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids) + return e + }); err != nil { + return nil, fmt.Errorf("deleting orphaned items: %w", err) + } + stats.OrphanedItems += len(ids) + if progress != nil { + progress(stats.OrphanedItems, orphanTotal, "Deleting orphaned items") + } + } + + // Filter accumulated image dirs once, now that orphans are gone, against + // any surviving content. Empty deleting-set means "exclude nothing". + if len(rawDirs) > 0 { + filtered, err := filterUnreferencedImageDirs(ctx, r.pool, dirSetToSlice(rawDirs), []string{}) + if err != nil { + return nil, err + } + stats.OrphanedImageDirs = filtered + } + + // Phase 2: delete this folder's media_files (folder-tied, not item-tied). + if progress != nil { + progress(orphanTotal, orphanTotal, "Deleting media files") + } + if _, err := deleteInBatches(ctx, folderChildDeleteBatch, func(ctx context.Context) (int64, error) { + tag, e := r.pool.Exec(ctx, ` + DELETE FROM media_files + WHERE id IN ( + SELECT id FROM media_files WHERE media_folder_id = $1 LIMIT $2 + )`, id, folderChildDeleteBatch) + if e != nil { + return 0, e + } + return tag.RowsAffected(), nil + }); err != nil { + return nil, fmt.Errorf("deleting media files: %w", err) + } + + // Phase 3: delete remaining folder memberships (shared items kept; only the + // membership in this folder is removed). + if progress != nil { + progress(orphanTotal, orphanTotal, "Removing library memberships") + } + if _, err := deleteInBatches(ctx, folderChildDeleteBatch, func(ctx context.Context) (int64, error) { + tag, e := r.pool.Exec(ctx, ` + DELETE FROM media_item_libraries + WHERE ctid IN ( + SELECT ctid FROM media_item_libraries WHERE media_folder_id = $1 LIMIT $2 + )`, id, folderChildDeleteBatch) + if e != nil { + return 0, e + } + return tag.RowsAffected(), nil + }); err != nil { + return nil, fmt.Errorf("deleting media item links: %w", err) + } + + // Phase 4: delete the now-lightweight folder row. Tolerate 0 rows so a + // resumed run that already removed it still succeeds. + if err := retryOnDeadlock(ctx, func() error { + _, e := r.pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, id) + return e + }); err != nil { + return nil, fmt.Errorf("deleting folder: %w", err) + } + + if progress != nil { + progress(orphanTotal, orphanTotal, "Library deletion completed") + } + return stats, nil +} + +// collectOrphanBatch returns up to limit content IDs whose only library +// membership is the given folder. +func (r *FolderRepository) collectOrphanBatch(ctx context.Context, folderID, limit int) ([]string, error) { + rows, err := r.pool.Query(ctx, ` SELECT mil.content_id FROM media_item_libraries mil WHERE mil.media_folder_id = $1 AND NOT EXISTS ( SELECT 1 FROM media_item_libraries other WHERE other.content_id = mil.content_id - AND other.media_folder_id != $1 - )`, id) + AND other.media_folder_id <> $1 + ) + LIMIT $2`, folderID, limit) if err != nil { return nil, fmt.Errorf("finding orphaned items: %w", err) } - - var orphanIDs []string + defer rows.Close() + var ids []string for rows.Next() { var contentID string if err := rows.Scan(&contentID); err != nil { - rows.Close() return nil, fmt.Errorf("scanning orphan content_id: %w", err) } - orphanIDs = append(orphanIDs, contentID) + ids = append(ids, contentID) } - rows.Close() - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterating orphan rows: %w", err) - } - stats.OrphanedItems = len(orphanIDs) + return ids, rows.Err() +} - // Collect S3 image paths from orphaned items (and their seasons/episodes) - // before deletion so the caller can clean up S3 objects afterward. - if len(orphanIDs) > 0 { - stats.OrphanedImageDirs, err = collectImageDirs(ctx, tx, orphanIDs) - if err != nil { - return nil, err - } +// dirSetToSlice returns the keys of set as a slice (nil if empty). +func dirSetToSlice(set map[string]struct{}) []string { + if len(set) == 0 { + return nil } - - // Delete the folder; CASCADE removes junction rows, media_files, and - // library_provider_chains automatically. - if progress != nil { - progress(1, 3, "Deleting library folder data") + dirs := make([]string, 0, len(set)) + for d := range set { + dirs = append(dirs, d) } - tag, err := tx.Exec(ctx, "DELETE FROM media_folders WHERE id = $1", id) - if err != nil { - return nil, fmt.Errorf("deleting folder: %w", err) - } - if tag.RowsAffected() == 0 { - return nil, ErrFolderNotFound - } - - // Remove orphaned media items (episodes CASCADE from media_items). - if len(orphanIDs) > 0 { - if progress != nil { - progress(2, 3, "Deleting orphaned items") - } - _, err = tx.Exec(ctx, "DELETE FROM media_items WHERE content_id = ANY($1)", orphanIDs) - if err != nil { - return nil, fmt.Errorf("deleting orphaned items: %w", err) - } - } - - if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("committing delete transaction: %w", err) - } - if progress != nil { - progress(3, 3, "Library deletion completed") - } - return stats, nil + return dirs } // pathDir extracts the directory portion of an S3 image path. From c9294947b1a980b2e1ceb1d6708a190377a3d3c8 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 15:41:35 -0400 Subject: [PATCH 097/201] refactor(catalog): wrap orphan-batch iteration error --- internal/catalog/folder_repo.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/catalog/folder_repo.go b/internal/catalog/folder_repo.go index f6bdd356..b5a11004 100644 --- a/internal/catalog/folder_repo.go +++ b/internal/catalog/folder_repo.go @@ -633,7 +633,10 @@ func (r *FolderRepository) collectOrphanBatch(ctx context.Context, folderID, lim } ids = append(ids, contentID) } - return ids, rows.Err() + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating orphan rows: %w", err) + } + return ids, nil } // dirSetToSlice returns the keys of set as a slice (nil if empty). From 196f753d1ab97247a2ee25c89de09aefc4f541c6 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 17:11:11 -0400 Subject: [PATCH 098/201] fix(catalog): clamp still/poster/logo backdrops to largest cached variant Episode stills used as backdrops only exist at w500/w300 in the cache, so requesting a w1280/w1920 backdrop width 404s. Add catalog.BackdropVariantPath + imageTypeFromCachedPath and route featured (w1920) and Continue Watching / Next Up (w1280) backdrops through it; still/poster/logo paths clamp to their type's largest cached variant while real backdrops keep the requested width. Co-Authored-By: Claude Opus 4.7 --- internal/api/handlers/items.go | 13 ++-- internal/api/handlers/sections.go | 10 +-- internal/api/handlers/sections_test.go | 12 ++++ internal/catalog/detail.go | 37 +++++++++++ internal/catalog/image_variants_test.go | 85 +++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 internal/catalog/image_variants_test.go diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index ffa4ffe5..e99f0c51 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -996,16 +996,11 @@ func featuredPosterPath(path string) string { } // featuredBackdropPath converts an S3 backdrop path from original to w1920 for -// featured/hero contexts (displayed at full viewport width). -// Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is. +// featured/hero contexts (displayed at full viewport width). Episode stills +// used as backdrops lack a w1920 variant and clamp to their largest cached +// size. Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is. func featuredBackdropPath(path string) string { - if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { - return path - } - if strings.Contains(path, "://") { - return path - } - return strings.Replace(path, "/original.", "/w1920.", 1) + return catalog.BackdropVariantPath(path, "w1920") } // presignURL resolves an image path to a usable URL, delegating to the diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go index 5ae1cb9e..3441b026 100644 --- a/internal/api/handlers/sections.go +++ b/internal/api/handlers/sections.go @@ -1359,15 +1359,7 @@ func (h *SectionHandler) toSectionItemResponse(sectionType sections.SectionType, // uses the cached w1280 backdrop for Continue Watching / Next Up rows. func sectionBackdropPath(sectionType sections.SectionType, path string) string { if sectionType == sections.SectionContinueWatching || sectionType == sections.SectionNextUp { - if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { - return path - } - // Plugin-prefixed paths pass through; the resolver still receives the - // existing "featured" semantic variant. - if strings.Contains(path, "://") { - return path - } - return strings.Replace(path, "/original.", "/w1280.", 1) + return catalog.BackdropVariantPath(path, "w1280") } return featuredBackdropPath(path) } diff --git a/internal/api/handlers/sections_test.go b/internal/api/handlers/sections_test.go index a46f5d2b..982e565c 100644 --- a/internal/api/handlers/sections_test.go +++ b/internal/api/handlers/sections_test.go @@ -52,6 +52,18 @@ func TestSectionBackdropPathUsesExpectedVariants(t *testing.T) { path: "/tmdb/shows/1399/backdrop/original.jpg", want: "/tmdb/shows/1399/backdrop/w1920.jpg", }, + { + name: "continue watching episode still clamps to w500", + sectionType: sections.SectionContinueWatching, + path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", + want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp", + }, + { + name: "featured section episode still clamps to w500", + sectionType: sections.SectionRecentlyAdded, + path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", + want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp", + }, { name: "http paths pass through", sectionType: sections.SectionContinueWatching, diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index c4f3c49d..5f628643 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -2189,6 +2189,43 @@ func cachedImageVariantPath(path, imageType, size string) string { return path } +// imageTypeFromCachedPath returns the image type segment ("poster", "backdrop", +// "logo", "still") encoded in a cached S3 image path of the form +// ".../{imageType}/{variant}.{ext}". It returns "" for full URLs, +// plugin-prefixed paths, or paths with no directory segment. +func imageTypeFromCachedPath(path string) string { + if path == "" || strings.Contains(path, "://") { + return "" + } + lastSlash := strings.LastIndex(path, "/") + if lastSlash <= 0 { + return "" + } + dir := path[:lastSlash] + return dir[strings.LastIndex(dir, "/")+1:] +} + +// BackdropVariantPath rewrites a cached "/original." image path to the +// requested backdrop variant (e.g. "w1280" or "w1920"). Episode "backdrops" +// are frequently the episode still, which the cache only generates at +// w500/w300 — so requesting a backdrop width 404s. For still/poster/logo +// paths this clamps to that type's largest cached variant instead. Full URLs, +// plugin-prefixed paths, and non-"/original." paths pass through unchanged. +func BackdropVariantPath(path, desiredVariant string) string { + if path == "" || strings.Contains(path, "://") || !strings.Contains(path, "/original.") { + return path + } + variant := desiredVariant + switch imageType := imageTypeFromCachedPath(path); imageType { + case "still", "poster", "logo": + variant = cachedImageVariantKey(imageType, "") + } + if variant == "" { + return path + } + return strings.Replace(path, "/original.", "/"+variant+".", 1) +} + func cachedImageVariantKey(imageType, size string) string { if size == "original" { return "original" diff --git a/internal/catalog/image_variants_test.go b/internal/catalog/image_variants_test.go new file mode 100644 index 00000000..300ae0af --- /dev/null +++ b/internal/catalog/image_variants_test.go @@ -0,0 +1,85 @@ +package catalog + +import "testing" + +func TestImageTypeFromCachedPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {"episode still", "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", "still"}, + {"movie backdrop", "tmdb/movies/550/backdrop/original.webp", "backdrop"}, + {"leading slash poster", "/tmdb/movies/550/poster/original.jpg", "poster"}, + {"logo", "tmdb/movies/550/logo/original.png", "logo"}, + {"http url", "https://images.example.com/backdrop/original.jpg", ""}, + {"plugin path", "plugin://tmdb/backdrop/original.jpg", ""}, + {"no slash", "original.webp", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := imageTypeFromCachedPath(tt.path); got != tt.want { + t.Fatalf("imageTypeFromCachedPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestBackdropVariantPath(t *testing.T) { + tests := []struct { + name string + path string + desired string + want string + }{ + { + name: "real backdrop keeps requested w1280", + path: "tmdb/movies/550/backdrop/original.webp", + desired: "w1280", + want: "tmdb/movies/550/backdrop/w1280.webp", + }, + { + name: "real backdrop keeps requested w1920", + path: "/tmdb/shows/1399/backdrop/original.jpg", + desired: "w1920", + want: "/tmdb/shows/1399/backdrop/w1920.jpg", + }, + { + name: "episode still clamps to w500 (no large variant exists)", + path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", + desired: "w1280", + want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp", + }, + { + name: "episode still clamps w1920 to w500", + path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", + desired: "w1920", + want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp", + }, + { + name: "http url passes through", + path: "https://images.example.com/backdrop/original.jpg", + desired: "w1280", + want: "https://images.example.com/backdrop/original.jpg", + }, + { + name: "plugin path passes through", + path: "plugin://tmdb/backdrop/original.jpg", + desired: "w1280", + want: "plugin://tmdb/backdrop/original.jpg", + }, + { + name: "path without original segment passes through", + path: "tmdb/movies/550/backdrop/w300.webp", + desired: "w1280", + want: "tmdb/movies/550/backdrop/w300.webp", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := BackdropVariantPath(tt.path, tt.desired); got != tt.want { + t.Fatalf("BackdropVariantPath(%q, %q) = %q, want %q", tt.path, tt.desired, got, tt.want) + } + }) + } +} From dfa0f6861f554ac360473bcf317f3e5f2daa3a7c Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 17:11:11 -0400 Subject: [PATCH 099/201] perf(startup): defer non-critical init off the HTTP listener path Collect catalog-size-dependent seeding (metadata match queues, legacy series-group cleanup) and the watch-provider scrobble sweep into a backgroundInit slice that runs sequentially in a background goroutine after the server is ready, instead of blocking startup before the listener accepts connections. Steps log failures and stop early on shutdown. Co-Authored-By: Claude Opus 4.7 --- cmd/silo/main.go | 60 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 213c0e8e..819ecd96 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -724,6 +724,12 @@ func main() { pluginService.OnLifecycleChange(appCtx) } + // backgroundInit collects non-critical startup work (catalog-size-dependent + // seeding, network-bound reconciliation) that must not block the HTTP + // listener. The steps run sequentially in a background goroutine once the + // server is ready to serve. Failures are logged, never fatal. + var backgroundInit []func(context.Context) + // Step 4b: Create metadata service and match worker (if needed). var metadataService *metadata.MetadataService var personRefreshService *metadata.PersonRefreshService @@ -830,23 +836,28 @@ func main() { matchWorker.SetMovieFileClaimer(movieQueueRepo) } if seriesQueueRepo != nil { - if cleaned, err := seriesQueueRepo.CleanupLegacySeriesGroupQueue(appCtx); err != nil { - slog.Warn("failed to clean legacy series group queue rows", "error", err) - } else if cleaned > 0 { - slog.Info("cleaned legacy series group queue rows", "count", cleaned) - } matchWorker.SetSeriesRootClaimer(seriesQueueRepo, cfg.Matcher.TVSeriesRootQueueEnabled()) + backgroundInit = append(backgroundInit, func(ctx context.Context) { + if cleaned, err := seriesQueueRepo.CleanupLegacySeriesGroupQueue(ctx); err != nil { + slog.Warn("failed to clean legacy series group queue rows", "error", err) + } else if cleaned > 0 { + slog.Info("cleaned legacy series group queue rows", "count", cleaned) + } + }) } if deps.FolderRepo != nil { - enabledFolders, err := deps.FolderRepo.GetEnabled(appCtx) - if err != nil { - slog.Warn("failed to seed metadata queues", "error", err) - } else { + backgroundInit = append(backgroundInit, func(ctx context.Context) { + start := time.Now() + enabledFolders, err := deps.FolderRepo.GetEnabled(ctx) + if err != nil { + slog.Warn("failed to seed metadata queues", "error", err) + return + } seedMovieQueue := func(folderID int) { if movieQueueRepo == nil { return } - if err := movieQueueRepo.SyncForFolder(appCtx, folderID); err != nil { + if err := movieQueueRepo.SyncForFolder(ctx, folderID); err != nil { slog.Warn("failed to seed movie match queue", "folder_id", folderID, "error", err) } } @@ -854,7 +865,7 @@ func main() { if seriesQueueRepo == nil { return } - if err := seriesQueueRepo.SyncForFolder(appCtx, folderID); err != nil { + if err := seriesQueueRepo.SyncForFolder(ctx, folderID); err != nil { slog.Warn("failed to seed series root queue", "folder_id", folderID, "error", err) } } @@ -872,7 +883,8 @@ func main() { seedMovieQueue(folder.ID) } } - } + slog.Info("deferred init: metadata match queues seeded", "folders", len(enabledFolders), "duration", time.Since(start)) + }) } deps.SkippedRootRepo = skippedRootRepo @@ -1011,9 +1023,11 @@ func main() { WithMatcher(historyimport.NewMatcher(historyRepo)). WithWatchState(watchstate.NewService(userStoreProvider).WithStableIdentityResolver(historyIdentity)). WithUserStoreProvider(userStoreProvider) - if err := watchProviderService.SweepOpenScrobbles(appCtx); err != nil { - slog.Warn("failed to sweep open watch provider scrobbles", "error", err) - } + backgroundInit = append(backgroundInit, func(ctx context.Context) { + if err := watchProviderService.SweepOpenScrobbles(ctx); err != nil { + slog.Warn("failed to sweep open watch provider scrobbles", "error", err) + } + }) } deps.SessionMgr = sessionMgr deps.PlaybackRealtimeHub = playback.NewRealtimeHub() @@ -1653,6 +1667,22 @@ func main() { compatSrv.IdleTimeout = 120 * time.Second } + // Run non-critical startup work in the background so it doesn't delay the + // HTTP listener from accepting connections. Steps run sequentially and stop + // early if the app context is cancelled (shutdown). + if len(backgroundInit) > 0 { + go func() { + start := time.Now() + for _, step := range backgroundInit { + if appCtx.Err() != nil { + return + } + step(appCtx) + } + slog.Info("deferred startup init completed", "steps", len(backgroundInit), "duration", time.Since(start)) + }() + } + errCh := make(chan error, 2) go func() { slog.Info("HTTP server listening", "addr", cfg.Server.Listen) From 731cdd35fc62d3f8521cd0430fa19f7ea4edb653 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 17:11:11 -0400 Subject: [PATCH 100/201] fix(migrations): make air_timezone column add idempotent Use ADD COLUMN IF NOT EXISTS so re-running 162 on a database that already has the column is a no-op. Co-Authored-By: Claude Opus 4.7 --- migrations/162_media_items_air_timezone.up.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/162_media_items_air_timezone.up.sql b/migrations/162_media_items_air_timezone.up.sql index af9cf2aa..b4530669 100644 --- a/migrations/162_media_items_air_timezone.up.sql +++ b/migrations/162_media_items_air_timezone.up.sql @@ -1 +1 @@ -ALTER TABLE media_items ADD COLUMN air_timezone text; +ALTER TABLE media_items ADD COLUMN IF NOT EXISTS air_timezone text; From 5c3c92da282e029a38004cc237a0cffd8586afa5 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 18:46:56 -0400 Subject: [PATCH 101/201] build: stamp git revision via Makefile ldflags `make build` did not inject buildinfo's `revisionOverride`/`dirtyOverride` ldflags (the Dockerfile already does), so binaries built via make report their version as "unavailable" in the admin Build panel whenever Go's VCS metadata isn't embedded. Mirror the Dockerfile by computing the git revision + dirty state and passing them through `-ldflags -X`. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 32cff7f3..60a2eb4c 100644 --- a/Makefile +++ b/Makefile @@ -14,13 +14,20 @@ endif JELLYFIN_WEB_OUTPUT_DIR ?= third_party/jellyfin-web +# Build version stamping: inject the git revision so the admin Build panel shows a +# version even when Go's VCS metadata isn't embedded (mirrors the Dockerfile ldflags). +BUILDINFO_PKG := github.com/Silo-Server/silo-server/internal/buildinfo +BUILD_REVISION ?= $(shell git rev-parse HEAD 2>/dev/null) +BUILD_DIRTY ?= $(shell test -n "$$(git status --porcelain 2>/dev/null)" && echo true || echo false) +GO_LDFLAGS := -X $(BUILDINFO_PKG).revisionOverride=$(BUILD_REVISION) -X $(BUILDINFO_PKG).dirtyOverride=$(BUILD_DIRTY) + # Build the frontend (requires pnpm) frontend: cd web && pnpm install --frozen-lockfile && pnpm run build # Build the Go binary (depends on frontend) build: frontend - go build -o silo ./cmd/silo/ + go build -ldflags "$(GO_LDFLAGS)" -o silo ./cmd/silo/ # Run frontend dev server (proxies API to localhost:8080) dev-frontend: From f8493a47f16f2f5398c5a445c894ec0b1c72aad8 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 18:47:11 -0400 Subject: [PATCH 102/201] fix(web): accessibility & UX fixes from visual QA pass Accessibility (WCAG AA): - Lighten the Standard-theme `--muted-foreground` (#6e6e78 -> #9696a0, ~3.4:1 -> >=5.3:1) and darken the light-theme equivalent so secondary text meets 1.4.3 contrast app-wide; the opt-in High Contrast mode is no longer the only conformant path. - Give icon-only controls accessible names (4.1.2): the password show/hide toggle (also drop tabIndex={-1} so it's keyboard reachable), and the Edit/Delete/health/copy/refresh actions across the Users, Libraries, Nodes, API Keys, Catalog Maintenance and Job History admin tables. - Fix the Switch off-state (invisible track -> visible border + fill) and the PlaybackSettings SettingRow label association (the
    diff --git a/web/src/components/AdminSidebar.tsx b/web/src/components/AdminSidebar.tsx index 7fb67565..cd077b53 100644 --- a/web/src/components/AdminSidebar.tsx +++ b/web/src/components/AdminSidebar.tsx @@ -59,7 +59,10 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) { const location = useLocation(); const sessionCount = useSessionCount(); const buildInfo = useBuildInfo(); - let buildDisplay = "unavailable"; + // Falls back to "dev build" when the binary carries no VCS/ldflags revision + // (e.g. `go run` or an image built without BUILD_REVISION) rather than a stark + // "unavailable". + let buildDisplay = "dev build"; if (buildInfo.isPending && !buildInfo.data) { buildDisplay = "loading..."; } else if (buildInfo.isError) { diff --git a/web/src/components/ItemCard.tsx b/web/src/components/ItemCard.tsx index 4fc6cba5..611a587d 100644 --- a/web/src/components/ItemCard.tsx +++ b/web/src/components/ItemCard.tsx @@ -88,7 +88,11 @@ export default function ItemCard({ return (
    - +
    setVisible((v) => !v)} - tabIndex={-1} + aria-label={visible ? "Hide password" : "Show password"} + aria-pressed={visible} > - {visible ? : } + {visible ? ( +
    ); diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx index 8de14435..80af3489 100644 --- a/web/src/components/ui/switch.tsx +++ b/web/src/components/ui/switch.tsx @@ -15,7 +15,7 @@ function Switch({ data-slot="switch" data-size={size} className={cn( - "peer group/switch focus-visible:border-ring focus-visible:ring-ring/50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input/80 inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6", + "peer group/switch focus-visible:border-ring focus-visible:ring-ring/50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-border data-[state=unchecked]:border-muted-foreground/30 inline-flex shrink-0 items-center rounded-full border shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:border-transparent", className, )} {...props} diff --git a/web/src/hooks/admin/useAdminLogStream.ts b/web/src/hooks/admin/useAdminLogStream.ts index 600aea74..9b32ed67 100644 --- a/web/src/hooks/admin/useAdminLogStream.ts +++ b/web/src/hooks/admin/useAdminLogStream.ts @@ -27,6 +27,8 @@ export interface AdminLogStreamResult { isLive: boolean; error?: string; connectionState: ConnectionState; + /** Force a fresh connection attempt (the stream does not auto-retry on drop). */ + reconnect: () => void; } export function buildAdminLogStreamQuery(params: AdminLogQuery) { @@ -83,6 +85,7 @@ export function useAdminLogStream( const [nextCursor, setNextCursor] = useState(); const [connectionState, setConnectionState] = useState("disconnected"); const [error, setError] = useState(); + const [reconnectNonce, setReconnectNonce] = useState(0); const deferredParams = useDeferredValue(params); const queryString = useMemo(() => buildAdminLogStreamQuery(deferredParams), [deferredParams]); @@ -126,6 +129,8 @@ export function useAdminLogStream( flushTimer = window.setTimeout(flushAppends, APPEND_FLUSH_MS); }; + // Debounce the initial attempt; an explicit reconnect() retries immediately. + const connectDelay = reconnectNonce === 0 ? 250 : 0; const connectTimer = window.setTimeout(() => { const url = buildAdminLogStreamUrl(stream, deferredParams, getAccessToken(), window.location); try { @@ -177,7 +182,7 @@ export function useAdminLogStream( ws.onclose = () => { setConnectionState("disconnected"); }; - }, 250); + }, connectDelay); return () => { window.clearTimeout(connectTimer); @@ -186,7 +191,7 @@ export function useAdminLogStream( ws.close(); } }; - }, [stream, queryString, limit, enabled, deferredParams]); + }, [stream, queryString, limit, enabled, deferredParams, reconnectNonce]); return { rows, @@ -195,6 +200,7 @@ export function useAdminLogStream( isLive: connectionState === "live", error, connectionState, + reconnect: () => setReconnectNonce((n) => n + 1), }; } diff --git a/web/src/lib/documentTitle.ts b/web/src/lib/documentTitle.ts index a4f02200..df7ca6cc 100644 --- a/web/src/lib/documentTitle.ts +++ b/web/src/lib/documentTitle.ts @@ -24,10 +24,14 @@ const ADMIN_TITLES: Record = { activity: "Admin Activity", "api-keys": "Admin API Keys", collections: "Admin Collections", + devices: "Admin Devices", + history: "Admin Playback History", + "history-import": "Admin History Import", libraries: "Admin Libraries", logs: "Admin Logs", maintenance: "Admin Maintenance", nodes: "Admin Nodes", + plugins: "Admin Plugins", recommendations: "Admin Recommendations", requests: "Admin Requests", sections: "Admin Sections", diff --git a/web/src/pages/AdminApiKeys.tsx b/web/src/pages/AdminApiKeys.tsx index acd30ae6..ba69edd0 100644 --- a/web/src/pages/AdminApiKeys.tsx +++ b/web/src/pages/AdminApiKeys.tsx @@ -94,7 +94,7 @@ export default function AdminApiKeys() { />
    -

    API keys

    +

    API Keys

    Create and manage machine credentials for integrations, automation, and internal tools.

    @@ -148,9 +148,10 @@ export default function AdminApiKeys() { variant="ghost" size="icon" className="h-6 w-6" + aria-label={`Copy API key ${key.label}`} onClick={() => handleCopy(key.key)} > - +
    @@ -179,9 +180,10 @@ export default function AdminApiKeys() { variant="ghost" size="icon" className="h-7 w-7" + aria-label={`Revoke API key ${key.label}`} onClick={() => handleRevoke(key)} > - +
    {mountCheck ? ( diff --git a/web/src/pages/AdminLogs.tsx b/web/src/pages/AdminLogs.tsx index 9b372daf..6b7014f5 100644 --- a/web/src/pages/AdminLogs.tsx +++ b/web/src/pages/AdminLogs.tsx @@ -1,6 +1,7 @@ import { memo, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { useSearchParams } from "react-router"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Sheet, @@ -87,6 +88,16 @@ export default function AdminLogs() { {activeStream.error && (
    {activeStream.error}
    )} + {activeStream.connectionState === "disconnected" && ( + + )}
    diff --git a/web/src/pages/AdminNodes.tsx b/web/src/pages/AdminNodes.tsx index fbc65c35..98760ddd 100644 --- a/web/src/pages/AdminNodes.tsx +++ b/web/src/pages/AdminNodes.tsx @@ -138,25 +138,31 @@ function NodeSection({ size="icon" className="h-7 w-7" disabled={isChecking} + aria-label={`Check health of ${node.name}`} onClick={() => onCheckHealth(node)} > - +
    @@ -297,7 +303,7 @@ export default function AdminNodes() {
    -

    Stream nodes

    +

    Stream Nodes

    Manage proxy and transcode workers that distribute playback load across your infrastructure. diff --git a/web/src/pages/AdminTasks.tsx b/web/src/pages/AdminTasks.tsx index ecaf5280..33262de0 100644 --- a/web/src/pages/AdminTasks.tsx +++ b/web/src/pages/AdminTasks.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router"; import { useEventChannel } from "@/components/realtimeEventsContext"; -import { Play, Square } from "lucide-react"; +import { AlertTriangle, Play, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -66,6 +66,10 @@ function describeSchedule(triggers: TriggerConfig[]): string | null { return triggers.map(describeTrigger).join(", "); } +function isOverdue(dateStr: string): boolean { + return new Date(dateStr).getTime() < Date.now(); +} + function formatNextRun(dateStr: string): string { const diff = new Date(dateStr).getTime() - Date.now(); if (diff < 0) return "overdue"; @@ -160,9 +164,15 @@ function TaskRow({ {!task.last_execution && !describeSchedule(task.triggers) && ( · Never run )} - {task.next_run_at && ( - · Next: {formatNextRun(task.next_run_at)} - )} + {task.next_run_at && + (isOverdue(task.next_run_at) ? ( + + + ) : ( + · Next: {formatNextRun(task.next_run_at)} + ))} )}

    @@ -241,7 +251,7 @@ export default function AdminTasks() {
    -

    Scheduled tasks

    +

    Scheduled Tasks

    View and manage background tasks. You can trigger tasks manually or adjust their schedules, including whether a task runs on server startup. diff --git a/web/src/pages/AdminUsers.tsx b/web/src/pages/AdminUsers.tsx index eedf6d78..255bee36 100644 --- a/web/src/pages/AdminUsers.tsx +++ b/web/src/pages/AdminUsers.tsx @@ -303,27 +303,29 @@ export default function AdminUsers() { to={`/admin/history?user_id=${u.id}`} aria-label={`View ${u.username} playback history`} > - +

    diff --git a/web/src/pages/Catalog.tsx b/web/src/pages/Catalog.tsx index 688f8d1e..2ad08ed5 100644 --- a/web/src/pages/Catalog.tsx +++ b/web/src/pages/Catalog.tsx @@ -31,6 +31,13 @@ function defaultCatalogTitle(source: string, searchQuery?: string) { return "Catalog"; } +function defaultCatalogSubtitle(source: string): string { + if (source === "favorites") return "Movies and shows you've marked as favorites."; + if (source === "watchlist") return "Things you've saved to watch later."; + if (source === "history") return "Everything you've recently watched."; + return "Refine the archive by type, era, rating, or genre."; +} + export default function Catalog() { const [searchParams, setSearchParams] = useSearchParams(); const state = useMemo(() => parseCatalogSearchParams(searchParams), [searchParams]); @@ -175,28 +182,34 @@ function CatalogResults({ }); }, []); + const totalItems = catalogQuery.data?.totalItems ?? 0; + // For an in-app search the count reflects local library hits only; requestable + // matches live in a separate section, so scope the label to avoid a "0 results" + // reading while an outside-library result is visible. + const resultNoun = + state.source === "query" ? "in library" : `result${totalItems === 1 ? "" : "s"}`; + return (

    {title}

    - Refine the archive by type, era, rating, or genre. + {defaultCatalogSubtitle(state.source)}

    {showExactResultCount ? ( -
    +
    - {catalogQuery.data?.totalItems ?? 0} + {totalItems} - result{(catalogQuery.data?.totalItems ?? 0) === 1 ? "" : "s"} + {resultNoun} - {catalogQuery.data?.totalItems ?? 0} result - {(catalogQuery.data?.totalItems ?? 0) === 1 ? "" : "s"} + {totalItems} {resultNoun}
    ) : null} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 10665673..7f0f3950 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -211,9 +211,13 @@ export default function Login() { if (loading || setupLoading) { return ( -
    -
    -
    +
    +
    +
    ); } @@ -260,7 +264,8 @@ export default function Login() { : "/signup"; return ( -
    +
    +

    Sign in to {serverName}

    {serverName} @@ -423,6 +428,6 @@ export default function Login() {

    -
    + ); } diff --git a/web/src/pages/Profiles.tsx b/web/src/pages/Profiles.tsx index 1dfa754f..11b3766c 100644 --- a/web/src/pages/Profiles.tsx +++ b/web/src/pages/Profiles.tsx @@ -119,18 +119,23 @@ export default function Profiles() {
    ))} diff --git a/web/src/pages/WatchTogetherJoin.tsx b/web/src/pages/WatchTogetherJoin.tsx index 4fb06124..86059266 100644 --- a/web/src/pages/WatchTogetherJoin.tsx +++ b/web/src/pages/WatchTogetherJoin.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from "react-router"; import { ApiClientError } from "@/api/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { createWatchTogetherRoom, joinWatchTogetherRoom, @@ -24,6 +25,7 @@ function describeJoinError(error: unknown) { } export default function WatchTogetherJoin() { + useDocumentTitle("Watch Party"); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const token = searchParams.get("token")?.trim() ?? ""; diff --git a/web/src/pages/settings/PlaybackSettings.tsx b/web/src/pages/settings/PlaybackSettings.tsx index 4ddff7aa..4abe67d6 100644 --- a/web/src/pages/settings/PlaybackSettings.tsx +++ b/web/src/pages/settings/PlaybackSettings.tsx @@ -58,21 +58,20 @@ function AutoPlayNextSetting({ profileId }: { profileId: string }) { label="Auto-play next episode" description="Start the next episode automatically after the current one ends." control={(id) => ( -
    - - setDeviceSetting.mutate( - { key: AUTO_PLAY_NEXT_KEY, value: checked ? "true" : "false" }, - { - onSuccess: () => toast.success("Auto-play preference saved"), - onError: () => toast.error("Failed to save auto-play preference"), - }, - ) - } - /> -
    + + setDeviceSetting.mutate( + { key: AUTO_PLAY_NEXT_KEY, value: checked ? "true" : "false" }, + { + onSuccess: () => toast.success("Auto-play preference saved"), + onError: () => toast.error("Failed to save auto-play preference"), + }, + ) + } + /> )} /> ); @@ -89,7 +88,7 @@ function NextUpSetting() { label="Next up episodes" description="Choose whether upcoming episodes stay with Continue Watching or get their own row." control={(id) => ( -
    +
    saveProfileField({ quality_preference: value })} > - + @@ -197,14 +200,18 @@ export default function PlaybackSettings() { label="Spoken language" description="Prefer a spoken language for this profile when multiple tracks are available." control={(id) => ( -
    +
    ` block immediately after it unchanged): + +```tsx + {/* Preset pills (desktop) */} +
    + {PRESET_OPTIONS.map((opt) => ( + + ))} +
    + + {/* Preset dropdown (smaller displays) */} +
    + +
    +``` + +- [ ] **Step 6: Update the empty state to nudge between presets** + +Replace the render-site usage: + +```tsx +
    + +
    +``` + +And replace the `CalendarEmpty` component: + +```tsx +function CalendarEmpty({ + filter, + onSelectPreset, +}: { + filter: string; + onSelectPreset: (f: string) => void; +}) { + const isEverything = filter === "everything" || filter === "all"; + return ( +
    + +

    + {filter === "following" + ? "Nothing upcoming from shows you follow this week." + : isEverything + ? "Nothing scheduled this week." + : "No events this week for this view."} +

    + {!isEverything && ( +
    + + + +
    + )} +
    + ); +} +``` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `cd web && pnpm exec vitest run src/pages/Calendar.test.tsx` +Expected: PASS (both cases — `filter=watchlist` still passes through; no-filter now resolves to `following`). + +- [ ] **Step 8: Build + lint** + +Run: `cd web && pnpm run build && pnpm run lint` +Expected: build succeeds; lint passes for `Calendar.tsx` (ignore unrelated pre-existing `format:check` failures). + +- [ ] **Step 9: Commit** + +```bash +git add web/src/pages/Calendar.tsx web/src/pages/Calendar.test.tsx +git commit -m "feat(calendar): preset selector with responsive pills, persistence, empty-state nudge" +``` + +--- + +## Task 7: Watched overlay on the event card + +**Files:** +- Modify: `web/src/components/calendar/CalendarEventCard.tsx` + +- [ ] **Step 1: Add the watched treatment** + +In `CalendarEventCard`, compute `watched` and apply it to the image wrapper + title, and add a check overlay. Replace the component body's relevant parts: + +After `const thumbhashUrl = ...;` add: + +```tsx + const watched = event.watched === true; +``` + +Change the image wrapper `className` to dim when watched: + +```tsx +
    ` of the `media-card-image` block (right after the badges block): + +```tsx + {watched && ( +
    + + ✓ + +
    + )} +``` + +Dim the title when watched: + +```tsx +
    + {event.title} +
    +``` + +- [ ] **Step 2: Build + lint** + +Run: `cd web && pnpm run build && pnpm run lint` +Expected: build succeeds; lint passes for `CalendarEventCard.tsx`. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/components/calendar/CalendarEventCard.tsx +git commit -m "feat(calendar): dim and check-mark already-watched event cards" +``` + +--- + +## Final verification + +- [ ] **Backend:** `go build ./... && go test ./internal/catalog/ ./internal/api/...` → PASS +- [ ] **Frontend:** `cd web && pnpm run build && pnpm exec vitest run src/pages/Calendar.test.tsx && pnpm run lint` → PASS +- [ ] **Manual smoke (optional, via dev server):** load `/calendar` → defaults to **Following**; switch presets (pills on desktop, dropdown when narrow); reload → remembers last preset; an empty **Following** week shows the Popular/Trending/Everything nudge; watched episodes render dimmed with a ✓. + +--- + +## Notes & deviations from spec + +- **Preset persistence is browser-level** (`localStorage` key `calendar:preset`), not keyed by profile id, to avoid depending on a profile-id hook. Per-profile keying (or a server-side preference) is a clean follow-up once the current-profile hook is confirmed. +- **Favorites/watchlist** remain valid `filter` values (resolved via the same id-set path) for backward-compatible client links, even though the web UI no longer surfaces them as separate presets — **Following** subsumes them. +- **Watched overlay** is best-effort: a lookup error logs (`slog.WarnContext`) and renders no watched marks rather than failing the calendar. +- **Trending** reads the canonical `tmdb` / `week` snapshot. Unioning sources/windows is deferred (YAGNI). diff --git a/docs/superpowers/specs/2026-05-29-calendar-presets-design.md b/docs/superpowers/specs/2026-05-29-calendar-presets-design.md new file mode 100644 index 00000000..56091176 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-calendar-presets-design.md @@ -0,0 +1,141 @@ +# Calendar Presets & Personalized Default — Design + +- **Date:** 2026-05-29 +- **Status:** Approved (design); ready for implementation planning +- **Scope:** `silo-server` (Go backend + web admin UI). Client follow-ups in `silo-android` / `silo-apple` are additive and optional. +- **Note:** All paths are repository-relative. Commands assume the repository root is the cwd. + +## Problem + +The calendar works but is not practical. It defaults to *every* upcoming airing in the catalog, so it is noisy and undifferentiated — the user has to mentally filter out shows they don't care about. We want a Simkl-style preset switcher with a personalized default, powered by the server's real watch data rather than a manually curated list. + +## Goals + +- Default the calendar to a personalized **Following** view (shows the profile actually engages with). +- Offer a small set of presets, including a **server-wide** popularity view and an **external trending** view. +- Keep it cheap under load (hundreds of concurrent users): presets must **filter, not compute**. +- Make "what do I still need to watch this week" visible via a watched overlay. +- Preserve backward compatibility for existing clients. + +## Non-Goals + +- No cross-user caching of the windowed airing query yet (phased — add only if metrics show strain). +- No pulling in external content that isn't in the library (e.g. a followed show whose next season hasn't been added). The calendar stays catalog-scoped. +- No change to the week-view layout, timezone handling, or event ordering (recently tuned; left intact). +- No region/country or "For Kids" presets (considered and cut). + +## Presets + +The selector replaces today's `All / Favorites / Watchlist` toggles. + +| Preset | Shows | Source | +|---|---|---| +| **Following** *(default)* | Upcoming airings for series the profile has watched, favorited, or watchlisted | per-profile: `user_favorites` ∪ `user_watchlist` ∪ watched-series rollup | +| **Popular here** | Airings for what is most-watched server-wide | cached `recommendation_cache` (`RecTypePopular`, global key) | +| **Trending** | Airings for externally-trending shows that exist in the library | `trending_discover_snapshots` | +| **Everything** | All catalog airings (today's behavior) | no restriction | + +Per-profile vs server-wide is split cleanly across two presets: **Following** is personal (this profile), **Popular here** is the server-wide aggregate. + +## Core principle: filter, don't compute + +Every preset resolves to **the windowed airing candidate list ∩ a small id-set**. No preset triggers a fresh aggregation at request time. + +1. **Step 1 — windowed candidate fetch** (unchanged from today): the 3-branch UNION in `internal/catalog/calendar_repo.go` over movies/episodes/season-premieres, scoped only by library access + content rating, across the ±2-day-padded window. Its result depends only on the access signature, not on viewer identity, so it remains cacheable across users later. +2. **Step 2 — intersect with one id-set**, chosen by preset: + - **Following** → resolve the profile's followed id-set once per request, then filter `itemIDExpr = ANY($set)`. + - **Popular / Trending** → read the already-cached global id-set and intersect. + - **Everything** → no restriction. +3. **Step 3 — group by day, sort by viewer-local wall-clock time, respond** (unchanged). + +The only heavy SQL is Step 1, which already runs today for every calendar load. Presets add a bounded id-set intersection, never a new aggregation. + +### Unify the filter mechanism + +All personal/preset filters resolve to "an id-set, then `itemIDExpr = ANY($set)`". This replaces the special-cased favorites/watchlist `EXISTS` subqueries in `appendPersonalFilterClause` with a single code path (favorites and watchlist become id-sets resolved from their tables — they are subsets of Following). One mechanism is easier to reason about and removes duplicated clause-building. + +`itemIDExpr` continues to be `mi.content_id` for movies and `e.series_id` / `s.series_id` for episodes / season premieres, so a watched/favorited **movie** matches by its own content_id while a followed **series** matches by series id. + +### Following id-set + +Resolved per request as the DISTINCT union of: + +- `SELECT media_item_id FROM user_favorites WHERE user_id = $u AND profile_id = $p` +- `SELECT media_item_id FROM user_watchlist WHERE user_id = $u AND profile_id = $p` +- `SELECT DISTINCT COALESCE(e.series_id, wp.media_item_id) FROM user_watch_progress wp LEFT JOIN episodes e ON e.content_id = wp.media_item_id WHERE wp.user_id = $u AND wp.profile_id = $p` + +"Engaged" = the presence of a `user_watch_progress` row (you've started it); no completion threshold required for Following. The rollup mirrors the established pattern in `GetPopularItems` (`internal/recommendations/repo.go`) — reuse that rollup expression rather than duplicate it. + +The candidate list (Step 1) is small (one week of airings), so intersecting against a bounded array is cheap. Caveat: a pathologically large followed-set (a profile that has watched thousands of distinct series) makes the array large; if that ever shows up as hot, switch the Following preset to correlated `EXISTS` against the user's tables. Not building that now. + +### Popular / Trending id-sets + +- **Popular**: `GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypePopular, "")` → `[]ScoredItem`; use `MediaItemID` values (already series-level via the `GetPopularItems` rollup). Refreshed by the recommendations worker on a TTL; readers never aggregate live. +- **Trending**: `TrendingSnapshotRepository.Get(ctx, source, window)` → `TrendingSnapshot.ContentIDs` (pre-resolved to catalog content_ids, viewer-agnostic). The calendar reads the canonical default snapshot (`tmdb` / `week`). Unioning multiple sources/windows is possible but out of scope. + +**Access is preserved automatically:** Popular/Trending id-sets are viewer-agnostic and may include items a given profile cannot access. Because we intersect them with the access-scoped Step-1 candidates (library scoping + `MaxContentRating` already applied there), inaccessible items drop out naturally. No extra access check needed on the id-sets. + +**Cold/empty cache:** if the Popular cache or Trending snapshot is empty (fresh server, worker hasn't run, provider unconfigured), the id-set is empty and the preset returns no rows. This must render the empty state, not an error. + +## Watched overlay + +After Step 1, decorate the windowed events with per-profile watched status: + +- One bounded lookup: `SELECT media_item_id FROM user_watch_progress WHERE user_id = $u AND profile_id = $p AND completed = true AND media_item_id = ANY($episodeContentIds)` over the window's episode/movie content_ids. +- Watched items render dimmed + ✓. This is a per-profile overlay applied after Step 1 (like the Following filter), so it does not affect the shareability of the Step-1 candidate set. +- Premiere / finale / new-season badges extend the existing `buildBadges` logic in `internal/api/handlers/calendar.go`. + +## UI / UX + +- **Selector** (`web/src/pages/Calendar.tsx`): segmented pills on desktop, collapsing to a dropdown on narrow viewports — the same responsive swap the week navigator already uses. The library picker stays beside it. Both presentations bind to one selected-preset state. +- **Empty state**: when **Following** has no upcoming airings (new or sparse profile), show a nudge with quick switches to Popular / Trending / Everything instead of a blank week. +- **Persistence**: remember the profile's last-chosen preset in `localStorage` (keyed by profile id) so the calendar reopens where the user left it. Server-side profile preference is a possible future enhancement. +- **Card cues** (`web/src/components/calendar/CalendarEventCard.tsx`, `web/src/lib/upcomingEventPresentation.ts`): badges + watched overlay as above. + +## API contract + +- Extend `/calendar`'s `filter` parameter to accept `following | popular | trending | everything`. +- **Keep `all | favorites | watchlist` valid** so existing `silo-android` / `silo-apple` clients keep working unchanged. `all` and `everything` are equivalent. This is purely additive. +- Add an additive `watched: bool` field (and any new badge string) to each event item. Older clients ignore unknown fields. +- Default when `filter` is omitted: today it is `all`. The **web client** will request `following` explicitly; the server default stays `all` to avoid changing behavior for existing API consumers. (Default-view personalization is a client choice, not a server-contract change.) +- **Profile requirement:** the `/calendar` route already runs behind `RequireProfile`, so a profile is always present. `following` (and legacy `favorites` / `watchlist`) plus the watched overlay are per-profile and resolve against it; `popular` / `trending` / `everything` / `all` are profile-agnostic and ignore it. + +### Multi-repo coordination (per CLAUDE.md) + +- `silo-android` / `silo-apple`: may adopt the new presets and the watched cue as independent follow-ups. Nothing here forces a lockstep change because old filter values and response shape remain valid. + +## Implementation surface + +Backend: +- `internal/catalog/calendar_repo.go` — add an id-set restriction to `CalendarFilter`; generalize `appendPersonalFilterClause` into a single `ANY($set)` path; keep library/rating clauses intact. +- A followed-id-set resolver, `ListFollowedSeriesIDs(ctx, userID, profileID)`, placed in the package that owns the favorites/watchlist/episode join; reuse the `GetPopularItems` rollup expression. +- `internal/api/handlers/calendar.go` — validate the expanded `filter` set; orchestrate preset → id-set resolution (Following resolver, Popular cache read, Trending snapshot read); apply the watched overlay; extend `buildBadges`. + +Frontend: +- `web/src/pages/Calendar.tsx` — responsive selector + preset persistence + empty state. +- `web/src/hooks/queries/calendar.ts` — new `filter` values; `watched` field on the event type. +- `web/src/components/calendar/CalendarEventCard.tsx` — watched overlay + badge rendering. +- `web/src/lib/upcomingEventPresentation.ts` — badge/treatment helpers. + +## Edge cases + +- Cold/empty Popular cache or Trending snapshot → empty preset → empty state, never an error. +- Profile with no follows → Following empty → empty state with preset nudges. +- Large followed-set → bounded array today; correlated-`EXISTS` fallback noted for later. +- Movie vs episode/season id matching (`content_id` vs `series_id`) handled by the existing `itemIDExpr` selection per branch. +- Specials (`season_number = 0`) remain excluded. +- Access control (`MaxContentRating`, library scoping, disabled libraries) still enforced for every preset via Step 1; viewer-agnostic id-sets cannot leak inaccessible items because of the intersection. +- Backward-compat filter values (`all | favorites | watchlist`) still resolve through the unified id-set path with identical results. + +## Testing strategy + +- **Repo** (`internal/catalog/calendar_repo_test.go`): each preset's id-set filter (following / popular / trending / everything), empty id-set → zero rows, access + rating enforcement preserved across presets, movie-by-content_id vs series-by-series_id matching, legacy favorites/watchlist parity. +- **Handler** (`internal/api/handlers/calendar_test.go`): filter validation accepts new + legacy values; Popular/Trending cold cache degrades to empty gracefully; `watched` field populated correctly; watched lookup bounded to the window. +- **Frontend**: selector responsive swap (pills ↔ dropdown), preset persistence across reloads, watched overlay + badge rendering, Following empty state. + +## Future work + +- Cross-user cache of the Step-1 windowed candidate set keyed by (window, access-signature), if load metrics justify it. +- External enrichment of followed shows whose next season is not yet in the library. +- Server-side persisted preset preference per profile. +- Optional union of multiple Trending sources/windows. From 62b3861da05fab988cd7dfea280df24310762518 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 29 May 2026 13:49:34 -0400 Subject: [PATCH 127/201] feat(calendar): generalize personal filter to an id-set restriction Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/catalog/calendar_repo.go | 73 +++++++++++++------------- internal/catalog/calendar_repo_test.go | 44 ++++++++++++++++ 2 files changed, 80 insertions(+), 37 deletions(-) diff --git a/internal/catalog/calendar_repo.go b/internal/catalog/calendar_repo.go index 46dd7069..7469eca2 100644 --- a/internal/catalog/calendar_repo.go +++ b/internal/catalog/calendar_repo.go @@ -32,13 +32,17 @@ type CalendarEvent struct { type CalendarFilter struct { Start time.Time End time.Time - Filter string // "all" | "favorites" | "watchlist" LibraryID *int AllowedLibraryIDs []int DisabledLibraryIDs []int MaxContentRating string - UserID int - ProfileID string + + // RestrictByIDs limits results to items whose movie content_id (movies) or + // series_id (episodes / season premieres) is in RestrictToIDs. When + // RestrictByIDs is true and RestrictToIDs is empty, no rows match. Callers + // resolve the id-set (Following / Popular / Trending) before querying. + RestrictByIDs bool + RestrictToIDs []string } // CalendarRepository provides calendar queries across movies, episodes, and seasons. @@ -102,9 +106,18 @@ func (r *CalendarRepository) buildListEventsQuery(f CalendarFilter) (string, []a args = append(args, f.End) argIdx++ - movieBranch := r.buildMovieBranch(startArg, endArg, f, &args, &argIdx) - filteredEpisodes := r.buildFilteredEpisodesCTE(startArg, endArg, f, &args, &argIdx) - filteredSeasons := r.buildFilteredSeasonsCTE(startArg, endArg, f, &args, &argIdx) + // Optional id-set restriction, appended once and shared by all branches. + // restrictArg stays 0 when the set is empty so branches short-circuit. + restrictArg := 0 + if f.RestrictByIDs && len(f.RestrictToIDs) > 0 { + restrictArg = argIdx + args = append(args, f.RestrictToIDs) + argIdx++ + } + + movieBranch := r.buildMovieBranch(startArg, endArg, restrictArg, f, &args, &argIdx) + filteredEpisodes := r.buildFilteredEpisodesCTE(startArg, endArg, restrictArg, f, &args, &argIdx) + filteredSeasons := r.buildFilteredSeasonsCTE(startArg, endArg, restrictArg, f, &args, &argIdx) episodeBranch := r.buildEpisodeBranch() seasonBranch := r.buildSeasonBranch() @@ -139,14 +152,14 @@ FROM ( return query, args } -func (r *CalendarRepository) buildMovieBranch(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string { +func (r *CalendarRepository) buildMovieBranch(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string { conditions := []string{ "mi.type = 'movie'", fmt.Sprintf("mi.release_date BETWEEN $%d::date AND $%d::date", startArg, endArg), } r.appendLibraryExistsClauses("mi.content_id", f, &conditions, args, argIdx) r.appendContentRatingClause("mi", f, &conditions, args, argIdx) - r.appendPersonalFilterClause("mi.content_id", f, &conditions, args, argIdx) + r.appendRestrictClause("mi.content_id", restrictArg, f, &conditions) return fmt.Sprintf(`SELECT mi.content_id, 'movie'::text AS type, mi.title, NULL::text AS episode_title, NULL::text AS series_id, @@ -158,14 +171,14 @@ FROM media_items mi WHERE %s`, strings.Join(conditions, " AND ")) } -func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string { +func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string { conditions := []string{ fmt.Sprintf("e.air_date BETWEEN $%d::date AND $%d::date", startArg, endArg), "e.season_number > 0", // exclude specials } r.appendLibraryExistsClauses("e.series_id", f, &conditions, args, argIdx) r.appendContentRatingClause("mi", f, &conditions, args, argIdx) - r.appendPersonalFilterClause("e.series_id", f, &conditions, args, argIdx) + r.appendRestrictClause("e.series_id", restrictArg, f, &conditions) return fmt.Sprintf(`SELECT e.content_id, e.series_id, e.season_number, e.episode_number, e.title AS episode_title, e.air_date, @@ -188,14 +201,14 @@ FROM filtered_episodes fe LEFT JOIN season_finales sf ON sf.series_id = fe.series_id AND sf.season_number = fe.season_number` } -func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string { +func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string { conditions := []string{ fmt.Sprintf("s.air_date BETWEEN $%d::date AND $%d::date", startArg, endArg), "s.season_number > 0", // exclude specials } r.appendLibraryExistsClauses("s.series_id", f, &conditions, args, argIdx) r.appendContentRatingClause("mi", f, &conditions, args, argIdx) - r.appendPersonalFilterClause("s.series_id", f, &conditions, args, argIdx) + r.appendRestrictClause("s.series_id", restrictArg, f, &conditions) return fmt.Sprintf(`SELECT s.content_id, s.series_id, s.season_number, s.title AS episode_title, s.air_date, mi.title AS title, mi.air_time, mi.air_timezone, @@ -282,30 +295,16 @@ func (r *CalendarRepository) appendLibraryExistsClauses(contentIDExpr string, f *argIdx++ } -// appendPersonalFilterClause adds favorites/watchlist EXISTS subqueries. -func (r *CalendarRepository) appendPersonalFilterClause(itemIDExpr string, f CalendarFilter, conditions *[]string, args *[]any, argIdx *int) { - switch f.Filter { - case "favorites": - userArg := *argIdx - *args = append(*args, f.UserID) - *argIdx++ - profileArg := *argIdx - *args = append(*args, f.ProfileID) - *argIdx++ - *conditions = append(*conditions, fmt.Sprintf( - "EXISTS (SELECT 1 FROM user_favorites uf WHERE uf.user_id = $%d AND uf.profile_id = $%d AND uf.media_item_id = %s)", - userArg, profileArg, itemIDExpr, - )) - case "watchlist": - userArg := *argIdx - *args = append(*args, f.UserID) - *argIdx++ - profileArg := *argIdx - *args = append(*args, f.ProfileID) - *argIdx++ - *conditions = append(*conditions, fmt.Sprintf( - "EXISTS (SELECT 1 FROM user_watchlist uw WHERE uw.user_id = $%d AND uw.profile_id = $%d AND uw.media_item_id = %s)", - userArg, profileArg, itemIDExpr, - )) +// appendRestrictClause limits a branch to the id-set in CalendarFilter. restrictArg +// is the positional parameter holding the id array (0 when the set is empty). An +// empty restriction matches nothing. +func (r *CalendarRepository) appendRestrictClause(itemIDExpr string, restrictArg int, f CalendarFilter, conditions *[]string) { + if !f.RestrictByIDs { + return } + if restrictArg == 0 { + *conditions = append(*conditions, "1 = 0") + return + } + *conditions = append(*conditions, fmt.Sprintf("%s = ANY($%d)", itemIDExpr, restrictArg)) } diff --git a/internal/catalog/calendar_repo_test.go b/internal/catalog/calendar_repo_test.go index f846dbb0..fc6ca066 100644 --- a/internal/catalog/calendar_repo_test.go +++ b/internal/catalog/calendar_repo_test.go @@ -102,3 +102,47 @@ func TestBuildListEventsQuery_RejectsExplicitLibraryOutsideAllowedScope(t *testi t.Fatalf("expected season branch to short-circuit inaccessible library selection, got:\n%s", query) } } + +func TestBuildListEventsQuery_AppliesIDRestriction(t *testing.T) { + t.Parallel() + + repo := &CalendarRepository{} + query, args := repo.buildListEventsQuery(CalendarFilter{ + Start: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, time.April, 12, 0, 0, 0, 0, time.UTC), + RestrictByIDs: true, + RestrictToIDs: []string{"series-1", "movie-2"}, + }) + + for _, fragment := range []string{ + "mi.content_id = ANY($3)", + "e.series_id = ANY($3)", + "s.series_id = ANY($3)", + } { + if !strings.Contains(query, fragment) { + t.Fatalf("expected query to contain %q, got:\n%s", fragment, query) + } + } + if len(args) != 3 { + t.Fatalf("expected start/end/ids args, got %d", len(args)) + } +} + +func TestBuildListEventsQuery_EmptyRestrictionMatchesNothing(t *testing.T) { + t.Parallel() + + repo := &CalendarRepository{} + query, args := repo.buildListEventsQuery(CalendarFilter{ + Start: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, time.April, 12, 0, 0, 0, 0, time.UTC), + RestrictByIDs: true, + RestrictToIDs: nil, + }) + + if strings.Count(query, "1 = 0") != 3 { + t.Fatalf("expected each branch to short-circuit with 1 = 0, got:\n%s", query) + } + if len(args) != 2 { + t.Fatalf("expected only start/end args, got %d", len(args)) + } +} From 1ba76af353ab968d2e2ebaf4d43c553df941e78f Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 29 May 2026 13:50:10 -0400 Subject: [PATCH 128/201] feat(calendar): add per-profile followed/favorites/watchlist/watched resolvers Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/catalog/calendar_personal.go | 87 ++++++++++++++++++++++ internal/catalog/calendar_personal_test.go | 44 +++++++++++ 2 files changed, 131 insertions(+) create mode 100644 internal/catalog/calendar_personal.go create mode 100644 internal/catalog/calendar_personal_test.go diff --git a/internal/catalog/calendar_personal.go b/internal/catalog/calendar_personal.go new file mode 100644 index 00000000..02d31642 --- /dev/null +++ b/internal/catalog/calendar_personal.go @@ -0,0 +1,87 @@ +package catalog + +import ( + "context" + "fmt" +) + +// Per-profile id-set queries backing the calendar presets. All run on the same +// pool as the base calendar query. The watched-series rollup intentionally +// mirrors recommendations.GetPopularItems (COALESCE(e.series_id, wp.media_item_id)) +// so "engaged with a series" means the same thing everywhere; it is a one-line +// SQL expression, not worth a cross-package extraction. +const ( + followedItemIDsQuery = ` +SELECT media_item_id FROM user_favorites WHERE user_id = $1 AND profile_id = $2 +UNION +SELECT media_item_id FROM user_watchlist WHERE user_id = $1 AND profile_id = $2 +UNION +SELECT DISTINCT COALESCE(e.series_id, wp.media_item_id) +FROM user_watch_progress wp +LEFT JOIN episodes e ON e.content_id = wp.media_item_id +WHERE wp.user_id = $1 AND wp.profile_id = $2` + + favoriteItemIDsQuery = `SELECT media_item_id FROM user_favorites WHERE user_id = $1 AND profile_id = $2` + watchlistItemIDsQuery = `SELECT media_item_id FROM user_watchlist WHERE user_id = $1 AND profile_id = $2` + + watchedItemIDsQuery = ` +SELECT media_item_id +FROM user_watch_progress +WHERE user_id = $1 AND profile_id = $2 AND completed = true AND media_item_id = ANY($3)` +) + +// ListFollowedItemIDs returns the profile's followed set: favorited ∪ watchlisted ∪ +// any series/movie they have watch progress on. +func (r *CalendarRepository) ListFollowedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) { + return r.queryIDs(ctx, followedItemIDsQuery, userID, profileID) +} + +// ListFavoriteItemIDs returns the profile's favorited content ids. +func (r *CalendarRepository) ListFavoriteItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) { + return r.queryIDs(ctx, favoriteItemIDsQuery, userID, profileID) +} + +// ListWatchlistItemIDs returns the profile's watchlisted content ids. +func (r *CalendarRepository) ListWatchlistItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) { + return r.queryIDs(ctx, watchlistItemIDsQuery, userID, profileID) +} + +func (r *CalendarRepository) queryIDs(ctx context.Context, query string, userID int, profileID string) ([]string, error) { + rows, err := r.pool.Query(ctx, query, userID, profileID) + if err != nil { + return nil, fmt.Errorf("calendar personal query: %w", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scanning personal id: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// ListWatchedItemIDs returns the subset of contentIDs the profile has completed, +// as a set. An empty input returns an empty set without querying. +func (r *CalendarRepository) ListWatchedItemIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]bool, error) { + watched := make(map[string]bool, len(contentIDs)) + if len(contentIDs) == 0 { + return watched, nil + } + rows, err := r.pool.Query(ctx, watchedItemIDsQuery, userID, profileID, contentIDs) + if err != nil { + return nil, fmt.Errorf("calendar watched query: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scanning watched id: %w", err) + } + watched[id] = true + } + return watched, rows.Err() +} diff --git a/internal/catalog/calendar_personal_test.go b/internal/catalog/calendar_personal_test.go new file mode 100644 index 00000000..cb1bed6d --- /dev/null +++ b/internal/catalog/calendar_personal_test.go @@ -0,0 +1,44 @@ +package catalog + +import ( + "strings" + "testing" +) + +func TestFollowedItemIDsQuery_UnionsAllSignals(t *testing.T) { + for _, fragment := range []string{ + "FROM user_favorites", + "FROM user_watchlist", + "user_watch_progress wp", + "LEFT JOIN episodes e ON e.content_id = wp.media_item_id", + "COALESCE(e.series_id, wp.media_item_id)", + "UNION", + } { + if !strings.Contains(followedItemIDsQuery, fragment) { + t.Fatalf("followedItemIDsQuery missing %q:\n%s", fragment, followedItemIDsQuery) + } + } +} + +func TestWatchedItemIDsQuery_FiltersCompletedWithinSet(t *testing.T) { + for _, fragment := range []string{ + "FROM user_watch_progress", + "completed = true", + "media_item_id = ANY($3)", + } { + if !strings.Contains(watchedItemIDsQuery, fragment) { + t.Fatalf("watchedItemIDsQuery missing %q:\n%s", fragment, watchedItemIDsQuery) + } + } +} + +func TestFavoriteAndWatchlistQueries_ScopeToProfile(t *testing.T) { + if !strings.Contains(favoriteItemIDsQuery, "FROM user_favorites") || + !strings.Contains(favoriteItemIDsQuery, "profile_id = $2") { + t.Fatalf("favoriteItemIDsQuery wrong:\n%s", favoriteItemIDsQuery) + } + if !strings.Contains(watchlistItemIDsQuery, "FROM user_watchlist") || + !strings.Contains(watchlistItemIDsQuery, "profile_id = $2") { + t.Fatalf("watchlistItemIDsQuery wrong:\n%s", watchlistItemIDsQuery) + } +} From 2bd0edb264a6fc8b6c49888613709fae238d291b Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 29 May 2026 13:55:39 -0400 Subject: [PATCH 129/201] feat(calendar): resolve presets to id-sets and overlay watched status Also drops the now-unused Filter/UserID/ProfileID fields from the blendUpcomingIntoDiscoverRows CalendarFilter literal in recommendations.go, which only wants an unrestricted windowed query. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/api/handlers/calendar.go | 148 ++++++++++++++++++--- internal/api/handlers/calendar_test.go | 161 +++++++++++++++++++++++ internal/api/handlers/recommendations.go | 3 - 3 files changed, 294 insertions(+), 18 deletions(-) diff --git a/internal/api/handlers/calendar.go b/internal/api/handlers/calendar.go index cecbb382..9925adb8 100644 --- a/internal/api/handlers/calendar.go +++ b/internal/api/handlers/calendar.go @@ -2,27 +2,62 @@ package handlers import ( "context" + "log/slog" "net/http" "sort" "strconv" "time" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/recommendations" + "github.com/Silo-Server/silo-server/internal/sections" ) type calendarRepository interface { ListEvents(ctx context.Context, f catalog.CalendarFilter) ([]catalog.CalendarEvent, error) } +// calendarPersonalRepo resolves per-profile id-sets and watched status. +type calendarPersonalRepo interface { + ListFollowedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) + ListFavoriteItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) + ListWatchlistItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) + ListWatchedItemIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]bool, error) +} + +// calendarPopularSource reads the cached server-wide popular id-set. +type calendarPopularSource interface { + GetRecommendationCache(ctx context.Context, userID int, profileID, recType, sourceItemID string) ([]recommendations.ScoredItem, error) +} + +// calendarTrendingSource reads the external-trending snapshot. +type calendarTrendingSource interface { + Get(ctx context.Context, source, window string) (sections.TrendingSnapshot, bool, error) +} + +const ( + calendarFilterAll = "all" + calendarFilterEverything = "everything" + calendarFilterFollowing = "following" + calendarFilterFavorites = "favorites" + calendarFilterWatchlist = "watchlist" + calendarFilterPopular = "popular" + calendarFilterTrending = "trending" +) + // CalendarHandler handles the calendar endpoint. type CalendarHandler struct { repo calendarRepository detailSvc *catalog.DetailService + personal calendarPersonalRepo // nil-tolerant (per-profile presets degrade to empty) + popular calendarPopularSource // nil when recommendations disabled + trending calendarTrendingSource // nil when trending disabled } -// NewCalendarHandler creates a new CalendarHandler. -func NewCalendarHandler(repo *catalog.CalendarRepository, detailSvc *catalog.DetailService) *CalendarHandler { - return &CalendarHandler{repo: repo, detailSvc: detailSvc} +// NewCalendarHandler creates a new CalendarHandler. The repo doubles as the +// per-profile resolver since *catalog.CalendarRepository implements both. +func NewCalendarHandler(repo *catalog.CalendarRepository, detailSvc *catalog.DetailService, popular calendarPopularSource, trending calendarTrendingSource) *CalendarHandler { + return &CalendarHandler{repo: repo, detailSvc: detailSvc, personal: repo, popular: popular, trending: trending} } // --- Response types --- @@ -42,6 +77,7 @@ type calendarEventResponse struct { LocalAirDate string `json:"local_air_date"` PosterURL string `json:"poster_url,omitempty"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` + Watched bool `json:"watched"` Badges []string `json:"badges"` } @@ -79,33 +115,39 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque filter := q.Get("filter") if filter == "" { - filter = "all" + filter = calendarFilterAll } switch filter { - case "all", "favorites", "watchlist": + case calendarFilterAll, calendarFilterEverything, calendarFilterFollowing, + calendarFilterFavorites, calendarFilterWatchlist, calendarFilterPopular, calendarFilterTrending: default: - writeError(w, http.StatusBadRequest, "bad_request", "filter must be all, favorites, or watchlist") + writeError(w, http.StatusBadRequest, "bad_request", "invalid filter") return } af := requestAccessFilter(r) - if (filter == "favorites" || filter == "watchlist") && af.ProfileID == "" { - writeError(w, http.StatusBadRequest, "bad_request", "profile required for favorites/watchlist filter") + viewerLocation := catalog.CalendarLocation(q.Get("timezone")) + + restrict, ids, err := h.resolveCalendarRestriction(r.Context(), filter, af) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to resolve calendar filter") + return + } + // A restricting preset with no ids can never match — skip the windowed query. + if restrict && len(ids) == 0 { + writeJSON(w, http.StatusOK, calendarResponse{Events: []calendarDayResponse{}}) return } - - viewerLocation := catalog.CalendarLocation(q.Get("timezone")) cf := catalog.CalendarFilter{ Start: start.AddDate(0, 0, -2), End: end.AddDate(0, 0, 2), - Filter: filter, AllowedLibraryIDs: af.AllowedLibraryIDs, DisabledLibraryIDs: af.DisabledLibraryIDs, MaxContentRating: af.MaxContentRating, - UserID: af.UserID, - ProfileID: af.ProfileID, + RestrictByIDs: restrict, + RestrictToIDs: ids, } if v := q.Get("library_id"); v != "" { @@ -123,12 +165,87 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque return } + watched := h.resolveWatched(r.Context(), af, events) + // Group events by date and build response. - days := groupEventsByDate(events, r, h.detailSvc, start, end, viewerLocation) + days := groupEventsByDate(events, r, h.detailSvc, start, end, viewerLocation, watched) writeJSON(w, http.StatusOK, calendarResponse{Events: days}) } -func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService, start, end time.Time, viewerLocation *time.Location) []calendarDayResponse { +// resolveCalendarRestriction maps a preset to an id-set restriction. restrict=false +// means no restriction (everything/all). A nil/missing source degrades that preset +// to an empty id-set (which the caller renders as an empty calendar). +func (h *CalendarHandler) resolveCalendarRestriction(ctx context.Context, filter string, af catalog.AccessFilter) (restrict bool, ids []string, err error) { + switch filter { + case calendarFilterAll, calendarFilterEverything: + return false, nil, nil + case calendarFilterFollowing: + if h.personal == nil { + return true, nil, nil + } + ids, err = h.personal.ListFollowedItemIDs(ctx, af.UserID, af.ProfileID) + return true, ids, err + case calendarFilterFavorites: + if h.personal == nil { + return true, nil, nil + } + ids, err = h.personal.ListFavoriteItemIDs(ctx, af.UserID, af.ProfileID) + return true, ids, err + case calendarFilterWatchlist: + if h.personal == nil { + return true, nil, nil + } + ids, err = h.personal.ListWatchlistItemIDs(ctx, af.UserID, af.ProfileID) + return true, ids, err + case calendarFilterPopular: + if h.popular == nil { + return true, nil, nil + } + items, err := h.popular.GetRecommendationCache(ctx, recommendations.GlobalCacheUserID, recommendations.GlobalCacheProfileID, recommendations.RecTypePopular, "") + if err != nil { + return true, nil, err + } + ids = make([]string, 0, len(items)) + for _, it := range items { + ids = append(ids, it.MediaItemID) + } + return true, ids, nil + case calendarFilterTrending: + if h.trending == nil { + return true, nil, nil + } + snap, ok, err := h.trending.Get(ctx, "tmdb", "week") + if err != nil { + return true, nil, err + } + if !ok { + return true, nil, nil + } + return true, snap.ContentIDs, nil + default: + return false, nil, nil + } +} + +// resolveWatched decorates events with the profile's completed status. Best-effort: +// a lookup failure logs and returns no watched marks rather than failing the page. +func (h *CalendarHandler) resolveWatched(ctx context.Context, af catalog.AccessFilter, events []catalog.CalendarEvent) map[string]bool { + if h.personal == nil || len(events) == 0 { + return map[string]bool{} + } + ids := make([]string, 0, len(events)) + for _, ev := range events { + ids = append(ids, ev.ContentID) + } + watched, err := h.personal.ListWatchedItemIDs(ctx, af.UserID, af.ProfileID, ids) + if err != nil { + slog.WarnContext(ctx, "calendar watched overlay failed", "error", err) + return map[string]bool{} + } + return watched +} + +func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService, start, end time.Time, viewerLocation *time.Location, watched map[string]bool) []calendarDayResponse { if len(events) == 0 { return []calendarDayResponse{} } @@ -241,6 +358,7 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv LocalAirDate: item.localDate, PosterURL: posterURLs[ev.PosterPath], PosterThumbhash: ev.PosterThumbhash, + Watched: watched[ev.ContentID], Badges: badges, }) } diff --git a/internal/api/handlers/calendar_test.go b/internal/api/handlers/calendar_test.go index 7ffe5d62..0387e35c 100644 --- a/internal/api/handlers/calendar_test.go +++ b/internal/api/handlers/calendar_test.go @@ -9,6 +9,8 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/recommendations" + "github.com/Silo-Server/silo-server/internal/sections" ) type stubCalendarRepo struct { @@ -422,3 +424,162 @@ func ptrString(value string) *string { func ptrInt(value int) *int { return &value } + +type stubCalendarPersonal struct { + followed []string + favorites []string + watchlist []string + watched map[string]bool + lastWatchedIDs []string +} + +func (s *stubCalendarPersonal) ListFollowedItemIDs(_ context.Context, _ int, _ string) ([]string, error) { + return s.followed, nil +} +func (s *stubCalendarPersonal) ListFavoriteItemIDs(_ context.Context, _ int, _ string) ([]string, error) { + return s.favorites, nil +} +func (s *stubCalendarPersonal) ListWatchlistItemIDs(_ context.Context, _ int, _ string) ([]string, error) { + return s.watchlist, nil +} +func (s *stubCalendarPersonal) ListWatchedItemIDs(_ context.Context, _ int, _ string, ids []string) (map[string]bool, error) { + s.lastWatchedIDs = ids + return s.watched, nil +} + +type stubPopularSource struct{ items []recommendations.ScoredItem } + +func (s *stubPopularSource) GetRecommendationCache(_ context.Context, _ int, _, _, _ string) ([]recommendations.ScoredItem, error) { + return s.items, nil +} + +type stubTrendingSource struct { + snap sections.TrendingSnapshot + ok bool +} + +func (s *stubTrendingSource) Get(_ context.Context, _, _ string) (sections.TrendingSnapshot, bool, error) { + return s.snap, s.ok, nil +} + +func TestHandleGetCalendar_FollowingPassesFollowedIDs(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{followed: []string{"s1", "s2"}}} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=following", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !repo.last.RestrictByIDs { + t.Fatalf("expected RestrictByIDs true") + } + if got := repo.last.RestrictToIDs; len(got) != 2 || got[0] != "s1" || got[1] != "s2" { + t.Fatalf("RestrictToIDs = %v, want [s1 s2]", got) + } +} + +func TestHandleGetCalendar_PopularReadsCache(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{ + repo: repo, + popular: &stubPopularSource{items: []recommendations.ScoredItem{{MediaItemID: "p1"}, {MediaItemID: "p2"}}}, + } + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=popular", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if !repo.last.RestrictByIDs || len(repo.last.RestrictToIDs) != 2 || repo.last.RestrictToIDs[0] != "p1" { + t.Fatalf("RestrictToIDs = %v, want [p1 p2]", repo.last.RestrictToIDs) + } +} + +func TestHandleGetCalendar_TrendingReadsSnapshot(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{ + repo: repo, + trending: &stubTrendingSource{ok: true, snap: sections.TrendingSnapshot{ContentIDs: []string{"t1"}}}, + } + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=trending", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if !repo.last.RestrictByIDs || len(repo.last.RestrictToIDs) != 1 || repo.last.RestrictToIDs[0] != "t1" { + t.Fatalf("RestrictToIDs = %v, want [t1]", repo.last.RestrictToIDs) + } +} + +func TestHandleGetCalendar_EverythingHasNoRestriction(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=everything", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if repo.last.RestrictByIDs { + t.Fatalf("expected no restriction for everything") + } +} + +func TestHandleGetCalendar_RejectsUnknownFilter(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=bogus", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if repo.calls != 0 { + t.Fatalf("expected repo not called, got %d", repo.calls) + } +} + +func TestHandleGetCalendar_EmptyFollowedShortCircuits(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{followed: nil}} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=following", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if repo.calls != 0 { + t.Fatalf("expected ListEvents skipped for empty followed set, got %d calls", repo.calls) + } +} + +func TestHandleGetCalendar_MarksWatchedItems(t *testing.T) { + repo := &stubCalendarRepo{events: []catalog.CalendarEvent{ + {ContentID: "ep-1", Type: "episode", Title: "Show", AirDate: time.Date(2026, time.April, 8, 0, 0, 0, 0, time.UTC)}, + {ContentID: "ep-2", Type: "episode", Title: "Show", AirDate: time.Date(2026, time.April, 8, 0, 0, 0, 0, time.UTC)}, + }} + handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{watched: map[string]bool{"ep-1": true}}} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=everything", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + var resp calendarResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + watched := map[string]bool{} + for _, day := range resp.Events { + for _, item := range day.Items { + watched[item.ContentID] = item.Watched + } + } + if !watched["ep-1"] || watched["ep-2"] { + t.Fatalf("watched flags = %v, want ep-1 true / ep-2 false", watched) + } +} diff --git a/internal/api/handlers/recommendations.go b/internal/api/handlers/recommendations.go index c6a57bb2..efa2b322 100644 --- a/internal/api/handlers/recommendations.go +++ b/internal/api/handlers/recommendations.go @@ -789,12 +789,9 @@ func (h *RecommendationsHandler) blendUpcomingIntoDiscoverRows( events, err := h.CalendarRepo.ListEvents(ctx, catalog.CalendarFilter{ Start: start, End: end, - Filter: "all", AllowedLibraryIDs: filter.AllowedLibraryIDs, DisabledLibraryIDs: filter.DisabledLibraryIDs, MaxContentRating: filter.MaxContentRating, - UserID: userID, - ProfileID: profileID, }) if err != nil { return discoverRows, err From 4dc0522cf27d368964b1ebe1d3ba4616e97935db Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 29 May 2026 13:56:14 -0400 Subject: [PATCH 130/201] feat(calendar): wire popular and trending sources into calendar handler Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/api/router.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/api/router.go b/internal/api/router.go index 9888ea68..d988241b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1268,7 +1268,9 @@ func NewRouter(deps Dependencies) chi.Router { } if calendarRepo != nil { - calendarHandler := handlers.NewCalendarHandler(calendarRepo, detailSvc) + calendarPopular := recommendations.NewRepo(deps.DB) + calendarTrending := sections.NewTrendingSnapshotRepository(deps.DB) + calendarHandler := handlers.NewCalendarHandler(calendarRepo, detailSvc, calendarPopular, calendarTrending) r.With(apimw.RequireProfile).Get("/calendar", calendarHandler.HandleGetCalendar) } From 431341c2358bfc842c89626b7bf1fb98efe851ed Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 29 May 2026 14:13:51 -0400 Subject: [PATCH 131/201] feat(calendar): add watched field to CalendarEvent type Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/hooks/queries/calendar.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/hooks/queries/calendar.ts b/web/src/hooks/queries/calendar.ts index ccbd1104..9b8a51f4 100644 --- a/web/src/hooks/queries/calendar.ts +++ b/web/src/hooks/queries/calendar.ts @@ -18,6 +18,7 @@ export interface CalendarEvent { local_air_date: string; poster_url?: string; poster_thumbhash?: string; + watched?: boolean; badges: string[]; } From c32b5703ace9a0524a81bcbc9394d1d1276ada18 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 29 May 2026 14:13:51 -0400 Subject: [PATCH 132/201] feat(calendar): preset selector with responsive pills, persistence, empty-state nudge Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/pages/Calendar.test.tsx | 2 +- web/src/pages/Calendar.tsx | 120 ++++++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 22 deletions(-) diff --git a/web/src/pages/Calendar.test.tsx b/web/src/pages/Calendar.test.tsx index 770980aa..afb7d05e 100644 --- a/web/src/pages/Calendar.test.tsx +++ b/web/src/pages/Calendar.test.tsx @@ -83,7 +83,7 @@ describe("Calendar page", () => { renderCalendar("/calendar?week=2026-04-06&library=7"); expect(mockUseCalendarWeek).toHaveBeenCalledWith("2026-04-06", { - filter: "all", + filter: "following", libraryId: 7, }); }); diff --git a/web/src/pages/Calendar.tsx b/web/src/pages/Calendar.tsx index f583b3e2..a6bb9936 100644 --- a/web/src/pages/Calendar.tsx +++ b/web/src/pages/Calendar.tsx @@ -17,25 +17,52 @@ import WeekNavigator from "@/components/calendar/WeekNavigator"; import DayGroup from "@/components/calendar/DayGroup"; import { addWeeks, formatDayHeading, getWeekDays, getWeekStart } from "@/lib/calendarWeek"; -type CalendarFilter = "all" | "favorites" | "watchlist"; +type CalendarFilter = "following" | "popular" | "trending" | "everything"; -const FILTER_OPTIONS: { value: CalendarFilter; label: string }[] = [ - { value: "all", label: "All" }, - { value: "favorites", label: "Favorites" }, - { value: "watchlist", label: "Watchlist" }, +const PRESET_OPTIONS: { value: CalendarFilter; label: string }[] = [ + { value: "following", label: "Following" }, + { value: "popular", label: "Popular" }, + { value: "trending", label: "Trending" }, + { value: "everything", label: "All" }, ]; +const DEFAULT_PRESET: CalendarFilter = "following"; +const PRESET_STORAGE_KEY = "calendar:preset"; + +// Accept the four presets plus legacy server values so old shared links keep working. +const KNOWN_FILTERS = new Set([ + "following", + "popular", + "trending", + "everything", + "all", + "favorites", + "watchlist", +]); + /** Skeleton rows mirror a full week; enough slides per row to fill wide viewports. */ const CALENDAR_SKELETON_DAY_ROWS = 7; const CALENDAR_SKELETON_ITEMS_PER_ROW = 18; +function readStoredPreset(): CalendarFilter { + if (typeof window === "undefined") return DEFAULT_PRESET; + const stored = window.localStorage.getItem(PRESET_STORAGE_KEY); + return stored && PRESET_OPTIONS.some((o) => o.value === stored) + ? (stored as CalendarFilter) + : DEFAULT_PRESET; +} + +function writeStoredPreset(value: string) { + if (typeof window === "undefined") return; + window.localStorage.setItem(PRESET_STORAGE_KEY, value); +} + function parseCalendarParams(searchParams: URLSearchParams) { const weekRaw = searchParams.get("week"); const weekStart = weekRaw && /^\d{4}-\d{2}-\d{2}$/.test(weekRaw) ? weekRaw : getWeekStart(new Date()); const rawFilter = searchParams.get("filter"); - const filter: CalendarFilter = - rawFilter === "favorites" || rawFilter === "watchlist" ? rawFilter : "all"; + const filter = rawFilter && KNOWN_FILTERS.has(rawFilter) ? rawFilter : readStoredPreset(); const libraryIdRaw = searchParams.get("library"); const libraryId = libraryIdRaw ? Number(libraryIdRaw) : undefined; return { weekStart, filter, libraryId }; @@ -77,7 +104,10 @@ export default function Calendar() { } const goToWeek = (w: string) => setParams({ week: w }); - const setFilter = (f: string) => setParams({ filter: f === "all" ? undefined : f }); + const setFilter = (f: string) => { + writeStoredPreset(f); + setParams({ filter: f === DEFAULT_PRESET ? undefined : f }); + }; const setLibrary = (id: string) => setParams({ library: id === "all" ? undefined : id }); const onSelectDay = (date: string) => { @@ -101,13 +131,13 @@ export default function Calendar() {

    Calendar

    - {/* Filter toggle */} + {/* Preset pills (desktop) */}
    - {FILTER_OPTIONS.map((opt) => ( + {PRESET_OPTIONS.map((opt) => ( + +
    + +

    + Playback pauses while the first lines are translated, then resumes with subtitles + streaming in. The finished track is saved for everyone. +

    + + )} +
    +
    +
    + ); + + return createPortal(modal, document.body); +} diff --git a/web/src/player/components/VideoPlayer.tsx b/web/src/player/components/VideoPlayer.tsx index bacf1149..b7d3d523 100644 --- a/web/src/player/components/VideoPlayer.tsx +++ b/web/src/player/components/VideoPlayer.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ParsedCue } from "../utils/parseVTT"; import { resolveSubtitleAutoSelect } from "../utils/subtitleSort"; import type HlsType from "hls.js"; import { PlayerControls } from "./PlayerControls"; @@ -50,6 +51,13 @@ import { toMediaTime, toPlayerTime } from "../utils/mediaTimeline"; import { buildWatchTogetherInviteUrl } from "@/lib/watchTogether"; import { toast } from "sonner"; +// Reserved index for the in-progress live AI translation track. Sits well above +// any real subtitle index so it never collides. +const LIVE_SUBTITLE_INDEX = 1_000_000; +// Resume playback once translated cues cover at least this far ahead of the +// playhead; a hard cap also resumes so we never wait forever. +const TRANSLATION_RESUME_TIMEOUT_MS = 30_000; + interface VideoPlayerProps { title: string; year?: number; @@ -244,6 +252,57 @@ export function VideoPlayer({ setSubtitleDelayMs(0); }, [activeFileId]); + // -- Live AI subtitle translation (streamed over the realtime websocket) -- + // While a translation runs, a synthetic "live" track is added to the list and + // selected; cues arrive over the websocket and the player pauses until the + // region near the playhead is covered, then resumes. + const [liveTranslation, setLiveTranslation] = useState<{ + trackKey: string; + language: string; + label: string; + } | null>(null); + const [liveCues, setLiveCues] = useState([]); + const [translationBuffering, setTranslationBuffering] = useState(false); + const translationPauseRef = useRef(false); + const translationResumeTimerRef = useRef(null); + // Whether playback should auto-resume once buffering ends. Captured at + // translation start: if the viewer had deliberately paused, we don't yank + // them back into playback. + const translationResumeOnFinishRef = useRef(false); + // The subtitle selection active before a translation hijacked it, so a failed + // translation can restore it instead of leaving subtitles off. + const preTranslationSubtitleIndexRef = useRef(null); + // The persisted downloaded-subtitle id to switch to once a completed + // translation's track lands in the refreshed list. + const pendingTranslatedSubtitleIdRef = useRef(null); + + // Drop any live translation when the media changes so a stale track from the + // previous file never lingers. + useEffect(() => { + setLiveTranslation(null); + setLiveCues([]); + setTranslationBuffering(false); + translationPauseRef.current = false; + pendingTranslatedSubtitleIdRef.current = null; + }, [activeFileId]); + + // Merge the live track into the track list the player + menu see. + const effectiveSubtitleTracks = useMemo(() => { + if (!liveTranslation) return subtitleUrls; + return [ + ...subtitleUrls, + { + index: LIVE_SUBTITLE_INDEX, + language: liveTranslation.language, + label: liveTranslation.label || "AI translation", + source: "downloaded" as const, + codec: "srt", + url: "", + live: true, + }, + ]; + }, [subtitleUrls, liveTranslation]); + // -- Transcode quality switching -- // Remux also uses HLS (codec copy) via the transcode pipeline. const transcodeQuality = useTranscodeQuality({ @@ -739,11 +798,161 @@ export function VideoPlayer({ (index: number | null) => { subtitleSelectionWasManualRef.current = true; setActiveSubtitleIndex(index); + // The in-progress live translation track is synthetic (a sentinel index + // that exists only in memory); never persist it as the saved preference or + // we'd store a nonexistent track and lose the real selection. + if (index === LIVE_SUBTITLE_INDEX) return; onSubtitleChanged?.(index); }, [onSubtitleChanged], ); + // The media-time playhead, sent with a translate request so the server starts + // where the viewer is watching. + const getSubtitleStartPosition = useCallback( + () => toMediaTime(videoRef.current?.currentTime ?? 0, streamOriginRef.current ?? 0), + [], + ); + + const resumeFromTranslationPause = useCallback(() => { + if (translationResumeTimerRef.current !== null) { + window.clearTimeout(translationResumeTimerRef.current); + translationResumeTimerRef.current = null; + } + if (translationPauseRef.current) { + translationPauseRef.current = false; + // Only resume if the viewer was playing when the translation began; if + // they had paused on purpose, leave them paused. + if (translationResumeOnFinishRef.current) { + void videoRef.current?.play().catch(() => {}); + } + } + setTranslationBuffering(false); + }, []); + + // Intercept live-translation events; forward everything else to the parent. + const handleRealtimeEvent = useCallback( + (event: PlaybackRealtimeEventEnvelope) => { + switch (event.name) { + case "subtitle_ready": { + // Broadcast to every viewer of the file when a generated track is + // persisted. Refresh the list so it appears (the requesting session + // also auto-selects it via the completed handler below). + if (event.payload.file_id === activeFileId) { + onRefreshSubtitles?.(); + } + break; + } + case "subtitle_translation_started": { + // Remember the real selection we're displacing and whether we were + // playing, so completion/failure can restore the right state. + const wasPlaying = !(videoRef.current?.paused ?? true); + translationResumeOnFinishRef.current = wasPlaying; + setActiveSubtitleIndex((idx) => { + if (idx !== LIVE_SUBTITLE_INDEX) { + preTranslationSubtitleIndexRef.current = idx; + } + return LIVE_SUBTITLE_INDEX; + }); + pendingTranslatedSubtitleIdRef.current = null; + setLiveCues([]); + setLiveTranslation({ + trackKey: event.payload.track_key, + language: event.payload.language, + label: event.payload.label ?? "", + }); + subtitleSelectionWasManualRef.current = true; + translationPauseRef.current = true; + setTranslationBuffering(true); + // Only pause if the viewer was playing; don't disturb a deliberate pause. + if (wasPlaying) videoRef.current?.pause(); + if (translationResumeTimerRef.current !== null) { + window.clearTimeout(translationResumeTimerRef.current); + } + translationResumeTimerRef.current = window.setTimeout( + resumeFromTranslationPause, + TRANSLATION_RESUME_TIMEOUT_MS, + ); + break; + } + case "subtitle_translation_cues": { + const cues = event.payload.cues.map((c) => ({ + start: c.start, + end: c.end, + text: c.text, + })); + setLiveCues((prev) => [...prev, ...cues]); + break; + } + case "subtitle_translation_completed": { + resumeFromTranslationPause(); + // Hand off from the ephemeral live track to the persisted downloaded + // track: refresh the list and let the effect below select it by id + // once it lands. Without a refresh callback we keep the live track + // (which already holds the full cue set) as a best-effort fallback. + if (onRefreshSubtitles) { + pendingTranslatedSubtitleIdRef.current = event.payload.subtitle_id; + onRefreshSubtitles(); + } + break; + } + case "subtitle_translation_failed": { + resumeFromTranslationPause(); + setLiveTranslation(null); + setLiveCues([]); + pendingTranslatedSubtitleIdRef.current = null; + // Restore the selection the translation displaced rather than leaving + // subtitles off. + const restore = preTranslationSubtitleIndexRef.current; + setActiveSubtitleIndex((idx) => (idx === LIVE_SUBTITLE_INDEX ? restore : idx)); + toast.error( + event.payload.message + ? `Translation failed: ${event.payload.message}` + : "Subtitle translation failed", + ); + break; + } + default: + onRealtimeEvent?.(event); + } + }, + [onRealtimeEvent, onRefreshSubtitles, activeFileId, resumeFromTranslationPause], + ); + + // Once a completed translation's persisted track lands in the refreshed list, + // switch to it (selecting by downloaded-subtitle id) and drop the live track, + // so the viewer ends up on the real saved subtitle rather than the synthetic + // one that would vanish on reload. + useEffect(() => { + const pendingId = pendingTranslatedSubtitleIdRef.current; + if (pendingId == null) return; + const match = subtitleUrls.find((t) => t.id === pendingId); + if (!match) return; + pendingTranslatedSubtitleIdRef.current = null; + setLiveTranslation(null); + setLiveCues([]); + handleSubtitleSelect(match.index); + }, [subtitleUrls, handleSubtitleSelect]); + + // Resume as soon as the first translated cues arrive. Playhead-first + // translation means the cues covering the current position are delivered + // first, so the first batch is enough; and when the playhead is past the last + // cue (e.g. end credits) there is nothing at the playhead to wait for, so we + // still resume here rather than stalling until the 30s timeout. + useEffect(() => { + if (!translationPauseRef.current || liveCues.length === 0) return; + resumeFromTranslationPause(); + }, [liveCues, resumeFromTranslationPause]); + + useEffect( + () => () => { + if (translationResumeTimerRef.current !== null) { + window.clearTimeout(translationResumeTimerRef.current); + } + }, + [], + ); + // -- PiP toggle -- const handleTogglePiP = useCallback(async () => { const video = videoRef.current; @@ -1335,10 +1544,12 @@ export function VideoPlayer({ // (which has browser bugs with stale cues persisting after seek). const activeCueTexts = useSubtitleTracks( videoRef, - subtitleUrls, + effectiveSubtitleTracks, activeSubtitleIndex, streamOriginRef, subtitleDelayMs, + liveCues, + liveTranslation?.trackKey ?? null, ); // -- ASS/SSA subtitle rendering via JASSUB (client-side libass) -- @@ -1356,14 +1567,14 @@ export function VideoPlayer({ if (subtitleSelectionWasManualRef.current) { const selectionStillExists = activeSubtitleIndex === null || - subtitleUrls.some((track) => track.index === activeSubtitleIndex); + effectiveSubtitleTracks.some((track) => track.index === activeSubtitleIndex); if (selectionStillExists) { return; } subtitleSelectionWasManualRef.current = false; } - if (subtitleUrls.length === 0) { + if (effectiveSubtitleTracks.length === 0) { setActiveSubtitleIndex(null); lastSubtitleIndexRef.current = null; return; @@ -1376,7 +1587,7 @@ export function VideoPlayer({ const match = resolveSubtitleAutoSelect({ mode: effectiveMode, - tracks: subtitleUrls, + tracks: effectiveSubtitleTracks, preferredLanguage: preferredSubtitleLanguage ?? null, preferredTrackSignature: preferredSubtitleTrackSignature ?? null, audioLanguage: audioLang, @@ -1395,7 +1606,7 @@ export function VideoPlayer({ activeSubtitleIndex, preferredSubtitleLanguage, preferredSubtitleTrackSignature, - subtitleUrls, + effectiveSubtitleTracks, subtitleMode, showForcedSubtitles, profileLanguage, @@ -1719,7 +1930,7 @@ export function VideoPlayer({ const realtime = usePlaybackRealtime({ sessionId, onCommand: executeRealtimeCommand, - onEvent: onRealtimeEvent, + onEvent: handleRealtimeEvent, }); useEffect(() => { @@ -2044,6 +2255,16 @@ export function VideoPlayer({ /> )} + {/* Live translation buffering indicator */} + {translationBuffering && ( +
    +
    + + Preparing {liveTranslation?.label || "translated"} subtitles… +
    +
    + )} + {/* Controls */} {!isDetached && isPlayerReady && ( s.index === index) : null; + // Never persist an index we can't resolve to a real track (e.g. the + // in-progress AI live track's sentinel index): it would store a + // nonexistent track with empty language and clobber the saved preference. + if (index !== null && !track) return; const trackSignature: PlayerSubtitleTrackSignature | null = track ? { source: track.source, diff --git a/web/src/player/hooks/usePlaybackSession.ts b/web/src/player/hooks/usePlaybackSession.ts index f102e87e..6794aa03 100644 --- a/web/src/player/hooks/usePlaybackSession.ts +++ b/web/src/player/hooks/usePlaybackSession.ts @@ -513,6 +513,7 @@ export function usePlaybackSession( const token = config.getAccessToken(); const newTracks: PlayerSubtitleInfo[] = downloaded.map((dl, i) => ({ index: baseIndex + i, + id: dl.id, language: dl.language, codec: dl.format, label: `${dl.release_name} (${dl.provider})`, diff --git a/web/src/player/hooks/useSubtitleTracks.ts b/web/src/player/hooks/useSubtitleTracks.ts index 670e2465..90d7b0b8 100644 --- a/web/src/player/hooks/useSubtitleTracks.ts +++ b/web/src/player/hooks/useSubtitleTracks.ts @@ -23,6 +23,29 @@ function stripVTTTags(text: string): string { return text.replace(/<[^>]+>/g, ""); } +/** + * Add parsed cues to a TextTrack, applying the stream origin and user delay and + * deduping against `seen`. Shared by the URL fetcher and the live-cue path. + */ +function addCuesToTrack( + track: TextTrack, + cues: ParsedCue[], + origin: number, + delaySec: number, + seen: Set, +): void { + for (const parsed of cues) { + if (parsed.end <= parsed.start) continue; + const startTime = Math.max(0, parsed.start - origin + delaySec); + const endTime = parsed.end - origin + delaySec; + if (endTime <= 0) continue; + const key = `${startTime}|${endTime}|${parsed.text}`; + if (seen.has(key)) continue; + seen.add(key); + track.addCue(new VTTCue(startTime, endTime, parsed.text)); + } +} + /** Append or replace the `position` query param on a subtitle URL. */ function appendPosition(url: string, position: number): string { const sep = url.includes("?") ? "&" : "?"; @@ -55,6 +78,10 @@ export function useSubtitleTracks( activeSubtitleIndex: number | null, streamOriginRef: React.RefObject, subtitleDelayMs: number, + liveCues?: ParsedCue[] | null, + // Identifies the current live translation job. Changing it (a new job) rebuilds + // the track and resets the dedup set so cues from a prior run never linger. + liveTrackKey?: string | null, ): string[] { const [activeCueTexts, setActiveCueTexts] = useState([]); @@ -65,12 +92,22 @@ export function useSubtitleTracks( const activeUrl = activeSub?.url ?? null; const activeCodec = activeSub?.codec; const activeLang = activeSub?.language ?? ""; + // A live track's cues arrive over the websocket (liveCues) instead of from a + // URL; the main effect builds the track but skips the sliding-window fetcher. + const activeIsLive = activeSub?.live === true; // Track which delay is currently baked into the VTTCues on the active track, // so the delay-update effect below can compute the exact shift to apply. // Cue-add paths also read this to keep new cues aligned with existing ones. const appliedDelayMsRef = useRef(0); const trackRef = useRef(null); + // Cue dedup set, held in a ref so the live-cue effect and the URL fetcher + // share it. Reset whenever a fresh track is built. + const seenCueKeysRef = useRef>(new Set()); + // How many of `liveCues` have already been pushed onto the live track. Lets the + // live-cue effect add only the new tail each batch instead of rescanning the + // whole (growing) array. Reset on every track rebuild. + const processedLiveCuesRef = useRef(0); useEffect(() => { const video = videoRef.current; @@ -80,7 +117,11 @@ export function useSubtitleTracks( setActiveCueTexts([]); // Skip entirely for ASS/SSA: JASSUB handles those via useASSSubtitles. - if (!activeUrl || isASSCodec(activeCodec)) { + if (isASSCodec(activeCodec)) { + return; + } + // Need either a URL to stream from or a live cue source. + if (!activeUrl && !activeIsLive) { return; } @@ -91,6 +132,8 @@ export function useSubtitleTracks( const track = videoEl.addTextTrack("subtitles", "Silo", activeLang || undefined); track.mode = "hidden"; trackRef.current = track; + seenCueKeysRef.current = new Set(); + processedLiveCuesRef.current = 0; let cancelled = false; let hasFetched = false; @@ -101,10 +144,6 @@ export function useSubtitleTracks( let atEOF = false; let inflight: AbortController | null = null; - // Persistent dedup: cues from overlapping windows key to the same string. - // Cleared alongside track cues on backward-seek resets. - const seenCueKeys = new Set(); - function handleCueChange() { const active = track.activeCues; if (!active || active.length === 0) { @@ -124,7 +163,7 @@ export function useSubtitleTracks( for (const cue of Array.from(cues)) { track.removeCue(cue); } - seenCueKeys.clear(); + seenCueKeysRef.current.clear(); } function addParsedCues(newCues: ParsedCue[]) { @@ -136,17 +175,7 @@ export function useSubtitleTracks( // baked in here so new cues line up with existing ones. const origin = streamOriginRef.current ?? 0; const delaySec = appliedDelayMsRef.current / 1000; - - for (const parsed of newCues) { - if (parsed.end <= parsed.start) continue; - const startTime = Math.max(0, parsed.start - origin + delaySec); - const endTime = parsed.end - origin + delaySec; - if (endTime <= 0) continue; - const key = `${startTime}|${endTime}|${parsed.text}`; - if (seenCueKeys.has(key)) continue; - seenCueKeys.add(key); - track.addCue(new VTTCue(startTime, endTime, parsed.text)); - } + addCuesToTrack(track, newCues, origin, delaySec, seenCueKeysRef.current); } async function fetchWindow(seekStart: number, resetExisting: boolean) { @@ -251,15 +280,19 @@ export function useSubtitleTracks( } } - // Kick off the first window before any player event fires so cues - // are already in flight for the current position. - maybeFetch(); + // URL-backed tracks run the sliding-window fetcher; live tracks receive + // their cues from the liveCues effect below instead. + if (!activeIsLive) { + // Kick off the first window before any player event fires so cues + // are already in flight for the current position. + maybeFetch(); - // Cue activation is driven by the browser via `cuechange`; these - // listeners exist only to keep the sliding-window fetcher scheduled. - videoEl.addEventListener("timeupdate", maybeFetch); - videoEl.addEventListener("seeking", maybeFetch); - videoEl.addEventListener("seeked", maybeFetch); + // Cue activation is driven by the browser via `cuechange`; these + // listeners exist only to keep the sliding-window fetcher scheduled. + videoEl.addEventListener("timeupdate", maybeFetch); + videoEl.addEventListener("seeking", maybeFetch); + videoEl.addEventListener("seeked", maybeFetch); + } return () => { cancelled = true; @@ -281,7 +314,7 @@ export function useSubtitleTracks( // `subtitleDelayMs` is intentionally excluded — nudging delay must not // tear down and refetch the track. The delay-update effect below shifts // existing cues in place instead. - }, [activeUrl, activeCodec, activeLang, streamOriginRef, videoRef]); + }, [activeUrl, activeCodec, activeLang, activeIsLive, liveTrackKey, streamOriginRef, videoRef]); // Apply delay changes to already-loaded cues without rebuilding the track. // Runs after the main effect, so trackRef is current. @@ -303,5 +336,37 @@ export function useSubtitleTracks( } }, [subtitleDelayMs]); + // Feed websocket-pushed cues into the active live track as they arrive. Only + // the new tail since the last push is added (liveCues is append-only within a + // job), so ingestion stays O(batch) rather than O(total) per push. When the + // job restarts liveCues is replaced with a shorter array, which the length + // check below detects to start over (the track itself is rebuilt via + // liveTrackKey, so a fresh seen-set and pointer are already in place). + useEffect(() => { + if (!activeIsLive) return; + const track = trackRef.current; + if (!track || !liveCues) return; + if (liveCues.length < processedLiveCuesRef.current) { + processedLiveCuesRef.current = 0; + } + const fresh = liveCues.slice(processedLiveCuesRef.current); + if (fresh.length === 0) return; + processedLiveCuesRef.current = liveCues.length; + const origin = streamOriginRef.current ?? 0; + const delaySec = appliedDelayMsRef.current / 1000; + addCuesToTrack(track, fresh, origin, delaySec, seenCueKeysRef.current); + // While paused, adding a cue over the playhead doesn't reliably fire + // `cuechange`, so refresh the on-screen text by hand. While playing the + // browser drives `cuechange`, so skip the redundant state update. + if (videoRef.current?.paused) { + const active = track.activeCues; + setActiveCueTexts( + active && active.length > 0 + ? Array.from(active).map((c) => stripVTTTags((c as VTTCue).text)) + : [], + ); + } + }, [liveCues, activeIsLive, activeSubtitleIndex, liveTrackKey, streamOriginRef, videoRef]); + return activeCueTexts; } diff --git a/web/src/player/realtime-protocol.test.ts b/web/src/player/realtime-protocol.test.ts index 5a0dd5b7..2b5b17e1 100644 --- a/web/src/player/realtime-protocol.test.ts +++ b/web/src/player/realtime-protocol.test.ts @@ -104,6 +104,49 @@ describe("realtime protocol", () => { }); }); + it("parses subtitle ready events", () => { + const event = parsePlaybackRealtimeMessage( + JSON.stringify({ + type: "event", + session_id: "session-1", + name: "subtitle_ready", + payload: { + session_id: "session-1", + file_id: 42, + subtitle_id: 7, + language: "es", + label: "English → Spanish (AI)", + }, + }), + ); + + expect(event).toEqual({ + type: "event", + session_id: "session-1", + name: "subtitle_ready", + payload: { + session_id: "session-1", + file_id: 42, + subtitle_id: 7, + language: "es", + label: "English → Spanish (AI)", + }, + }); + }); + + it("rejects subtitle ready events missing the subtitle id", () => { + const event = parsePlaybackRealtimeMessage( + JSON.stringify({ + type: "event", + session_id: "session-1", + name: "subtitle_ready", + payload: { session_id: "session-1", file_id: 42, language: "es" }, + }), + ); + + expect(event).toBeNull(); + }); + it("builds hello, ack, and result envelopes", () => { expect(buildPlaybackRealtimeHello("session-1")).toEqual({ type: "hello", diff --git a/web/src/player/realtime-protocol.ts b/web/src/player/realtime-protocol.ts index 0893c6c2..65be2ef7 100644 --- a/web/src/player/realtime-protocol.ts +++ b/web/src/player/realtime-protocol.ts @@ -17,7 +17,14 @@ export type PlaybackCommandName = export type PlaybackRealtimeAckStatus = "accepted"; export type PlaybackRealtimeResultStatus = "completed" | "rejected"; -export type PlaybackRealtimeEventName = "chapter_thumbnail_ready" | "markers_updated"; +export type PlaybackRealtimeEventName = + | "chapter_thumbnail_ready" + | "markers_updated" + | "subtitle_ready" + | "subtitle_translation_started" + | "subtitle_translation_cues" + | "subtitle_translation_completed" + | "subtitle_translation_failed"; export interface PlaybackRealtimeCommandEnvelope { type: "command"; @@ -66,6 +73,64 @@ export interface PlaybackMarkersUpdatedPayload { preview?: PlaybackTimeRangePayload | null; } +/** + * Broadcast to every session watching a file when a newly generated subtitle + * track (AI translation, later ASR) has been persisted, so players can refresh + * their track list and pick it up without a manual reload. + */ +export interface PlaybackSubtitleReadyPayload { + session_id: string; + file_id: number; + subtitle_id: number; + language: string; + label?: string; +} + +/** One translated subtitle cue pushed during a live translation (media seconds). */ +export interface PlaybackStreamCue { + start: number; + end: number; + text: string; +} + +export interface PlaybackSubtitleTranslationStartedPayload { + session_id: string; + file_id: number; + job_id: number; + track_key: string; + language: string; + label?: string; + total_cues: number; +} + +export interface PlaybackSubtitleTranslationCuesPayload { + session_id: string; + file_id: number; + job_id: number; + track_key: string; + cues: PlaybackStreamCue[]; + done: number; + total: number; +} + +export interface PlaybackSubtitleTranslationCompletedPayload { + session_id: string; + file_id: number; + job_id: number; + track_key: string; + subtitle_id: number; + language: string; + label?: string; +} + +export interface PlaybackSubtitleTranslationFailedPayload { + session_id: string; + file_id: number; + job_id: number; + track_key: string; + message?: string; +} + export interface PlaybackRealtimeEventEnvelopeBase { type: "event"; session_id: string; @@ -79,6 +144,26 @@ export type PlaybackRealtimeEventEnvelope = | (PlaybackRealtimeEventEnvelopeBase & { name: "markers_updated"; payload: PlaybackMarkersUpdatedPayload; + }) + | (PlaybackRealtimeEventEnvelopeBase & { + name: "subtitle_ready"; + payload: PlaybackSubtitleReadyPayload; + }) + | (PlaybackRealtimeEventEnvelopeBase & { + name: "subtitle_translation_started"; + payload: PlaybackSubtitleTranslationStartedPayload; + }) + | (PlaybackRealtimeEventEnvelopeBase & { + name: "subtitle_translation_cues"; + payload: PlaybackSubtitleTranslationCuesPayload; + }) + | (PlaybackRealtimeEventEnvelopeBase & { + name: "subtitle_translation_completed"; + payload: PlaybackSubtitleTranslationCompletedPayload; + }) + | (PlaybackRealtimeEventEnvelopeBase & { + name: "subtitle_translation_failed"; + payload: PlaybackSubtitleTranslationFailedPayload; }); export interface PlaybackRealtimeAckEnvelope { @@ -164,6 +249,79 @@ function isMarkersUpdatedPayload(value: unknown): value is PlaybackMarkersUpdate ); } +function isSubtitleReadyPayload(value: unknown): value is PlaybackSubtitleReadyPayload { + return ( + isRecord(value) && + typeof value.session_id === "string" && + typeof value.file_id === "number" && + typeof value.subtitle_id === "number" && + typeof value.language === "string" + ); +} + +function isStreamCue(value: unknown): value is PlaybackStreamCue { + return ( + isRecord(value) && + typeof value.start === "number" && + typeof value.end === "number" && + typeof value.text === "string" + ); +} + +function isTranslationStartedPayload( + value: unknown, +): value is PlaybackSubtitleTranslationStartedPayload { + return ( + isRecord(value) && + typeof value.session_id === "string" && + typeof value.file_id === "number" && + typeof value.job_id === "number" && + typeof value.track_key === "string" && + typeof value.language === "string" && + typeof value.total_cues === "number" + ); +} + +function isTranslationCuesPayload(value: unknown): value is PlaybackSubtitleTranslationCuesPayload { + return ( + isRecord(value) && + typeof value.session_id === "string" && + typeof value.file_id === "number" && + typeof value.job_id === "number" && + typeof value.track_key === "string" && + Array.isArray(value.cues) && + value.cues.every(isStreamCue) && + typeof value.done === "number" && + typeof value.total === "number" + ); +} + +function isTranslationCompletedPayload( + value: unknown, +): value is PlaybackSubtitleTranslationCompletedPayload { + return ( + isRecord(value) && + typeof value.session_id === "string" && + typeof value.file_id === "number" && + typeof value.job_id === "number" && + typeof value.track_key === "string" && + typeof value.subtitle_id === "number" && + typeof value.language === "string" + ); +} + +function isTranslationFailedPayload( + value: unknown, +): value is PlaybackSubtitleTranslationFailedPayload { + return ( + isRecord(value) && + typeof value.session_id === "string" && + typeof value.file_id === "number" && + typeof value.job_id === "number" && + typeof value.track_key === "string" + ); +} + export function parsePlaybackRealtimeMessage( data: string, ): PlaybackRealtimeCommandEnvelope | PlaybackRealtimeEventEnvelope | null { @@ -206,6 +364,14 @@ export function parsePlaybackRealtimeMessage( payload: value.payload, }; } + if (value.name === "subtitle_ready" && isSubtitleReadyPayload(value.payload)) { + return { + type: "event", + session_id: value.session_id, + name: value.name, + payload: value.payload, + }; + } if (value.name === "markers_updated" && isMarkersUpdatedPayload(value.payload)) { return { type: "event", @@ -214,6 +380,47 @@ export function parsePlaybackRealtimeMessage( payload: value.payload, }; } + if ( + value.name === "subtitle_translation_started" && + isTranslationStartedPayload(value.payload) + ) { + return { + type: "event", + session_id: value.session_id, + name: value.name, + payload: value.payload, + }; + } + if (value.name === "subtitle_translation_cues" && isTranslationCuesPayload(value.payload)) { + return { + type: "event", + session_id: value.session_id, + name: value.name, + payload: value.payload, + }; + } + if ( + value.name === "subtitle_translation_completed" && + isTranslationCompletedPayload(value.payload) + ) { + return { + type: "event", + session_id: value.session_id, + name: value.name, + payload: value.payload, + }; + } + if ( + value.name === "subtitle_translation_failed" && + isTranslationFailedPayload(value.payload) + ) { + return { + type: "event", + session_id: value.session_id, + name: value.name, + payload: value.payload, + }; + } } return null; } catch { diff --git a/web/src/player/types.ts b/web/src/player/types.ts index 9946bc60..6e1c7f92 100644 --- a/web/src/player/types.ts +++ b/web/src/player/types.ts @@ -110,6 +110,12 @@ export interface PlaybackSessionPlaybackInfo { /** Subtitle track information. */ export interface PlayerSubtitleInfo { index: number; + /** + * Downloaded-subtitle row id, when this track is a stored downloaded subtitle. + * Lets the player match a translation-completed / `subtitle_ready` event + * (which carries the DB id) to a track after a list refresh. + */ + id?: number; language: string; codec?: string; label: string; @@ -117,6 +123,12 @@ export interface PlayerSubtitleInfo { forced?: boolean; hearing_impaired?: boolean; url: string; + /** + * When true, this is an in-progress AI translation whose cues arrive over the + * realtime websocket rather than from `url`. `useSubtitleTracks` feeds it from + * the `liveCues` source instead of fetching. + */ + live?: boolean; } export interface PlayerSubtitleTrackSignature { From 669050d025fcef4a267f181a4d77ecc12b99e65c Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sat, 30 May 2026 00:19:02 -0400 Subject: [PATCH 149/201] fix(subtitles): address review feedback on AI translation - Heartbeat queued jobs (before the semaphore), not just running ones, so the stale-job reaper can't fail a job that is alive but waiting for a slot (which would otherwise let it resurrect itself on acquire, or admit a duplicate once the partial unique index no longer sees it as active). - Filter the translate modal's source list to the formats the server accepts (text external/downloaded, non-bitmap embedded), so an unsupported source is prevented up front instead of failing the job asynchronously. - Tell a duplicate requester (e.g. a second viewer, or after a reload) that a translation is already in progress instead of silently closing; the finished track still arrives via the subtitle_ready refresh. - Note in the admin card that AI subtitle settings take effect after a restart (the running service snapshots them at construction). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/subtitles/ai/service.go | 44 ++++++++------ .../admin-settings/IntegrationsSettings.tsx | 3 + .../components/SubtitleTranslateModal.test.ts | 38 ++++++++++++ .../components/SubtitleTranslateModal.tsx | 60 ++++++++++++++----- 4 files changed, 112 insertions(+), 33 deletions(-) create mode 100644 web/src/player/components/SubtitleTranslateModal.test.ts diff --git a/internal/subtitles/ai/service.go b/internal/subtitles/ai/service.go index e7d773f9..ffbacf49 100644 --- a/internal/subtitles/ai/service.go +++ b/internal/subtitles/ai/service.go @@ -279,6 +279,14 @@ func (s *Service) dispatch(job Job) { cancel() }() + // Heartbeat for the whole lifetime — crucially including while queued + // behind the semaphore — so the stale-job reaper never reaps a job that is + // alive but merely waiting for a slot (which would otherwise mark it failed + // and let it resurrect itself on acquire, or admit a duplicate). + stopHeartbeat := make(chan struct{}) + defer close(stopHeartbeat) + go s.heartbeatLoop(runCtx, job.ID, stopHeartbeat) + // Bound concurrency so translation never starves transcodes. select { case s.sem <- struct{}{}: @@ -292,27 +300,25 @@ func (s *Service) dispatch(job Job) { }() } -func (s *Service) run(ctx context.Context, job *Job) { - // Keep heartbeat_at fresh while the job runs (progress updates also bump it), - // so the stale-job reaper never resets a job that is still alive during a long - // single LLM call. - stopHeartbeat := make(chan struct{}) - defer close(stopHeartbeat) - go func() { - ticker := time.NewTicker(heartbeatInterval) - defer ticker.Stop() - for { - select { - case <-stopHeartbeat: - return - case <-ctx.Done(): - return - case <-ticker.C: - _ = s.repo.Heartbeat(context.WithoutCancel(ctx), job.ID) - } +// heartbeatLoop keeps a job's heartbeat_at fresh until the job ends or the +// context is cancelled, so the stale-job reaper only ever reaps jobs orphaned by +// a crashed worker. +func (s *Service) heartbeatLoop(ctx context.Context, jobID int64, stop <-chan struct{}) { + ticker := time.NewTicker(heartbeatInterval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ctx.Done(): + return + case <-ticker.C: + _ = s.repo.Heartbeat(context.WithoutCancel(ctx), jobID) } - }() + } +} +func (s *Service) run(ctx context.Context, job *Job) { if err := s.repo.UpdateProgress(ctx, job.ID, JobStatusRunning, 0, "Loading subtitle"); err != nil { s.logger.Warn("failed to mark subtitle ai job running", "job", job.ID, "error", err) } diff --git a/web/src/pages/admin-settings/IntegrationsSettings.tsx b/web/src/pages/admin-settings/IntegrationsSettings.tsx index 2088e3e7..ce49d782 100644 --- a/web/src/pages/admin-settings/IntegrationsSettings.tsx +++ b/web/src/pages/admin-settings/IntegrationsSettings.tsx @@ -536,6 +536,9 @@ function AISubtitleTranslationCard() { +

    + Changes take effect after a server restart. +

    ); diff --git a/web/src/player/components/SubtitleTranslateModal.test.ts b/web/src/player/components/SubtitleTranslateModal.test.ts new file mode 100644 index 00000000..a1510887 --- /dev/null +++ b/web/src/player/components/SubtitleTranslateModal.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { isTranslatableSource } from "./SubtitleTranslateModal"; +import type { PlayerSubtitleInfo } from "../types"; + +function track(p: Partial): PlayerSubtitleInfo { + return { index: 0, language: "en", label: "", url: "", ...p }; +} + +describe("isTranslatableSource", () => { + it("accepts text external/downloaded subtitles", () => { + expect(isTranslatableSource(track({ source: "external", codec: "srt" }))).toBe(true); + expect(isTranslatableSource(track({ source: "downloaded", codec: "subrip" }))).toBe(true); + expect(isTranslatableSource(track({ source: "external", codec: "vtt" }))).toBe(true); + }); + + it("rejects ASS/SSA external/downloaded subtitles the server can't parse", () => { + expect(isTranslatableSource(track({ source: "external", codec: "ass" }))).toBe(false); + expect(isTranslatableSource(track({ source: "downloaded", codec: "ssa" }))).toBe(false); + }); + + it("accepts non-bitmap embedded tracks (extracted via ffmpeg, incl. ASS)", () => { + expect(isTranslatableSource(track({ source: "embedded", codec: "ass" }))).toBe(true); + expect(isTranslatableSource(track({ source: "embedded", codec: "subrip" }))).toBe(true); + }); + + it("rejects bitmap embedded tracks", () => { + expect(isTranslatableSource(track({ source: "embedded", codec: "hdmv_pgs_subtitle" }))).toBe( + false, + ); + expect(isTranslatableSource(track({ source: "embedded", codec: "dvd_subtitle" }))).toBe(false); + }); + + it("rejects the in-progress live track", () => { + expect(isTranslatableSource(track({ source: "downloaded", codec: "srt", live: true }))).toBe( + false, + ); + }); +}); diff --git a/web/src/player/components/SubtitleTranslateModal.tsx b/web/src/player/components/SubtitleTranslateModal.tsx index 350aa6a1..b702bced 100644 --- a/web/src/player/components/SubtitleTranslateModal.tsx +++ b/web/src/player/components/SubtitleTranslateModal.tsx @@ -1,10 +1,29 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { createPortal } from "react-dom"; +import { toast } from "sonner"; import type { PlayerConfig } from "../context/PlayerConfigContext"; import type { PlayerSubtitleInfo } from "../types"; import { playerFetch } from "../player-fetch"; import { LANGUAGES, getLanguageName } from "../utils/languageNames"; +// Formats the server can parse directly from an external/downloaded file. +const TRANSLATABLE_TEXT_CODECS = new Set(["srt", "subrip", "vtt", "webvtt"]); +// Bitmap codecs the server can't extract as text (it would burn them in). +const BITMAP_CODECS = new Set(["pgs", "hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"]); + +// Mirror the server's loadSource acceptance: embedded non-bitmap tracks are +// extracted to text via ffmpeg, while external/downloaded sources must already +// be a parseable text format. Offering anything else starts a job that fails +// asynchronously instead of preventing the choice up front. +export function isTranslatableSource(track: PlayerSubtitleInfo): boolean { + if (track.live) return false; + const codec = (track.codec ?? "").toLowerCase(); + if (track.source === "embedded") { + return !BITMAP_CODECS.has(codec); + } + return TRANSLATABLE_TEXT_CODECS.has(codec); +} + interface SubtitleTranslateModalProps { mediaFileId: number; playerConfig: PlayerConfig; @@ -30,8 +49,9 @@ export function SubtitleTranslateModal({ getStartPosition, onClose, }: SubtitleTranslateModalProps) { - // Live (in-progress) tracks can't be a translation source. - const sourceTracks = useMemo(() => tracks.filter((t) => !t.live), [tracks]); + // Only offer sources the server can actually translate (excludes live tracks, + // bitmap embedded tracks, and ASS/non-text external/downloaded tracks). + const sourceTracks = useMemo(() => tracks.filter(isTranslatableSource), [tracks]); const [sourceIndex, setSourceIndex] = useState(null); const [targetLang, setTargetLang] = useState("en"); const [submitting, setSubmitting] = useState(false); @@ -54,18 +74,30 @@ export function SubtitleTranslateModal({ setSubmitting(true); setError(null); try { - await playerFetch(playerConfig, "/subtitles/ai/translate", { - method: "POST", - body: JSON.stringify({ - media_file_id: mediaFileId, - source_index: effectiveSourceIndex, - source_language: source?.language ?? "", - target_language: targetLang, - session_id: sessionId ?? "", - start_position: getStartPosition?.() ?? 0, - }), - }); - // The player takes over from here: it pauses, streams cues in as they're + const res = await playerFetch<{ job?: { status?: string } }>( + playerConfig, + "/subtitles/ai/translate", + { + method: "POST", + body: JSON.stringify({ + media_file_id: mediaFileId, + source_index: effectiveSourceIndex, + source_language: source?.language ?? "", + target_language: targetLang, + session_id: sessionId ?? "", + start_position: getStartPosition?.() ?? 0, + }), + }, + ); + // A request that collapses onto an already-running job (e.g. after a + // reload, or a second viewer) won't get its own live stream — tell the + // user it's underway; it'll appear via the subtitle-ready refresh. + if (res?.job?.status === "running") { + toast.info( + "A translation for this track is already in progress — it'll appear when it's ready.", + ); + } + // Otherwise the player takes over: it pauses, streams cues in as they're // translated, then resumes once your position is covered. onClose(); } catch (err) { From 4f2e94c8c5e99f9dd5bba8c8e8d175371d0b4293 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sat, 30 May 2026 00:23:16 -0400 Subject: [PATCH 150/201] fix(subtitles): guard AI job state transitions against terminal rows UpdateProgress/CompleteJob/FailJob now only update a job that is still pending/running, so a job cancelled or reaped as stale can't be resurrected by a late write from its own worker goroutine. Defense-in-depth alongside the queued-job heartbeat fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/subtitles/ai/pgrepo.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/subtitles/ai/pgrepo.go b/internal/subtitles/ai/pgrepo.go index 3960a3d6..5bbb4d6b 100644 --- a/internal/subtitles/ai/pgrepo.go +++ b/internal/subtitles/ai/pgrepo.go @@ -101,11 +101,15 @@ func (r *PgJobRepository) ListJobsByMediaFile(ctx context.Context, mediaFileID i return jobs, rows.Err() } +// UpdateProgress, CompleteJob, and FailJob only transition a job that is still +// active ("pending"/"running"). The guard makes them no-ops on an already +// terminal row, so a job that was cancelled or reaped as stale can never be +// resurrected by a late write from its own worker goroutine. func (r *PgJobRepository) UpdateProgress(ctx context.Context, id int64, status JobStatus, progress float64, message string) error { _, err := r.pool.Exec(ctx, `UPDATE subtitle_ai_jobs SET status = $2, progress = $3, progress_message = $4, updated_at = now(), heartbeat_at = now() - WHERE id = $1`, id, status, progress, message) + WHERE id = $1 AND status IN ('pending', 'running')`, id, status, progress, message) if err != nil { return fmt.Errorf("update subtitle ai job progress: %w", err) } @@ -117,7 +121,7 @@ func (r *PgJobRepository) CompleteJob(ctx context.Context, id int64, subtitleID `UPDATE subtitle_ai_jobs SET status = 'completed', progress = 1, result_subtitle_id = $2, error_message = '', updated_at = now(), heartbeat_at = now() - WHERE id = $1`, id, subtitleID) + WHERE id = $1 AND status IN ('pending', 'running')`, id, subtitleID) if err != nil { return fmt.Errorf("complete subtitle ai job: %w", err) } @@ -128,7 +132,7 @@ func (r *PgJobRepository) FailJob(ctx context.Context, id int64, status JobStatu _, err := r.pool.Exec(ctx, `UPDATE subtitle_ai_jobs SET status = $2, error_message = $3, updated_at = now(), heartbeat_at = now() - WHERE id = $1`, id, status, message) + WHERE id = $1 AND status IN ('pending', 'running')`, id, status, message) if err != nil { return fmt.Errorf("fail subtitle ai job: %w", err) } From 0b5f815c7ba2013443ae831c3ed4657407c00919 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sat, 30 May 2026 00:29:28 -0400 Subject: [PATCH 151/201] fix(subtitles): address CodeRabbit review feedback Backend: - client.go: handle the io.ReadAll error on the chat response (retry rather than misparse a truncated read), and stop logging raw upstream response bodies (log byte count instead of subtitle/model text). - srt.go: reject negative/reversed cue time ranges in parseTimingLine. - translator.go: fail fast on a nil client, non-positive batch size, or empty target language instead of panicking / looping. Frontend: - VideoPlayer: clear the pending translation resume timer when the file changes, so a stale 30s callback can't fire against the new media. - useSubtitleTracks: dedup cues on source times, not delay-shifted display times, so adjusting subtitle delay can't re-add the same cue. - SubtitleMenu: only show "Translate with AI" when a translatable (non-live, text/non-bitmap) source exists, matching the modal's filter. - IntegrationsSettings: validate AI settings before saving (non-empty endpoint/model when enabled, positive concurrency) instead of persisting an unusable config. - realtime-protocol: validate optional label/message fields are strings; add parser tests for the subtitle_translation_* events. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/subtitles/ai/client.go | 13 ++- internal/subtitles/ai/srt.go | 3 + internal/subtitles/ai/translator.go | 9 ++ .../admin-settings/IntegrationsSettings.tsx | 25 ++++- web/src/player/components/SubtitleMenu.tsx | 4 +- web/src/player/components/VideoPlayer.tsx | 6 + web/src/player/hooks/useSubtitleTracks.ts | 5 +- web/src/player/realtime-protocol.test.ts | 104 ++++++++++++++++++ web/src/player/realtime-protocol.ts | 17 ++- 9 files changed, 174 insertions(+), 12 deletions(-) diff --git a/internal/subtitles/ai/client.go b/internal/subtitles/ai/client.go index 1343c5b0..5fa2bd24 100644 --- a/internal/subtitles/ai/client.go +++ b/internal/subtitles/ai/client.go @@ -98,8 +98,17 @@ func (c *Client) chat(ctx context.Context, messages []chatMessage, jsonObject bo continue } - respBody, _ := io.ReadAll(resp.Body) + respBody, readErr := io.ReadAll(resp.Body) resp.Body.Close() + if readErr != nil { + // A truncated/failed read could otherwise be misparsed as a valid + // (empty) response; treat it as a retryable transport error. + lastErr = fmt.Errorf("read chat response: %w", readErr) + if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil { + return "", waitErr + } + continue + } switch { case resp.StatusCode == http.StatusTooManyRequests: @@ -130,7 +139,7 @@ func (c *Client) chat(ctx context.Context, messages []chatMessage, jsonObject bo } else if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" { lastErr = fmt.Errorf("chat API returned no choices") slog.Warn("subtitle AI chat returned no choices, retrying", - "attempt", attempt+1, "model", c.cfg.ChatModel, "body", truncate(string(respBody), 300)) + "attempt", attempt+1, "model", c.cfg.ChatModel, "response_bytes", len(respBody)) } else { return parsed.Choices[0].Message.Content, nil } diff --git a/internal/subtitles/ai/srt.go b/internal/subtitles/ai/srt.go index b8d4d834..65b50d23 100644 --- a/internal/subtitles/ai/srt.go +++ b/internal/subtitles/ai/srt.go @@ -92,6 +92,9 @@ func parseTimingLine(line string) (time.Duration, time.Duration, error) { if err != nil { return 0, 0, err } + if start < 0 || end < 0 || end < start { + return 0, 0, fmt.Errorf("invalid cue range") + } return start, end, nil } diff --git a/internal/subtitles/ai/translator.go b/internal/subtitles/ai/translator.go index adda62a7..b8b64265 100644 --- a/internal/subtitles/ai/translator.go +++ b/internal/subtitles/ai/translator.go @@ -42,6 +42,15 @@ func NewLLMTranslator(client *Client, batchSize, contextNeighbors int) *LLMTrans // Translate implements Translator. func (t *LLMTranslator) Translate(ctx context.Context, req TranslateRequest, onBatch func(batch []SubtitleCue, done, total int)) ([]SubtitleCue, error) { + if t.client == nil { + return nil, fmt.Errorf("translator client is nil") + } + if t.batchSize <= 0 { + return nil, fmt.Errorf("invalid batch size: %d", t.batchSize) + } + if strings.TrimSpace(req.TargetLanguage) == "" { + return nil, fmt.Errorf("target language is required") + } total := len(req.Cues) if total == 0 { return nil, fmt.Errorf("no cues to translate") diff --git a/web/src/pages/admin-settings/IntegrationsSettings.tsx b/web/src/pages/admin-settings/IntegrationsSettings.tsx index ce49d782..6b2b90c4 100644 --- a/web/src/pages/admin-settings/IntegrationsSettings.tsx +++ b/web/src/pages/admin-settings/IntegrationsSettings.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { toast } from "sonner"; import { useSubtitleProviders, useUpdateSubtitleProvider, @@ -471,11 +472,29 @@ function AISubtitleTranslationCard() { }, [settings]); function save() { + const trimmedBaseUrl = baseUrl.trim(); + const trimmedChatModel = chatModel.trim(); + const parsedMaxConcurrent = Number.parseInt(maxConcurrent, 10); + + // Don't let an admin persist a config that would break translation for + // everyone (a blank endpoint/model when enabled, or a bad concurrency value). + if (enabled === "true" && (trimmedBaseUrl === "" || trimmedChatModel === "")) { + toast.error("Base URL and chat model are required to enable AI translation."); + return; + } + if (!Number.isInteger(parsedMaxConcurrent) || parsedMaxConcurrent < 1) { + toast.error("Max concurrent jobs must be a positive whole number."); + return; + } + const updates = [ updateSetting.mutateAsync({ key: "subtitle_ai.enabled", value: enabled }), - updateSetting.mutateAsync({ key: "subtitle_ai.base_url", value: baseUrl }), - updateSetting.mutateAsync({ key: "subtitle_ai.chat_model", value: chatModel }), - updateSetting.mutateAsync({ key: "subtitle_ai.max_concurrent_jobs", value: maxConcurrent }), + updateSetting.mutateAsync({ key: "subtitle_ai.base_url", value: trimmedBaseUrl }), + updateSetting.mutateAsync({ key: "subtitle_ai.chat_model", value: trimmedChatModel }), + updateSetting.mutateAsync({ + key: "subtitle_ai.max_concurrent_jobs", + value: String(parsedMaxConcurrent), + }), ]; if (apiKey.trim() !== "") { updates.push(updateSetting.mutateAsync({ key: "subtitle_ai.api_key", value: apiKey })); diff --git a/web/src/player/components/SubtitleMenu.tsx b/web/src/player/components/SubtitleMenu.tsx index cbcd8b92..2310d031 100644 --- a/web/src/player/components/SubtitleMenu.tsx +++ b/web/src/player/components/SubtitleMenu.tsx @@ -4,7 +4,7 @@ import { Captions, CaptionsOff, Languages, Minus, Plus, SlidersHorizontal } from import type { PlayerSubtitleInfo } from "../types"; import type { PlayerConfig } from "../context/PlayerConfigContext"; import { SubtitleSearchModal } from "./SubtitleSearchModal"; -import { SubtitleTranslateModal } from "./SubtitleTranslateModal"; +import { SubtitleTranslateModal, isTranslatableSource } from "./SubtitleTranslateModal"; import { SubtitleAppearancePanel } from "./SubtitleAppearancePanel"; import { playerFetch } from "../player-fetch"; import { getLanguageName } from "../utils/languageNames"; @@ -301,7 +301,7 @@ export function SubtitleMenu({ Search Online… )} - {aiEnabled && mediaFileId && playerConfig && tracks.length > 0 && ( + {aiEnabled && mediaFileId && playerConfig && tracks.some(isTranslatableSource) && (
    diff --git a/web/src/components/RecipeGallery/RecipeConfigDrawer.test.tsx b/web/src/components/RecipeGallery/RecipeConfigDrawer.test.tsx index 32a19b72..73f4e21b 100644 --- a/web/src/components/RecipeGallery/RecipeConfigDrawer.test.tsx +++ b/web/src/components/RecipeGallery/RecipeConfigDrawer.test.tsx @@ -1,8 +1,20 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import RecipeConfigDrawer from "./RecipeConfigDrawer"; +vi.mock("@/api/client", () => ({ + api: vi.fn(async (path: string) => { + if (path === "/libraries") { + return [ + { id: 1, name: "Movies" }, + { id: 2, name: "Shows" }, + ]; + } + return {}; + }), +})); + vi.mock("@/hooks/queries/useAllUserCollections", () => ({ useAllUserCollections: () => ({ collections: [], isLoading: false }), })); @@ -125,4 +137,27 @@ describe("RecipeConfigDrawer", () => { }), ); }); + + it("delegates manually selected bulk libraries to onAdd", async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render( {}} onAdd={onAdd} />); + + await userEvent.click(screen.getByLabelText(/apply to all libraries/i)); + await userEvent.click(screen.getByRole("button", { name: /add section/i })); + + await screen.findByLabelText("Movies"); + await userEvent.click(screen.getByLabelText("Shows")); + await userEvent.click(screen.getByRole("button", { name: /apply \(1\)/i })); + + await waitFor(() => { + expect(onAdd).toHaveBeenCalledWith( + expect.objectContaining({ + apply_to_all_libraries: true, + library_ids: [1], + section_type: "recently_added", + title: "Recently Added", + }), + ); + }); + }); }); diff --git a/web/src/components/RecipeGallery/RecipeConfigDrawer.tsx b/web/src/components/RecipeGallery/RecipeConfigDrawer.tsx index fc58029e..ca5209f2 100644 --- a/web/src/components/RecipeGallery/RecipeConfigDrawer.tsx +++ b/web/src/components/RecipeGallery/RecipeConfigDrawer.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import BulkApplyDialog from "./BulkApplyDialog"; import RecipeParamFields from "./RecipeParamFields"; -import { api } from "@/api/client"; import type { RecipeDefinition, GalleryPreset } from "@/lib/recipes"; export interface AddPayload { @@ -12,6 +11,7 @@ export interface AddPayload { enabled: boolean; config: Record; apply_to_all_libraries?: boolean; + library_ids?: number[]; } interface Props { @@ -24,7 +24,7 @@ interface Props { * falls back to onCancel (closes the drawer entirely). */ onBackToGallery?: () => void; - onAdd: (payload: AddPayload) => void; + onAdd: (payload: AddPayload) => void | Promise; showBulkApply?: boolean; showEnabled?: boolean; } @@ -61,11 +61,7 @@ export default function RecipeConfigDrawer({ if (collectionMissing) { return; } - if (showBulkApply && applyAll) { - setBulkOpen(true); - return; - } - onAdd({ + const payload = { section_type: def.type, title, item_limit: limit, @@ -73,6 +69,13 @@ export default function RecipeConfigDrawer({ enabled, config: params, apply_to_all_libraries: false, + }; + if (showBulkApply && applyAll) { + setBulkOpen(true); + return; + } + void Promise.resolve(onAdd(payload)).catch(() => { + // The owner reports the failure and keeps the drawer mounted for retry. }); }; @@ -174,27 +177,18 @@ export default function RecipeConfigDrawer({ setBulkOpen(false)} - onConfirm={async (libraryIDs) => { - try { - await api("/admin/sections/bulk-create", { - method: "POST", - body: JSON.stringify({ - scope: "library", - library_ids: libraryIDs, - section_type: def.type, - title, - item_limit: limit, - featured, - enabled, - config: params, - }), - }); - } catch (err) { - console.error("bulk-create failed:", err); - } - setBulkOpen(false); - onCancel(); - }} + onConfirm={(libraryIDs) => + onAdd({ + section_type: def.type, + title, + item_limit: limit, + featured, + enabled, + config: params, + apply_to_all_libraries: true, + library_ids: libraryIDs, + }) + } /> ) : null}
    diff --git a/web/src/components/SectionDrawer.test.tsx b/web/src/components/SectionDrawer.test.tsx index 026686a6..09d0fd0e 100644 --- a/web/src/components/SectionDrawer.test.tsx +++ b/web/src/components/SectionDrawer.test.tsx @@ -81,4 +81,21 @@ describe("SectionDrawer helpers", () => { config: expect.objectContaining({ media_scope: "movie" }), }); }); + + it("uses the section type label when an admin title is left blank", () => { + const payload = buildAdminSectionPayload({ + section: null, + scope: "home", + currentLibraryId: null, + sectionType: "recently_added", + title: " ", + itemLimit: 20, + featured: false, + enabled: true, + queryDefinition: queryDefinitionFromSectionConfig(), + selectedCollectionId: "", + }); + + expect(payload.title).toBe("Recently Added"); + }); }); diff --git a/web/src/components/sections/SectionEditorDrawer.tsx b/web/src/components/sections/SectionEditorDrawer.tsx index 369b578d..ef9a84b7 100644 --- a/web/src/components/sections/SectionEditorDrawer.tsx +++ b/web/src/components/sections/SectionEditorDrawer.tsx @@ -197,11 +197,13 @@ export function buildAdminSectionPayload({ config = recipeParams ?? {}; } + const safeTitle = title.trim() || sectionTypeLabel(sectionType); + return { ...(section ? { id: section.id } : {}), scope, ...(scope === "library" && currentLibraryId != null ? { library_id: currentLibraryId } : {}), - title, + title: safeTitle, section_type: sectionType, item_limit: itemLimit, featured, @@ -327,6 +329,7 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) { collections, }), ); + props.onOpenChange(false); } else { props.onSave( buildAdminSectionPayload({ @@ -345,7 +348,6 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) { }), ); } - props.onOpenChange(false); } const saveDisabled = @@ -475,9 +477,7 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) { setQueryDefinition({ ...queryDefinition, media_scope: - value === "all" - ? undefined - : (value as "movie" | "series" | "episode"), + value === "all" ? undefined : (value as "movie" | "series" | "episode"), }) } > diff --git a/web/src/hooks/queries/sections.ts b/web/src/hooks/queries/sections.ts index 97eb4ef4..216bfc76 100644 --- a/web/src/hooks/queries/sections.ts +++ b/web/src/hooks/queries/sections.ts @@ -141,6 +141,35 @@ export function useCreateSection() { }); } +export interface BulkCreateSectionsRequest { + scope: "home" | "library"; + library_ids?: number[]; + section_type: string; + title: string; + featured: boolean; + item_limit: number; + config: Record; + enabled: boolean; +} + +export interface BulkCreateSectionsResponse { + created: number; +} + +export function useBulkCreateSections() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: BulkCreateSectionsRequest) => + api("/admin/sections/bulk-create", { + method: "POST", + body: JSON.stringify(data), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: sectionKeys.all }); + }, + }); +} + export function useUpdateSection() { const qc = useQueryClient(); return useMutation({ diff --git a/web/src/pages/AdminSections.tsx b/web/src/pages/AdminSections.tsx index b0639088..1bd72fca 100644 --- a/web/src/pages/AdminSections.tsx +++ b/web/src/pages/AdminSections.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import type { Library, PageSectionConfig } from "@/api/types"; import { useAdminSections, + useBulkCreateSections, useCreateSection, useUpdateSection, useDeleteSection, @@ -194,6 +195,7 @@ export default function AdminSections() { const createFromGalleryMutation = useCreateSection(); const importTraktMutation = useImportTraktCollection(); const createMutation = useCreateSection(); + const bulkCreateMutation = useBulkCreateSections(); const updateMutation = useUpdateSection(); const sections = useMemo(() => data?.sections ?? [], [data?.sections]); @@ -262,7 +264,93 @@ export default function AdminSections() { const activeSection = activeId ? (orderedSections.find((s) => s.id === activeId) ?? null) : null; + function normalizeLibraryIDs(ids: number[] | undefined): number[] { + if (!ids || ids.length === 0) return []; + return Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0))); + } + + async function ensureTraktSectionCollection( + payload: AddPayload, + traktRecipe: TraktPublicRecipeConfig, + libraryID: number, + ): Promise { + const existing = collectionsData.find((collection) => + isMatchingTraktCollection(collection, traktRecipe.preset, traktRecipe.mediaType, libraryID), + ); + if (existing) return existing; + + const managementKey = buildTraktSectionManagedCollectionKey( + traktRecipe.preset, + traktRecipe.mediaType, + libraryID, + ); + const imported = await importTraktMutation.mutateAsync({ + body: { + library_id: libraryID, + title: payload.title, + description: "", + preset: traktRecipe.preset, + media_type: traktRecipe.mediaType, + limit: payload.item_limit, + featured: payload.featured, + management_mode: "section", + management_source: "recipe_gallery", + management_key: managementKey, + }, + }); + return imported.collection; + } + + async function createBulkSectionsFromGallery( + payload: AddPayload, + libraryIDs: number[], + ): Promise { + if (libraryIDs.length === 0) { + throw new Error("Choose at least one library before applying this section"); + } + + const config = payload.config; + const traktRecipe = getTraktRecipeConfig(config); + const selectedCollectionID = + typeof config.library_collection_id === "string" ? config.library_collection_id.trim() : ""; + if (traktRecipe && selectedCollectionID === "") { + for (const libraryID of libraryIDs) { + const collection = await ensureTraktSectionCollection(payload, traktRecipe, libraryID); + await createMutation.mutateAsync({ + scope: "library", + library_id: libraryID, + section_type: payload.section_type, + title: payload.title, + item_limit: payload.item_limit, + featured: payload.featured, + enabled: payload.enabled, + config: { ...config, library_collection_id: collection.id }, + }); + } + toast.success(`Created ${libraryIDs.length} section${libraryIDs.length === 1 ? "" : "s"}`); + return; + } + + const result = await bulkCreateMutation.mutateAsync({ + scope: "library", + library_ids: libraryIDs, + section_type: payload.section_type, + title: payload.title, + item_limit: payload.item_limit, + featured: payload.featured, + enabled: payload.enabled, + config, + }); + toast.success(`Created ${result.created} section${result.created === 1 ? "" : "s"}`); + } + async function createSectionFromGallery(payload: AddPayload) { + const bulkLibraryIDs = normalizeLibraryIDs(payload.library_ids); + if (payload.apply_to_all_libraries || bulkLibraryIDs.length > 0) { + await createBulkSectionsFromGallery(payload, bulkLibraryIDs); + return; + } + let config = payload.config; const traktRecipe = getTraktRecipeConfig(config); const selectedCollectionID = @@ -271,40 +359,9 @@ export default function AdminSections() { const targetLibraryID = activeLibraryId ?? findDefaultTraktLibrary(librariesList, traktRecipe.mediaType); if (!targetLibraryID) { - toast.error("Choose a library before adding this Trakt section"); - return; + throw new Error("Choose a library before adding this Trakt section"); } - const managementKey = buildTraktSectionManagedCollectionKey( - traktRecipe.preset, - traktRecipe.mediaType, - targetLibraryID, - ); - const existing = collectionsData.find((collection) => - isMatchingTraktCollection( - collection, - traktRecipe.preset, - traktRecipe.mediaType, - targetLibraryID, - ), - ); - const collection = - existing ?? - ( - await importTraktMutation.mutateAsync({ - body: { - library_id: targetLibraryID, - title: payload.title, - description: "", - preset: traktRecipe.preset, - media_type: traktRecipe.mediaType, - limit: payload.item_limit, - featured: payload.featured, - management_mode: "section", - management_source: "recipe_gallery", - management_key: managementKey, - }, - }) - ).collection; + const collection = await ensureTraktSectionCollection(payload, traktRecipe, targetLibraryID); config = { ...config, library_collection_id: collection.id }; } @@ -541,7 +598,9 @@ export default function AdminSections() { currentLibraryId={activeLibraryId} libraries={librariesList} recipeCatalog={recipeCatalog} - isSubmitting={createMutation.isPending || updateMutation.isPending} + isSubmitting={ + createMutation.isPending || updateMutation.isPending || bulkCreateMutation.isPending + } onSave={(section) => { if (section.id) { updateMutation.mutate( @@ -551,6 +610,9 @@ export default function AdminSections() { setDialogOpen(false); setEditingSection(null); }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to update section"); + }, }, ); } else { @@ -559,6 +621,9 @@ export default function AdminSections() { setDialogOpen(false); setEditingSection(null); }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to create section"); + }, }); } }} @@ -584,8 +649,13 @@ export default function AdminSections() { setGalleryOpen(true); }} onAdd={async (payload) => { - await createSectionFromGallery(payload); - setPickedRecipe(null); + try { + await createSectionFromGallery(payload); + setPickedRecipe(null); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to create section"); + throw error; + } }} />
    diff --git a/web/src/pages/ProfileCustomizeHome.tsx b/web/src/pages/ProfileCustomizeHome.tsx index cd39182b..229f65d9 100644 --- a/web/src/pages/ProfileCustomizeHome.tsx +++ b/web/src/pages/ProfileCustomizeHome.tsx @@ -265,6 +265,7 @@ export default function ProfileCustomizeHome() { setPicked(null)} onBackToGallery={() => { setPicked(null); From 0a27dac9cd9987a32c45ae18a536a2b2da3f1e5f Mon Sep 17 00:00:00 2001 From: zZebrahz Date: Sat, 30 May 2026 18:42:03 -0700 Subject: [PATCH 155/201] fix(auth): avoid revoking sessions for unchanged user policy --- internal/api/handlers/admin.go | 58 +++++++++++++++++++++----- internal/api/handlers/admin_test.go | 64 +++++++++++++++++++++++------ 2 files changed, 99 insertions(+), 23 deletions(-) diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 10e27250..dffffc2a 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -9,6 +9,7 @@ import ( "io" "log/slog" "net/http" + "slices" "sort" "strconv" "strings" @@ -463,7 +464,7 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request) permissions = &normalized } - err = h.userRepo.Update(r.Context(), id, models.UpdateUserInput{ + updateInput := models.UpdateUserInput{ Username: req.Username, Email: req.Email, Password: req.Password, @@ -477,12 +478,23 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request) MaxProfiles: req.MaxProfiles, DownloadAllowed: req.DownloadAllowed, DownloadTranscodeAllowed: req.DownloadTranscodeAllowed, - }) + } + + var currentUser *models.User + if updateMayRequireSessionRevocation(updateInput) { + currentUser, err = h.userRepo.GetByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "User not found") + return + } + } + + err = h.userRepo.Update(r.Context(), id, updateInput) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user") return } - if updateRequiresSessionRevocation(req) { + if updateRequiresSessionRevocation(currentUser, updateInput) { if err := h.revokeUserSessions(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke updated user sessions") return @@ -750,13 +762,39 @@ func (h *AdminHandler) HandleListUserProfiles(w http.ResponseWriter, r *http.Req writeJSON(w, http.StatusOK, resp) } -func updateRequiresSessionRevocation(req updateUserRequest) bool { - return req.Password != nil || - req.Role != nil || - req.Enabled != nil || - req.LibraryIDs.Set || - req.Permissions.Set || - req.MaxPlaybackQuality != nil +func updateMayRequireSessionRevocation(input models.UpdateUserInput) bool { + return input.Password != nil || + input.Role != nil || + input.Enabled != nil || + input.LibraryIDs != nil || + input.Permissions != nil || + input.MaxPlaybackQuality != nil +} + +func updateRequiresSessionRevocation(current *models.User, input models.UpdateUserInput) bool { + if input.Password != nil { + return true + } + if current == nil { + return updateMayRequireSessionRevocation(input) + } + if input.Role != nil && *input.Role != current.Role { + return true + } + if input.Enabled != nil && *input.Enabled != current.Enabled { + return true + } + if input.LibraryIDs != nil && !slices.Equal(*input.LibraryIDs, current.LibraryIDs) { + return true + } + if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) { + return true + } + if input.MaxPlaybackQuality != nil && + access.NormalizePlaybackQuality(*input.MaxPlaybackQuality) != access.NormalizePlaybackQuality(current.MaxPlaybackQuality) { + return true + } + return false } func (h *AdminHandler) revokeUserSessions(ctx context.Context, userID int) error { diff --git a/internal/api/handlers/admin_test.go b/internal/api/handlers/admin_test.go index d434a029..d3bd8d64 100644 --- a/internal/api/handlers/admin_test.go +++ b/internal/api/handlers/admin_test.go @@ -1,71 +1,109 @@ package handlers -import "testing" +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) func TestUpdateRequiresSessionRevocation(t *testing.T) { role := "admin" + sameRole := "user" enabled := true + disabled := false libraryIDs := []int{1, 2} + sameLibraryIDs := []int{1} maxPlaybackQuality := "1080p" + sameMaxPlaybackQuality := "original" password := "new-password" username := "renamed" maxStreams := 4 + permissions := []string{"metadata_curation"} + samePermissions := []string{"download"} + + current := &models.User{ + Role: "user", + Permissions: []string{"download"}, + Enabled: false, + LibraryIDs: []int{1}, + MaxPlaybackQuality: "original", + } tests := []struct { name string - req updateUserRequest + in models.UpdateUserInput want bool }{ { name: "permissions set", - req: updateUserRequest{Permissions: updateStringSliceField{Set: true, Value: []string{"metadata_curation"}}}, + in: models.UpdateUserInput{Permissions: &permissions}, want: true, }, { - name: "permissions unset", - req: updateUserRequest{Permissions: updateStringSliceField{Set: false, Value: []string{"metadata_curation"}}}, + name: "permissions unchanged", + in: models.UpdateUserInput{Permissions: &samePermissions}, want: false, }, { name: "role", - req: updateUserRequest{Role: &role}, + in: models.UpdateUserInput{Role: &role}, want: true, }, + { + name: "role unchanged", + in: models.UpdateUserInput{Role: &sameRole}, + want: false, + }, { name: "enabled", - req: updateUserRequest{Enabled: &enabled}, + in: models.UpdateUserInput{Enabled: &enabled}, want: true, }, + { + name: "enabled unchanged", + in: models.UpdateUserInput{Enabled: &disabled}, + want: false, + }, { name: "library ids", - req: updateUserRequest{LibraryIDs: updateLibraryIDsField{Set: true, Value: libraryIDs}}, + in: models.UpdateUserInput{LibraryIDs: &libraryIDs}, want: true, }, + { + name: "library ids unchanged", + in: models.UpdateUserInput{LibraryIDs: &sameLibraryIDs}, + want: false, + }, { name: "max playback quality", - req: updateUserRequest{MaxPlaybackQuality: &maxPlaybackQuality}, + in: models.UpdateUserInput{MaxPlaybackQuality: &maxPlaybackQuality}, want: true, }, + { + name: "max playback quality unchanged", + in: models.UpdateUserInput{MaxPlaybackQuality: &sameMaxPlaybackQuality}, + want: false, + }, { name: "password", - req: updateUserRequest{Password: &password}, + in: models.UpdateUserInput{Password: &password}, want: true, }, { name: "non access fields", - req: updateUserRequest{Username: &username, MaxStreams: &maxStreams}, + in: models.UpdateUserInput{Username: &username, MaxStreams: &maxStreams}, want: false, }, { name: "empty update", - req: updateUserRequest{}, + in: models.UpdateUserInput{}, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := updateRequiresSessionRevocation(tt.req); got != tt.want { + if got := updateRequiresSessionRevocation(current, tt.in); got != tt.want { t.Fatalf("updateRequiresSessionRevocation() = %v, want %v", got, tt.want) } }) From 3f554407529be3a64769eab80e03b19d3cf5449d Mon Sep 17 00:00:00 2001 From: zZebrahz Date: Sat, 30 May 2026 19:01:22 -0700 Subject: [PATCH 156/201] fix(auth): avoid unchanged access policy invalidation --- internal/api/handlers/admin.go | 10 +++++++++- internal/auth/repository.go | 17 +++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index dffffc2a..c40eed30 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -484,13 +484,21 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request) if updateMayRequireSessionRevocation(updateInput) { currentUser, err = h.userRepo.GetByID(r.Context(), id) if err != nil { - writeError(w, http.StatusNotFound, "not_found", "User not found") + if auth.IsNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "User not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch user") return } } err = h.userRepo.Update(r.Context(), id, updateInput) if err != nil { + if auth.IsNotFound(err) { + writeError(w, http.StatusNotFound, "not_found", "User not found") + return + } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user") return } diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 87f6cfc5..7c29579c 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -221,6 +221,7 @@ func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*models. // If the input contains a Password, it is bcrypt-hashed before storage. func (r *UserRepository) Update(ctx context.Context, id int, input models.UpdateUserInput) error { setClauses := []string{} + accessPolicyPredicates := []string{} args := []any{} argIndex := 1 @@ -250,6 +251,7 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update } if input.Role != nil { setClauses = append(setClauses, fmt.Sprintf("role = $%d", argIndex)) + accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("role IS DISTINCT FROM $%d", argIndex)) args = append(args, *input.Role) argIndex++ } @@ -259,21 +261,25 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update return err } setClauses = append(setClauses, fmt.Sprintf("permissions = $%d", argIndex)) + accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("permissions IS DISTINCT FROM $%d", argIndex)) args = append(args, permissions) argIndex++ } if input.Enabled != nil { setClauses = append(setClauses, fmt.Sprintf("enabled = $%d", argIndex)) + accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("enabled IS DISTINCT FROM $%d", argIndex)) args = append(args, *input.Enabled) argIndex++ } if input.LibraryIDs != nil { setClauses = append(setClauses, fmt.Sprintf("library_ids = $%d", argIndex)) + accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("library_ids IS DISTINCT FROM $%d", argIndex)) args = append(args, *input.LibraryIDs) argIndex++ } if input.MaxPlaybackQuality != nil { setClauses = append(setClauses, fmt.Sprintf("max_playback_quality = $%d", argIndex)) + accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("max_playback_quality IS DISTINCT FROM $%d", argIndex)) args = append(args, *input.MaxPlaybackQuality) argIndex++ } @@ -309,12 +315,11 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update return err } - 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") + if len(accessPolicyPredicates) > 0 { + setClauses = append(setClauses, fmt.Sprintf( + "access_policy_revision = CASE WHEN %s THEN access_policy_revision + 1 ELSE access_policy_revision END", + strings.Join(accessPolicyPredicates, " OR "), + )) } // Always bump updated_at. From af2c54c8f0635a9adffcdf9467a32cfa0f410230 Mon Sep 17 00:00:00 2001 From: zZebrahz Date: Sat, 30 May 2026 19:21:25 -0700 Subject: [PATCH 157/201] fix(auth): revoke sessions on library scope nil changes --- internal/api/handlers/admin.go | 9 +++++++-- internal/api/handlers/admin_test.go | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index c40eed30..e1ccb92f 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -792,8 +792,13 @@ func updateRequiresSessionRevocation(current *models.User, input models.UpdateUs if input.Enabled != nil && *input.Enabled != current.Enabled { return true } - if input.LibraryIDs != nil && !slices.Equal(*input.LibraryIDs, current.LibraryIDs) { - return true + if input.LibraryIDs != nil { + if (*input.LibraryIDs == nil) != (current.LibraryIDs == nil) { + return true + } + if *input.LibraryIDs != nil && !slices.Equal(*input.LibraryIDs, current.LibraryIDs) { + return true + } } if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) { return true diff --git a/internal/api/handlers/admin_test.go b/internal/api/handlers/admin_test.go index d3bd8d64..423e0f09 100644 --- a/internal/api/handlers/admin_test.go +++ b/internal/api/handlers/admin_test.go @@ -13,6 +13,8 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) { disabled := false libraryIDs := []int{1, 2} sameLibraryIDs := []int{1} + emptyLibraryIDs := []int{} + var allLibraryIDs []int maxPlaybackQuality := "1080p" sameMaxPlaybackQuality := "original" password := "new-password" @@ -74,6 +76,11 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) { in: models.UpdateUserInput{LibraryIDs: &sameLibraryIDs}, want: false, }, + { + name: "library ids nil differs from restricted", + in: models.UpdateUserInput{LibraryIDs: &allLibraryIDs}, + want: true, + }, { name: "max playback quality", in: models.UpdateUserInput{MaxPlaybackQuality: &maxPlaybackQuality}, @@ -108,4 +115,18 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) { } }) } + + unrestrictedCurrent := *current + unrestrictedCurrent.LibraryIDs = nil + t.Run("library ids empty differs from nil", func(t *testing.T) { + if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &emptyLibraryIDs}); !got { + t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got) + } + }) + + t.Run("library ids nil unchanged", func(t *testing.T) { + if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &allLibraryIDs}); got { + t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got) + } + }) } From 07e50b72b6d797df2645a5b0565c7a1bcd04ac06 Mon Sep 17 00:00:00 2001 From: zZebrahz Date: Sat, 30 May 2026 19:31:46 -0700 Subject: [PATCH 158/201] fix(auth): keep sessions on library scope changes --- internal/api/handlers/admin.go | 9 --------- internal/api/handlers/admin_test.go | 14 +++++++------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index e1ccb92f..4c84e8e1 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -774,7 +774,6 @@ func updateMayRequireSessionRevocation(input models.UpdateUserInput) bool { return input.Password != nil || input.Role != nil || input.Enabled != nil || - input.LibraryIDs != nil || input.Permissions != nil || input.MaxPlaybackQuality != nil } @@ -792,14 +791,6 @@ func updateRequiresSessionRevocation(current *models.User, input models.UpdateUs if input.Enabled != nil && *input.Enabled != current.Enabled { return true } - if input.LibraryIDs != nil { - if (*input.LibraryIDs == nil) != (current.LibraryIDs == nil) { - return true - } - if *input.LibraryIDs != nil && !slices.Equal(*input.LibraryIDs, current.LibraryIDs) { - return true - } - } if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) { return true } diff --git a/internal/api/handlers/admin_test.go b/internal/api/handlers/admin_test.go index 423e0f09..a0ce0136 100644 --- a/internal/api/handlers/admin_test.go +++ b/internal/api/handlers/admin_test.go @@ -67,9 +67,9 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) { want: false, }, { - name: "library ids", + name: "library ids does not revoke session", in: models.UpdateUserInput{LibraryIDs: &libraryIDs}, - want: true, + want: false, }, { name: "library ids unchanged", @@ -77,9 +77,9 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) { want: false, }, { - name: "library ids nil differs from restricted", + name: "library ids nil does not revoke session", in: models.UpdateUserInput{LibraryIDs: &allLibraryIDs}, - want: true, + want: false, }, { name: "max playback quality", @@ -118,9 +118,9 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) { unrestrictedCurrent := *current unrestrictedCurrent.LibraryIDs = nil - t.Run("library ids empty differs from nil", func(t *testing.T) { - if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &emptyLibraryIDs}); !got { - t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got) + t.Run("library ids empty does not revoke session", func(t *testing.T) { + if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &emptyLibraryIDs}); got { + t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got) } }) From cb1bc6ab62552f271ff4aa9a7123809c9281e4e3 Mon Sep 17 00:00:00 2001 From: zZebrahz Date: Sat, 30 May 2026 21:39:10 -0700 Subject: [PATCH 159/201] fix(auth): preserve profile tokens on library changes --- internal/auth/repository.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 7c29579c..2ddf4a07 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -273,7 +273,8 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update } if input.LibraryIDs != nil { setClauses = append(setClauses, fmt.Sprintf("library_ids = $%d", argIndex)) - accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("library_ids IS DISTINCT FROM $%d", argIndex)) + // Library scope is resolved from users.library_ids on each request, so + // changing it must not invalidate durable profile/session tokens. args = append(args, *input.LibraryIDs) argIndex++ } From bc921cac0e14ca33e76494ff82aa02a740bc40a5 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sun, 31 May 2026 17:34:48 -0400 Subject: [PATCH 160/201] [codex] show plugin OAuth providers on login (#32) * fix(auth): show oauth login providers * fix(auth): hide oauth providers when routes are unavailable --- internal/api/handlers/auth.go | 17 +++- internal/api/handlers/auth_providers_test.go | 97 ++++++++++++++++++++ internal/api/router.go | 1 + web/src/hooks/AuthProvider.test.tsx | 91 ++++++++++++++++++ web/src/hooks/useAuth.tsx | 2 +- 5 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 internal/api/handlers/auth_providers_test.go create mode 100644 web/src/hooks/AuthProvider.test.tsx diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go index 78b985ad..9b3ce6cd 100644 --- a/internal/api/handlers/auth.go +++ b/internal/api/handlers/auth.go @@ -17,9 +17,10 @@ import ( // AuthHandler handles authentication-related HTTP endpoints. type AuthHandler struct { - service *auth.Service - jwt *auth.JWTService - device *auth.DeviceLoginService + service *auth.Service + jwt *auth.JWTService + device *auth.DeviceLoginService + oauthRoutesAvailable bool } // NewAuthHandler creates a new AuthHandler backed by the given auth, JWT, @@ -32,6 +33,13 @@ func NewAuthHandler(service *auth.Service, jwt *auth.JWTService, device *auth.De } } +// SetOAuthRoutesAvailable controls whether OAuth login providers are +// advertised by /auth/providers. The router only mounts OAuth routes when the +// server has enough configuration to complete the flow. +func (h *AuthHandler) SetOAuthRoutesAvailable(available bool) { + h.oauthRoutesAvailable = available +} + // --- Request/Response types --- // loginRequest represents the JSON body of a POST /auth/login request. @@ -181,6 +189,9 @@ func (h *AuthHandler) HandleProviders(w http.ResponseWriter, r *http.Request) { providers := h.service.ListProviders() response := make([]authProviderResponse, 0, len(providers)) for _, provider := range providers { + if provider.Mode == "oauth" && !h.oauthRoutesAvailable { + continue + } response = append(response, authProviderResponse{ ID: provider.ID, DisplayName: provider.DisplayName, diff --git a/internal/api/handlers/auth_providers_test.go b/internal/api/handlers/auth_providers_test.go new file mode 100644 index 00000000..24c1c802 --- /dev/null +++ b/internal/api/handlers/auth_providers_test.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +type stubLoginProvider struct{} + +func (stubLoginProvider) Authenticate(context.Context, auth.Credentials) (*models.User, error) { + return nil, auth.ErrInvalidCredentials +} + +func (stubLoginProvider) ValidateSession(context.Context, string) (bool, error) { + return false, nil +} + +func newAuthProviderHandlerForTest(oauthRoutesAvailable bool) *AuthHandler { + service := auth.NewService(nil, nil, nil, nil, nil, nil, nil) + provider := stubLoginProvider{} + + service.RegisterProvider(auth.LoginProviderInfo{ + ID: "local", + DisplayName: "Local", + Mode: "credentials", + Default: true, + }, provider) + service.RegisterProvider(auth.LoginProviderInfo{ + ID: "plugin:41:oidc", + DisplayName: "OIDC", + Mode: "oauth", + InstallationID: 41, + }, provider) + + handler := NewAuthHandler(service, nil, nil) + handler.SetOAuthRoutesAvailable(oauthRoutesAvailable) + return handler +} + +func readProviderResponse(t *testing.T, handler *AuthHandler) []authProviderResponse { + t.Helper() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/auth/providers", nil) + handler.HandleProviders(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var providers []authProviderResponse + if err := json.NewDecoder(rec.Body).Decode(&providers); err != nil { + t.Fatalf("decode response: %v", err) + } + return providers +} + +func TestAuthProvidersHideOAuthWhenOAuthRoutesUnavailable(t *testing.T) { + providers := readProviderResponse(t, newAuthProviderHandlerForTest(false)) + + if len(providers) != 1 { + t.Fatalf("provider count = %d, want 1: %#v", len(providers), providers) + } + if providers[0].ID != "local" { + t.Fatalf("provider ID = %q, want local", providers[0].ID) + } +} + +func TestAuthProvidersIncludeOAuthWhenOAuthRoutesAvailable(t *testing.T) { + providers := readProviderResponse(t, newAuthProviderHandlerForTest(true)) + + if len(providers) != 2 { + t.Fatalf("provider count = %d, want 2: %#v", len(providers), providers) + } + + var foundOAuth bool + for _, provider := range providers { + if provider.ID == "plugin:41:oidc" { + foundOAuth = true + if provider.Mode != "oauth" { + t.Fatalf("OAuth provider mode = %q, want oauth", provider.Mode) + } + if provider.InstallationID != 41 { + t.Fatalf("OAuth provider installation ID = %d, want 41", provider.InstallationID) + } + } + } + if !foundOAuth { + t.Fatalf("OAuth provider missing from response: %#v", providers) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index aac5be6a..b5927215 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1161,6 +1161,7 @@ func NewRouter(deps Dependencies) chi.Router { StateTTL: 10 * time.Minute, }) } + authHandler.SetOAuthRoutesAvailable(oauthHandler != nil) r.Route("/auth", func(r chi.Router) { if deps.RateLimitMW != nil { diff --git a/web/src/hooks/AuthProvider.test.tsx b/web/src/hooks/AuthProvider.test.tsx new file mode 100644 index 00000000..9a2fcc6b --- /dev/null +++ b/web/src/hooks/AuthProvider.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AuthProviderOption, SetupStatusResponse } from "@/api/types"; +import { storage } from "@/utils/storage"; +import { AuthProvider, useAuth } from "./useAuth"; + +const apiMock = vi.hoisted(() => vi.fn()); +const bootstrapAccessTokenMock = vi.hoisted(() => vi.fn()); +const getAccessTokenMock = vi.hoisted(() => vi.fn()); +const onProfileUnverifiedMock = vi.hoisted(() => vi.fn()); +const restoreUserSessionMock = vi.hoisted(() => vi.fn()); +const setAccessTokenMock = vi.hoisted(() => vi.fn()); +const setProfileIdMock = vi.hoisted(() => vi.fn()); +const setProfileTokenMock = vi.hoisted(() => vi.fn()); +const setRefreshTokenMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/client", async () => { + const actual = await vi.importActual("@/api/client"); + + return { + ...actual, + api: apiMock, + bootstrapAccessToken: bootstrapAccessTokenMock, + getAccessToken: getAccessTokenMock, + onProfileUnverified: onProfileUnverifiedMock, + restoreUserSession: restoreUserSessionMock, + setAccessToken: setAccessTokenMock, + setProfileId: setProfileIdMock, + setProfileToken: setProfileTokenMock, + setRefreshToken: setRefreshTokenMock, + }; +}); + +function renderWithAuthProvider(children: ReactNode) { + return render({children}); +} + +function ProviderProbe() { + const { providers, setupLoading } = useAuth(); + + return ( +
    + {setupLoading ? "loading" : providers.map((entry) => `${entry.id}:${entry.mode}`).join(",")} +
    + ); +} + +describe("AuthProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.values(storage.KEYS).forEach((key) => storage.remove(key)); + + bootstrapAccessTokenMock.mockResolvedValue(false); + getAccessTokenMock.mockReturnValue(null); + restoreUserSessionMock.mockResolvedValue(null); + }); + + it("preserves OAuth login providers returned by the auth providers endpoint", async () => { + const providers: AuthProviderOption[] = [ + { id: "local", display_name: "Local", mode: "credentials", default: true }, + { + id: "plugin:41:oidc", + display_name: "OIDC", + mode: "oauth", + default: false, + installation_id: 41, + }, + ]; + + apiMock.mockImplementation( + (path: string): Promise => { + if (path === "/auth/setup") { + return Promise.resolve({ needs_setup: false }); + } + if (path === "/auth/providers") { + return Promise.resolve(providers); + } + return Promise.reject(new Error(`unexpected API call: ${path}`)); + }, + ); + + renderWithAuthProvider(); + + await waitFor(() => { + expect(screen.getByTestId("providers")).toHaveTextContent("local:credentials"); + expect(screen.getByTestId("providers")).toHaveTextContent("plugin:41:oidc:oauth"); + }); + }); +}); diff --git a/web/src/hooks/useAuth.tsx b/web/src/hooks/useAuth.tsx index af1c16c7..206e684d 100644 --- a/web/src/hooks/useAuth.tsx +++ b/web/src/hooks/useAuth.tsx @@ -358,7 +358,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return; } setSetupRequired(status.needs_setup); - setProviders((availableProviders ?? []).filter((entry) => entry.mode === "credentials")); + setProviders(availableProviders ?? []); } catch { if (!cancelled) { setSetupRequired(false); From 8bed6c645231b5f86b90c7901c358e4c6eeb0e0c Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sun, 31 May 2026 19:59:08 -0400 Subject: [PATCH 161/201] fix(auth): keep plugin-only passwords bcrypt-safe (#33) --- internal/auth/plugin_provider.go | 2 +- internal/auth/plugin_provider_test.go | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 internal/auth/plugin_provider_test.go diff --git a/internal/auth/plugin_provider.go b/internal/auth/plugin_provider.go index 1a1d1cca..d404f95a 100644 --- a/internal/auth/plugin_provider.go +++ b/internal/auth/plugin_provider.go @@ -279,7 +279,7 @@ func (p *PluginProvider) autoProvisionUser( } func randomPluginOnlyPassword() (string, error) { - buf := make([]byte, 32) + buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { return "", err } diff --git a/internal/auth/plugin_provider_test.go b/internal/auth/plugin_provider_test.go new file mode 100644 index 00000000..96b8e357 --- /dev/null +++ b/internal/auth/plugin_provider_test.go @@ -0,0 +1,21 @@ +package auth + +import ( + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestRandomPluginOnlyPasswordFitsBcryptLimit(t *testing.T) { + password, err := randomPluginOnlyPassword() + if err != nil { + t.Fatalf("randomPluginOnlyPassword() error = %v", err) + } + + if len(password) > 72 { + t.Fatalf("password length = %d, want <= 72", len(password)) + } + if _, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err != nil { + t.Fatalf("bcrypt.GenerateFromPassword() error = %v", err) + } +} From 0fb06670d948195fc7d8729cb13f7a0dc67728f5 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:03:49 -0400 Subject: [PATCH 162/201] docs: add discord link to readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index de86f351..892acfd4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A self-hosted media streaming server with a React frontend and Go backend. Supports direct play, remuxing, and hardware-accelerated transcoding. Compatible with Jellyfin/Emby clients (VidHub, Findroid) via a built-in compatibility layer. +Join the community on [Discord](https://discord.gg/4RxuUQAEnW). + ## Deploy with Docker (recommended) The easiest way to run Silo is with Docker Compose. The default stack assumes you do not already have PostgreSQL and Redis available, so it bundles PostgreSQL, Redis, FFmpeg, and the application for a one-command start. From a6b510c7c5246b3d85c4620190d9640001db515e Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:24:33 -0400 Subject: [PATCH 163/201] fix(catalog): derive next-up anchors without progress cap (#41) --- internal/catalog/nextup_repo.go | 43 +++++++++------------------- internal/catalog/nextup_repo_test.go | 14 +++++++-- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/internal/catalog/nextup_repo.go b/internal/catalog/nextup_repo.go index a564c4cc..4ccab104 100644 --- a/internal/catalog/nextup_repo.go +++ b/internal/catalog/nextup_repo.go @@ -52,34 +52,12 @@ func (r *NextUpRepository) ListNextUp(ctx context.Context, q NextUpQuery) ([]Nex return nil, nil } - store, err := r.storeProvider.ForUser(ctx, q.UserID) - if err != nil { - return nil, fmt.Errorf("getting user store: %w", err) - } - - // Get recently completed episodes (limit 100 for performance) - completedEntries, err := store.ListProgress(ctx, q.ProfileID, "completed", 100, 0) - if err != nil { - return nil, fmt.Errorf("listing completed progress: %w", err) - } - if len(completedEntries) == 0 { - if q.EnableResumable { - return r.listResumableFirstEpisodes(ctx, q) - } - return nil, nil - } - - completedIDs := make([]string, 0, len(completedEntries)) - for _, entry := range completedEntries { - completedIDs = append(completedIDs, entry.MediaItemID) - } - limit := q.Limit if limit <= 0 { limit = 20 } - query, args := buildListNextUpQuery(q, completedIDs, limit) + query, args := buildListNextUpQuery(q, limit) rows, err := r.pool.Query(ctx, query, args...) if err != nil { @@ -131,9 +109,9 @@ func (r *NextUpRepository) ListNextUp(ctx context.Context, q NextUpQuery) ([]Nex return results, nil } -func buildListNextUpQuery(q NextUpQuery, completedIDs []string, limit int) (string, []interface{}) { - args := []interface{}{q.UserID, q.ProfileID, completedIDs, limit} - argIdx := 5 +func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) { + args := []interface{}{q.UserID, q.ProfileID, limit} + argIdx := 4 seriesFilter := "" if q.SeriesID != "" { @@ -188,10 +166,17 @@ func buildListNextUpQuery(q NextUpQuery, completedIDs []string, limit int) (stri WHERE uwp.user_id = $1 AND uwp.profile_id = $2 AND uwp.completed = TRUE - AND uwp.media_item_id = ANY($3) + AND NOT EXISTS ( + SELECT 1 + FROM user_history_hidden_items hhi + WHERE hhi.user_id = uwp.user_id + AND hhi.profile_id = uwp.profile_id + AND hhi.media_item_id = uwp.media_item_id + AND uwp.updated_at <= hhi.hidden_before + ) %s %s - ORDER BY e.series_id, uwp.updated_at DESC + ORDER BY e.series_id, uwp.updated_at DESC, e.season_number DESC, e.episode_number DESC ) %s SELECT @@ -219,7 +204,7 @@ func buildListNextUpQuery(q NextUpQuery, completedIDs []string, limit int) (stri LIMIT 1 ) next_ep ON true ORDER BY es.updated_at DESC - LIMIT $4`, seriesFilter, dateCutoffFilter, inProgressExclusion, sourceTable) + LIMIT $3`, seriesFilter, dateCutoffFilter, inProgressExclusion, sourceTable) return query, args } diff --git a/internal/catalog/nextup_repo_test.go b/internal/catalog/nextup_repo_test.go index b453cf29..8b60a645 100644 --- a/internal/catalog/nextup_repo_test.go +++ b/internal/catalog/nextup_repo_test.go @@ -11,13 +11,17 @@ func TestBuildListNextUpQuery_PrefersRecentCompletedOverOlderPartialProgress(t * query, args := buildListNextUpQuery(NextUpQuery{ UserID: 7, ProfileID: "profile-1", - }, []string{"ep-19", "ep-20"}, 20) + }, 20) expectedFragments := []string{ "eligible_series AS (", "uwp_ip.completed = FALSE", "e_ip.series_id = ce.series_id", "uwp_ip.updated_at > ce.updated_at", + "FROM user_history_hidden_items hhi", + "uwp.updated_at <= hhi.hidden_before", + "ORDER BY e.series_id, uwp.updated_at DESC, e.season_number DESC, e.episode_number DESC", + "LIMIT $3", } for _, fragment := range expectedFragments { if !strings.Contains(query, fragment) { @@ -25,7 +29,11 @@ func TestBuildListNextUpQuery_PrefersRecentCompletedOverOlderPartialProgress(t * } } - if len(args) != 4 { + if strings.Contains(query, "uwp.media_item_id = ANY") { + t.Fatalf("query must not cap completed progress before deriving series anchors, got:\n%s", query) + } + + if len(args) != 3 { t.Fatalf("expected default arg count, got %d", len(args)) } } @@ -37,7 +45,7 @@ func TestBuildListNextUpQuery_EnableResumableSkipsSeriesSuppressionCTE(t *testin UserID: 7, ProfileID: "profile-1", EnableResumable: true, - }, []string{"ep-20"}, 20) + }, 20) if strings.Contains(query, "eligible_series AS (") { t.Fatalf("expected resumable query to skip eligible_series suppression CTE, got:\n%s", query) From ea3b5a2e2924383147ec91dd3c3d2a419f73509d Mon Sep 17 00:00:00 2001 From: fluxis Date: Tue, 2 Jun 2026 11:25:18 -0400 Subject: [PATCH 164/201] feat(requests): multi-instance Sonarr/Radarr routing with HD/4K defaults and anime overrides (#39) * docs: design spec for multi-instance Sonarr/Radarr request routing Seerr-style multi-instance arr management inside Silo's request system: many instances per kind, HD/4K default routing, entitlement-driven dual-quality fan-out, per-instance anime overrides (keyword 210024), and a one-to-many media_request_targets model. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for multi-instance arr request routing Co-Authored-By: Claude Opus 4.8 (1M context) * feat(requests): migration for multi-instance arr routing Adds migration 169 to convert request_integrations from a one-row-per-kind table keyed on `kind` to a multi-instance table keyed on `id`, with HD/4K defaults, anime overrides, and a new one-to-many media_request_targets table for per-quality fulfillment tracking. Co-Authored-By: Claude Sonnet 4.6 * feat(requests): instance, target, and dual-quality types Co-Authored-By: Claude Sonnet 4.6 * feat(requests): id-based integration CRUD Replace upsert-by-kind (UpsertIntegration/UpsertIntegrations) with GetIntegration, CreateIntegration, UpdateIntegration, DeleteIntegration, and ClearDefault. Rewrites scanIntegration and integrationColumns to cover all new multi-instance columns (id, name, is_4k, is_default, is_default_4k, anime_* fields). Updates the Store interface accordingly. Co-Authored-By: Claude Sonnet 4.6 * feat(requests): target persistence and aggregate status * feat(tmdb): expose keyword ids and original language on detail * feat(requests): Seerr-exact anime detection (keyword 210024) * feat(requests): quality/anime routing engine Co-Authored-By: Claude Opus 4.8 (1M context) * feat(requests): force_dual_quality setting Co-Authored-By: Claude Sonnet 4.6 * feat(requests): multi-target fulfillment, reconcile, retry, and instance CRUD Co-Authored-By: Claude Opus 4.8 (1M context) * feat(api): request integration CRUD endpoints, targets in responses, entitlement wiring Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): multi-instance request integration types and CRUD hooks Co-Authored-By: Claude Sonnet 4.6 * feat(web): multi-instance arr manager, dual-quality toggle, per-target queue Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): UX review fixes for arr manager (delete confirm, switch hints, test feedback, dirty + target status) * fix(requests): address code-review findings (test-connection by id, HD-only default ceiling, retryable partial failure, idempotent submit, transactional defaults, presence/target reconcile, auto-approve gate) Co-Authored-By: Claude Opus 4.8 (1M context) * fix: address CodeRabbit review (anime override fallback, non-null slices, save gate, a11y, DeleteTarget not-found) - routing: anime fields only override standard root/profile/tags when set, so enabling anime with blank fields reuses standard values instead of clearing them into an invalid submission - api: normalize nil Tags/AnimeTags to [] so they serialize as arrays not null - web: require an API key before saving a NEW instance; add aria-expanded/ aria-controls to the anime-overrides disclosure toggle - repo: DeleteTarget returns ErrNotFound when no row was deleted Co-Authored-By: Claude Opus 4.8 (1M context) * fix(requests): address PR review findings --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> --- cmd/silo/main.go | 9 + ...6-01-request-multi-instance-arr-routing.md | 1704 +++++++++++++++++ ...quest-multi-instance-arr-routing-design.md | 353 ++++ internal/api/handlers/requests.go | 138 +- internal/api/handlers/requests_test.go | 12 +- internal/api/router.go | 9 +- internal/metadata/tmdb/client.go | 56 +- internal/metadata/tmdb/types.go | 84 +- internal/requests/anime.go | 15 + internal/requests/anime_test.go | 15 + internal/requests/entitlements.go | 30 + internal/requests/repository.go | 327 ++-- internal/requests/routing.go | 85 + internal/requests/routing_test.go | 52 + internal/requests/service.go | 514 +++-- internal/requests/service_test.go | 341 +++- internal/requests/store.go | 15 +- internal/requests/targets.go | 192 ++ internal/requests/targets_test.go | 27 + internal/requests/types.go | 69 +- .../169_request_multi_instance.down.sql | 42 + migrations/169_request_multi_instance.up.sql | 86 + .../170_request_force_dual_quality.down.sql | 1 + .../170_request_force_dual_quality.up.sql | 2 + web/src/api/types.ts | 29 + web/src/hooks/queries/useRequests.ts | 58 +- web/src/pages/AdminRequests.tsx | 506 ++++- 27 files changed, 4257 insertions(+), 514 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-01-request-multi-instance-arr-routing.md create mode 100644 docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md create mode 100644 internal/requests/anime.go create mode 100644 internal/requests/anime_test.go create mode 100644 internal/requests/entitlements.go create mode 100644 internal/requests/routing.go create mode 100644 internal/requests/routing_test.go create mode 100644 internal/requests/targets.go create mode 100644 internal/requests/targets_test.go create mode 100644 migrations/169_request_multi_instance.down.sql create mode 100644 migrations/169_request_multi_instance.up.sql create mode 100644 migrations/170_request_force_dual_quality.down.sql create mode 100644 migrations/170_request_force_dual_quality.up.sql diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 907145fb..cde16fc4 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -29,6 +29,7 @@ import ( pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/activitylog" "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/api" @@ -1320,6 +1321,14 @@ func main() { ) requestReconcileSvc.SetSecretResolver(settingsRepo) requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil)) + if userStoreProvider != nil { + reconcileResolver := access.NewResolver( + auth.NewUserRepository(deps.DB), + userStoreProvider, + access.NewProfileTokenService(cfg.Auth.JWTSecret, 0), + ) + requestReconcileSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(reconcileResolver)) + } taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100)) reconcileProviderIDRepo := catalog.NewProviderIDRepository(deps.DB) reconcileEpisodeRepo := catalog.NewEpisodeRepository(deps.DB) diff --git a/docs/superpowers/plans/2026-06-01-request-multi-instance-arr-routing.md b/docs/superpowers/plans/2026-06-01-request-multi-instance-arr-routing.md new file mode 100644 index 00000000..f5cf21ab --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-request-multi-instance-arr-routing.md @@ -0,0 +1,1704 @@ +# Multi-Instance Sonarr/Radarr Routing 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:** Replicate Seerr's Sonarr/Radarr management inside Silo's request system — many instances per kind, HD/4K default routing, entitlement-driven dual-quality fan-out, and per-instance anime overrides. + +**Architecture:** `request_integrations` becomes a multi-row table keyed by `id` with default-HD/default-4K/anime fields. A new `media_request_targets` table makes fulfillment one-to-many. A pure `routeTargets` function chooses targets from the requester's `MaxPlaybackQuality` entitlement, a `force_dual_quality` setting, and detected anime status. The Radarr/Sonarr adapters are unchanged — the service builds a *resolved* `Integration` (right profile/folder/tags + `options["series_type"]`) and a per-target `Request` copy (`ExternalID`) per call. + +**Tech Stack:** Go (pgx, standard `testing`), PostgreSQL (paired numbered migrations), React/TypeScript (Vite, React Query). + +**Spec:** `docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md` + +**Commands assume the repository root is the cwd.** Run Go tests with `go test ./internal/requests/...`. Run the full lint with `make lint`. Frontend lint: `cd web && pnpm run lint`. + +--- + +## Phase 0 — Branch + +- [ ] **Step 0.1: Create a feature branch** + +```bash +git checkout main +git pull +git checkout -b feat/request-multi-instance-arr +``` + +Expected: on a new branch off `main`. + +--- + +## Phase 1 — Data model: migration & Go types + +### Task 1: Migration 169 (schema) + +**Files:** +- Create: `migrations/169_request_multi_instance.up.sql` +- Create: `migrations/169_request_multi_instance.down.sql` + +- [ ] **Step 1.1: Write the up migration** + +`migrations/169_request_multi_instance.up.sql`: + +```sql +-- request_integrations: one-row-per-kind -> many instances keyed by id. +ALTER TABLE public.request_integrations + ADD COLUMN IF NOT EXISTS id text, + ADD COLUMN IF NOT EXISTS name text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS is_4k boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS is_default_4k boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS anime_enabled boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS anime_quality_profile_id integer, + ADD COLUMN IF NOT EXISTS anime_root_folder text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS anime_tags integer[] NOT NULL DEFAULT '{}'; + +-- Backfill: the lone existing row per kind becomes that kind's HD default. +UPDATE public.request_integrations +SET id = gen_random_uuid()::text, + name = initcap(kind), + is_default = enabled +WHERE id IS NULL; + +-- Swap the primary key from kind to id; keep kind as a plain column. +ALTER TABLE public.request_integrations + DROP CONSTRAINT request_integrations_pkey; +ALTER TABLE public.request_integrations + ALTER COLUMN id SET NOT NULL, + ADD PRIMARY KEY (id); + +-- Quality-role invariants: at most one default / one 4K-default per kind. +CREATE UNIQUE INDEX IF NOT EXISTS idx_request_integrations_default_per_kind + ON public.request_integrations (kind) WHERE is_default; +CREATE UNIQUE INDEX IF NOT EXISTS idx_request_integrations_default4k_per_kind + ON public.request_integrations (kind) WHERE is_default_4k; + +-- Targets: one request -> N fulfillment targets. +CREATE TABLE IF NOT EXISTS public.media_request_targets ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + request_id text NOT NULL REFERENCES public.media_requests(id) ON DELETE CASCADE, + integration_id text REFERENCES public.request_integrations(id) ON DELETE SET NULL, + integration_kind text NOT NULL DEFAULT '', + quality text NOT NULL, + is_anime boolean NOT NULL DEFAULT false, + external_id text NOT NULL DEFAULT '', + external_status text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'queued', + last_error text NOT NULL DEFAULT '', + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT media_request_targets_quality_check CHECK (quality IN ('1080p', '2160p')), + CONSTRAINT media_request_targets_status_check + CHECK (status IN ('queued', 'downloading', 'completed', 'failed')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_media_request_targets_request_quality + ON public.media_request_targets (request_id, quality); +CREATE INDEX IF NOT EXISTS idx_media_request_targets_request + ON public.media_request_targets (request_id); + +-- Backfill targets from already-submitted requests (those with an external id). +INSERT INTO public.media_request_targets + (request_id, integration_id, integration_kind, quality, is_anime, + external_id, external_status, status, created_at, updated_at) +SELECT mr.id, + ri.id, + mr.integration_kind, + '1080p', + false, + mr.external_id, + mr.external_status, + CASE + WHEN mr.status = 'completed' THEN 'completed' + WHEN mr.status = 'downloading' THEN 'downloading' + WHEN mr.outcome = 'failed' THEN 'failed' + ELSE 'queued' + END, + mr.created_at, + mr.updated_at +FROM public.media_requests mr +LEFT JOIN public.request_integrations ri ON ri.kind = mr.integration_kind +WHERE mr.external_id <> ''; + +-- media_requests: add is_anime, move per-fulfillment columns out to targets. +ALTER TABLE public.media_requests + ADD COLUMN IF NOT EXISTS is_anime boolean NOT NULL DEFAULT false; +ALTER TABLE public.media_requests + DROP COLUMN IF EXISTS integration_kind, + DROP COLUMN IF EXISTS external_id, + DROP COLUMN IF EXISTS external_status; +``` + +- [ ] **Step 1.2: Write the down migration** + +`migrations/169_request_multi_instance.down.sql`: + +```sql +-- Restore per-fulfillment columns on media_requests. +ALTER TABLE public.media_requests + ADD COLUMN IF NOT EXISTS integration_kind text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS external_id text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS external_status text NOT NULL DEFAULT ''; + +-- Copy back the 1080p target's fulfillment fields (lossy: 4K/anime targets dropped). +UPDATE public.media_requests mr +SET integration_kind = t.integration_kind, + external_id = t.external_id, + external_status = t.external_status +FROM public.media_request_targets t +WHERE t.request_id = mr.id AND t.quality = '1080p'; + +ALTER TABLE public.media_requests DROP COLUMN IF EXISTS is_anime; + +DROP TABLE IF EXISTS public.media_request_targets; + +-- Collapse request_integrations back to kind-PK (lossy: keep one default per kind). +DELETE FROM public.request_integrations a +USING public.request_integrations b +WHERE a.kind = b.kind AND a.id <> b.id AND b.is_default AND NOT a.is_default; +-- If a kind has no default, keep an arbitrary row and drop the rest. +DELETE FROM public.request_integrations a +USING public.request_integrations b +WHERE a.kind = b.kind AND a.ctid < b.ctid; + +DROP INDEX IF EXISTS idx_request_integrations_default_per_kind; +DROP INDEX IF EXISTS idx_request_integrations_default4k_per_kind; + +ALTER TABLE public.request_integrations DROP CONSTRAINT request_integrations_pkey; +ALTER TABLE public.request_integrations ADD PRIMARY KEY (kind); +ALTER TABLE public.request_integrations + DROP COLUMN IF EXISTS id, + DROP COLUMN IF EXISTS name, + DROP COLUMN IF EXISTS is_4k, + DROP COLUMN IF EXISTS is_default, + DROP COLUMN IF EXISTS is_default_4k, + DROP COLUMN IF EXISTS anime_enabled, + DROP COLUMN IF EXISTS anime_quality_profile_id, + DROP COLUMN IF EXISTS anime_root_folder, + DROP COLUMN IF EXISTS anime_tags; +``` + +- [ ] **Step 1.3: Apply the migration against a local DB and verify** + +```bash +docker compose up -d postgres +# Apply via the project's migrate path (matches how the server migrates on boot): +make dev-backend # boots, runs migrations, then Ctrl-C; OR run your migrate tool. +psql "$DATABASE_URL" -c "\d public.media_request_targets" +psql "$DATABASE_URL" -c "\d public.request_integrations" +``` + +Expected: `media_request_targets` exists; `request_integrations` PK is `id`; `media_requests` has no `external_id` column. + +- [ ] **Step 1.4: Commit** + +```bash +git add migrations/169_request_multi_instance.up.sql migrations/169_request_multi_instance.down.sql +git commit -m "feat(requests): migration for multi-instance arr routing" +``` + +### Task 2: Go types for instances and targets + +**Files:** +- Modify: `internal/requests/types.go` (extend `Integration`, add `Target`, add settings field) + +- [ ] **Step 2.1: Extend `Integration` and add `Target` + `Quality` constants** + +In `internal/requests/types.go`, add quality constants and a `Target` type, and extend `Integration`: + +```go +type Quality string + +const ( + Quality1080p Quality = "1080p" + Quality2160p Quality = "2160p" +) + +// Target is one fulfillment of a request against a single instance at a single +// quality. A request fans out to one Target per resolved quality. +type Target struct { + ID int64 `json:"id"` + RequestID string `json:"request_id"` + IntegrationID string `json:"integration_id,omitempty"` + IntegrationKind string `json:"integration_kind,omitempty"` + InstanceName string `json:"instance_name,omitempty"` + Quality Quality `json:"quality"` + IsAnime bool `json:"is_anime"` + ExternalID string `json:"external_id,omitempty"` + ExternalStatus string `json:"external_status,omitempty"` + Status Status `json:"status"` + LastError string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +Add these fields to the existing `Integration` struct (keep all current fields): + +```go + ID string `json:"id"` + Name string `json:"name"` + Is4K bool `json:"is_4k"` + IsDefault bool `json:"is_default"` + IsDefault4K bool `json:"is_default_4k"` + AnimeEnabled bool `json:"anime_enabled"` + AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"` + AnimeRootFolder string `json:"anime_root_folder,omitempty"` + AnimeTags []int `json:"anime_tags"` +``` + +Add `IsAnime` to `Request` (after `IntegrationKind`) and `Targets` for responses: + +```go + IsAnime bool `json:"is_anime"` + Targets []Target `json:"targets,omitempty"` +``` + +Add `ForceDualQuality` to `Settings`: + +```go + ForceDualQuality bool `json:"force_dual_quality"` +``` + +> Note: the existing `Request.IntegrationKind`/`ExternalID`/`ExternalStatus` fields stay on the struct as transient per-target carriers for the adapters; they have `omitempty` JSON tags and are now empty at the request level, so responses surface fulfillment via `Targets` only. + +- [ ] **Step 2.2: Verify it compiles** + +```bash +go build ./internal/requests/... +``` + +Expected: builds (no usages broken yet — repository changes come next). + +- [ ] **Step 2.3: Commit** + +```bash +git add internal/requests/types.go +git commit -m "feat(requests): instance, target, and dual-quality types" +``` + +--- + +## Phase 2 — Repository layer + +### Task 3: Integration scan/CRUD by id + +**Files:** +- Modify: `internal/requests/repository.go` (`scanIntegration`, `ListIntegrations`, replace `upsertIntegration`, add `CreateIntegration`/`UpdateIntegration`/`DeleteIntegration`/`SetDefault`) +- Modify: `internal/requests/store.go` (Store interface) + +- [ ] **Step 3.1: Update `scanIntegration` and the column lists** + +Replace the column list used in `ListIntegrations` and `scanIntegration` to include the new fields. New shared column constant near the top of `repository.go`: + +```go +const integrationColumns = `id, kind, name, enabled, base_url, api_key_ref, + root_folder, quality_profile_id, tags, is_4k, is_default, is_default_4k, + anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags, + options, last_check_at, last_check_status, last_check_error, updated_at` +``` + +Rewrite `scanIntegration` to scan in that exact order: + +```go +func scanIntegration(row integrationScanner) (Integration, error) { + var i Integration + var quality, animeQuality sql.NullInt64 + var tags, animeTags []int32 + var optionsRaw []byte + var lastCheckAt sql.NullTime + if err := row.Scan( + &i.ID, &i.Kind, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef, + &i.RootFolder, &quality, &tags, &i.Is4K, &i.IsDefault, &i.IsDefault4K, + &i.AnimeEnabled, &animeQuality, &i.AnimeRootFolder, &animeTags, + &optionsRaw, &lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt, + ); err != nil { + return Integration{}, err + } + if quality.Valid { + v := int(quality.Int64) + i.QualityProfileID = &v + } + if animeQuality.Valid { + v := int(animeQuality.Int64) + i.AnimeQualityProfileID = &v + } + i.Tags = intsFromInt32(tags) + i.AnimeTags = intsFromInt32(animeTags) + if len(optionsRaw) > 0 { + if err := json.Unmarshal(optionsRaw, &i.Options); err != nil { + return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", i.ID, err) + } + } + if i.Options == nil { + i.Options = map[string]any{} + } + if lastCheckAt.Valid { + i.LastCheckAt = &lastCheckAt.Time + } + return i, nil +} +``` + +Update `ListIntegrations` to `SELECT `+integrationColumns+` FROM request_integrations ORDER BY kind, name`. + +- [ ] **Step 3.2: Replace `upsertIntegration`/`UpsertIntegration(s)` with id-based CRUD** + +Remove `upsertIntegration`, `UpsertIntegration`, `UpsertIntegrations`. Add: + +```go +func (r *Repository) GetIntegration(ctx context.Context, id string) (*Integration, error) { + row := r.pool.QueryRow(ctx, `SELECT `+integrationColumns+ + ` FROM request_integrations WHERE id = $1`, id) + i, err := scanIntegration(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get request integration: %w", err) + } + return &i, nil +} + +func (r *Repository) CreateIntegration(ctx context.Context, i Integration) (*Integration, error) { + if i.Options == nil { + i.Options = map[string]any{} + } + options, err := json.Marshal(i.Options) + if err != nil { + return nil, fmt.Errorf("marshal options: %w", err) + } + row := r.pool.QueryRow(ctx, ` + INSERT INTO request_integrations ( + id, kind, name, enabled, base_url, api_key_ref, root_folder, + quality_profile_id, tags, is_4k, is_default, is_default_4k, + anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags, + options, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, now()) + RETURNING `+integrationColumns, + i.ID, i.Kind, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL), + strings.TrimSpace(i.APIKeyRef), strings.TrimSpace(i.RootFolder), i.QualityProfileID, + int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled, + i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags), + options) + out, err := scanIntegration(row) + if err != nil { + return nil, fmt.Errorf("create request integration: %w", err) + } + return &out, nil +} + +func (r *Repository) UpdateIntegration(ctx context.Context, i Integration) (*Integration, error) { + if i.Options == nil { + i.Options = map[string]any{} + } + options, err := json.Marshal(i.Options) + if err != nil { + return nil, fmt.Errorf("marshal options: %w", err) + } + // Preserve the stored api_key_ref when the caller submits an empty one + // (write-only field: empty means "unchanged"). + row := r.pool.QueryRow(ctx, ` + UPDATE request_integrations SET + name=$2, enabled=$3, base_url=$4, + api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END, + root_folder=$6, quality_profile_id=$7, tags=$8, is_4k=$9, + is_default=$10, is_default_4k=$11, anime_enabled=$12, + anime_quality_profile_id=$13, anime_root_folder=$14, anime_tags=$15, + options=$16, updated_at=now() + WHERE id=$1 + RETURNING `+integrationColumns, + i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL), + strings.TrimSpace(i.APIKeyRef), strings.TrimSpace(i.RootFolder), i.QualityProfileID, + int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled, + i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags), + options) + out, err := scanIntegration(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("update request integration: %w", err) + } + return &out, nil +} + +func (r *Repository) DeleteIntegration(ctx context.Context, id string) error { + tag, err := r.pool.Exec(ctx, `DELETE FROM request_integrations WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete request integration: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} +``` + +> The single-default-per-kind invariant is enforced by the partial unique indexes from Task 1. The service (Task 12) clears the prior default in the same transaction before setting a new one; for the repository, add a transactional helper: + +```go +func (r *Repository) ClearDefault(ctx context.Context, exec requestExecutor, kind string, fourK bool) error { + col := "is_default" + if fourK { + col = "is_default_4k" + } + _, err := exec.Exec(ctx, `UPDATE request_integrations SET `+col+` = false WHERE kind = $1`, kind) + if err != nil { + return fmt.Errorf("clear default: %w", err) + } + return nil +} +``` + +- [ ] **Step 3.3: Update the `Store` interface** + +In `internal/requests/store.go`, replace the `UpsertIntegration`/`UpsertIntegrations` lines with: + +```go + GetIntegration(ctx context.Context, id string) (*Integration, error) + CreateIntegration(ctx context.Context, integration Integration) (*Integration, error) + UpdateIntegration(ctx context.Context, integration Integration) (*Integration, error) + DeleteIntegration(ctx context.Context, id string) error +``` + +(Keep `ListIntegrations`. The target methods are added in Task 4.) + +- [ ] **Step 3.4: Build** + +```bash +go build ./internal/requests/... +``` + +Expected: fails only in `service.go`/`router.go`/`main.go` referencing removed methods — those are fixed in Phase 4/5/6. Repository + store compile in isolation: + +```bash +go vet ./internal/requests/ 2>&1 | head +``` + +- [ ] **Step 3.5: Commit** + +```bash +git add internal/requests/repository.go internal/requests/store.go +git commit -m "feat(requests): id-based integration CRUD" +``` + +### Task 4: Target persistence + aggregate status + +**Files:** +- Create: `internal/requests/targets.go` (target SQL + aggregate logic) +- Create: `internal/requests/targets_test.go` +- Modify: `internal/requests/store.go` (add target methods) + +- [ ] **Step 4.1: Write the failing aggregate test** + +`internal/requests/targets_test.go`: + +```go +package requests + +import "testing" + +func TestAggregateStatus(t *testing.T) { + cases := []struct { + name string + targets []Target + status Status + outcome Outcome + }{ + {"all completed", []Target{{Status: StatusCompleted}, {Status: StatusCompleted}}, StatusCompleted, OutcomeActive}, + {"one downloading", []Target{{Status: StatusCompleted}, {Status: StatusDownloading}}, StatusDownloading, OutcomeActive}, + {"queued only", []Target{{Status: StatusQueued}}, StatusQueued, OutcomeActive}, + {"all failed", []Target{{Status: StatusFailed}, {Status: StatusFailed}}, StatusQueued, OutcomeFailed}, + {"partial fail stays active", []Target{{Status: StatusFailed}, {Status: StatusDownloading}}, StatusDownloading, OutcomeActive}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotStatus, gotOutcome := aggregateStatus(tc.targets) + if gotStatus != tc.status || gotOutcome != tc.outcome { + t.Fatalf("aggregateStatus = (%s,%s), want (%s,%s)", gotStatus, gotOutcome, tc.status, tc.outcome) + } + }) + } +} +``` + +> This requires a new `StatusFailed` constant. Add to `types.go`: +> ```go +> const StatusFailed Status = "failed" // target-only status; requests use outcome=failed +> ``` +> Note: `media_requests.status` CHECK does not include `failed`; only `media_request_targets.status` does. `aggregateStatus` never returns `StatusFailed` for the request (it returns `StatusQueued` + `OutcomeFailed`). + +- [ ] **Step 4.2: Run it — expect failure** + +```bash +go test ./internal/requests/ -run TestAggregateStatus -v +``` + +Expected: FAIL (`aggregateStatus` undefined). + +- [ ] **Step 4.3: Implement `aggregateStatus` and target SQL** + +`internal/requests/targets.go`: + +```go +package requests + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +const targetColumns = `t.id, t.request_id, t.integration_id, t.integration_kind, + COALESCE(ri.name, ''), t.quality, t.is_anime, t.external_id, t.external_status, + t.status, t.last_error, t.created_at, t.updated_at` + +// aggregateStatus derives a request's status/outcome from its targets. +func aggregateStatus(targets []Target) (Status, Outcome) { + if len(targets) == 0 { + return StatusApproved, OutcomeActive + } + failed, completed := 0, 0 + anyDownloading, anyQueued := false, false + for _, t := range targets { + switch t.Status { + case StatusFailed: + failed++ + case StatusCompleted: + completed++ + case StatusDownloading: + anyDownloading = true + case StatusQueued: + anyQueued = true + } + } + if failed == len(targets) { + return StatusQueued, OutcomeFailed + } + if completed == len(targets) { + return StatusCompleted, OutcomeActive + } + if anyDownloading { + return StatusDownloading, OutcomeActive + } + if anyQueued { + return StatusQueued, OutcomeActive + } + // remaining: mix of completed + failed, none active -> treat as completed. + return StatusCompleted, OutcomeActive +} + +func scanTarget(row requestScanner) (Target, error) { + var t Target + var integrationID *string + if err := row.Scan(&t.ID, &t.RequestID, &integrationID, &t.IntegrationKind, + &t.InstanceName, &t.Quality, &t.IsAnime, &t.ExternalID, &t.ExternalStatus, + &t.Status, &t.LastError, &t.CreatedAt, &t.UpdatedAt); err != nil { + return Target{}, err + } + if integrationID != nil { + t.IntegrationID = *integrationID + } + return t, nil +} + +func (r *Repository) ListTargets(ctx context.Context, requestID string) ([]Target, error) { + rows, err := r.pool.Query(ctx, `SELECT `+targetColumns+` + FROM media_request_targets t + LEFT JOIN request_integrations ri ON ri.id = t.integration_id + WHERE t.request_id = $1 ORDER BY t.quality`, requestID) + if err != nil { + return nil, fmt.Errorf("list targets: %w", err) + } + defer rows.Close() + var out []Target + for rows.Next() { + t, err := scanTarget(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (r *Repository) CreateTarget(ctx context.Context, t Target) (Target, error) { + var integrationID any + if t.IntegrationID != "" { + integrationID = t.IntegrationID + } + row := r.pool.QueryRow(ctx, ` + INSERT INTO media_request_targets + (request_id, integration_id, integration_kind, quality, is_anime, + external_id, external_status, status, last_error, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now()) + RETURNING id`, + t.RequestID, integrationID, t.IntegrationKind, t.Quality, t.IsAnime, + t.ExternalID, t.ExternalStatus, t.Status, t.LastError) + if err := row.Scan(&t.ID); err != nil { + return Target{}, fmt.Errorf("create target: %w", err) + } + return t, nil +} + +// UpdateTargetStatus updates one target and recomputes the parent request's +// aggregate status/outcome, all in one transaction. +func (r *Repository) UpdateTargetStatus(ctx context.Context, targetID int64, status Status, + externalID, externalStatus, lastErr string, actor Viewer) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin target update: %w", err) + } + defer tx.Rollback(ctx) + + var requestID string + if err := tx.QueryRow(ctx, ` + UPDATE media_request_targets + SET status=$2, + external_id = CASE WHEN $3 = '' THEN external_id ELSE $3 END, + external_status = CASE WHEN $4 = '' THEN external_status ELSE $4 END, + last_error=$5, updated_at=now() + WHERE id=$1 RETURNING request_id`, + targetID, status, externalID, externalStatus, lastErr).Scan(&requestID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("update target: %w", err) + } + + req, err := r.recomputeAggregate(ctx, tx, requestID, actor) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit target update: %w", err) + } + return req, nil +} + +func (r *Repository) recomputeAggregate(ctx context.Context, exec requestExecutor, requestID string, actor Viewer) (*Request, error) { + rows, err := exec.Query(ctx, `SELECT status FROM media_request_targets WHERE request_id = $1`, requestID) + if err != nil { + return nil, fmt.Errorf("load target statuses: %w", err) + } + var targets []Target + for rows.Next() { + var t Target + if err := rows.Scan(&t.Status); err != nil { + rows.Close() + return nil, err + } + targets = append(targets, t) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + status, outcome := aggregateStatus(targets) + + var lastErr string + for _, t := range targets { + if t.Status == StatusFailed { + lastErr = "one or more fulfillment targets failed" + break + } + } + req, err := scanRequest(exec.QueryRow(ctx, ` + UPDATE media_requests + SET status=$2, outcome=$3, + last_error = CASE WHEN $3 = 'failed' THEN $4 ELSE '' END, + completed_at = CASE WHEN $2 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END, + updated_at = now() + WHERE id=$1 RETURNING `+requestColumns(), requestID, status, outcome, lastErr)) + if err != nil { + return nil, fmt.Errorf("recompute aggregate: %w", err) + } + _ = r.recordEvent(ctx, exec, requestID, "status_"+string(status), actor, string(req.ExternalStatus)) + return req, nil +} +``` + +> `recomputeAggregate` uses `exec.Query`; ensure `requestExecutor` (defined in repository.go) includes `Query`. If it only has `QueryRow`/`Exec`, add `Query(ctx, sql, args...) (pgx.Rows, error)` to that interface — `*pgxpool.Pool` and `pgx.Tx` both satisfy it. + +- [ ] **Step 4.4: Add target methods to `Store`** + +In `store.go`: + +```go + ListTargets(ctx context.Context, requestID string) ([]Target, error) + CreateTarget(ctx context.Context, target Target) (Target, error) + UpdateTargetStatus(ctx context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, actor Viewer) (*Request, error) +``` + +- [ ] **Step 4.5: Update `requestColumns`/`scanRequest` to drop external fields + add is_anime** + +In `repository.go`, change `requestColumns()` to remove `integration_kind, external_id, external_status` and add `is_anime` (place it after `requested_by_profile_id`): + +```go +func requestColumns() string { + return `id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year, + overview, poster_path, backdrop_path, status, outcome, + requested_by_user_id, requested_by_profile_id, is_anime, + last_error, created_at, updated_at, approved_at, completed_at` +} +``` + +In `scanRequest`, replace the three `&req.IntegrationKind, &req.ExternalID, &req.ExternalStatus` scans with `&req.IsAnime` (matching column order). + +- [ ] **Step 4.6: Replace `MarkQueued` with target-based fulfillment write** + +`MarkQueued` and its `QueueUpdate` referenced the dropped columns. Remove `MarkQueued` from the repository and `store.go` (fulfillment now writes targets via `CreateTarget`/`UpdateTargetStatus`). Remove the `QueueUpdate` type from `types.go`. (Callers are rewritten in Task 11.) + +- [ ] **Step 4.7: Run the aggregate test** + +```bash +go test ./internal/requests/ -run TestAggregateStatus -v +``` + +Expected: PASS. + +- [ ] **Step 4.8: Commit** + +```bash +git add internal/requests/targets.go internal/requests/targets_test.go internal/requests/store.go internal/requests/repository.go internal/requests/types.go +git commit -m "feat(requests): target persistence and aggregate status" +``` + +--- + +## Phase 3 — Anime detection + +### Task 5: TMDB keyword ids + +**Files:** +- Modify: `internal/metadata/tmdb/types.go` (`MediaDetail` gets `Keywords []int`, `OriginalLanguage string`) +- Modify: `internal/metadata/tmdb/client.go` (request `append_to_response=keywords`, map ids) + +- [ ] **Step 5.1: Add fields to `tmdb.MediaDetail`** + +In `internal/metadata/tmdb/types.go`, add to `MediaDetail` (the public struct around line 173): + +```go + OriginalLanguage string + KeywordIDs []int +``` + +And to the internal detail-response structs (the ones with `Genres []genreEntry`, around lines 301 and 326) add: + +```go + OriginalLanguage string `json:"original_language"` + Keywords struct { + Keywords []idEntry `json:"keywords"` // movies + Results []idEntry `json:"results"` // tv + } `json:"keywords"` +``` + +Add an `idEntry` helper type if not present: + +```go +type idEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} +``` + +- [ ] **Step 5.2: Map keyword ids in `GetMediaDetail` and append the keywords block** + +In `client.go`, find where the detail request URL is built and add `append_to_response=keywords` to the query params (alongside any existing `append_to_response`; comma-join if one already exists). Where `MediaDetail` is populated (the two builders near `Genres: namesFromGenres(...)`), add: + +```go + OriginalLanguage: resp.OriginalLanguage, + KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results), +``` + +Add the helper: + +```go +func keywordIDs(groups ...[]idEntry) []int { + var out []int + for _, g := range groups { + for _, e := range g { + out = append(out, e.ID) + } + } + return out +} +``` + +- [ ] **Step 5.3: Verify build + existing tmdb tests** + +```bash +go test ./internal/metadata/tmdb/... -run TestGetMediaDetail -v +go build ./internal/metadata/... +``` + +Expected: existing tests pass (they assert `with_original_language` on discover, unaffected); build succeeds. + +- [ ] **Step 5.4: Commit** + +```bash +git add internal/metadata/tmdb/types.go internal/metadata/tmdb/client.go +git commit -m "feat(tmdb): expose keyword ids and original language on detail" +``` + +### Task 6: `detectAnime` + +**Files:** +- Create: `internal/requests/anime.go` +- Create: `internal/requests/anime_test.go` + +- [ ] **Step 6.1: Write the failing test** + +`internal/requests/anime_test.go`: + +```go +package requests + +import "testing" + +func TestDetectAnime(t *testing.T) { + if !detectAnime([]int{99, animeKeywordID, 7}) { + t.Fatal("expected anime when keyword 210024 present") + } + if detectAnime([]int{99, 7}) { + t.Fatal("expected non-anime when keyword 210024 absent") + } + if detectAnime(nil) { + t.Fatal("expected non-anime for empty keywords") + } +} +``` + +- [ ] **Step 6.2: Run it — expect failure** + +```bash +go test ./internal/requests/ -run TestDetectAnime -v +``` + +Expected: FAIL (`detectAnime`/`animeKeywordID` undefined). + +- [ ] **Step 6.3: Implement** + +`internal/requests/anime.go`: + +```go +package requests + +// animeKeywordID is TMDB's "anime" keyword id. Matches Seerr's ANIME_KEYWORD_ID +// exactly (server/api/themoviedb/constants.ts). Detection is keyword-id only — +// no genre/language fallback — to mirror upstream behavior. +const animeKeywordID = 210024 + +func detectAnime(keywordIDs []int) bool { + for _, id := range keywordIDs { + if id == animeKeywordID { + return true + } + } + return false +} +``` + +- [ ] **Step 6.4: Run it — expect pass** + +```bash +go test ./internal/requests/ -run TestDetectAnime -v +``` + +Expected: PASS. + +- [ ] **Step 6.5: Commit** + +```bash +git add internal/requests/anime.go internal/requests/anime_test.go +git commit -m "feat(requests): Seerr-exact anime detection (keyword 210024)" +``` + +--- + +## Phase 4 — Routing engine + +### Task 7: `routeTargets` + +**Files:** +- Create: `internal/requests/routing.go` +- Create: `internal/requests/routing_test.go` + +- [ ] **Step 7.1: Write the failing table-driven test** + +`internal/requests/routing_test.go`: + +```go +package requests + +import "testing" + +func inst(kind, id string, def, def4k, anime bool) Integration { + qp := 1 + return Integration{ + ID: id, Kind: kind, Name: id, Enabled: true, BaseURL: "http://x", + APIKeyRef: "k", RootFolder: "/std", QualityProfileID: &qp, + Is4K: def4k, IsDefault: def, IsDefault4K: def4k, AnimeEnabled: anime, + } +} + +func TestRouteTargets(t *testing.T) { + hd := inst("radarr", "hd", true, false, false) + uhd := inst("radarr", "uhd", false, true, false) + hdAnime := inst("radarr", "hda", true, false, true) + + cases := []struct { + name string + req Request + ceiling string + force bool + instances []Integration + want []Quality + wantAnime bool + }{ + {"hd only, sd user", Request{MediaType: MediaTypeMovie}, "1080p", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false}, + {"4k user dual", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false}, + {"force dual overrides role", Request{MediaType: MediaTypeMovie}, "1080p", true, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false}, + {"4k user but no 4k default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd}, []Quality{Quality1080p}, false}, + {"no hd default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{uhd}, []Quality{Quality2160p}, false}, + {"anime on anime-enabled hd", Request{MediaType: MediaTypeMovie, IsAnime: true}, "1080p", false, []Integration{hdAnime}, []Quality{Quality1080p}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := routeTargets(tc.req, tc.ceiling, Settings{ForceDualQuality: tc.force}, tc.instances) + if len(got) != len(tc.want) { + t.Fatalf("got %d targets, want %d (%v)", len(got), len(tc.want), got) + } + for i, q := range tc.want { + if got[i].Quality != q { + t.Fatalf("target %d quality = %s, want %s", i, got[i].Quality, q) + } + if got[i].IsAnime != tc.wantAnime { + t.Fatalf("target %d isAnime = %v, want %v", i, got[i].IsAnime, tc.wantAnime) + } + } + }) + } +} +``` + +- [ ] **Step 7.2: Run it — expect failure** + +```bash +go test ./internal/requests/ -run TestRouteTargets -v +``` + +Expected: FAIL (`routeTargets`/`plannedTarget` undefined). + +- [ ] **Step 7.3: Implement** + +`internal/requests/routing.go`: + +```go +package requests + +import "github.com/Silo-Server/silo-server/internal/access" + +// plannedTarget is a routing decision: which instance, at which quality, with +// which profile/folder/tags resolved (standard vs anime). +type plannedTarget struct { + Instance Integration + Quality Quality + IsAnime bool +} + +func integrationKindForMediaType(mediaType MediaType) string { + if mediaType == MediaTypeSeries { + return "sonarr" + } + return "radarr" +} + +// routeTargets decides the fulfillment targets for an approved request. +// 1080p is always desired; 2160p is added when the requester's ceiling allows +// 4K OR force-dual is on. A quality is emitted only if its default instance +// exists for the kind. +func routeTargets(req Request, ceiling string, settings Settings, instances []Integration) []plannedTarget { + kind := integrationKindForMediaType(req.MediaType) + + wants4K := settings.ForceDualQuality || access.QualityAllowed(access.PlaybackQuality4K, ceiling) + + var hd, uhd *Integration + for i := range instances { + in := instances[i] + if in.Kind != kind || !in.Enabled { + continue + } + if in.IsDefault && hd == nil { + hd = &instances[i] + } + if in.IsDefault4K && uhd == nil { + uhd = &instances[i] + } + } + + var out []plannedTarget + if hd != nil { + out = append(out, plannedTarget{Instance: *hd, Quality: Quality1080p, IsAnime: req.IsAnime && hd.AnimeEnabled}) + } + if wants4K && uhd != nil { + out = append(out, plannedTarget{Instance: *uhd, Quality: Quality2160p, IsAnime: req.IsAnime && uhd.AnimeEnabled}) + } + return out +} + +// resolveInstance returns a copy of the instance with root folder / quality +// profile / tags (and Sonarr series_type) set for standard vs anime fulfillment. +func resolveInstance(pt plannedTarget) Integration { + in := pt.Instance + if in.Options == nil { + in.Options = map[string]any{} + } else { + clone := make(map[string]any, len(in.Options)) + for k, v := range in.Options { + clone[k] = v + } + in.Options = clone + } + if pt.IsAnime { + in.RootFolder = in.AnimeRootFolder + in.QualityProfileID = in.AnimeQualityProfileID + in.Tags = in.AnimeTags + if in.Kind == "sonarr" { + in.Options["series_type"] = "anime" + } + } + return in +} +``` + +> `access.QualityAllowed(file, ceiling)` returns true when `file <= ceiling`. So `QualityAllowed("2160p", ceiling)` is true exactly when the user's ceiling is 2160p (or unlimited/empty). An empty ceiling means "any" → 4K allowed; confirm that matches your entitlement semantics (it mirrors playback, where empty = no cap). + +- [ ] **Step 7.4: Run it — expect pass** + +```bash +go test ./internal/requests/ -run TestRouteTargets -v +``` + +Expected: PASS. + +- [ ] **Step 7.5: Commit** + +```bash +git add internal/requests/routing.go internal/requests/routing_test.go +git commit -m "feat(requests): quality/anime routing engine" +``` + +--- + +## Phase 5 — Fulfillment, reconcile, retry, settings + +### Task 8: Requester entitlement lookup + +**Files:** +- Modify: `internal/requests/service.go` (add an entitlement resolver dependency) + +The service needs each requester's `MaxPlaybackQuality`. It already has a `PresenceResolver` and `SecretResolver` injected. Add an `EntitlementResolver`. + +- [ ] **Step 8.1: Define the interface and wire a setter** + +In `service.go`: + +```go +type EntitlementResolver interface { + MaxPlaybackQuality(ctx context.Context, userID int) (string, error) +} +``` + +Add a field `entitlements EntitlementResolver` to `Service` and: + +```go +func (s *Service) SetEntitlementResolver(r EntitlementResolver) { s.entitlements = r } + +func (s *Service) requesterCeiling(ctx context.Context, userID int) string { + if s.entitlements == nil { + return "" // no resolver -> treat as unlimited (1080p baseline still applies) + } + q, err := s.entitlements.MaxPlaybackQuality(ctx, userID) + if err != nil { + return access.PlaybackQualityStandard // fail safe: HD only + } + return q +} +``` + +Add the `access` import. + +- [ ] **Step 8.2: Implement the resolver against `userstore`** + +`userstore` exposes per-user `MaxPlaybackQuality` (see `internal/userstore/types.go`). In the package that wires the service (where `userstore` is available — `internal/api/router.go` and `cmd/silo/main.go`), add a small adapter. Create `internal/requests/entitlements.go`: + +```go +package requests + +import "context" + +// QualityLookup is the minimal dependency for resolving a user's ceiling. +type QualityLookup interface { + GetUserMaxPlaybackQuality(ctx context.Context, userID int) (string, error) +} + +type userstoreEntitlements struct{ lookup QualityLookup } + +func NewUserstoreEntitlements(lookup QualityLookup) EntitlementResolver { + return userstoreEntitlements{lookup: lookup} +} + +func (e userstoreEntitlements) MaxPlaybackQuality(ctx context.Context, userID int) (string, error) { + return e.lookup.GetUserMaxPlaybackQuality(ctx, userID) +} +``` + +> If `userstore` lacks a `GetUserMaxPlaybackQuality(ctx, userID)` method, add a thin one that selects `max_playback_quality` for the user. Keep it in `userstore`, not here. + +- [ ] **Step 8.3: Build** + +```bash +go build ./internal/requests/... +``` + +Expected: builds. + +- [ ] **Step 8.4: Commit** + +```bash +git add internal/requests/service.go internal/requests/entitlements.go +git commit -m "feat(requests): requester playback-quality entitlement resolver" +``` + +### Task 9: Detect anime at request creation + +**Files:** +- Modify: `internal/requests/service.go` (`CreateRequest`) + +- [ ] **Step 9.1: Set `is_anime` from the TMDB detail during creation** + +In `CreateRequest`, after `s.enrichExternalIDs(ctx, &normalized)` and before persisting, fetch the detail's keyword ids and set anime on the record. Add a helper: + +```go +func (s *Service) detectRequestAnime(ctx context.Context, mediaType MediaType, tmdbID int) bool { + detail, err := s.tmdb.GetMediaDetail(ctx, tmdbMediaType(mediaType), tmdbID) + if err != nil || detail == nil { + return false + } + return detectAnime(detail.KeywordIDs) +} +``` + +In `CreateRequest`, compute `isAnime := s.detectRequestAnime(ctx, normalized.MediaType, normalized.TMDBID)` and pass it into `CreateRequestRecord` (add an `IsAnime bool` field to `CreateRequestRecord` in `store.go` and persist it in `Repository.CreateRequest`'s INSERT — add the `is_anime` column there). + +- [ ] **Step 9.2: Persist `is_anime` in `Repository.CreateRequest`** + +In `repository.go` `insertRequest`/`CreateRequest`, add `is_anime` to the INSERT column list and values, sourced from `record.IsAnime`. + +- [ ] **Step 9.3: Build** + +```bash +go build ./internal/requests/... +``` + +Expected: builds. + +- [ ] **Step 9.4: Commit** + +```bash +git add internal/requests/service.go internal/requests/store.go internal/requests/repository.go +git commit -m "feat(requests): record anime detection at request creation" +``` + +### Task 10: Settings — `force_dual_quality` + +**Files:** +- Create: `migrations/170_request_force_dual_quality.up.sql` / `.down.sql` +- Modify: `internal/requests/repository.go` (`GetSettings`/`UpdateSettings`) + +- [ ] **Step 10.1: Migration 170** + +`migrations/170_request_force_dual_quality.up.sql`: + +```sql +ALTER TABLE public.request_settings + ADD COLUMN IF NOT EXISTS force_dual_quality boolean NOT NULL DEFAULT false; +``` + +`migrations/170_request_force_dual_quality.down.sql`: + +```sql +ALTER TABLE public.request_settings DROP COLUMN IF EXISTS force_dual_quality; +``` + +- [ ] **Step 10.2: Read/write the new column** + +In `repository.go`, add `force_dual_quality` to the SELECT in `GetSettings` and the UPDATE in `UpdateSettings`, scanning/binding `Settings.ForceDualQuality`. + +- [ ] **Step 10.3: Build + commit** + +```bash +go build ./internal/requests/... && git add migrations/170_request_force_dual_quality.up.sql migrations/170_request_force_dual_quality.down.sql internal/requests/repository.go && git commit -m "feat(requests): force_dual_quality setting" +``` + +### Task 11: Multi-target fulfillment + +**Files:** +- Modify: `internal/requests/service.go` (`submitApprovedRequest`, `integrationConfigured`) +- Modify: `internal/requests/service_test.go` (extend fakes) + +- [ ] **Step 11.1: Write the failing service test** + +Add to `service_test.go` a test that a 4K-entitled user's approved movie request submits to **both** HD and 4K adapters. Use the existing fake-adapter pattern (see existing `service_test.go` around the `SetFulfillmentAdapters` usages). Sketch: + +```go +func TestSubmitApprovedFansOutDualQuality(t *testing.T) { + store := newFakeStore(t) // existing helper + // seed two enabled radarr instances: one is_default (hd), one is_default_4k (uhd) + store.addIntegration(inst("radarr", "hd", true, false, false)) + store.addIntegration(inst("radarr", "uhd", false, true, false)) + + rec := &recordingMovieAdapter{} + svc := NewService(store, fakeTMDB{}, fakePresence{}) + svc.SetFulfillmentAdapters(rec, nil) + svc.SetEntitlementResolver(fixedCeiling{"2160p"}) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}); err != nil { + t.Fatal(err) + } + if len(rec.calls) != 2 { + t.Fatalf("expected 2 submissions (hd+uhd), got %d", len(rec.calls)) + } +} +``` + +> Define `recordingMovieAdapter` (captures each `SubmitMovie` call's `integration.ID`) and `fixedCeiling` (implements `EntitlementResolver`) in the test file. Extend `newFakeStore` to support `addIntegration`, `ListIntegrations`, `ListTargets`, `CreateTarget`, `UpdateTargetStatus` if not already present. + +- [ ] **Step 11.2: Run it — expect failure** + +```bash +go test ./internal/requests/ -run TestSubmitApprovedFansOutDualQuality -v +``` + +Expected: FAIL (still single-target logic). + +- [ ] **Step 11.3: Rewrite `submitApprovedRequest`** + +```go +func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) { + if req.Outcome != OutcomeActive || req.Status != StatusApproved { + return &req, nil + } + instances, err := s.store.ListIntegrations(ctx) + if err != nil { + return nil, err + } + ceiling := s.requesterCeiling(ctx, req.RequestedByUserID) + settings, err := s.store.GetSettings(ctx) + if err != nil { + return nil, err + } + planned := routeTargets(req, ceiling, settings, instances) + if len(planned) == 0 { + // No routable instance: leave approved, surface in the queue. + return s.markSubmissionFailed(ctx, req.ID, actor, + fmt.Errorf("no %s instance configured for the requested quality", + integrationKindForMediaType(req.MediaType))) + } + + var lastReq *Request + for _, pt := range planned { + resolved := resolveInstance(pt) + apiKey, err := s.resolveAPIKey(ctx, resolved) + if err != nil || apiKey == "" { + lastReq, _ = s.recordFailedTarget(ctx, req, pt, actor, err) + continue + } + resolved.APIKeyRef = apiKey + + target, cerr := s.store.CreateTarget(ctx, Target{ + RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind, + Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusQueued, + }) + if cerr != nil { + return nil, cerr + } + + result, serr := s.submitTarget(ctx, req, resolved) + if serr != nil { + lastReq, _ = s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", serr.Error(), actor) + continue + } + lastReq, err = s.store.UpdateTargetStatus(ctx, target.ID, StatusQueued, + result.ExternalID, result.ExternalStatus, "", actor) + if err != nil { + return nil, err + } + } + if lastReq == nil { + return &req, nil + } + return lastReq, nil +} + +// submitTarget calls the right adapter with a per-target Request copy carrying +// the target's (eventual) external id. ExternalID is empty on first submit. +func (s *Service) submitTarget(ctx context.Context, req Request, resolved Integration) (FulfillmentResult, error) { + switch req.MediaType { + case MediaTypeMovie: + if s.movieAdapter == nil { + return FulfillmentResult{}, fmt.Errorf("no movie adapter") + } + return s.movieAdapter.SubmitMovie(ctx, req, resolved) + case MediaTypeSeries: + if s.seriesAdapter == nil { + return FulfillmentResult{}, fmt.Errorf("no series adapter") + } + return s.seriesAdapter.SubmitSeries(ctx, req, resolved) + default: + return FulfillmentResult{}, fmt.Errorf("unsupported media type") + } +} +``` + +Add `recordFailedTarget` (creates a target row already in `failed` state for unresolved api-key cases) and keep `markSubmissionFailed` for the zero-targets case (it already sets the request `outcome=failed` + `last_error`). Update `integrationConfigured` to mean "is there an enabled default instance for this kind" (used by `CreateRequest`'s auto-approve gate): + +```go +func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) { + instances, err := s.store.ListIntegrations(ctx) + if err != nil { + return false, err + } + kind := integrationKindForMediaType(mediaType) + for _, in := range instances { + if in.Kind == kind && in.Enabled && in.IsDefault && integrationIsConfigured(in) { + return true, nil + } + } + return false, nil +} +``` + +Delete the now-unused `integrationForMediaType` and `integrationKindForMediaType` duplicate (keep the one in `routing.go`). + +- [ ] **Step 11.4: Run it — expect pass; then full package tests** + +```bash +go test ./internal/requests/ -run TestSubmitApprovedFansOutDualQuality -v +go test ./internal/requests/... +``` + +Expected: the new test passes; fix any remaining compile/test breaks in `service_test.go` from the dropped `MarkQueued`/`QueueUpdate`. + +- [ ] **Step 11.5: Commit** + +```bash +git add internal/requests/service.go internal/requests/service_test.go +git commit -m "feat(requests): multi-target fulfillment fan-out" +``` + +### Task 12: Reconcile per target + target-scoped retry + +**Files:** +- Modify: `internal/requests/service.go` (`reconcileRequest`, `checkFulfillmentStatus`, `Retry`) + +- [ ] **Step 12.1: Reconcile each non-terminal target** + +Rewrite `reconcileRequest` to iterate the request's targets, load each target's instance by `integration_id`, build a per-target `Request` copy with `ExternalID = target.ExternalID`, call the status adapter, and `UpdateTargetStatus`. Map the adapter's `FulfillmentStatus.Status` onto the target status (`queued`/`downloading`/`completed`, or `failed` on outcome). Skip targets already `completed`/`failed`. + +```go +func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileChange, error) { + targets, err := s.store.ListTargets(ctx, req.ID) + if err != nil { + return reconcileChange{}, err + } + instances, err := s.store.ListIntegrations(ctx) + if err != nil { + return reconcileChange{}, err + } + byID := map[string]Integration{} + for _, in := range instances { + byID[in.ID] = in + } + for _, t := range targets { + if t.Status == StatusCompleted || t.Status == StatusFailed { + continue + } + in, ok := byID[t.IntegrationID] + if !ok { + continue + } + apiKey, err := s.resolveAPIKey(ctx, in) + if err != nil || apiKey == "" { + continue + } + in.APIKeyRef = apiKey + probe := req + probe.ExternalID = t.ExternalID + st, err := s.checkFulfillmentStatus(ctx, probe, in) + if err != nil { + continue + } + newStatus := targetStatusFromFulfillment(st) + if newStatus == t.Status { + continue + } + if _, err := s.store.UpdateTargetStatus(ctx, t.ID, newStatus, + st.ExternalID, st.ExternalStatus, "", Viewer{}); err != nil { + return reconcileChange{}, err + } + } + return reconcileChange{ /* counters as today */ }, nil +} +``` + +Add the mapping helper: + +```go +func targetStatusFromFulfillment(st FulfillmentStatus) Status { + switch st.Status { + case StatusCompleted: + return StatusCompleted + case StatusDownloading: + return StatusDownloading + default: + if st.Outcome == OutcomeFailed { + return StatusFailed + } + return StatusQueued + } +} +``` + +> `checkFulfillmentStatus` already dispatches to the movie/series status adapter using `(req, integration)`; keep it but have it take the resolved instance. Update `reconcileChange` counter population to match existing fields used by `ReconcileResult`. + +- [ ] **Step 12.2: Target-scoped retry** + +Rewrite `Retry` to re-submit only `failed` targets (and create missing targets via `routeTargets` if the plan now yields a quality with no target row). Reuse the submission loop from `submitApprovedRequest` by extracting a `submitPlannedTarget(ctx, req, pt, actor)` helper and calling it for the failed/missing qualities. + +- [ ] **Step 12.3: Test reconcile + retry** + +```bash +go test ./internal/requests/... +``` + +Expected: PASS (extend `service_test.go` with a reconcile case where one target completes and another stays downloading → request `downloading`; and a retry case re-submitting only the failed target). + +- [ ] **Step 12.4: Commit** + +```bash +git add internal/requests/service.go internal/requests/service_test.go +git commit -m "feat(requests): per-target reconcile and retry" +``` + +### Task 13: Service-level integration CRUD + default toggling + +**Files:** +- Modify: `internal/requests/service.go` (replace `UpsertIntegration(s)` with `CreateIntegration`/`UpdateIntegration`/`DeleteIntegration`, enforce invariants) + +- [ ] **Step 13.1: Validation + default handling** + +Add service methods that validate the invariants before persisting: + +```go +func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) { + if err := requireAdmin(viewer); err != nil { return nil, err } + if err := validateInstance(&in); err != nil { return nil, err } + id, err := idgen.NextID() + if err != nil { return nil, err } + in.ID = id + return s.persistInstanceWithDefaults(ctx, in, true) +} +``` + +`validateInstance` enforces: `kind ∈ {radarr,sonarr}`, non-empty name/base_url, `IsDefault ⇒ !Is4K`, `IsDefault4K ⇒ Is4K`. `persistInstanceWithDefaults` runs in a transaction: if `in.IsDefault` clear other defaults for the kind, if `in.IsDefault4K` clear other 4K defaults, then create/update. (Add a repo method `WithTx(ctx, func(exec) error)` or expose `ClearDefault` + `CreateIntegration`/`UpdateIntegration` accepting an `exec` — simplest is a repo method `SaveIntegrationWithDefaults(ctx, in, isCreate)` that does the clear+write atomically using `ClearDefault`.) + +Mirror for `UpdateIntegration`; `DeleteIntegration` just calls the repo (FK `ON DELETE SET NULL` keeps target history). + +- [ ] **Step 13.2: Build + test + commit** + +```bash +go test ./internal/requests/... +git add internal/requests/service.go internal/requests/repository.go +git commit -m "feat(requests): instance CRUD with default-toggle invariants" +``` + +--- + +## Phase 6 — API handlers & wiring + +### Task 14: HTTP handlers + service wiring + +**Files:** +- Modify: the request integration handlers (find with `grep -rn "UpsertIntegration\|LoadIntegrationOptions\|ListIntegrations" internal/api`) +- Modify: `internal/api/router.go` (wire entitlement resolver; update adapter wiring stays the same) +- Modify: `cmd/silo/main.go` (same wiring for the reconcile service) + +- [ ] **Step 14.1: Replace upsert handler with CRUD endpoints** + +Locate the admin handler that currently calls `UpsertIntegrations` (it serves the single-form save). Replace with: +- `GET /…/requests/integrations` → `ListIntegrations` +- `POST /…/requests/integrations` → `CreateIntegration` +- `PUT /…/requests/integrations/{id}` → `UpdateIntegration` +- `DELETE /…/requests/integrations/{id}` → `DeleteIntegration` +- `POST /…/requests/integrations/{id}/test` (or keep the existing options endpoint) → `LoadIntegrationOptions` + +Follow the existing handler/router patterns in the same file (JSON decode into `Integration`, `mediarequests` service call, `_SENSITIVE_METADATA_KEYS`-style stripping is not needed here but never echo `api_key_ref` back — set it to `""` in responses). + +- [ ] **Step 14.2: Add the request settings `force_dual_quality` to the settings handler** + +The settings GET/PUT already round-trips `Settings`; the new field flows through automatically once `repository.go` reads/writes it (Task 10). + +- [ ] **Step 14.3: Wire the entitlement resolver** + +In `router.go` and `main.go`, after `requestSvc := mediarequests.NewService(...)`, add: + +```go +requestSvc.SetEntitlementResolver(mediarequests.NewUserstoreEntitlements(userStore)) +``` + +where `userStore` implements `GetUserMaxPlaybackQuality(ctx, userID)` (add that method in `userstore` if missing, Task 8). + +- [ ] **Step 14.4: Build the whole server + lint** + +```bash +go build ./... +make lint +``` + +Expected: builds clean; lint passes. + +- [ ] **Step 14.5: Commit** + +```bash +git add internal/api cmd/silo/main.go internal/userstore +git commit -m "feat(api): request integration CRUD endpoints and entitlement wiring" +``` + +--- + +## Phase 7 — Frontend + +> Frontend tasks follow existing patterns in `web/src/pages/admin-settings/IntegrationsSettings.tsx`, `web/src/hooks/queries/useRequests.ts`, and `web/src/pages/AdminRequests.tsx`. Use the existing React Query mutation/query conventions and the shared form components in those files. Each task ends with `cd web && pnpm run lint && pnpm run format:check`. + +### Task 15: Types + query hooks + +**Files:** +- Modify: `web/src/hooks/queries/useRequests.ts` + +- [ ] **Step 15.1: Update TS types** + +Add to the `Integration` type: `id: string; name: string; is_4k: boolean; is_default: boolean; is_default_4k: boolean; anime_enabled: boolean; anime_quality_profile_id?: number; anime_root_folder?: string; anime_tags: number[];`. Add a `RequestTarget` type (`quality: "1080p" | "2160p"; instance_name?: string; status: string; external_status?: string; last_error?: string;`) and add `is_anime: boolean; targets?: RequestTarget[]` to the `Request` type. Add `force_dual_quality: boolean` to the `Settings` type. + +- [ ] **Step 15.2: Replace the upsert mutation with CRUD hooks** + +Replace `useUpsertIntegrations` with `useCreateIntegration`, `useUpdateIntegration`, `useDeleteIntegration` (mutations hitting the Task 14 endpoints) and keep `useIntegrationOptions` (test-connection). Invalidate the integrations query key on success. + +- [ ] **Step 15.3: Lint + commit** + +```bash +cd web && pnpm run lint && pnpm run format:check +git add web/src/hooks/queries/useRequests.ts +git commit -m "feat(web): request integration multi-instance types and hooks" +``` + +### Task 16: Instance-list manager UI + +**Files:** +- Modify: `web/src/pages/admin-settings/IntegrationsSettings.tsx` +- Modify: `web/src/pages/setup-wizard/steps/IntegrationsStep.tsx` + +- [ ] **Step 16.1: Render an instance list per kind** + +Replace the single Radarr/Sonarr forms with, per kind, a list of instance cards plus an "Add instance" action. Each card is an editable form with: name, base URL, API key (write-only — placeholder "configured" when `api_key_ref` is empty-from-server but instance exists), a **Test connection** button that calls `useIntegrationOptions` and populates root-folder/quality-profile **select** inputs, tags multiselect, `is_4k` switch, **Default (HD)** toggle, **Default 4K** toggle, and a collapsible Anime section (`anime_enabled` + anime quality profile / root folder / tags selects). + +Client-side invariant enforcement: disable **Default (HD)** when `is_4k` is on; disable **Default 4K** when `is_4k` is off; when the user enables a default, optimistically clear that default on sibling cards (server is source of truth on save). + +- [ ] **Step 16.2: Setup wizard stays minimal** + +In `IntegrationsStep.tsx`, keep a single Radarr + single Sonarr quick form that creates one instance each via `useCreateIntegration` with `is_default: true`. Link to full settings for advanced config. + +- [ ] **Step 16.3: Lint + commit** + +```bash +cd web && pnpm run lint && pnpm run format:check +git add web/src/pages/admin-settings/IntegrationsSettings.tsx web/src/pages/setup-wizard/steps/IntegrationsStep.tsx +git commit -m "feat(web): multi-instance arr settings manager" +``` + +### Task 17: Force-dual toggle + queue targets + +**Files:** +- Modify: `web/src/pages/admin-settings/IntegrationsSettings.tsx` (or the request settings panel) +- Modify: `web/src/pages/AdminRequests.tsx` + +- [ ] **Step 17.1: Add the global toggle** + +In the request settings panel, add a switch bound to `settings.force_dual_quality` with helper text: "Always fulfill in both 1080p and 4K when both a Default HD and Default 4K instance exist, regardless of user role." Save via the existing settings mutation. + +- [ ] **Step 17.2: Show targets in the admin queue** + +In `AdminRequests.tsx`, expand each request row to render `request.targets`: a quality badge (1080p/2160p), `instance_name`, per-target `status`/`external_status`, and a per-target **Retry** button (calls the existing retry mutation; if retry is request-level today, it re-submits failed targets — acceptable, since Task 12 made retry target-scoped server-side). + +- [ ] **Step 17.3: Lint + commit** + +```bash +cd web && pnpm run lint && pnpm run format:check +git add web/src/pages/admin-settings/IntegrationsSettings.tsx web/src/pages/AdminRequests.tsx +git commit -m "feat(web): force-dual toggle and per-target request queue" +``` + +--- + +## Phase 8 — Verification & cross-repo flag + +### Task 18: Full verification + +- [ ] **Step 18.1: Backend tests + lint** + +```bash +go test ./... +make lint +``` + +Expected: all pass. + +- [ ] **Step 18.2: Frontend checks** + +```bash +cd web && pnpm run lint && pnpm run format:check && pnpm run build +``` + +Expected: clean build. + +- [ ] **Step 18.3: Manual smoke (local)** + +Start `docker compose up -d postgres redis`, `make dev-backend`, `make dev-frontend`. As admin: add two Radarr instances (HD default + 4K default), set a user's `MaxPlaybackQuality` to 2160p, request a movie as that user, confirm two targets appear (1080p + 2160p) in the admin queue. Request an anime series on an anime-enabled Sonarr; confirm the target shows `is_anime` and Sonarr received `seriesType=anime`. + +- [ ] **Step 18.4: `make verify-local-paths`** + +```bash +make verify-local-paths +``` + +Expected: passes (no absolute/local paths committed). + +### Task 19: Cross-repo client follow-up flag + +- [ ] **Step 19.1: Record the client-model follow-up** + +The `Request` API response dropped top-level `integration_kind`/`external_id`/`external_status` and added `is_anime`/`targets`. Open a tracking note/issue for `silo-android` and `silo-apple` to verify their request-model deserialization tolerates the removed fields and ignores/parses `targets`. End-user flow is otherwise unchanged (no 4K toggle, no new permission). + +--- + +## Self-review notes (resolved) + +- **Spec §1 data model** → Tasks 1–4. **§2 routing** → Task 7. **§3 lifecycle** → Tasks 11–12. **§4 anime** → Tasks 5–6, 9. **§5 admin UI** → Tasks 16–17. **§6 API/clients** → Tasks 14, 19. **§7 migration** → Tasks 1, 10. +- **Adapters unchanged**: confirmed Sonarr reads `options["series_type"]` and status adapters read `req.ExternalID`; the service supplies both via `resolveInstance` + per-target `Request` copy (Tasks 11–12). +- **`StatusFailed`** is a target-only status; `aggregateStatus` never assigns it to a request (Task 4). +- **Entitlement empty-ceiling** semantics flagged in Task 7.3 for confirmation during implementation. diff --git a/docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md b/docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md new file mode 100644 index 00000000..68a6a8aa --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md @@ -0,0 +1,353 @@ +# Multi-Instance Sonarr/Radarr Routing for Requests (Seerr-style) + +**Status:** Design approved, ready for implementation plan +**Date:** 2026-06-01 +**Area:** `internal/requests`, `migrations/`, `web/src` (admin settings + request queue) + +## Summary + +Silo's request system today fulfills every approved request through a single +Radarr (movies) or single Sonarr (series) instance, at whatever quality that +one instance's profile is configured for. There is no 4K concept, no anime +handling, and exactly one instance per kind (`request_integrations.kind` is the +primary key). + +This work replicates Seerr's Sonarr/Radarr management model *inside* Silo's own +request system (no external Seerr dependency): + +- **Many instances per kind** — N Radarr and N Sonarr servers, each with its own + connection, root folder, quality profile, and tags. +- **Default HD + Default 4K routing** — one HD-default and one 4K-default + instance per kind; requests route to the matching default(s). +- **Per-server anime overrides** (Overseerr-style) — each instance carries an + optional anime profile / root folder / tags applied when a title is detected + as anime. +- **Entitlement-driven dual-quality fan-out** — a single user request can be + fulfilled in **both** 1080p and 2160p when the requester's `MaxPlaybackQuality` + allows 4K, or when an admin global toggle forces it for everyone. + +The end-user request action is unchanged: quality is derived from the user's +existing entitlement, not a per-request choice. + +## Goals + +- Support multiple Radarr/Sonarr instances per kind. +- Route by quality (HD vs 4K) to per-kind default instances. +- Fan a single request out to both 1080p and 2160p targets based on the + requester's `MaxPlaybackQuality` entitlement, with an admin override that + forces dual-quality regardless of role. +- Apply per-instance anime overrides using Seerr-exact anime detection. +- Keep the request lifecycle (quotas, approval, reconcile) coherent under + one-to-many fulfillment. + +## Non-Goals + +- No integration *with* a Seerr instance (an earlier idea, dropped). Silo + remains the system of record. +- No per-request manual server/profile picker (Overseerr's advanced UI). Routing + is automatic from defaults + entitlement. +- No separate 1080p/4K *availability* tracking. The pre-request presence check + stays binary ("is this title in the Silo library at all"). +- No admin-configurable anime keyword list (the heuristic is isolated so one can + be added later). +- No dedicated-anime-instance flag — anime is handled by per-instance overrides + (decided during design). + +## Background: current flow + +When a user requests a movie today (`internal/requests/service.go`): + +1. **Browse/discover** via TMDB; each result carries a `RequestState`. +2. `CreateRequest` validates: requests enabled, not already available + (presence), not already actively requested (dedup by `tmdb_id`), computes the + requester's `EffectivePolicy` (quota, blocked, auto-approve), enforces quota. +3. Auto-approve users with a configured integration → row created `approved`, + else `pending` for the admin queue. +4. `submitApprovedRequest` → `integrationForMediaType(mediaType)` finds the + single instance by kind → `radarr.Client.SubmitMovie` POSTs to Radarr using + that instance's root folder / quality profile / tags → request goes `queued`. +5. `ReconcileRequests` polls the instance: `queued → downloading → completed` + (or `failed`). + +The single insertion point that changes is step 4's instance selection. + +## Design + +### 1. Data model + +**`request_integrations` — reworked from one-row-per-kind to many instances:** + +| column | purpose | +|---|---| +| `id` (text PK, via `idgen`) | replaces `kind` as PK | +| `kind` (text, `radarr`\|`sonarr`) | now a plain column, keeps its CHECK | +| `name` (text) | admin-facing label | +| `enabled` (bool) | | +| `base_url`, `api_key_ref` | connection; API key stays a Fernet secret ref, never plaintext | +| `root_folder`, `quality_profile_id`, `tags[]` | standard (non-anime) defaults | +| `is_4k` (bool) | marks this as a 4K server | +| `is_default` (bool) | the HD default for its kind | +| `is_default_4k` (bool) | the 4K default for its kind | +| `anime_enabled` (bool) | apply anime overrides on this instance | +| `anime_quality_profile_id`, `anime_root_folder`, `anime_tags[]` | anime overrides | +| `options` (jsonb) | kind-specific extras (Radarr min-availability, Sonarr season-folders/series-type) | +| `last_check_*`, `created_at`, `updated_at` | unchanged | + +**Invariants** (partial unique indexes + service validation): + +- At most one `is_default` per kind; at most one `is_default_4k` per kind. +- `is_default ⇒ NOT is_4k` and `is_default_4k ⇒ is_4k`. The HD default is an HD + server; the 4K default is a 4K server. HD and 4K defaults are therefore always + distinct instances. + +**New `media_request_targets`:** + +| column | purpose | +|---|---| +| `id` (bigint identity PK) | | +| `request_id` (text → `media_requests` ON DELETE CASCADE) | | +| `integration_id` (text → `request_integrations` ON DELETE SET NULL) | | +| `integration_kind` (text snapshot) | survives instance deletion for history | +| `quality` (text, `1080p`\|`2160p`) | reuses `internal/access` presets | +| `is_anime` (bool) | which profile set was applied | +| `external_id`, `external_status`, `status`, `last_error` | per-target Radarr/Sonarr lifecycle | +| `created_at`, `updated_at` | | +| UNIQUE `(request_id, quality)` | one target per quality per request | + +**`media_requests` changes:** the per-fulfillment columns +(`integration_kind`, `external_id`, `external_status`) move out to +`media_request_targets`. Add `is_anime` (bool). The request's `status` becomes an +**aggregate** over its targets (see §3). `idx_media_requests_active_tmdb` stays — +dedup is still one active request per title regardless of quality, and a +dual-quality request counts as **one** row against the user's quota. + +### 2. Routing engine + +A pure function `routeTargets(req, entitlement, settings, instances) -> []plannedTarget` +replaces `integrationForMediaType` as the selection point in +`submitApprovedRequest`. + +**Inputs:** the request (incl. detected `is_anime`), the requester's +`MaxPlaybackQuality`, the global `force_dual_quality` setting, the configured +instances for the kind. + +**Algorithm:** + +1. **Kind** = `movie → radarr`, `series → sonarr`. +2. **Desired qualities:** + - `1080p` is always desired (baseline for everyone). + - Add `2160p` if `access.QualityAllowed("2160p", user.MaxPlaybackQuality)` + **OR** `force_dual_quality` is on (the toggle overrides role). +3. **Pick the default instance per desired quality:** `1080p → is_default`, + `2160p → is_default_4k` (for that kind). +4. **Profile selection per chosen instance:** if `req.is_anime` **and** the + instance has `anime_enabled` → use its anime profile / root folder / tags + (and, for Sonarr, `seriesType: anime`); otherwise the standard set. +5. Emit one `plannedTarget{instance, quality, isAnime, profile, rootFolder, tags}` + per resolved quality. + +**Edge cases:** + +- A desired quality with **no default instance assigned** is silently skipped + (cannot route 4K without a 4K default — matches "when both are assigned"). +- **Zero** resolved targets (e.g. no HD default configured): the request stays + `approved` but unfulfilled, with `last_error` = "no Radarr/Sonarr instance + configured for this quality" so it surfaces in the admin queue. +- No dedup needed: the `is_default`/`is_default_4k` invariants guarantee HD and + 4K defaults are distinct instances. + +### 3. Fulfillment & reconcile lifecycle + +The adapter interfaces (`SubmitMovie`/`SubmitSeries`, +`CheckMovieStatus`/`CheckSeriesStatus`) are **unchanged** — they already take +`(req, integration)`. We call them **once per target**, passing a *resolved* +`Integration` whose `RootFolder`/`QualityProfileID`/`Tags` are filled from the +standard or anime block. Radarr/Sonarr's notion of "4K" is just that instance's +quality profile + root folder, so adapters stay quality-agnostic. (The Sonarr +adapter additionally sets `seriesType: anime` for anime targets.) + +**On approval (`submitApprovedRequest`):** + +1. `routeTargets(...)` → planned targets. +2. For each: insert a `media_request_targets` row, then call the matching + adapter with the resolved instance + decrypted key. +3. On success → target `queued` with its `external_id`/`external_status`; on + error → target `failed` + `last_error`. +4. **Recompute the request aggregate** in the same transaction as the target + writes. + +**Reconcile loop (`ReconcileRequests`):** for each candidate request, iterate its +**non-terminal targets**, load each target's instance by `integration_id`, call +the status adapter, update the target, then recompute the aggregate. Targets +reconcile independently. + +**Aggregate recompute (single source of truth):** + +- all targets `completed` → request `completed` (+ `completed_at`) +- any target `downloading` → `downloading`; else any `queued` → `queued` +- all targets `failed` → `outcome = failed`; **some** failed while others active + → request stays active, `last_error` set from the failed target(s) + +**Retry** is target-aware: an admin retry re-submits only the `failed` targets +(re-routing if instance config changed), leaving healthy targets alone. + +### 4. Anime detection (Seerr-exact) + +Detection matches upstream Seerr exactly — a single TMDB keyword id, no genre or +language fallback: + +- `server/api/themoviedb/constants.ts`: `ANIME_KEYWORD_ID = 210024` +- `server/entity/MediaRequest.ts`: anime ⇔ `keywords.results` contains that id. + +Implementation: + +- A named constant `animeKeywordID = 210024`. +- `detectAnime(detail) bool` → true iff the TMDB keyword **ids** contain + `210024`. Matching by id (not the name string) avoids false positives from + neighboring keywords like "based on anime". +- Detected once at `CreateRequest` time and stored on `media_requests.is_anime`, + so routing, reconcile, and retries stay consistent. +- Applies to both movies and series; ignored if no instance for that kind has + `anime_enabled`. + +**Supporting TMDB-client change:** extend `tmdb.MediaDetail` to carry keyword +**ids** (add `append_to_response=keywords` to the detail fetch). `GetMediaDetail` +is already called in the request path, so no extra round-trip beyond the append. +(`OriginalLanguage` is not needed under the parity-only decision but may be +mapped opportunistically since it is already in the response.) + +The heuristic is isolated in one function so an admin-configurable keyword list +can be added later without touching routing. + +### 5. Admin UI + +`web/src/pages/admin-settings/IntegrationsSettings.tsx` and the setup wizard +`web/src/pages/setup-wizard/steps/IntegrationsStep.tsx` move from a single +Radarr/Sonarr form to an **instance-list manager** per kind: + +**Per kind — list of instance cards** (add / edit / delete): + +- Connection: name, base URL, API key (write-only; shows "configured" once set, + never echoes the secret), **Test connection** → `LoadIntegrationOptions` + populates root-folder and quality-profile **dropdowns**. +- Standard block: root folder, quality profile, tags. +- Quality role: `is_4k` switch, **Default (HD)** toggle, **Default 4K** toggle. + The form enforces invariants client-side (no Default-HD on a 4K server; + selecting a new default clears the prior one visually); server is source of + truth. +- Anime block (collapsible, `anime_enabled`): anime quality profile, anime root + folder, anime tags — dropdowns from the same test-connection options. + +**Request settings panel** gains the **"Always fulfill in both 1080p and 4K"** +toggle (`force_dual_quality`) with helper text: applies to all requests when both +a Default HD and Default 4K instance exist, regardless of user role. + +**`web/src/pages/AdminRequests.tsx`** (queue): each request row expands to show +its **targets** — quality badge (1080p/2160p), instance name, per-target status, +and per-target **retry** on failed ones. The aggregate status stays the headline. + +**Setup wizard** stays minimal: add one Radarr + one Sonarr, auto-marked Default +HD. Advanced multi-instance/4K/anime config lives in the full settings page. + +### 6. API contract & multi-repo coordination + +**End-user request flow is unchanged** — quality is derived from +`MaxPlaybackQuality`, so the request action and payload are identical (no 4K +toggle, no new permission). + +**`Request` response model** gains `is_anime` and a `targets[]` array +(`quality`, `instance_name`, `status`, `external_status`, `last_error`); the +top-level `integration_kind`/`external_id`/`external_status` are removed in favor +of the aggregate `status` + `targets`. + +- Per `CLAUDE.md`'s multi-repo rule: end-user `silo-android` / `silo-apple` + clients show a single aggregate status, so they likely need **no change** — + but this is flagged as explicit Apple/Android follow-up to verify the request + model tolerates the removed fields and ignores/parses the new `targets`. + +**Admin endpoints** change from "upsert by kind" to instance CRUD: + +- `GET/POST/PUT/DELETE` request integrations **by `id`**; `LoadIntegrationOptions` + (test-connection) stays, keyed by instance. +- Default-toggle handled server-side transactionally (a new HD/4K default clears + the prior one for that kind). +- `request_settings` gains `force_dual_quality`. +- Admin-web-only; no mobile client surface. + +### 7. Migration & backfill + +Next migration number: **169** (`169_request_multi_instance.{up,down}.sql`). + +**Up:** + +1. `ALTER request_integrations`: add `id`, `name`, `is_4k`, `is_default`, + `is_default_4k`, `anime_enabled`, `anime_quality_profile_id`, + `anime_root_folder`, `anime_tags[]`. Backfill existing rows: `id = gen`, + `name = initcap(kind)`, `is_default = enabled` (the lone existing instance + becomes that kind's HD default), everything else default/false. Swap the PK + from `kind` to `id`; keep `kind` as a plain column with its CHECK. +2. Partial unique indexes: one `is_default` per kind, one `is_default_4k` per + kind. +3. `CREATE media_request_targets` (per §1). +4. Backfill targets: for any `media_requests` row with an `external_id`, insert a + target `(quality='1080p', is_anime=false, integration_id = that kind's + instance, integration_kind, external_id, external_status, status = request's + current status)`. Rows never submitted get no target yet. +5. `ALTER media_requests`: add `is_anime bool DEFAULT false`; drop + `integration_kind`, `external_id`, `external_status`. +6. `idx_media_requests_active_tmdb` stays unchanged. + +**Down:** re-add the three columns to `media_requests` and copy back the **1080p** +target's external fields; drop `media_request_targets`; drop `is_anime`; collapse +`request_integrations` to `kind`-PK by keeping each kind's `is_default` instance +and discarding extras; drop the new columns/indexes. + +**Caveat (explicit):** the down migration is **lossy** — rollback discards any +additional instances (beyond one default per kind) and any 4K/anime target +history. This is the unavoidable cost of collapsing a one-to-many back to +one-to-one and is acceptable for a down migration. + +**Go-side data access** (implementation, not SQL): `Integration` struct + +`scanIntegration` gain the new fields; the `Store` interface gains target CRUD + +`ListInstances(kind)`; `scanRequest` drops the external fields; `UpsertIntegration` +becomes id-based CRUD with default-toggle handling in-transaction. + +### 8. Testing + +- **`routeTargets` — table-driven (core of the feature):** matrix of + {1080p vs 2160p entitlement} × {`force_dual_quality` on/off} × {which defaults + exist} × {anime vs not} → expected target set. +- **`detectAnime`:** keyword `210024` present/absent; no name-substring false + positives. +- **Aggregate recompute:** all-complete, partial-failure (request stays active + + `last_error`), all-failed (`outcome=failed`), mixed downloading/queued. +- **Repository:** target CRUD; single-default-per-kind invariant rejects a second + default; migration backfill yields exactly one 1080p target per previously + submitted request. +- **Adapter:** Sonarr `SubmitSeries` sets `seriesType=anime` only for anime + targets on anime-enabled instances. +- **Service:** extend `service_test.go` fakes — a 4K-entitled user's single + request fans out to two adapter submissions with the right resolved instances; + target-scoped retry re-submits only the failed target. +- **Frontend:** instance-form default-invariant enforcement; queue renders + per-target rows + retry. + +## Risks & open considerations + +- **Reconcile cost** scales with targets, not requests (≤2× today). Bounded and + acceptable; the reconcile loop already batches. +- **Anime keyword imperfection** is inherited from Seerr by design (parity + choice). Mitigated by isolating `detectAnime` for future tuning. +- **Client model drift**: removing top-level external fields from `Request` must + be verified against `silo-android` / `silo-apple` deserialization before + release (flagged in §6). + +## References + +- Seerr upstream: https://github.com/seerr-team/seerr + (`server/api/themoviedb/constants.ts`, `server/entity/MediaRequest.ts`) +- Overseerr anime discussions: #2876 (Sonarr for anime only), #3777 (configurable + series type) +- Existing code: `internal/requests/{service,repository,types,store}.go`, + `internal/requests/{radarr,sonarr,arrclient}`, `internal/access/quality.go`, + `migrations/139_media_requests.up.sql` diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index a7ca5f6b..420b3152 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -35,8 +35,9 @@ type RequestService interface { GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error) UpsertUserLimit(ctx context.Context, viewer mediarequests.Viewer, limit mediarequests.UserLimit) (*mediarequests.UserLimit, error) ListIntegrations(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.Integration, error) - UpsertIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) - UpsertIntegrations(ctx context.Context, viewer mediarequests.Viewer, integrations []mediarequests.Integration) ([]mediarequests.Integration, error) + CreateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) + UpdateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) + DeleteIntegration(ctx context.Context, viewer mediarequests.Viewer, id string) error LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) @@ -422,26 +423,53 @@ func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http. }{Integrations: toIntegrationResponses(integrations)}) } -func (h *RequestsHandler) HandleUpdateIntegrations(w http.ResponseWriter, r *http.Request) { +func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http.Request) { viewer, ok := requestViewer(w, r, false) if !ok { return } - var body struct { - Integrations []mediarequests.Integration `json:"integrations"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + var integration mediarequests.Integration + if err := json.NewDecoder(r.Body).Decode(&integration); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } - updated, err := h.service.UpsertIntegrations(r.Context(), viewer, body.Integrations) + created, err := h.service.CreateIntegration(r.Context(), viewer, integration) if err != nil { writeRequestServiceError(w, err) return } - writeJSON(w, http.StatusOK, struct { - Integrations []requestIntegrationResponse `json:"integrations"` - }{Integrations: toIntegrationResponses(updated)}) + writeJSON(w, http.StatusCreated, requestIntegrationResponseFrom(*created)) +} + +func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + var integration mediarequests.Integration + if err := json.NewDecoder(r.Body).Decode(&integration); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + integration.ID = chi.URLParam(r, "id") + updated, err := h.service.UpdateIntegration(r.Context(), viewer, integration) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, requestIntegrationResponseFrom(*updated)) +} + +func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, false) + if !ok { + return + } + if err := h.service.DeleteIntegration(r.Context(), viewer, chi.URLParam(r, "id")); err != nil { + writeRequestServiceError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) } func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) { @@ -456,7 +484,9 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r return } } - integration.Kind = chi.URLParam(r, "kind") + if id := strings.TrimSpace(chi.URLParam(r, "id")); id != "" { + integration.ID = id + } options, err := h.service.LoadIntegrationOptions(r.Context(), viewer, integration) if err != nil { writeRequestServiceError(w, err) @@ -558,37 +588,69 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i } type requestIntegrationResponse struct { - Kind string `json:"kind"` - Enabled bool `json:"enabled"` - BaseURL string `json:"base_url"` - HasAPIKey bool `json:"has_api_key"` - RootFolder string `json:"root_folder"` - QualityProfileID *int `json:"quality_profile_id,omitempty"` - Tags []int `json:"tags"` - Options map[string]any `json:"options"` - LastCheckAt *time.Time `json:"last_check_at,omitempty"` - LastCheckStatus string `json:"last_check_status,omitempty"` - LastCheckError string `json:"last_check_error,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + Is4K bool `json:"is_4k"` + IsDefault bool `json:"is_default"` + IsDefault4K bool `json:"is_default_4k"` + AnimeEnabled bool `json:"anime_enabled"` + AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"` + AnimeRootFolder string `json:"anime_root_folder,omitempty"` + AnimeTags []int `json:"anime_tags"` + BaseURL string `json:"base_url"` + HasAPIKey bool `json:"has_api_key"` + RootFolder string `json:"root_folder"` + QualityProfileID *int `json:"quality_profile_id,omitempty"` + Tags []int `json:"tags"` + Options map[string]any `json:"options"` + LastCheckAt *time.Time `json:"last_check_at,omitempty"` + LastCheckStatus string `json:"last_check_status,omitempty"` + LastCheckError string `json:"last_check_error,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +func requestIntegrationResponseFrom(integration mediarequests.Integration) requestIntegrationResponse { + // Normalize nil slices so they serialize as [] (not null); the frontend + // types them as number[] and indexes/maps over them. + tags := integration.Tags + if tags == nil { + tags = []int{} + } + animeTags := integration.AnimeTags + if animeTags == nil { + animeTags = []int{} + } + return requestIntegrationResponse{ + ID: integration.ID, + Name: integration.Name, + Kind: integration.Kind, + Enabled: integration.Enabled, + Is4K: integration.Is4K, + IsDefault: integration.IsDefault, + IsDefault4K: integration.IsDefault4K, + AnimeEnabled: integration.AnimeEnabled, + AnimeQualityProfileID: integration.AnimeQualityProfileID, + AnimeRootFolder: integration.AnimeRootFolder, + AnimeTags: animeTags, + BaseURL: integration.BaseURL, + HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "", + RootFolder: integration.RootFolder, + QualityProfileID: integration.QualityProfileID, + Tags: tags, + Options: integration.Options, + LastCheckAt: integration.LastCheckAt, + LastCheckStatus: integration.LastCheckStatus, + LastCheckError: integration.LastCheckError, + UpdatedAt: integration.UpdatedAt, + } } func toIntegrationResponses(integrations []mediarequests.Integration) []requestIntegrationResponse { out := make([]requestIntegrationResponse, 0, len(integrations)) for _, integration := range integrations { - out = append(out, requestIntegrationResponse{ - Kind: integration.Kind, - Enabled: integration.Enabled, - BaseURL: integration.BaseURL, - HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "", - RootFolder: integration.RootFolder, - QualityProfileID: integration.QualityProfileID, - Tags: integration.Tags, - Options: integration.Options, - LastCheckAt: integration.LastCheckAt, - LastCheckStatus: integration.LastCheckStatus, - LastCheckError: integration.LastCheckError, - UpdatedAt: integration.UpdatedAt, - }) + out = append(out, requestIntegrationResponseFrom(integration)) } return out } diff --git a/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go index 2f0da73e..95ea16b7 100644 --- a/internal/api/handlers/requests_test.go +++ b/internal/api/handlers/requests_test.go @@ -127,12 +127,16 @@ func (f *fakeRequestService) ListIntegrations(context.Context, mediarequests.Vie return nil, nil } -func (f *fakeRequestService) UpsertIntegration(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.Integration, error) { - return nil, nil +func (f *fakeRequestService) CreateIntegration(_ context.Context, _ mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) { + return &integration, nil } -func (f *fakeRequestService) UpsertIntegrations(context.Context, mediarequests.Viewer, []mediarequests.Integration) ([]mediarequests.Integration, error) { - return nil, nil +func (f *fakeRequestService) UpdateIntegration(_ context.Context, _ mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) { + return &integration, nil +} + +func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Viewer, string) error { + return nil } func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) { diff --git a/internal/api/router.go b/internal/api/router.go index b5927215..6bf72947 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -410,6 +410,9 @@ func NewRouter(deps Dependencies) chi.Router { ) requestSvc.SetSecretResolver(settingsRepo) requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil)) + if viewerResolver != nil { + requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver)) + } requestHandler = handlers.NewRequestsHandler(requestSvc) if deps.PersonRepo != nil { @@ -2040,8 +2043,10 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit) r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit) r.Get("/request-integrations", requestHandler.HandleListIntegrations) - r.Put("/request-integrations", requestHandler.HandleUpdateIntegrations) - r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions) + r.Post("/request-integrations", requestHandler.HandleCreateIntegration) + r.Put("/request-integrations/{id}", requestHandler.HandleUpdateIntegration) + r.Delete("/request-integrations/{id}", requestHandler.HandleDeleteIntegration) + r.Post("/request-integrations/{id}/options", requestHandler.HandleLoadIntegrationOptions) } if deps.ActivityLogRepo != nil { diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 57bab21b..ec15e2ab 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -827,14 +827,14 @@ func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) ( switch mediaType { case "movie": - path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates", id) + path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates,keywords", id) var resp movieDetailResponse if err := c.doGet(ctx, path, &resp); err != nil { return nil, err } return normalizeMovieDetail(&resp), nil case "series", "tv": - path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings", id) + path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings,keywords", id) var resp tvDetailResponse if err := c.doGet(ctx, path, &resp); err != nil { return nil, err @@ -847,24 +847,26 @@ func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) ( func normalizeMovieDetail(resp *movieDetailResponse) *MediaDetail { detail := &MediaDetail{ - MediaType: "movie", - ID: resp.ID, - IMDbID: resp.IMDbID, - Title: resp.Title, - OriginalTitle: resp.OriginalTitle, - Tagline: resp.Tagline, - Overview: resp.Overview, - PosterPath: resp.PosterPath, - BackdropPath: resp.BackdropPath, - ReleaseDate: resp.ReleaseDate, - Year: releaseYear(resp.ReleaseDate), - Runtime: resp.Runtime, - Genres: namesFromGenres(resp.Genres), - VoteAverage: resp.VoteAverage, - VoteCount: resp.VoteCount, - Status: resp.Status, - Homepage: resp.Homepage, - ContentRating: pickMovieCertification(resp.ReleaseDates), + MediaType: "movie", + ID: resp.ID, + IMDbID: resp.IMDbID, + Title: resp.Title, + OriginalTitle: resp.OriginalTitle, + Tagline: resp.Tagline, + Overview: resp.Overview, + PosterPath: resp.PosterPath, + BackdropPath: resp.BackdropPath, + ReleaseDate: resp.ReleaseDate, + Year: releaseYear(resp.ReleaseDate), + Runtime: resp.Runtime, + Genres: namesFromGenres(resp.Genres), + VoteAverage: resp.VoteAverage, + VoteCount: resp.VoteCount, + Status: resp.Status, + Homepage: resp.Homepage, + ContentRating: pickMovieCertification(resp.ReleaseDates), + OriginalLanguage: resp.OriginalLanguage, + KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results), } for _, company := range resp.ProductionCompanies { if name := strings.TrimSpace(company.Name); name != "" { @@ -923,6 +925,8 @@ func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail { NumberOfSeasons: resp.NumberOfSeasons, NumberOfEpisodes: resp.NumberOfEpisodes, ContentRating: pickTVRating(resp.ContentRatings), + OriginalLanguage: resp.OriginalLanguage, + KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results), } if len(resp.EpisodeRunTime) > 0 { detail.Runtime = resp.EpisodeRunTime[0] @@ -977,6 +981,18 @@ func namesFromGenres(genres []genreEntry) []string { return out } +// keywordIDs flattens one or more TMDB keyword lists (movies use the +// "keywords" array, tv uses "results") into their numeric ids. +func keywordIDs(groups ...[]idEntry) []int { + var out []int + for _, g := range groups { + for _, e := range g { + out = append(out, e.ID) + } + } + return out +} + // normalizeCast sorts by billing order and caps the result so the response // payload stays bounded — the request detail UI surfaces only the top of the // list anyway. diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index e3cffe4a..2a6cbe23 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -191,6 +191,8 @@ type MediaDetail struct { Homepage string ContentRating string ProductionCompanies []string + OriginalLanguage string + KeywordIDs []int NumberOfSeasons int NumberOfEpisodes int @@ -255,6 +257,12 @@ type personEntry struct { Name string `json:"name"` } +// idEntry is a generic {id,name} TMDB object used for keyword lists. +type idEntry struct { + ID int `json:"id"` + Name string `json:"name"` +} + type recommendationsMovieResponse struct { Results []mediaMovieResponse `json:"results"` } @@ -288,16 +296,21 @@ type contentRatingEntry struct { } type movieDetailResponse struct { - ID int `json:"id"` - IMDbID string `json:"imdb_id"` - Title string `json:"title"` - OriginalTitle string `json:"original_title"` - Tagline string `json:"tagline"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - ReleaseDate string `json:"release_date"` - Runtime int `json:"runtime"` + ID int `json:"id"` + IMDbID string `json:"imdb_id"` + Title string `json:"title"` + OriginalTitle string `json:"original_title"` + Tagline string `json:"tagline"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date"` + Runtime int `json:"runtime"` + OriginalLanguage string `json:"original_language"` + Keywords struct { + Keywords []idEntry `json:"keywords"` // movies + Results []idEntry `json:"results"` // tv + } `json:"keywords"` Genres []genreEntry `json:"genres"` VoteAverage float64 `json:"vote_average"` VoteCount int `json:"vote_count"` @@ -311,27 +324,32 @@ type movieDetailResponse struct { } type tvDetailResponse struct { - ID int `json:"id"` - Name string `json:"name"` - OriginalName string `json:"original_name"` - Tagline string `json:"tagline"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - FirstAirDate string `json:"first_air_date"` - LastAirDate string `json:"last_air_date"` - EpisodeRunTime []int `json:"episode_run_time"` - NumberOfSeasons int `json:"number_of_seasons"` - NumberOfEpisodes int `json:"number_of_episodes"` - Genres []genreEntry `json:"genres"` - VoteAverage float64 `json:"vote_average"` - VoteCount int `json:"vote_count"` - Status string `json:"status"` - Homepage string `json:"homepage"` - Networks []networkEntry `json:"networks"` - CreatedBy []personEntry `json:"created_by"` - Credits *creditsResponse `json:"credits"` - ExternalIDs *ExternalIDs `json:"external_ids"` - Recommendations *recommendationsTVResponse `json:"recommendations"` - ContentRatings *contentRatingsResponse `json:"content_ratings"` + ID int `json:"id"` + Name string `json:"name"` + OriginalName string `json:"original_name"` + Tagline string `json:"tagline"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + FirstAirDate string `json:"first_air_date"` + LastAirDate string `json:"last_air_date"` + EpisodeRunTime []int `json:"episode_run_time"` + NumberOfSeasons int `json:"number_of_seasons"` + NumberOfEpisodes int `json:"number_of_episodes"` + OriginalLanguage string `json:"original_language"` + Keywords struct { + Keywords []idEntry `json:"keywords"` // movies + Results []idEntry `json:"results"` // tv + } `json:"keywords"` + Genres []genreEntry `json:"genres"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` + Status string `json:"status"` + Homepage string `json:"homepage"` + Networks []networkEntry `json:"networks"` + CreatedBy []personEntry `json:"created_by"` + Credits *creditsResponse `json:"credits"` + ExternalIDs *ExternalIDs `json:"external_ids"` + Recommendations *recommendationsTVResponse `json:"recommendations"` + ContentRatings *contentRatingsResponse `json:"content_ratings"` } diff --git a/internal/requests/anime.go b/internal/requests/anime.go new file mode 100644 index 00000000..809e49e5 --- /dev/null +++ b/internal/requests/anime.go @@ -0,0 +1,15 @@ +package requests + +// animeKeywordID is TMDB's "anime" keyword id. Matches Seerr's ANIME_KEYWORD_ID +// exactly (server/api/themoviedb/constants.ts). Detection is keyword-id only — +// no genre/language fallback — to mirror upstream behavior. +const animeKeywordID = 210024 + +func detectAnime(keywordIDs []int) bool { + for _, id := range keywordIDs { + if id == animeKeywordID { + return true + } + } + return false +} diff --git a/internal/requests/anime_test.go b/internal/requests/anime_test.go new file mode 100644 index 00000000..cdc73a26 --- /dev/null +++ b/internal/requests/anime_test.go @@ -0,0 +1,15 @@ +package requests + +import "testing" + +func TestDetectAnime(t *testing.T) { + if !detectAnime([]int{99, animeKeywordID, 7}) { + t.Fatal("expected anime when keyword 210024 present") + } + if detectAnime([]int{99, 7}) { + t.Fatal("expected non-anime when keyword 210024 absent") + } + if detectAnime(nil) { + t.Fatal("expected non-anime for empty keywords") + } +} diff --git a/internal/requests/entitlements.go b/internal/requests/entitlements.go new file mode 100644 index 00000000..bcd7734c --- /dev/null +++ b/internal/requests/entitlements.go @@ -0,0 +1,30 @@ +package requests + +import ( + "context" + + "github.com/Silo-Server/silo-server/internal/access" +) + +// accessEntitlements resolves a requester's effective playback-quality ceiling +// (account + profile caps combined) via the shared access resolver. +type accessEntitlements struct { + resolver *access.Resolver +} + +// NewAccessEntitlements wraps the shared access resolver as an EntitlementResolver. +func NewAccessEntitlements(resolver *access.Resolver) EntitlementResolver { + return accessEntitlements{resolver: resolver} +} + +func (e accessEntitlements) MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) { + scope, err := e.resolver.Resolve(ctx, access.ResolveInput{ + UserID: userID, + ProfileID: profileID, + SkipPINVerification: true, + }) + if err != nil { + return "", err + } + return scope.MaxPlaybackQuality, nil +} diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 24ce7e54..cf110dc6 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -27,10 +27,10 @@ func (r *Repository) GetSettings(ctx context.Context) (Settings, error) { var s Settings err := r.pool.QueryRow(ctx, ` SELECT requests_enabled, global_max_requests, global_window_days, - global_auto_approval_enabled, updated_at + global_auto_approval_enabled, force_dual_quality, updated_at FROM request_settings WHERE id = true - `).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt) + `).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return Settings{ @@ -57,19 +57,20 @@ func (r *Repository) UpdateSettings(ctx context.Context, settings Settings) (Set err := r.pool.QueryRow(ctx, ` INSERT INTO request_settings ( id, requests_enabled, global_max_requests, global_window_days, - global_auto_approval_enabled, updated_at + global_auto_approval_enabled, force_dual_quality, updated_at ) - VALUES (true, $1, $2, $3, $4, now()) + VALUES (true, $1, $2, $3, $4, $5, now()) ON CONFLICT (id) DO UPDATE SET requests_enabled = EXCLUDED.requests_enabled, global_max_requests = EXCLUDED.global_max_requests, global_window_days = EXCLUDED.global_window_days, global_auto_approval_enabled = EXCLUDED.global_auto_approval_enabled, + force_dual_quality = EXCLUDED.force_dual_quality, updated_at = now() RETURNING requests_enabled, global_max_requests, global_window_days, - global_auto_approval_enabled, updated_at - `, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled). - Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt) + global_auto_approval_enabled, force_dual_quality, updated_at + `, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled, settings.ForceDualQuality). + Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.UpdatedAt) if err != nil { return Settings{}, fmt.Errorf("update request settings: %w", err) } @@ -272,6 +273,7 @@ func (r *Repository) CreateRequest(ctx context.Context, input CreateRequestRecor type requestExecutor interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) } @@ -296,18 +298,18 @@ func (r *Repository) insertRequest( INSERT INTO media_requests ( id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year, overview, poster_path, backdrop_path, status, outcome, - requested_by_user_id, requested_by_profile_id, created_at, updated_at, approved_at + requested_by_user_id, requested_by_profile_id, is_anime, created_at, updated_at, approved_at ) VALUES ( $1, 'tmdb', $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, - $13, $14, $15, $15, $16 + $13, $14, $15, $16, $16, $17 ) RETURNING `+requestColumns(), input.ID, input.Input.MediaType, input.Input.TMDBID, tvdbID, strings.TrimSpace(input.Input.IMDbID), strings.TrimSpace(input.Input.Title), year, strings.TrimSpace(input.Input.Overview), strings.TrimSpace(input.Input.PosterPath), strings.TrimSpace(input.Input.BackdropPath), status, outcome, - input.Requester.UserID, input.Requester.ProfileID, now, approvedAt) + input.Requester.UserID, input.Requester.ProfileID, input.IsAnime, now, approvedAt) req, err := scanRequest(row) if err != nil { return nil, fmt.Errorf("insert request: %w", err) @@ -417,48 +419,6 @@ func (r *Repository) SetStatus(ctx context.Context, id string, status Status, ac return req, nil } -func (r *Repository) MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error) { - tx, err := r.pool.Begin(ctx) - if err != nil { - return nil, fmt.Errorf("begin request queue transaction: %w", err) - } - defer tx.Rollback(ctx) - - externalStatus := strings.TrimSpace(update.ExternalStatus) - if externalStatus == "" { - externalStatus = "queued" - } - req, err := scanRequest(tx.QueryRow(ctx, ` - UPDATE media_requests - SET status = 'queued', - outcome = 'active', - integration_kind = $2, - external_id = $3, - external_status = $4, - last_error = '', - updated_at = now(), - approved_at = CASE WHEN approved_at IS NULL THEN now() ELSE approved_at END - WHERE id = $1 - RETURNING `+requestColumns(), id, - strings.TrimSpace(update.IntegrationKind), - strings.TrimSpace(update.ExternalID), - externalStatus, - )) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("mark request queued: %w", err) - } - if err := r.recordEvent(ctx, tx, id, "status_queued", actor, externalStatus); err != nil { - return nil, err - } - if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("commit request queue transaction: %w", err) - } - return req, nil -} - func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) { tx, err := r.pool.Begin(ctx) if err != nil { @@ -492,13 +452,13 @@ func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, return req, nil } +const integrationColumns = `id, kind, name, enabled, base_url, api_key_ref, + root_folder, quality_profile_id, tags, is_4k, is_default, is_default_4k, + anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags, + options, last_check_at, last_check_status, last_check_error, updated_at` + func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) { - rows, err := r.pool.Query(ctx, ` - SELECT kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id, - tags, options, last_check_at, last_check_status, last_check_error, updated_at - FROM request_integrations - ORDER BY kind - `) + rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY kind, name`) if err != nil { return nil, fmt.Errorf("list request integrations: %w", err) } @@ -518,72 +478,181 @@ func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error return out, nil } -func (r *Repository) UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) { - out, err := r.upsertIntegration(ctx, r.pool, integration) +func (r *Repository) GetIntegration(ctx context.Context, id string) (*Integration, error) { + row := r.pool.QueryRow(ctx, `SELECT `+integrationColumns+ + ` FROM request_integrations WHERE id = $1`, id) + i, err := scanIntegration(row) if err != nil { - return nil, fmt.Errorf("upsert request integration: %w", err) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get request integration: %w", err) } - return out, nil + return &i, nil } -func (r *Repository) UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) { +func (r *Repository) CreateIntegration(ctx context.Context, i Integration) (*Integration, error) { + return r.insertIntegration(ctx, r.pool, i) +} + +func (r *Repository) UpdateIntegration(ctx context.Context, i Integration) (*Integration, error) { + return r.updateIntegration(ctx, r.pool, i) +} + +// insertIntegration runs the integration INSERT against any executor (pool or +// tx) so the same SQL is reused by the plain create path and the transactional +// SaveIntegrationWithDefaults path. +func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) { + if i.Options == nil { + i.Options = map[string]any{} + } + options, err := json.Marshal(i.Options) + if err != nil { + return nil, fmt.Errorf("marshal options: %w", err) + } + row := exec.QueryRow(ctx, ` + INSERT INTO request_integrations ( + id, kind, name, enabled, base_url, api_key_ref, root_folder, + quality_profile_id, tags, is_4k, is_default, is_default_4k, + anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags, + options, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, now()) + RETURNING `+integrationColumns, + i.ID, i.Kind, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL), + strings.TrimSpace(i.APIKeyRef), strings.TrimSpace(i.RootFolder), i.QualityProfileID, + int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled, + i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags), + options) + out, err := scanIntegration(row) + if err != nil { + return nil, fmt.Errorf("create request integration: %w", err) + } + return &out, nil +} + +// updateIntegration runs the integration UPDATE against any executor (pool or +// tx) so the plain update path and SaveIntegrationWithDefaults share the SQL. +func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) { + if i.Options == nil { + i.Options = map[string]any{} + } + options, err := json.Marshal(i.Options) + if err != nil { + return nil, fmt.Errorf("marshal options: %w", err) + } + row := exec.QueryRow(ctx, ` + UPDATE request_integrations SET + name=$2, enabled=$3, base_url=$4, + api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END, + root_folder=$6, quality_profile_id=$7, tags=$8, is_4k=$9, + is_default=$10, is_default_4k=$11, anime_enabled=$12, + anime_quality_profile_id=$13, anime_root_folder=$14, anime_tags=$15, + options=$16, updated_at=now() + WHERE id=$1 + RETURNING `+integrationColumns, + i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL), + strings.TrimSpace(i.APIKeyRef), strings.TrimSpace(i.RootFolder), i.QualityProfileID, + int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled, + i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags), + options) + out, err := scanIntegration(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("update request integration: %w", err) + } + return &out, nil +} + +// SaveIntegrationWithDefaults clears the conflicting kind default(s) and +// creates/updates the instance in a single transaction so a save failure can +// never leave the kind with zero defaults. +func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integration, isCreate bool) (*Integration, error) { tx, err := r.pool.Begin(ctx) if err != nil { - return nil, fmt.Errorf("begin request integrations transaction: %w", err) + return nil, fmt.Errorf("begin save integration: %w", err) } defer tx.Rollback(ctx) - out := make([]Integration, 0, len(integrations)) - for _, integration := range integrations { - updated, err := r.upsertIntegration(ctx, tx, integration) - if err != nil { + if in.IsDefault { + if err := r.ClearDefault(ctx, tx, in.Kind, false, in.ID); err != nil { return nil, err } - out = append(out, *updated) + } + if in.IsDefault4K { + if err := r.ClearDefault(ctx, tx, in.Kind, true, in.ID); err != nil { + return nil, err + } + } + + var out *Integration + if isCreate { + out, err = r.insertIntegration(ctx, tx, in) + } else { + out, err = r.updateIntegration(ctx, tx, in) + } + if err != nil { + return nil, err } if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("commit request integrations transaction: %w", err) + return nil, fmt.Errorf("commit save integration: %w", err) } return out, nil } -func (r *Repository) upsertIntegration(ctx context.Context, exec requestExecutor, integration Integration) (*Integration, error) { - if integration.Options == nil { - integration.Options = map[string]any{} - } - options, err := json.Marshal(integration.Options) +func (r *Repository) DeleteIntegration(ctx context.Context, id string) error { + tx, err := r.pool.Begin(ctx) if err != nil { - return nil, fmt.Errorf("marshal request integration options: %w", err) + return fmt.Errorf("begin delete integration: %w", err) } - tags := int32Slice(integration.Tags) - row := exec.QueryRow(ctx, ` - INSERT INTO request_integrations ( - kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id, - tags, options, updated_at + defer tx.Rollback(ctx) + + var lockedID string + if err := tx.QueryRow(ctx, ` + SELECT id FROM request_integrations WHERE id = $1 FOR UPDATE + `, id).Scan(&lockedID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("lock request integration: %w", err) + } + + var hasLiveTargets bool + if err := tx.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM media_request_targets + WHERE integration_id = $1 AND status IN ('queued', 'downloading') ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) - ON CONFLICT (kind) DO UPDATE SET - enabled = EXCLUDED.enabled, - base_url = EXCLUDED.base_url, - api_key_ref = CASE - WHEN EXCLUDED.api_key_ref = '' THEN request_integrations.api_key_ref - ELSE EXCLUDED.api_key_ref - END, - root_folder = EXCLUDED.root_folder, - quality_profile_id = EXCLUDED.quality_profile_id, - tags = EXCLUDED.tags, - options = EXCLUDED.options, - updated_at = now() - RETURNING kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id, - tags, options, last_check_at, last_check_status, last_check_error, updated_at - `, integration.Kind, integration.Enabled, strings.TrimSpace(integration.BaseURL), - strings.TrimSpace(integration.APIKeyRef), strings.TrimSpace(integration.RootFolder), - integration.QualityProfileID, tags, options) - out, err := scanIntegration(row) - if err != nil { - return nil, err + `, id).Scan(&hasLiveTargets); err != nil { + return fmt.Errorf("check integration targets: %w", err) } - return &out, nil + if hasLiveTargets { + return ErrInvalidState + } + + if _, err := tx.Exec(ctx, `DELETE FROM request_integrations WHERE id = $1`, id); err != nil { + return fmt.Errorf("delete request integration: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit delete integration: %w", err) + } + return nil +} + +// ClearDefault unsets the HD (or 4K) default flag for every instance of a kind +// except excludeID, so saving an instance that is itself the new default does +// not clear its own freshly-written flag. +func (r *Repository) ClearDefault(ctx context.Context, exec requestExecutor, kind string, fourK bool, excludeID string) error { + col := "is_default" + if fourK { + col = "is_default_4k" + } + _, err := exec.Exec(ctx, `UPDATE request_integrations SET `+col+` = false WHERE kind = $1 AND id <> $2`, kind, excludeID) + if err != nil { + return fmt.Errorf("clear default: %w", err) + } + return nil } func (r *Repository) recordEvent(ctx context.Context, exec requestExecutor, requestID, eventType string, actor Viewer, message string) error { @@ -636,9 +705,8 @@ func requestSelectSQL() string { func requestColumns() string { return `id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year, overview, poster_path, backdrop_path, status, outcome, - requested_by_user_id, requested_by_profile_id, integration_kind, - external_id, external_status, last_error, created_at, updated_at, - approved_at, completed_at` + requested_by_user_id, requested_by_profile_id, is_anime, + last_error, created_at, updated_at, approved_at, completed_at` } type requestScanner interface { @@ -665,9 +733,7 @@ func scanRequest(row requestScanner) (*Request, error) { &req.Outcome, &req.RequestedByUserID, &req.RequestedByProfileID, - &req.IntegrationKind, - &req.ExternalID, - &req.ExternalStatus, + &req.IsAnime, &req.LastError, &req.CreatedAt, &req.UpdatedAt, @@ -698,44 +764,41 @@ type integrationScanner interface { } func scanIntegration(row integrationScanner) (Integration, error) { - var integration Integration - var quality sql.NullInt64 - var tags []int32 + var i Integration + var quality, animeQuality sql.NullInt64 + var tags, animeTags []int32 var optionsRaw []byte var lastCheckAt sql.NullTime if err := row.Scan( - &integration.Kind, - &integration.Enabled, - &integration.BaseURL, - &integration.APIKeyRef, - &integration.RootFolder, - &quality, - &tags, - &optionsRaw, - &lastCheckAt, - &integration.LastCheckStatus, - &integration.LastCheckError, - &integration.UpdatedAt, + &i.ID, &i.Kind, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef, + &i.RootFolder, &quality, &tags, &i.Is4K, &i.IsDefault, &i.IsDefault4K, + &i.AnimeEnabled, &animeQuality, &i.AnimeRootFolder, &animeTags, + &optionsRaw, &lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt, ); err != nil { return Integration{}, err } if quality.Valid { v := int(quality.Int64) - integration.QualityProfileID = &v + i.QualityProfileID = &v } - integration.Tags = intsFromInt32(tags) + if animeQuality.Valid { + v := int(animeQuality.Int64) + i.AnimeQualityProfileID = &v + } + i.Tags = intsFromInt32(tags) + i.AnimeTags = intsFromInt32(animeTags) if len(optionsRaw) > 0 { - if err := json.Unmarshal(optionsRaw, &integration.Options); err != nil { - return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", integration.Kind, err) + if err := json.Unmarshal(optionsRaw, &i.Options); err != nil { + return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", i.ID, err) } } - if integration.Options == nil { - integration.Options = map[string]any{} + if i.Options == nil { + i.Options = map[string]any{} } if lastCheckAt.Valid { - integration.LastCheckAt = &lastCheckAt.Time + i.LastCheckAt = &lastCheckAt.Time } - return integration, nil + return i, nil } func int32Slice(values []int) []int32 { diff --git a/internal/requests/routing.go b/internal/requests/routing.go new file mode 100644 index 00000000..918957b7 --- /dev/null +++ b/internal/requests/routing.go @@ -0,0 +1,85 @@ +package requests + +import "github.com/Silo-Server/silo-server/internal/access" + +// plannedTarget is a routing decision: which instance, at which quality, with +// which profile/folder/tags resolved (standard vs anime). +type plannedTarget struct { + Instance Integration + Quality Quality + IsAnime bool +} + +func integrationKindForMediaType(mediaType MediaType) string { + if mediaType == MediaTypeSeries { + return "sonarr" + } + return "radarr" +} + +// routeTargets decides the fulfillment targets for an approved request. +// 1080p is always desired; 2160p is added when the requester's ceiling allows +// 4K OR force-dual is on. A quality is emitted only if its default instance +// exists for the kind. +func routeTargets(req Request, ceiling string, settings Settings, instances []Integration) []plannedTarget { + kind := integrationKindForMediaType(req.MediaType) + + wants4K := settings.ForceDualQuality || access.CompareQuality(ceiling, access.PlaybackQuality4K) >= 0 + + var hd, uhd *Integration + for i := range instances { + in := instances[i] + if in.Kind != kind || !in.Enabled { + continue + } + if in.IsDefault && hd == nil { + hd = &instances[i] + } + if in.IsDefault4K && uhd == nil { + uhd = &instances[i] + } + } + + var out []plannedTarget + if hd != nil { + out = append(out, plannedTarget{Instance: *hd, Quality: Quality1080p, IsAnime: req.IsAnime && hd.AnimeEnabled}) + } + if wants4K && uhd != nil { + out = append(out, plannedTarget{Instance: *uhd, Quality: Quality2160p, IsAnime: req.IsAnime && uhd.AnimeEnabled}) + } + return out +} + +// resolveInstance returns a copy of the instance with root folder / quality +// profile / tags (and Sonarr series_type) set for standard vs anime fulfillment. +func resolveInstance(pt plannedTarget) Integration { + in := pt.Instance + if in.Options == nil { + in.Options = map[string]any{} + } else { + clone := make(map[string]any, len(in.Options)) + for k, v := range in.Options { + clone[k] = v + } + in.Options = clone + } + if pt.IsAnime { + // Anime fields are overrides: only replace the standard value when the + // anime counterpart is set, so an admin can enable anime detection while + // reusing the standard root folder / quality profile / tags for any field + // they leave blank (rather than clearing them into an invalid submission). + if in.AnimeRootFolder != "" { + in.RootFolder = in.AnimeRootFolder + } + if in.AnimeQualityProfileID != nil { + in.QualityProfileID = in.AnimeQualityProfileID + } + if len(in.AnimeTags) > 0 { + in.Tags = in.AnimeTags + } + if in.Kind == "sonarr" { + in.Options["series_type"] = "anime" + } + } + return in +} diff --git a/internal/requests/routing_test.go b/internal/requests/routing_test.go new file mode 100644 index 00000000..8e855154 --- /dev/null +++ b/internal/requests/routing_test.go @@ -0,0 +1,52 @@ +package requests + +import "testing" + +func inst(kind, id string, def, def4k, anime bool) Integration { + qp := 1 + return Integration{ + ID: id, Kind: kind, Name: id, Enabled: true, BaseURL: "http://x", + APIKeyRef: "k", RootFolder: "/std", QualityProfileID: &qp, + Is4K: def4k, IsDefault: def, IsDefault4K: def4k, AnimeEnabled: anime, + } +} + +func TestRouteTargets(t *testing.T) { + hd := inst("radarr", "hd", true, false, false) + uhd := inst("radarr", "uhd", false, true, false) + hdAnime := inst("radarr", "hda", true, false, true) + + cases := []struct { + name string + req Request + ceiling string + force bool + instances []Integration + want []Quality + wantAnime bool + }{ + {"hd only, sd user", Request{MediaType: MediaTypeMovie}, "1080p", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false}, + {"4k user dual", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false}, + {"force dual overrides role", Request{MediaType: MediaTypeMovie}, "1080p", true, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false}, + {"4k user but no 4k default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd}, []Quality{Quality1080p}, false}, + {"no hd default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{uhd}, []Quality{Quality2160p}, false}, + {"anime on anime-enabled hd", Request{MediaType: MediaTypeMovie, IsAnime: true}, "1080p", false, []Integration{hdAnime}, []Quality{Quality1080p}, true}, + {"empty ceiling hd only", Request{MediaType: MediaTypeMovie}, "", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := routeTargets(tc.req, tc.ceiling, Settings{ForceDualQuality: tc.force}, tc.instances) + if len(got) != len(tc.want) { + t.Fatalf("got %d targets, want %d (%v)", len(got), len(tc.want), got) + } + for i, q := range tc.want { + if got[i].Quality != q { + t.Fatalf("target %d quality = %s, want %s", i, got[i].Quality, q) + } + if got[i].IsAnime != tc.wantAnime { + t.Fatalf("target %d isAnime = %v, want %v", i, got[i].IsAnime, tc.wantAnime) + } + } + }) + } +} diff --git a/internal/requests/service.go b/internal/requests/service.go index dc265871..781627cd 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/idgen" "github.com/Silo-Server/silo-server/internal/metadata/tmdb" "golang.org/x/sync/errgroup" @@ -54,6 +55,13 @@ type SeriesIntegrationOptionsAdapter interface { ListSeriesIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error) } +type EntitlementResolver interface { + // MaxPlaybackQuality returns the requester's effective playback-quality + // ceiling (already combining account- and profile-level caps). Empty string + // means "no cap". + MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) +} + type Service struct { store Store tmdb TMDBClient @@ -61,6 +69,7 @@ type Service struct { secrets SecretResolver movieAdapter MovieFulfillmentAdapter seriesAdapter SeriesFulfillmentAdapter + entitlements EntitlementResolver Now func() time.Time } @@ -91,6 +100,19 @@ func (s *Service) SetFulfillmentAdapters(movie MovieFulfillmentAdapter, series S s.seriesAdapter = series } +func (s *Service) SetEntitlementResolver(r EntitlementResolver) { s.entitlements = r } + +func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID string) string { + if s.entitlements == nil { + return "" // no resolver -> unlimited (1080p baseline still applies) + } + q, err := s.entitlements.MaxPlaybackQuality(ctx, userID, profileID) + if err != nil { + return access.PlaybackQualityStandard // fail safe: HD only + } + return q +} + func (s *Service) Search(ctx context.Context, viewer Viewer, query string, mediaType MediaType, page int) (*MediaPage, error) { if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") @@ -282,6 +304,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create return nil, err } s.enrichExternalIDs(ctx, &normalized) + isAnime := s.detectRequestAnime(ctx, normalized.MediaType, normalized.TMDBID) matches, err := s.lookupPresence(ctx, normalized.MediaType, []PresenceCandidate{createPresenceCandidate(normalized)}) if err != nil { @@ -329,6 +352,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create Input: normalized, Status: status, Outcome: OutcomeActive, + IsAnime: isAnime, Requester: viewer, Now: s.now(), } @@ -366,14 +390,44 @@ func (s *Service) ListMine(ctx context.Context, viewer Viewer, filter ListFilter if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } - return s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter)) + reqs, err := s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter)) + if err != nil { + return nil, err + } + if err := s.attachTargets(ctx, reqs...); err != nil { + return nil, err + } + return reqs, nil } func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) { if !viewer.IsAdmin { return nil, ErrForbidden } - return s.store.ListAdmin(ctx, normalizeListFilter(filter)) + reqs, err := s.store.ListAdmin(ctx, normalizeListFilter(filter)) + if err != nil { + return nil, err + } + if err := s.attachTargets(ctx, reqs...); err != nil { + return nil, err + } + return reqs, nil +} + +// attachTargets loads and attaches the per-instance fulfillment targets for each +// request so callers (admin queue, detail view) can surface multi-target status. +func (s *Service) attachTargets(ctx context.Context, reqs ...*Request) error { + for _, r := range reqs { + if r == nil { + continue + } + targets, err := s.store.ListTargets(ctx, r.ID) + if err != nil { + return err + } + r.Targets = targets + } + return nil } func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Request, error) { @@ -387,6 +441,9 @@ func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Re if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID { return nil, ErrForbidden } + if err := s.attachTargets(ctx, req); err != nil { + return nil, err + } return req, nil } @@ -478,12 +535,65 @@ func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request if err != nil { return nil, err } - if active.Status == StatusApproved || active.Status == StatusQueued || active.Status == StatusDownloading { - retry := *active - retry.Status = StatusApproved - return s.submitApprovedRequest(ctx, retry, viewer) + + instances, err := s.store.ListIntegrations(ctx) + if err != nil { + return nil, err } - return active, nil + settings, err := s.store.GetSettings(ctx) + if err != nil { + return nil, err + } + existing, err := s.store.ListTargets(ctx, active.ID) + if err != nil { + return nil, err + } + ceiling := s.requesterCeiling(ctx, active.RequestedByUserID, active.RequestedByProfileID) + planned := routeTargets(*active, ceiling, settings, instances) + if len(planned) == 0 { + return s.markSubmissionFailed(ctx, active.ID, viewer, + fmt.Errorf("no %s instance configured for the requested quality", + integrationKindForMediaType(active.MediaType))) + } + + return s.submitPlannedTargets(ctx, *active, planned, existing, viewer) +} + +// submitPlannedTargets submits each planned target idempotently against the +// already-recorded targets: a non-failed target for a quality is left alone, a +// failed one is deleted and re-submitted, and a missing one is submitted fresh. +// This is shared by Retry and the reconcile-driven submit so a re-run never +// violates the UNIQUE(request_id, quality) constraint. +func (s *Service) submitPlannedTargets(ctx context.Context, req Request, planned []plannedTarget, existing []Target, actor Viewer) (*Request, error) { + hasOK := make(map[Quality]bool) + for _, t := range existing { + if t.Status != StatusFailed { + hasOK[t.Quality] = true + } + } + + latest := &req + for _, pt := range planned { + if hasOK[pt.Quality] { + continue // healthy target already exists for this quality + } + // remove the stale failed target for this quality before re-submitting + for _, t := range existing { + if t.Quality == pt.Quality && t.Status == StatusFailed { + if err := s.store.DeleteTarget(ctx, t.ID); err != nil { + return nil, err + } + } + } + updated, err := s.submitPlannedTarget(ctx, req, pt, actor) + if err != nil { + return nil, err + } + if updated != nil { + latest = updated + } + } + return latest, nil } func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) { @@ -606,43 +716,88 @@ func (s *Service) ListIntegrations(ctx context.Context, viewer Viewer) ([]Integr return s.store.ListIntegrations(ctx) } -func (s *Service) UpsertIntegration(ctx context.Context, viewer Viewer, integration Integration) (*Integration, error) { +func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) { if !viewer.IsAdmin { return nil, ErrForbidden } - normalized, err := normalizeIntegration(integration) + if err := validateInstance(&in); err != nil { + return nil, err + } + id, err := idgen.NextID() if err != nil { return nil, err } - return s.store.UpsertIntegration(ctx, normalized) + in.ID = id + return s.store.SaveIntegrationWithDefaults(ctx, in, true) } -func (s *Service) UpsertIntegrations(ctx context.Context, viewer Viewer, integrations []Integration) ([]Integration, error) { +func (s *Service) UpdateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) { if !viewer.IsAdmin { return nil, ErrForbidden } - normalized := make([]Integration, 0, len(integrations)) - for _, integration := range integrations { - item, err := normalizeIntegration(integration) - if err != nil { - return nil, err - } - normalized = append(normalized, item) + if strings.TrimSpace(in.ID) == "" { + return nil, fmt.Errorf("%w: integration id required", ErrInvalidInput) } - return s.store.UpsertIntegrations(ctx, normalized) + if err := validateInstance(&in); err != nil { + return nil, err + } + return s.store.SaveIntegrationWithDefaults(ctx, in, false) +} + +func (s *Service) DeleteIntegration(ctx context.Context, viewer Viewer, id string) error { + if !viewer.IsAdmin { + return ErrForbidden + } + return s.store.DeleteIntegration(ctx, strings.TrimSpace(id)) +} + +func validateInstance(in *Integration) error { + in.Kind = strings.TrimSpace(in.Kind) + if in.Kind != "radarr" && in.Kind != "sonarr" { + return fmt.Errorf("%w: kind must be radarr or sonarr", ErrInvalidInput) + } + if strings.TrimSpace(in.Name) == "" { + return fmt.Errorf("%w: name is required", ErrInvalidInput) + } + if in.IsDefault && in.Is4K { + return fmt.Errorf("%w: the HD default cannot be a 4K server", ErrInvalidInput) + } + if in.IsDefault4K && !in.Is4K { + return fmt.Errorf("%w: the 4K default must be a 4K server", ErrInvalidInput) + } + return nil } func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (*IntegrationOptions, error) { if !viewer.IsAdmin { return nil, ErrForbidden } + // For a saved instance the request body carries only the path id (no kind and + // often no creds), so resolve the saved row by id and backfill what the body + // omitted. This makes "Test connection" reuse the correct per-instance key + // (each kind can have multiple instances) instead of borrowing a sibling's. + if id := strings.TrimSpace(integration.ID); id != "" && id != "new" { + stored, err := s.store.GetIntegration(ctx, id) + if err != nil && !errors.Is(err, ErrNotFound) { + return nil, err + } + if stored != nil { + if strings.TrimSpace(integration.Kind) == "" { + integration.Kind = stored.Kind + } + if strings.TrimSpace(integration.BaseURL) == "" { + integration.BaseURL = stored.BaseURL + } + if strings.TrimSpace(integration.APIKeyRef) == "" { + integration.APIKeyRef = stored.APIKeyRef + } + } + } + normalized, err := normalizeIntegrationConnection(integration) if err != nil { return nil, err } - if err := s.applyStoredIntegrationCredentials(ctx, &normalized); err != nil { - return nil, err - } if strings.TrimSpace(normalized.BaseURL) == "" { return nil, fmt.Errorf("%w: base_url is required", ErrInvalidInput) } @@ -941,85 +1096,118 @@ func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInp } } -func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) { - integration, err := s.integrationForMediaType(ctx, mediaType) - if err != nil { - return false, err +func (s *Service) detectRequestAnime(ctx context.Context, mediaType MediaType, tmdbID int) bool { + detail, err := s.tmdb.GetMediaDetail(ctx, tmdbMediaType(mediaType), tmdbID) + if err != nil || detail == nil { + return false } - if integration == nil || !integrationIsConfigured(*integration) { - return false, nil - } - apiKey, err := s.resolveAPIKey(ctx, *integration) - if err != nil { - return false, err - } - return apiKey != "", nil + return detectAnime(detail.KeywordIDs) } -func (s *Service) integrationForMediaType(ctx context.Context, mediaType MediaType) (*Integration, error) { - want := integrationKindForMediaType(mediaType) - integrations, err := s.store.ListIntegrations(ctx) +func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) { + instances, err := s.store.ListIntegrations(ctx) if err != nil { - return nil, err + return false, err } - for _, integration := range integrations { - if integration.Kind == want { - integration := integration - return &integration, nil + kind := integrationKindForMediaType(mediaType) + for _, in := range instances { + if in.Kind == kind && in.Enabled && (in.IsDefault || in.IsDefault4K) && integrationIsConfigured(in) { + return true, nil } } - return nil, nil + return false, nil } func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) { if req.Outcome != OutcomeActive || req.Status != StatusApproved { return &req, nil } - - integration, err := s.integrationForMediaType(ctx, req.MediaType) + instances, err := s.store.ListIntegrations(ctx) if err != nil { return nil, err } - if integration == nil || !integrationIsConfigured(*integration) { - return &req, nil + settings, err := s.store.GetSettings(ctx) + if err != nil { + return nil, err + } + ceiling := s.requesterCeiling(ctx, req.RequestedByUserID, req.RequestedByProfileID) + planned := routeTargets(req, ceiling, settings, instances) + if len(planned) == 0 { + return s.markSubmissionFailed(ctx, req.ID, actor, + fmt.Errorf("no %s instance configured for the requested quality", + integrationKindForMediaType(req.MediaType))) } - resolved := *integration - apiKey, err := s.resolveAPIKey(ctx, resolved) + // Reconcile can re-run submit while the request is still 'approved'; skip + // qualities that already have a live target and replace failed ones so the + // UNIQUE(request_id, quality) constraint is never violated. + existing, err := s.store.ListTargets(ctx, req.ID) if err != nil { - return s.markSubmissionFailed(ctx, req.ID, actor, err) + return nil, err } - if apiKey == "" { - return &req, nil + return s.submitPlannedTargets(ctx, req, planned, existing, actor) +} + +// submitPlannedTarget creates a target row, submits it to the adapter, and +// records the result (queued or failed) via UpdateTargetStatus (which recomputes +// the request aggregate). Returns the latest request snapshot. +func (s *Service) submitPlannedTarget(ctx context.Context, req Request, pt plannedTarget, actor Viewer) (*Request, error) { + resolved := resolveInstance(pt) + apiKey, err := s.resolveAPIKey(ctx, resolved) + if err != nil || apiKey == "" { + msg := "missing api key" + if err != nil { + msg = err.Error() + } + return s.createFailedTarget(ctx, req, pt, resolved, msg, actor) } resolved.APIKeyRef = apiKey - var result FulfillmentResult + target, err := s.store.CreateTarget(ctx, Target{ + RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind, + Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusQueued, + }) + if err != nil { + return nil, err + } + + result, serr := s.submitTarget(ctx, req, resolved) + if serr != nil { + return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", serr.Error(), actor) + } + return s.store.UpdateTargetStatus(ctx, target.ID, StatusQueued, + result.ExternalID, result.ExternalStatus, "", actor) +} + +func (s *Service) createFailedTarget(ctx context.Context, req Request, pt plannedTarget, resolved Integration, msg string, actor Viewer) (*Request, error) { + target, err := s.store.CreateTarget(ctx, Target{ + RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind, + Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusFailed, LastError: msg, + }) + if err != nil { + return nil, err + } + return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", msg, actor) +} + +// submitTarget calls the correct adapter with a per-target Request copy. The +// adapters read root folder/quality/tags (and Sonarr series_type) from the +// resolved Integration. +func (s *Service) submitTarget(ctx context.Context, req Request, resolved Integration) (FulfillmentResult, error) { switch req.MediaType { case MediaTypeMovie: if s.movieAdapter == nil { - return &req, nil + return FulfillmentResult{}, fmt.Errorf("no movie adapter configured") } - result, err = s.movieAdapter.SubmitMovie(ctx, req, resolved) + return s.movieAdapter.SubmitMovie(ctx, req, resolved) case MediaTypeSeries: if s.seriesAdapter == nil { - return &req, nil + return FulfillmentResult{}, fmt.Errorf("no series adapter configured") } - result, err = s.seriesAdapter.SubmitSeries(ctx, req, resolved) + return s.seriesAdapter.SubmitSeries(ctx, req, resolved) default: - return &req, nil + return FulfillmentResult{}, fmt.Errorf("unsupported media type %q", req.MediaType) } - if err != nil { - return s.markSubmissionFailed(ctx, req.ID, actor, err) - } - if result.IntegrationKind == "" { - result.IntegrationKind = resolved.Kind - } - return s.store.MarkQueued(ctx, req.ID, QueueUpdate{ - IntegrationKind: result.IntegrationKind, - ExternalID: result.ExternalID, - ExternalStatus: result.ExternalStatus, - }, actor) } func (s *Service) markSubmissionFailed(ctx context.Context, requestID string, actor Viewer, submitErr error) (*Request, error) { @@ -1047,13 +1235,24 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC return reconcileUnchanged, err } if completed { - if req.Status == StatusCompleted { - return reconcileUnchanged, nil - } - if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil { + // The presence check is quality-agnostic (TMDB id only), so it must not + // force-complete a request whose targets are still in flight — that would + // orphan in-progress downloads. Only take the shortcut for legacy/no-live + // -target requests; otherwise let per-target reconcile + aggregate drive + // completion. + hasLiveTargets, err := s.hasLiveTargets(ctx, req.ID) + if err != nil { return reconcileUnchanged, err } - return reconcileCompleted, nil + if !hasLiveTargets { + if req.Status == StatusCompleted { + return reconcileUnchanged, nil + } + if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil { + return reconcileUnchanged, err + } + return reconcileCompleted, nil + } } if req.Status == StatusApproved { @@ -1071,33 +1270,91 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC } } - status, err := s.checkFulfillmentStatus(ctx, req) + targets, err := s.store.ListTargets(ctx, req.ID) if err != nil { return reconcileUnchanged, err } - if status.Status == "" && status.Outcome == "" { - return reconcileSkipped, nil + instances, err := s.store.ListIntegrations(ctx) + if err != nil { + return reconcileUnchanged, err } - if status.Outcome == OutcomeFailed { - message := strings.TrimSpace(status.Message) - if message == "" { - message = strings.TrimSpace(status.ExternalStatus) + byID := make(map[string]Integration, len(instances)) + for _, in := range instances { + byID[in.ID] = in + } + change := reconcileUnchanged + for _, t := range targets { + if t.Status == StatusCompleted || t.Status == StatusFailed { + continue } - if message == "" { - message = "external fulfillment failed" + in, ok := byID[t.IntegrationID] + if !ok { + continue } - if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeFailed, Viewer{}, message); err != nil { + apiKey, err := s.resolveAPIKey(ctx, in) + if err != nil || apiKey == "" { + continue + } + in.APIKeyRef = apiKey + probe := req + probe.ExternalID = t.ExternalID + st, err := s.checkFulfillmentStatus(ctx, probe, in) + if err != nil { + continue + } + if st.Status == "" && st.Outcome == "" { + continue + } + newStatus := targetStatusFromFulfillment(st) + if newStatus == t.Status { + continue + } + if _, err := s.store.UpdateTargetStatus(ctx, t.ID, newStatus, st.ExternalID, st.ExternalStatus, "", Viewer{}); err != nil { return reconcileUnchanged, err } - return reconcileFailed, nil - } - if status.Status == StatusDownloading && req.Status != StatusDownloading { - if _, err := s.store.SetStatus(ctx, req.ID, StatusDownloading, Viewer{}); err != nil { - return reconcileUnchanged, err + switch newStatus { + case StatusCompleted: + change = reconcileCompleted + case StatusDownloading: + if change == reconcileUnchanged { + change = reconcileDownloading + } + case StatusFailed: + if change == reconcileUnchanged { + change = reconcileFailed + } } - return reconcileDownloading, nil } - return reconcileUnchanged, nil + return change, nil +} + +func targetStatusFromFulfillment(st FulfillmentStatus) Status { + switch st.Status { + case StatusCompleted: + return StatusCompleted + case StatusDownloading: + return StatusDownloading + default: + if st.Outcome == OutcomeFailed { + return StatusFailed + } + return StatusQueued + } +} + +// hasLiveTargets reports whether the request has any non-terminal (queued or +// downloading) fulfillment target. +func (s *Service) hasLiveTargets(ctx context.Context, requestID string) (bool, error) { + targets, err := s.store.ListTargets(ctx, requestID) + if err != nil { + return false, err + } + for _, t := range targets { + if t.Status == StatusQueued || t.Status == StatusDownloading { + return true, nil + } + } + return false, nil } func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, error) { @@ -1108,24 +1365,7 @@ func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, erro return matches[req.TMDBID].Available, nil } -func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request) (FulfillmentStatus, error) { - integration, err := s.integrationForMediaType(ctx, req.MediaType) - if err != nil { - return FulfillmentStatus{}, err - } - if integration == nil || !integrationIsConfigured(*integration) { - return FulfillmentStatus{}, nil - } - resolved := *integration - apiKey, err := s.resolveAPIKey(ctx, resolved) - if err != nil { - return FulfillmentStatus{}, err - } - if apiKey == "" { - return FulfillmentStatus{}, nil - } - resolved.APIKeyRef = apiKey - +func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request, resolved Integration) (FulfillmentStatus, error) { switch req.MediaType { case MediaTypeMovie: checker, ok := s.movieAdapter.(MovieStatusAdapter) @@ -1160,13 +1400,6 @@ func (s *Service) resolveAPIKey(ctx context.Context, integration Integration) (s return resolved, nil } -func integrationKindForMediaType(mediaType MediaType) string { - if mediaType == MediaTypeSeries { - return "sonarr" - } - return "radarr" -} - func integrationIsConfigured(integration Integration) bool { return integration.Enabled && strings.TrimSpace(integration.BaseURL) != "" && @@ -1283,29 +1516,6 @@ func normalizeUserLimit(limit UserLimit) (UserLimit, error) { return limit, nil } -func normalizeIntegration(integration Integration) (Integration, error) { - var err error - integration, err = normalizeIntegrationConnection(integration) - if err != nil { - return Integration{}, err - } - integration.RootFolder = strings.TrimSpace(integration.RootFolder) - if integration.QualityProfileID != nil && *integration.QualityProfileID <= 0 { - return Integration{}, fmt.Errorf("%w: quality_profile_id must be positive", ErrInvalidInput) - } - filteredTags := make([]int, 0, len(integration.Tags)) - for _, tag := range integration.Tags { - if tag > 0 { - filteredTags = append(filteredTags, tag) - } - } - integration.Tags = filteredTags - if integration.Options == nil { - integration.Options = map[string]any{} - } - return integration, nil -} - func normalizeIntegrationConnection(integration Integration) (Integration, error) { integration.Kind = strings.ToLower(strings.TrimSpace(integration.Kind)) switch integration.Kind { @@ -1321,32 +1531,6 @@ func normalizeIntegrationConnection(integration Integration) (Integration, error return integration, nil } -func (s *Service) applyStoredIntegrationCredentials(ctx context.Context, integration *Integration) error { - if integration == nil || s.store == nil { - return nil - } - if strings.TrimSpace(integration.BaseURL) != "" && strings.TrimSpace(integration.APIKeyRef) != "" { - return nil - } - stored, err := s.store.ListIntegrations(ctx) - if err != nil { - return err - } - for _, candidate := range stored { - if candidate.Kind != integration.Kind { - continue - } - if strings.TrimSpace(integration.BaseURL) == "" { - integration.BaseURL = candidate.BaseURL - } - if strings.TrimSpace(integration.APIKeyRef) == "" { - integration.APIKeyRef = candidate.APIKeyRef - } - return nil - } - return nil -} - func normalizeMediaType(mediaType MediaType) (MediaType, error) { switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) { case MediaTypeMovie: diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index b25af25f..1f40c400 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -164,12 +164,20 @@ func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) { store.integrations = []Integration{{ Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "request.radarr.api_key", RootFolder: "/movies", QualityProfileID: &qualityProfileID, }} + adapter := &fakeMovieAdapter{result: FulfillmentResult{ + IntegrationKind: "radarr", + ExternalID: "123", + ExternalStatus: "queued", + }} service := newTestService(store) + service.SetSecretResolver(fakeSecrets{"request.radarr.api_key": "radarr-key"}) + service.SetFulfillmentAdapters(adapter, nil) req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ MediaType: MediaTypeMovie, @@ -179,12 +187,14 @@ func TestCreateRequestAutoApprovesWithConfiguredIntegration(t *testing.T) { if err != nil { t.Fatalf("CreateRequest returned error: %v", err) } - if req.Status != StatusApproved { - t.Fatalf("status = %q, want approved", req.Status) + // A configured HD default auto-approves and immediately submits, so the + // request lands in the fulfillment pipeline (aggregate of one queued target). + if req.Status != StatusQueued { + t.Fatalf("status = %q, want queued (auto-approved and submitted)", req.Status) } } -func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *testing.T) { +func TestCreateRequestAutoApprovalSecretFailureMarksTargetFailed(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true store.settings.GlobalAutoApprovalEnabled = true @@ -192,6 +202,7 @@ func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *t store.integrations = []Integration{{ Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "requests.radarr.api_key", RootFolder: "/movies", @@ -199,6 +210,7 @@ func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *t }} service := newTestService(store) service.SetSecretResolver(fakeSecretError{err: errors.New("secret lookup unavailable")}) + service.SetFulfillmentAdapters(&fakeMovieAdapter{}, nil) req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ MediaType: MediaTypeMovie, @@ -208,8 +220,10 @@ func TestCreateRequestAutoApprovalFallsBackToPendingOnIntegrationCheckError(t *t if err != nil { t.Fatalf("CreateRequest returned error: %v", err) } - if req.Status != StatusPending { - t.Fatalf("status = %q, want pending", req.Status) + // The auto-approval gate no longer resolves secrets; submission does. A + // secret-resolution failure now surfaces as a failed fulfillment target. + if req.Outcome != OutcomeFailed { + t.Fatalf("outcome = %q, want failed (secret resolution failed during submit)", req.Outcome) } } @@ -221,6 +235,7 @@ func TestCreateRequestAutoApprovalSubmitsMovie(t *testing.T) { store.integrations = []Integration{{ Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "requests.radarr.api_key", RootFolder: "/movies", @@ -262,6 +277,7 @@ func TestCreateRequestSubmissionFailureMarksFailed(t *testing.T) { store.integrations = []Integration{{ Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "radarr-key", RootFolder: "/movies", @@ -306,6 +322,37 @@ func TestCreateRequestEnrichesSeriesTVDBID(t *testing.T) { } } +func TestListMineAttachesTargets(t *testing.T) { + store := newFakeStore() + store.mine = []*Request{{ + ID: "req-1", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusQueued, + Outcome: OutcomeActive, + RequestedByUserID: 1, + }} + store.targets = map[string][]Target{ + "req-1": {{ + ID: 10, + RequestID: "req-1", + Quality: Quality2160p, + Status: StatusQueued, + }}, + } + + got, err := newTestService(store).ListMine(context.Background(), testViewer(1), ListFilter{}) + if err != nil { + t.Fatalf("ListMine returned error: %v", err) + } + if len(got) != 1 { + t.Fatalf("ListMine returned %d requests, want 1", len(got)) + } + if len(got[0].Targets) != 1 || got[0].Targets[0].Quality != Quality2160p { + t.Fatalf("targets = %+v, want attached 2160p target", got[0].Targets) + } +} + func TestCreateRequestBlocksWhenHydratedTVDBIDIsAvailable(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true @@ -742,8 +789,10 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) { store := newFakeStore() qualityProfileID := 1 store.integrations = []Integration{{ + ID: "radarr-hd", Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "radarr-key", RootFolder: "/movies", @@ -757,6 +806,14 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) { Outcome: OutcomeActive, ExternalID: "123", }} + // Reconcile now drives status per-target; seed a queued target for the request. + store.requests["req-1"] = &Request{ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusQueued, Outcome: OutcomeActive} + if _, err := store.CreateTarget(context.Background(), Target{ + RequestID: "req-1", IntegrationID: "radarr-hd", IntegrationKind: "radarr", + Quality: Quality1080p, Status: StatusQueued, ExternalID: "123", + }); err != nil { + t.Fatalf("seed target: %v", err) + } adapter := &fakeMovieAdapter{status: FulfillmentStatus{ Status: StatusDownloading, IntegrationKind: "radarr", @@ -778,6 +835,36 @@ func TestReconcileRequestsMarksDownloadingFromAdapter(t *testing.T) { } } +func TestDeleteIntegrationRejectsLiveTargets(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{{ + ID: "radarr-hd", + Kind: "radarr", + Enabled: true, + }} + store.targets = map[string][]Target{ + "req-1": {{ + ID: 10, + RequestID: "req-1", + IntegrationID: "radarr-hd", + Quality: Quality1080p, + Status: StatusDownloading, + }}, + } + + err := newTestService(store).DeleteIntegration( + context.Background(), + Viewer{UserID: 1, IsAdmin: true}, + "radarr-hd", + ) + if !errors.Is(err, ErrInvalidState) { + t.Fatalf("err = %v, want ErrInvalidState", err) + } + if len(store.integrations) != 1 { + t.Fatalf("integrations = %d, want delete blocked", len(store.integrations)) + } +} + func TestCancelOwnerCanWithdrawPendingRequest(t *testing.T) { store := newFakeStore() store.requests["req-mine"] = &Request{ @@ -903,6 +990,7 @@ func TestRetryResubmitsFailedQueuedRequest(t *testing.T) { store.integrations = []Integration{{ Kind: "radarr", Enabled: true, + IsDefault: true, BaseURL: "http://radarr.local", APIKeyRef: "radarr-key", RootFolder: "/movies", @@ -930,9 +1018,9 @@ func TestRetryResubmitsFailedQueuedRequest(t *testing.T) { if adapter.calls != 1 { t.Fatalf("adapter calls = %d, want 1", adapter.calls) } - if adapter.gotReq.Status != StatusApproved { - t.Fatalf("submitted status = %q, want approved", adapter.gotReq.Status) - } + // Retry re-routes the failed request to its default instance and re-submits + // a fresh target; the request aggregate returns to queued with the new + // external id reported by the adapter. if req.Status != StatusQueued || req.ExternalID != "99" { t.Fatalf("request = %+v, want re-queued with external id 99", req) } @@ -960,10 +1048,12 @@ type fakeStore struct { active map[MediaType]map[int]*Request created []CreateRequestRecord integrations []Integration - queued []QueueUpdate candidates []*Request + mine []*Request statusUpdates []Status requests map[string]*Request + targets map[string][]Target + targetSeq int64 } func newFakeStore() *fakeStore { @@ -1067,7 +1157,7 @@ func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) } } f.created = append(f.created, input) - return &Request{ + req := &Request{ ID: input.ID, Provider: "tmdb", MediaType: input.Input.MediaType, @@ -1077,11 +1167,15 @@ func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) Title: input.Input.Title, Status: input.Status, Outcome: input.Outcome, + IsAnime: input.IsAnime, RequestedByUserID: input.Requester.UserID, RequestedByProfileID: input.Requester.ProfileID, CreatedAt: input.Now, UpdatedAt: input.Now, - }, nil + } + f.requests[input.ID] = req + copy := *req + return ©, nil } func (f *fakeStore) GetRequest(_ context.Context, id string) (*Request, error) { @@ -1102,7 +1196,9 @@ func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Reque } func (f *fakeStore) ListMine(context.Context, int, ListFilter) ([]*Request, error) { - return nil, nil + f.mu.Lock() + defer f.mu.Unlock() + return append([]*Request(nil), f.mine...), nil } func (f *fakeStore) ListAdmin(context.Context, ListFilter) ([]*Request, error) { @@ -1123,24 +1219,6 @@ func (f *fakeStore) SetStatus(_ context.Context, id string, status Status, _ Vie return ©, nil } -func (f *fakeStore) MarkQueued(_ context.Context, id string, update QueueUpdate, _ Viewer) (*Request, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.queued = append(f.queued, update) - req := f.requests[id] - if req == nil { - req = &Request{ID: id} - f.requests[id] = req - } - req.Status = StatusQueued - req.Outcome = OutcomeActive - req.IntegrationKind = update.IntegrationKind - req.ExternalID = update.ExternalID - req.ExternalStatus = update.ExternalStatus - copy := *req - return ©, nil -} - func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ Viewer, message string) (*Request, error) { f.mu.Lock() defer f.mu.Unlock() @@ -1161,15 +1239,165 @@ func (f *fakeStore) ListIntegrations(context.Context) ([]Integration, error) { return f.integrations, nil } -func (f *fakeStore) UpsertIntegration(context.Context, Integration) (*Integration, error) { - return nil, nil -} - -func (f *fakeStore) UpsertIntegrations(_ context.Context, integrations []Integration) ([]Integration, error) { +func (f *fakeStore) GetIntegration(_ context.Context, id string) (*Integration, error) { f.mu.Lock() defer f.mu.Unlock() - f.integrations = append([]Integration(nil), integrations...) - return append([]Integration(nil), integrations...), nil + for i := range f.integrations { + if f.integrations[i].ID == id { + cp := f.integrations[i] + return &cp, nil + } + } + return nil, ErrNotFound +} + +func (f *fakeStore) CreateIntegration(_ context.Context, in Integration) (*Integration, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.integrations = append(f.integrations, in) + cp := in + return &cp, nil +} + +func (f *fakeStore) UpdateIntegration(_ context.Context, in Integration) (*Integration, error) { + f.mu.Lock() + defer f.mu.Unlock() + for i := range f.integrations { + if f.integrations[i].ID == in.ID { + f.integrations[i] = in + cp := in + return &cp, nil + } + } + return nil, ErrNotFound +} + +func (f *fakeStore) SaveIntegrationWithDefaults(_ context.Context, in Integration, isCreate bool) (*Integration, error) { + f.mu.Lock() + defer f.mu.Unlock() + for i := range f.integrations { + if f.integrations[i].Kind != in.Kind || f.integrations[i].ID == in.ID { + continue + } + if in.IsDefault { + f.integrations[i].IsDefault = false + } + if in.IsDefault4K { + f.integrations[i].IsDefault4K = false + } + } + if isCreate { + f.integrations = append(f.integrations, in) + cp := in + return &cp, nil + } + for i := range f.integrations { + if f.integrations[i].ID == in.ID { + f.integrations[i] = in + cp := in + return &cp, nil + } + } + return nil, ErrNotFound +} + +func (f *fakeStore) DeleteIntegration(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + for _, targets := range f.targets { + for _, target := range targets { + if target.IntegrationID == id && (target.Status == StatusQueued || target.Status == StatusDownloading) { + return ErrInvalidState + } + } + } + for i := range f.integrations { + if f.integrations[i].ID == id { + f.integrations = append(f.integrations[:i], f.integrations[i+1:]...) + return nil + } + } + return ErrNotFound +} + +func (f *fakeStore) ListTargets(_ context.Context, requestID string) ([]Target, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]Target(nil), f.targets[requestID]...), nil +} + +func (f *fakeStore) CreateTarget(_ context.Context, t Target) (Target, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.targets == nil { + f.targets = map[string][]Target{} + } + f.targetSeq++ + t.ID = f.targetSeq + f.targets[t.RequestID] = append(f.targets[t.RequestID], t) + return t, nil +} + +func (f *fakeStore) DeleteTarget(_ context.Context, id int64) error { + f.mu.Lock() + defer f.mu.Unlock() + for rid, ts := range f.targets { + for i := range ts { + if ts[i].ID == id { + f.targets[rid] = append(ts[:i], ts[i+1:]...) + return nil + } + } + } + return ErrNotFound +} + +func (f *fakeStore) UpdateTargetStatus(_ context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, _ Viewer) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + var requestID string + for rid, ts := range f.targets { + for i := range ts { + if ts[i].ID == targetID { + if externalID != "" { + f.targets[rid][i].ExternalID = externalID + } + if externalStatus != "" { + f.targets[rid][i].ExternalStatus = externalStatus + } + f.targets[rid][i].Status = status + f.targets[rid][i].LastError = lastErr + requestID = rid + } + } + } + if requestID == "" { + return nil, ErrNotFound + } + f.statusUpdates = append(f.statusUpdates, status) + st, outcome := aggregateStatus(f.targets[requestID]) + req := f.requests[requestID] + if req == nil { + req = &Request{ID: requestID} + f.requests[requestID] = req + } + req.Status = st + req.Outcome = outcome + // Surface the first target's external identity on the request snapshot so + // existing assertions on req.ExternalID/IntegrationKind keep working. + for _, t := range f.targets[requestID] { + if t.ExternalID != "" { + req.ExternalID = t.ExternalID + req.ExternalStatus = t.ExternalStatus + req.IntegrationKind = t.IntegrationKind + break + } + } + if outcome == OutcomeFailed { + req.LastError = lastErr + } + copy := *req + return ©, nil } func TestListStudiosReturnsBundleWithDuotoneLogos(t *testing.T) { @@ -1462,6 +1690,45 @@ func (f *fakeMovieAdapter) CheckMovieStatus(_ context.Context, req Request, inte return f.status, f.statusErr } +type fixedCeiling struct{ q string } + +func (f fixedCeiling) MaxPlaybackQuality(context.Context, int, string) (string, error) { + return f.q, nil +} + +type recordingMovieAdapter struct { + mu sync.Mutex + ids []string +} + +func (a *recordingMovieAdapter) SubmitMovie(_ context.Context, _ Request, integration Integration) (FulfillmentResult, error) { + a.mu.Lock() + defer a.mu.Unlock() + a.ids = append(a.ids, integration.ID) + return FulfillmentResult{IntegrationKind: integration.Kind, ExternalID: "ext-" + integration.ID, ExternalStatus: "queued"}, nil +} + +func TestSubmitApprovedFansOutDualQuality(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{ + inst("radarr", "hd", true, false, false), + inst("radarr", "uhd", false, true, false), + } + rec := &recordingMovieAdapter{} + svc := NewService(store, &fakeTMDBClient{}, &fakePresence{}) + svc.SetFulfillmentAdapters(rec, nil) + svc.SetEntitlementResolver(fixedCeiling{q: "2160p"}) + + req := Request{ID: "r1", MediaType: MediaTypeMovie, Status: StatusApproved, Outcome: OutcomeActive, RequestedByUserID: 7} + store.requests["r1"] = &req + if _, err := svc.submitApprovedRequest(context.Background(), req, Viewer{UserID: 7, IsAdmin: true}); err != nil { + t.Fatalf("submit: %v", err) + } + if len(rec.ids) != 2 { + t.Fatalf("expected 2 submissions (hd+uhd), got %d: %v", len(rec.ids), rec.ids) + } +} + type fakeSecrets map[string]string func (f fakeSecrets) Get(_ context.Context, key string) (string, error) { diff --git a/internal/requests/store.go b/internal/requests/store.go index 91637b4e..c70a8bae 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -21,11 +21,19 @@ type Store interface { ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) - MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error) SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) + ListTargets(ctx context.Context, requestID string) ([]Target, error) + CreateTarget(ctx context.Context, target Target) (Target, error) + DeleteTarget(ctx context.Context, id int64) error + UpdateTargetStatus(ctx context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, actor Viewer) (*Request, error) ListIntegrations(ctx context.Context) ([]Integration, error) - UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) - UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) + GetIntegration(ctx context.Context, id string) (*Integration, error) + CreateIntegration(ctx context.Context, integration Integration) (*Integration, error) + UpdateIntegration(ctx context.Context, integration Integration) (*Integration, error) + // SaveIntegrationWithDefaults clears the conflicting kind default(s) and + // creates (isCreate) or updates the instance atomically in one transaction. + SaveIntegrationWithDefaults(ctx context.Context, integration Integration, isCreate bool) (*Integration, error) + DeleteIntegration(ctx context.Context, id string) error } type CreateRequestRecord struct { @@ -33,6 +41,7 @@ type CreateRequestRecord struct { Input CreateRequestInput Status Status Outcome Outcome + IsAnime bool Requester Viewer Now time.Time // Quota, when non-nil, instructs the store to atomically verify the diff --git a/internal/requests/targets.go b/internal/requests/targets.go new file mode 100644 index 00000000..242e2dba --- /dev/null +++ b/internal/requests/targets.go @@ -0,0 +1,192 @@ +package requests + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +const targetColumns = `t.id, t.request_id, t.integration_id, t.integration_kind, + COALESCE(ri.name, ''), t.quality, t.is_anime, t.external_id, t.external_status, + t.status, t.last_error, t.created_at, t.updated_at` + +// aggregateStatus derives a request's status/outcome from its targets. +func aggregateStatus(targets []Target) (Status, Outcome) { + if len(targets) == 0 { + return StatusApproved, OutcomeActive + } + failed, completed := 0, 0 + anyDownloading, anyQueued := false, false + for _, t := range targets { + switch t.Status { + case StatusFailed: + failed++ + case StatusCompleted: + completed++ + case StatusDownloading: + anyDownloading = true + case StatusQueued: + anyQueued = true + } + } + if completed == len(targets) { + return StatusCompleted, OutcomeActive + } + // Active targets keep the request active even with a failed sibling so the + // in-flight targets can finish (partial failure stays active). + if anyDownloading { + return StatusDownloading, OutcomeActive + } + if anyQueued { + return StatusQueued, OutcomeActive + } + // No active targets remain and at least one failed (all-failed, or a mix of + // completed + failed) -> surface as failed so Retry can re-submit the failed + // target while leaving completed ones untouched. + if failed > 0 { + return StatusQueued, OutcomeFailed + } + return StatusCompleted, OutcomeActive +} + +func scanTarget(row requestScanner) (Target, error) { + var t Target + var integrationID *string + if err := row.Scan(&t.ID, &t.RequestID, &integrationID, &t.IntegrationKind, + &t.InstanceName, &t.Quality, &t.IsAnime, &t.ExternalID, &t.ExternalStatus, + &t.Status, &t.LastError, &t.CreatedAt, &t.UpdatedAt); err != nil { + return Target{}, err + } + if integrationID != nil { + t.IntegrationID = *integrationID + } + return t, nil +} + +func (r *Repository) ListTargets(ctx context.Context, requestID string) ([]Target, error) { + rows, err := r.pool.Query(ctx, `SELECT `+targetColumns+` + FROM media_request_targets t + LEFT JOIN request_integrations ri ON ri.id = t.integration_id + WHERE t.request_id = $1 ORDER BY t.quality`, requestID) + if err != nil { + return nil, fmt.Errorf("list targets: %w", err) + } + defer rows.Close() + var out []Target + for rows.Next() { + t, err := scanTarget(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (r *Repository) CreateTarget(ctx context.Context, t Target) (Target, error) { + var integrationID any + if t.IntegrationID != "" { + integrationID = t.IntegrationID + } + row := r.pool.QueryRow(ctx, ` + INSERT INTO media_request_targets + (request_id, integration_id, integration_kind, quality, is_anime, + external_id, external_status, status, last_error, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now()) + RETURNING id`, + t.RequestID, integrationID, t.IntegrationKind, t.Quality, t.IsAnime, + t.ExternalID, t.ExternalStatus, t.Status, t.LastError) + if err := row.Scan(&t.ID); err != nil { + return Target{}, fmt.Errorf("create target: %w", err) + } + return t, nil +} + +func (r *Repository) DeleteTarget(ctx context.Context, id int64) error { + tag, err := r.pool.Exec(ctx, `DELETE FROM media_request_targets WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete target: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// UpdateTargetStatus updates one target and recomputes the parent request's +// aggregate status/outcome, all in one transaction. +func (r *Repository) UpdateTargetStatus(ctx context.Context, targetID int64, status Status, + externalID, externalStatus, lastErr string, actor Viewer) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin target update: %w", err) + } + defer tx.Rollback(ctx) + + var requestID string + if err := tx.QueryRow(ctx, ` + UPDATE media_request_targets + SET status=$2, + external_id = CASE WHEN $3 = '' THEN external_id ELSE $3 END, + external_status = CASE WHEN $4 = '' THEN external_status ELSE $4 END, + last_error=$5, updated_at=now() + WHERE id=$1 RETURNING request_id`, + targetID, status, externalID, externalStatus, lastErr).Scan(&requestID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("update target: %w", err) + } + + req, err := r.recomputeAggregate(ctx, tx, requestID, actor) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit target update: %w", err) + } + return req, nil +} + +func (r *Repository) recomputeAggregate(ctx context.Context, exec requestExecutor, requestID string, actor Viewer) (*Request, error) { + rows, err := exec.Query(ctx, `SELECT status FROM media_request_targets WHERE request_id = $1`, requestID) + if err != nil { + return nil, fmt.Errorf("load target statuses: %w", err) + } + var targets []Target + for rows.Next() { + var t Target + if err := rows.Scan(&t.Status); err != nil { + rows.Close() + return nil, err + } + targets = append(targets, t) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + status, outcome := aggregateStatus(targets) + + var lastErr string + for _, t := range targets { + if t.Status == StatusFailed { + lastErr = "one or more fulfillment targets failed" + break + } + } + req, err := scanRequest(exec.QueryRow(ctx, ` + UPDATE media_requests + SET status=$2, outcome=$3, + last_error = CASE WHEN $3 = 'failed' THEN $4 ELSE '' END, + completed_at = CASE WHEN $2 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END, + updated_at = now() + WHERE id=$1 RETURNING `+requestColumns(), requestID, status, outcome, lastErr)) + if err != nil { + return nil, fmt.Errorf("recompute aggregate: %w", err) + } + _ = r.recordEvent(ctx, exec, requestID, "status_"+string(status), actor, string(req.ExternalStatus)) + return req, nil +} diff --git a/internal/requests/targets_test.go b/internal/requests/targets_test.go new file mode 100644 index 00000000..fd1bcd42 --- /dev/null +++ b/internal/requests/targets_test.go @@ -0,0 +1,27 @@ +package requests + +import "testing" + +func TestAggregateStatus(t *testing.T) { + cases := []struct { + name string + targets []Target + status Status + outcome Outcome + }{ + {"all completed", []Target{{Status: StatusCompleted}, {Status: StatusCompleted}}, StatusCompleted, OutcomeActive}, + {"one downloading", []Target{{Status: StatusCompleted}, {Status: StatusDownloading}}, StatusDownloading, OutcomeActive}, + {"queued only", []Target{{Status: StatusQueued}}, StatusQueued, OutcomeActive}, + {"all failed", []Target{{Status: StatusFailed}, {Status: StatusFailed}}, StatusQueued, OutcomeFailed}, + {"completed plus failed", []Target{{Status: StatusCompleted}, {Status: StatusFailed}}, StatusQueued, OutcomeFailed}, + {"partial fail stays active", []Target{{Status: StatusFailed}, {Status: StatusDownloading}}, StatusDownloading, OutcomeActive}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotStatus, gotOutcome := aggregateStatus(tc.targets) + if gotStatus != tc.status || gotOutcome != tc.outcome { + t.Fatalf("aggregateStatus = (%s,%s), want (%s,%s)", gotStatus, gotOutcome, tc.status, tc.outcome) + } + }) + } +} diff --git a/internal/requests/types.go b/internal/requests/types.go index b4c5b6c0..9d3b9fad 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -22,6 +22,8 @@ const ( StatusCompleted Status = "completed" ) +const StatusFailed Status = "failed" // target-only status; requests use outcome=failed + type Outcome string const ( @@ -31,6 +33,31 @@ const ( OutcomeFailed Outcome = "failed" ) +type Quality string + +const ( + Quality1080p Quality = "1080p" + Quality2160p Quality = "2160p" +) + +// Target is one fulfillment of a request against a single instance at a single +// quality. A request fans out to one Target per resolved quality. +type Target struct { + ID int64 `json:"id"` + RequestID string `json:"request_id"` + IntegrationID string `json:"integration_id,omitempty"` + IntegrationKind string `json:"integration_kind,omitempty"` + InstanceName string `json:"instance_name,omitempty"` + Quality Quality `json:"quality"` + IsAnime bool `json:"is_anime"` + ExternalID string `json:"external_id,omitempty"` + ExternalStatus string `json:"external_status,omitempty"` + Status Status `json:"status"` + LastError string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type Availability string const ( @@ -67,6 +94,7 @@ type Settings struct { GlobalMaxRequests int `json:"global_max_requests"` GlobalWindowDays int `json:"global_window_days"` GlobalAutoApprovalEnabled bool `json:"global_auto_approval_enabled"` + ForceDualQuality bool `json:"force_dual_quality"` UpdatedAt time.Time `json:"updated_at"` } @@ -112,6 +140,8 @@ type Request struct { RequestedByUserID int `json:"requested_by_user_id,omitempty"` RequestedByProfileID string `json:"requested_by_profile_id,omitempty"` IntegrationKind string `json:"integration_kind,omitempty"` + IsAnime bool `json:"is_anime"` + Targets []Target `json:"targets,omitempty"` ExternalID string `json:"external_id,omitempty"` ExternalStatus string `json:"external_status,omitempty"` LastError string `json:"last_error,omitempty"` @@ -221,18 +251,27 @@ type ListFilter struct { } type Integration struct { - Kind string `json:"kind"` - Enabled bool `json:"enabled"` - BaseURL string `json:"base_url"` - APIKeyRef string `json:"api_key_ref,omitempty"` - RootFolder string `json:"root_folder"` - QualityProfileID *int `json:"quality_profile_id,omitempty"` - Tags []int `json:"tags"` - Options map[string]any `json:"options"` - LastCheckAt *time.Time `json:"last_check_at,omitempty"` - LastCheckStatus string `json:"last_check_status,omitempty"` - LastCheckError string `json:"last_check_error,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + Is4K bool `json:"is_4k"` + IsDefault bool `json:"is_default"` + IsDefault4K bool `json:"is_default_4k"` + AnimeEnabled bool `json:"anime_enabled"` + AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"` + AnimeRootFolder string `json:"anime_root_folder,omitempty"` + AnimeTags []int `json:"anime_tags"` + BaseURL string `json:"base_url"` + APIKeyRef string `json:"api_key_ref,omitempty"` + RootFolder string `json:"root_folder"` + QualityProfileID *int `json:"quality_profile_id,omitempty"` + Tags []int `json:"tags"` + Options map[string]any `json:"options"` + LastCheckAt *time.Time `json:"last_check_at,omitempty"` + LastCheckStatus string `json:"last_check_status,omitempty"` + LastCheckError string `json:"last_check_error,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } type IntegrationRootFolder struct { @@ -259,12 +298,6 @@ type IntegrationOptions struct { Tags []IntegrationTag `json:"tags"` } -type QueueUpdate struct { - IntegrationKind string - ExternalID string - ExternalStatus string -} - type FulfillmentResult struct { IntegrationKind string ExternalID string diff --git a/migrations/169_request_multi_instance.down.sql b/migrations/169_request_multi_instance.down.sql new file mode 100644 index 00000000..21285744 --- /dev/null +++ b/migrations/169_request_multi_instance.down.sql @@ -0,0 +1,42 @@ +-- Restore per-fulfillment columns on media_requests. +ALTER TABLE public.media_requests + ADD COLUMN IF NOT EXISTS integration_kind text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS external_id text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS external_status text NOT NULL DEFAULT ''; + +-- Copy back the 1080p target's fulfillment fields (lossy: 4K/anime targets dropped). +UPDATE public.media_requests mr +SET integration_kind = t.integration_kind, + external_id = t.external_id, + external_status = t.external_status +FROM public.media_request_targets t +WHERE t.request_id = mr.id AND t.quality = '1080p'; + +ALTER TABLE public.media_requests DROP COLUMN IF EXISTS is_anime; + +DROP TABLE IF EXISTS public.media_request_targets; + +-- Collapse request_integrations back to kind-PK (lossy: keep one default per kind). +DELETE FROM public.request_integrations a +USING public.request_integrations b +WHERE a.kind = b.kind AND a.id <> b.id AND b.is_default AND NOT a.is_default; +-- If a kind has no default, keep an arbitrary row and drop the rest. +DELETE FROM public.request_integrations a +USING public.request_integrations b +WHERE a.kind = b.kind AND a.ctid < b.ctid; + +DROP INDEX IF EXISTS idx_request_integrations_default_per_kind; +DROP INDEX IF EXISTS idx_request_integrations_default4k_per_kind; + +ALTER TABLE public.request_integrations DROP CONSTRAINT request_integrations_pkey; +ALTER TABLE public.request_integrations ADD PRIMARY KEY (kind); +ALTER TABLE public.request_integrations + DROP COLUMN IF EXISTS id, + DROP COLUMN IF EXISTS name, + DROP COLUMN IF EXISTS is_4k, + DROP COLUMN IF EXISTS is_default, + DROP COLUMN IF EXISTS is_default_4k, + DROP COLUMN IF EXISTS anime_enabled, + DROP COLUMN IF EXISTS anime_quality_profile_id, + DROP COLUMN IF EXISTS anime_root_folder, + DROP COLUMN IF EXISTS anime_tags; diff --git a/migrations/169_request_multi_instance.up.sql b/migrations/169_request_multi_instance.up.sql new file mode 100644 index 00000000..e2658922 --- /dev/null +++ b/migrations/169_request_multi_instance.up.sql @@ -0,0 +1,86 @@ +-- request_integrations: one-row-per-kind -> many instances keyed by id. +ALTER TABLE public.request_integrations + ADD COLUMN IF NOT EXISTS id text, + ADD COLUMN IF NOT EXISTS name text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS is_4k boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS is_default_4k boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS anime_enabled boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS anime_quality_profile_id integer, + ADD COLUMN IF NOT EXISTS anime_root_folder text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS anime_tags integer[] NOT NULL DEFAULT '{}'; + +-- Backfill: the lone existing row per kind becomes that kind's HD default. +UPDATE public.request_integrations +SET id = gen_random_uuid()::text, + name = initcap(kind), + is_default = enabled +WHERE id IS NULL; + +-- Swap the primary key from kind to id; keep kind as a plain column. +ALTER TABLE public.request_integrations + DROP CONSTRAINT request_integrations_pkey; +ALTER TABLE public.request_integrations + ALTER COLUMN id SET NOT NULL, + ADD PRIMARY KEY (id); + +-- Quality-role invariants: at most one default / one 4K-default per kind. +CREATE UNIQUE INDEX IF NOT EXISTS idx_request_integrations_default_per_kind + ON public.request_integrations (kind) WHERE is_default; +CREATE UNIQUE INDEX IF NOT EXISTS idx_request_integrations_default4k_per_kind + ON public.request_integrations (kind) WHERE is_default_4k; + +-- Targets: one request -> N fulfillment targets. +CREATE TABLE IF NOT EXISTS public.media_request_targets ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + request_id text NOT NULL REFERENCES public.media_requests(id) ON DELETE CASCADE, + integration_id text REFERENCES public.request_integrations(id) ON DELETE SET NULL, + integration_kind text NOT NULL DEFAULT '', + quality text NOT NULL, + is_anime boolean NOT NULL DEFAULT false, + external_id text NOT NULL DEFAULT '', + external_status text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'queued', + last_error text NOT NULL DEFAULT '', + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT media_request_targets_quality_check CHECK (quality IN ('1080p', '2160p')), + CONSTRAINT media_request_targets_status_check + CHECK (status IN ('queued', 'downloading', 'completed', 'failed')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_media_request_targets_request_quality + ON public.media_request_targets (request_id, quality); +CREATE INDEX IF NOT EXISTS idx_media_request_targets_request + ON public.media_request_targets (request_id); + +-- Backfill targets from already-submitted requests (those with an external id). +INSERT INTO public.media_request_targets + (request_id, integration_id, integration_kind, quality, is_anime, + external_id, external_status, status, created_at, updated_at) +SELECT mr.id, + ri.id, + mr.integration_kind, + '1080p', + false, + mr.external_id, + mr.external_status, + CASE + WHEN mr.status = 'completed' THEN 'completed' + WHEN mr.status = 'downloading' THEN 'downloading' + WHEN mr.outcome = 'failed' THEN 'failed' + ELSE 'queued' + END, + mr.created_at, + mr.updated_at +FROM public.media_requests mr +LEFT JOIN public.request_integrations ri ON ri.kind = mr.integration_kind +WHERE mr.external_id <> ''; + +-- media_requests: add is_anime, move per-fulfillment columns out to targets. +ALTER TABLE public.media_requests + ADD COLUMN IF NOT EXISTS is_anime boolean NOT NULL DEFAULT false; +ALTER TABLE public.media_requests + DROP COLUMN IF EXISTS integration_kind, + DROP COLUMN IF EXISTS external_id, + DROP COLUMN IF EXISTS external_status; diff --git a/migrations/170_request_force_dual_quality.down.sql b/migrations/170_request_force_dual_quality.down.sql new file mode 100644 index 00000000..cf81072d --- /dev/null +++ b/migrations/170_request_force_dual_quality.down.sql @@ -0,0 +1 @@ +ALTER TABLE public.request_settings DROP COLUMN IF EXISTS force_dual_quality; diff --git a/migrations/170_request_force_dual_quality.up.sql b/migrations/170_request_force_dual_quality.up.sql new file mode 100644 index 00000000..1afb8bcf --- /dev/null +++ b/migrations/170_request_force_dual_quality.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.request_settings + ADD COLUMN IF NOT EXISTS force_dual_quality boolean NOT NULL DEFAULT false; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d67a8d57..8d9ba897 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1531,6 +1531,22 @@ export interface CreateMediaRequestInput { backdrop_path?: string; } +export interface RequestTarget { + id: number; + request_id: string; + integration_id?: string; + integration_kind?: string; + instance_name?: string; + quality: "1080p" | "2160p"; + is_anime: boolean; + external_id?: string; + external_status?: string; + status: MediaRequestStatus | "failed"; + last_error?: string; + created_at: string; + updated_at: string; +} + export interface MediaRequest { id: string; provider: string; @@ -1547,6 +1563,8 @@ export interface MediaRequest { outcome: MediaRequestOutcome; requested_by_user_id?: number; requested_by_profile_id?: string; + is_anime?: boolean; + targets?: RequestTarget[]; integration_kind?: string; external_id?: string; external_status?: string; @@ -1570,6 +1588,7 @@ export interface RequestSettings { global_max_requests: number; global_window_days: number; global_auto_approval_enabled: boolean; + force_dual_quality: boolean; updated_at: string; } @@ -1583,14 +1602,23 @@ export interface RequestUserLimit { } export interface RequestIntegration { + id: string; + name: string; kind: string; enabled: boolean; + is_4k: boolean; + is_default: boolean; + is_default_4k: boolean; base_url: string; api_key_ref?: string; has_api_key?: boolean; root_folder: string; quality_profile_id?: number | null; tags: number[]; + anime_enabled: boolean; + anime_quality_profile_id?: number | null; + anime_root_folder?: string; + anime_tags: number[]; options: Record; last_check_at?: string | null; last_check_status?: string; @@ -1623,6 +1651,7 @@ export interface RequestIntegrationOptions { } export interface LoadRequestIntegrationOptionsRequest { + kind: "radarr" | "sonarr"; base_url: string; api_key_ref?: string; } diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index f9423dda..422aa88d 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -326,35 +326,75 @@ export function useRequestIntegrations() { }); } -export function useUpdateRequestIntegrations() { +export function useCreateRequestIntegration() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (integrations: RequestIntegration[]) => - api("/admin/request-integrations", { - method: "PUT", - body: JSON.stringify({ integrations }), + mutationFn: (integration: RequestIntegration) => + api("/admin/request-integrations", { + method: "POST", + body: JSON.stringify(integration), }), onSuccess: () => { - toast.success("Integrations saved"); + toast.success("Integration created"); queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); invalidateRequestSurfaces(queryClient); }, onError: (err) => { - toast.error(err instanceof Error ? err.message : "Failed to save integrations"); + toast.error(err instanceof Error ? err.message : "Failed to create integration"); + }, + }); +} + +export function useUpdateRequestIntegration() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...integration }: RequestIntegration) => + api(`/admin/request-integrations/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify({ id, ...integration }), + }), + onSuccess: () => { + toast.success("Integration saved"); + queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to save integration"); + }, + }); +} + +export function useDeleteRequestIntegration() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + api(`/admin/request-integrations/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + onSuccess: () => { + toast.success("Integration deleted"); + queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to delete integration"); }, }); } export function useLoadRequestIntegrationOptions() { return useMutation({ - mutationFn: ({ kind, body }: { kind: string; body: LoadRequestIntegrationOptionsRequest }) => + mutationFn: ({ id, body }: { id: string; body: LoadRequestIntegrationOptionsRequest }) => api( - `/admin/request-integrations/${encodeURIComponent(kind)}/options`, + `/admin/request-integrations/${encodeURIComponent(id)}/options`, { method: "POST", body: JSON.stringify(body), }, ), + onSuccess: () => { + toast.success("Connection successful"); + }, onError: (err) => { toast.error(err instanceof Error ? err.message : "Failed to load integration settings"); }, diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx index e7c339a6..9c588bc2 100644 --- a/web/src/pages/AdminRequests.tsx +++ b/web/src/pages/AdminRequests.tsx @@ -1,7 +1,20 @@ -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { Link, useSearchParams } from "react-router"; -import { Check, Plug, RefreshCw, Save, Settings2, SlidersHorizontal, X } from "lucide-react"; +import { + AlertTriangle, + Check, + ChevronDown, + ChevronRight, + Plug, + Plus, + RefreshCw, + Save, + Settings2, + SlidersHorizontal, + Trash2, + X, +} from "lucide-react"; import type { MediaRequest, MediaRequestOutcome, @@ -11,6 +24,7 @@ import type { RequestIntegrationOptions, RequestLimitMode, RequestSettings, + RequestTarget, RequestUserLimit, } from "@/api/types"; import { Badge } from "@/components/ui/badge"; @@ -47,13 +61,15 @@ import { useAdminUsers } from "@/hooks/queries/admin/users"; import { useAdminMediaRequests, useApproveMediaRequest, + useCreateRequestIntegration, useDeclineMediaRequest, + useDeleteRequestIntegration, useLoadRequestIntegrationOptions, useRequestIntegrations, useRequestSettings, useRequestUserLimit, useRetryMediaRequest, - useUpdateRequestIntegrations, + useUpdateRequestIntegration, useUpdateRequestSettings, useUpdateRequestUserLimit, } from "@/hooks/queries/useRequests"; @@ -357,8 +373,20 @@ function RequestQueueRow({ - {request.integration_kind || "Not submitted"} - {request.external_status ? {request.external_status} : null} + {request.targets?.length ? ( +
    + {request.is_anime ? ( + + Anime + + ) : null} + {request.targets.map((target) => ( + + ))} +
    + ) : ( + "Not submitted" + )}
    @@ -390,11 +418,35 @@ function RequestQueueRow({ ); } +function RequestTargetBadge({ target }: { target: RequestTarget }) { + const qualityLabel = target.quality === "2160p" ? "2160p" : "1080p"; + const instanceLabel = target.instance_name || target.integration_kind || "Unknown"; + const failed = target.status === "failed"; + const statusLabel = target.status === "failed" ? "Failed" : formatRequestStatus(target.status); + return ( +
    +
    + {qualityLabel} + {instanceLabel} + {statusLabel} + {target.external_status ? {target.external_status} : null} +
    + {failed && target.last_error ? ( +

    + + {target.last_error} +

    + ) : null} +
    + ); +} + type SettingsFormState = { requests_enabled: boolean; global_max_requests: string; global_window_days: string; global_auto_approval_enabled: boolean; + force_dual_quality: boolean; updated_at: string; }; @@ -419,6 +471,7 @@ function RequestSettingsForm({ settings }: { settings: RequestSettings }) { global_max_requests: String(settings.global_max_requests), global_window_days: String(settings.global_window_days), global_auto_approval_enabled: settings.global_auto_approval_enabled, + force_dual_quality: settings.force_dual_quality, updated_at: settings.updated_at, })); @@ -428,6 +481,7 @@ function RequestSettingsForm({ settings }: { settings: RequestSettings }) { global_max_requests: Math.max(0, Number(form.global_max_requests) || 0), global_window_days: Math.max(1, Number(form.global_window_days) || 1), global_auto_approval_enabled: form.global_auto_approval_enabled, + force_dual_quality: form.force_dual_quality, updated_at: form.updated_at, }; updateSettings.mutate(payload); @@ -475,6 +529,16 @@ function RequestSettingsForm({ settings }: { settings: RequestSettings }) { } /> +
    + + setForm((current) => ({ ...current, force_dual_quality: checked })) + } + /> +
    +
    + {INTEGRATION_KINDS.map((kind) => { + const kindCards = cards.filter((card) => card.form.kind === kind); + const title = kind === "radarr" ? "Radarr" : "Sonarr"; + return ( +
    +
    +

    {title} instances

    + +
    + {kindCards.length === 0 ? ( + + ) : ( +
    + {kindCards.map((card) => ( + updateCard(card.key, patch)} + onRemove={() => removeCard(card.key)} + /> + ))} +
    + )} +
    + ); + })}
    ); } -function integrationsFormKey(integrations: RequestIntegration[]): string { - if (integrations.length === 0) return "empty"; - return integrations - .map( - (integration) => - `${integration.kind}:${integration.updated_at ?? ""}:${integration.enabled}:${integration.has_api_key}`, - ) - .join("|"); -} - function IntegrationEditor({ form, + source, onChange, + onRemove, }: { form: IntegrationFormState; + source: RequestIntegration | null; onChange: (patch: Partial) => void; + onRemove: () => void; }) { const title = form.kind === "radarr" ? "Radarr" : "Sonarr"; + const isNew = form.id === ""; + const createIntegration = useCreateRequestIntegration(); + const updateIntegration = useUpdateRequestIntegration(); + const deleteIntegration = useDeleteRequestIntegration(); + const [animeOpen, setAnimeOpen] = useState(form.anime_enabled); + const [confirmDelete, setConfirmDelete] = useState(false); + const animePanelID = useId(); + const isDirty = !isNew && source !== null && isIntegrationDirty(form, source); const loadOptions = useLoadRequestIntegrationOptions(); const [options, setOptions] = useState(null); const rootFolders = rootFolderChoices(options, form.root_folder); const qualityProfiles = qualityProfileChoices(options, form.quality_profile_id); + const animeRootFolders = rootFolderChoices(options, form.anime_root_folder); + const animeQualityProfiles = qualityProfileChoices(options, form.anime_quality_profile_id); const tags = options?.tags ?? []; const selectedTags = parseTags(form.tags); + const selectedAnimeTags = parseTags(form.anime_tags); const canLoadOptions = form.base_url.trim().length > 0 && Boolean(form.api_key_ref.trim() || form.has_api_key); async function handleLoadOptions() { try { const loaded = await loadOptions.mutateAsync({ - kind: form.kind, + id: form.id || "new", body: { + kind: form.kind, base_url: form.base_url, api_key_ref: form.api_key_ref.trim() || undefined, }, @@ -620,6 +740,21 @@ function IntegrationEditor({ } } + const saving = createIntegration.isPending || updateIntegration.isPending; + // New instances must carry an API key (there's no saved key to fall back on); + // edits may leave it blank to keep the stored key (has_api_key). + const hasApiKey = form.api_key_ref.trim().length > 0 || form.has_api_key; + const canSave = form.name.trim().length > 0 && form.base_url.trim().length > 0 && hasApiKey; + + function handleSave() { + const payload = formToIntegration(form); + if (isNew) { + createIntegration.mutate(payload); + } else { + updateIntegration.mutate(payload); + } + } + return (
    @@ -627,6 +762,8 @@ function IntegrationEditor({

    {title}

    {form.has_api_key ? Key saved : null} + {isNew ? New : null} + {isDirty ? Unsaved changes : null}
    onChange({ enabled })} />
    +
    + + onChange({ name: event.target.value })} + placeholder={`${title} instance`} + /> + + + onChange({ + is_4k, + is_default: is_4k ? false : form.is_default, + is_default_4k: is_4k ? form.is_default_4k : false, + }) + } + /> + onChange({ is_default })} + /> + onChange({ is_default_4k })} + /> +
    +
    ) : null} + +
    + + {animeOpen ? ( +
    + onChange({ anime_enabled })} + /> + {form.anime_enabled ? ( +
    + + {animeQualityProfiles.length > 0 ? ( + + ) : ( + + onChange({ anime_quality_profile_id: event.target.value }) + } + /> + )} + + + {animeRootFolders.length > 0 ? ( + + ) : ( + onChange({ anime_root_folder: event.target.value })} + placeholder="/media/anime" + /> + )} + + + onChange({ anime_tags: event.target.value })} + placeholder="1, 2" + /> + {tags.length > 0 ? ( +
    + {tags.map((tag) => { + const selected = selectedAnimeTags.includes(tag.id); + return ( + + ); + })} +
    + ) : null} +
    +
    + ) : null} +
    + ) : null} +
    + +
    + + {isNew ? ( + + ) : ( + + )} +
    + + { + if (!open) setConfirmDelete(false); + }} + > + + + Delete instance + + {`"${form.name.trim() || title}" will be permanently removed. New requests will no longer route to this instance.`} + + + + + + + +
    ); } @@ -795,8 +1143,13 @@ function integrationToForm( ): IntegrationFormState { const options = integration?.options ?? {}; return { + id: integration?.id ?? "", + name: integration?.name ?? "", kind, - enabled: integration?.enabled ?? false, + enabled: integration?.enabled ?? true, + is_4k: integration?.is_4k ?? false, + is_default: integration?.is_default ?? false, + is_default_4k: integration?.is_default_4k ?? false, base_url: integration?.base_url ?? "", api_key_ref: "", root_folder: integration?.root_folder ?? "", @@ -804,6 +1157,12 @@ function integrationToForm( ? String(integration.quality_profile_id) : "", tags: integration?.tags?.join(", ") ?? "", + anime_enabled: integration?.anime_enabled ?? false, + anime_quality_profile_id: integration?.anime_quality_profile_id + ? String(integration.anime_quality_profile_id) + : "", + anime_root_folder: integration?.anime_root_folder ?? "", + anime_tags: integration?.anime_tags?.join(", ") ?? "", search_on_add: boolOption(options, "search_on_add", true), minimum_availability: stringOption(options, "minimum_availability", "released"), series_type: stringOption(options, "series_type", "standard"), @@ -814,6 +1173,7 @@ function integrationToForm( function formToIntegration(form: IntegrationFormState): RequestIntegration { const qualityProfileID = Number(form.quality_profile_id); + const animeQualityProfileID = Number(form.anime_quality_profile_id); const options: Record = { search_on_add: form.search_on_add, }; @@ -825,18 +1185,51 @@ function formToIntegration(form: IntegrationFormState): RequestIntegration { } return { + id: form.id, + name: form.name.trim(), kind: form.kind, enabled: form.enabled, + is_4k: form.is_4k, + is_default: form.is_default, + is_default_4k: form.is_default_4k, base_url: form.base_url.trim(), api_key_ref: form.api_key_ref.trim() || undefined, root_folder: form.root_folder.trim(), quality_profile_id: Number.isFinite(qualityProfileID) && qualityProfileID > 0 ? qualityProfileID : undefined, tags: parseTags(form.tags), + anime_enabled: form.anime_enabled, + anime_quality_profile_id: + Number.isFinite(animeQualityProfileID) && animeQualityProfileID > 0 + ? animeQualityProfileID + : undefined, + anime_root_folder: form.anime_root_folder.trim() || undefined, + anime_tags: parseTags(form.anime_tags), options, }; } +function isIntegrationDirty(form: IntegrationFormState, source: RequestIntegration): boolean { + // A pending API key entry always counts as an unsaved change. + if (form.api_key_ref.trim().length > 0) return true; + + const next = formToIntegration(form); + const seeded = integrationToForm(form.kind, source); + const original = formToIntegration(seeded); + + return ( + JSON.stringify(stripIntegrationForCompare(next)) !== + JSON.stringify(stripIntegrationForCompare(original)) + ); +} + +function stripIntegrationForCompare(integration: RequestIntegration): Record { + // api_key_ref is write-only (never round-trips from the source) and is handled + // separately above, so exclude it from the structural comparison. + const { api_key_ref: _apiKeyRef, ...rest } = integration; + return rest; +} + function rootFolderChoices(options: RequestIntegrationOptions | null, currentPath: string) { const folders = options?.root_folders ?? []; if (!currentPath || folders.some((folder) => folder.path === currentPath)) { @@ -1077,15 +1470,22 @@ function SwitchField({ label, checked, onCheckedChange, + description, + disabled, }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; + description?: string; + disabled?: boolean; }) { return (
    - - +
    + + {description ?

    {description}

    : null} +
    +
    ); } From 621349a085af33727730ce93e924290ededc2ab3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:35:22 -0400 Subject: [PATCH 165/201] build(deps-dev): bump vitest from 3.2.4 to 4.1.0 in /web (#40) Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.4 to 4.1.0. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.0/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/package.json | 2 +- web/pnpm-lock.yaml | 315 +++++++++++++++++++-------------------------- 2 files changed, 131 insertions(+), 186 deletions(-) diff --git a/web/package.json b/web/package.json index f0fa6bed..1278eb2b 100644 --- a/web/package.json +++ b/web/package.json @@ -62,7 +62,7 @@ "typescript": "^5.7.0", "typescript-eslint": "^8.57.0", "vite": "^6.0.0", - "vitest": "^3.2.4" + "vitest": "^4.1.0" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 45f6b240..ba37eab4 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -150,8 +150,8 @@ importers: specifier: 6.4.2 version: 6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(jsdom@29.0.1)(lightningcss@1.32.0) + specifier: ^4.1.0 + version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1)(vite@6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)) packages: @@ -173,6 +173,10 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} @@ -215,6 +219,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -244,6 +252,10 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -1405,6 +1417,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -1569,6 +1584,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1648,11 +1666,11 @@ packages: peerDependencies: vite: 6.4.2 - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} peerDependencies: msw: ^2.4.9 vite: 6.4.2 @@ -1662,20 +1680,20 @@ packages: vite: optional: true - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} abslink@1.1.6: resolution: {integrity: sha512-8fQgnUoVSgc1IhOrYzdDY+wTDPktbuYjds2LKf9kWYWKwDnHgXU168gdV3sPei7vevFf5m2fPZ6IQSKZkSjHVg==} @@ -1750,10 +1768,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1761,18 +1775,14 @@ packages: caniuse-lite@1.0.30001779: resolution: {integrity: sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==} - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1827,10 +1837,6 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1881,8 +1887,8 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} @@ -2099,9 +2105,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -2303,9 +2306,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - lru-cache@11.3.1: resolution: {integrity: sha512-Y71HWT4hydF1IAG/2OPync4dgQ/J2iWye7eg6CuzJHI+E97tvqFPlADzxiNnjH6WSljg8ecfXMr9k6bfFuqA5w==} engines: {node: 20 || >=22} @@ -2363,6 +2363,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2393,10 +2396,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2632,8 +2631,8 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} @@ -2643,9 +2642,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2672,27 +2668,20 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tldts-core@7.0.28: @@ -2776,11 +2765,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - vite@6.4.2: resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2821,26 +2805,33 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 happy-dom: '*' jsdom: '*' + vite: 6.4.2 peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': optional: true '@vitest/ui': optional: true @@ -2932,6 +2923,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} '@babel/core@7.29.0': @@ -2994,6 +2991,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.28.6': @@ -3017,6 +3016,8 @@ snapshots: '@babel/runtime@7.29.2': {} + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -4094,6 +4095,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -4179,8 +4182,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -4243,6 +4246,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} '@types/node@25.5.0': @@ -4326,7 +4331,7 @@ snapshots: debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -4360,47 +4365,46 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/expect@3.2.4': + '@vitest/expect@4.1.0': dependencies: + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - tinyrainbow: 2.0.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0))': + '@vitest/mocker@4.1.0(vite@6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0) - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@4.1.0': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.0 - '@vitest/runner@3.2.4': + '@vitest/runner@4.1.0': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 4.1.0 pathe: 2.0.3 - strip-literal: 3.1.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@4.1.0': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.2.4': - dependencies: - tinyspy: 4.0.4 + '@vitest/spy@4.1.0': {} - '@vitest/utils@3.2.4': + '@vitest/utils@4.1.0': dependencies: - '@vitest/pretty-format': 3.2.4 - loupe: 3.2.1 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 abslink@1.1.6: {} @@ -4466,27 +4470,17 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) - cac@6.7.14: {} - callsites@3.1.0: {} caniuse-lite@1.0.30001779: {} - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 + chai@6.2.2: {} chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - check-error@2.1.3: {} - class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -4533,8 +4527,6 @@ snapshots: decimal.js@10.6.0: {} - deep-eql@5.0.2: {} - deep-is@0.1.4: {} dequal@2.0.3: {} @@ -4573,7 +4565,7 @@ snapshots: entities@6.0.1: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} esbuild@0.25.12: optionalDependencies: @@ -4697,7 +4689,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -4803,8 +4795,6 @@ snapshots: js-tokens@4.0.0: {} - js-tokens@9.0.1: {} - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -4965,8 +4955,6 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@3.2.1: {} - lru-cache@11.3.1: {} lru-cache@5.1.1: @@ -5011,6 +4999,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5042,8 +5032,6 @@ snapshots: pathe@2.0.3: {} - pathval@2.0.1: {} - picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -5266,7 +5254,7 @@ snapshots: stackback@0.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} strip-indent@3.0.0: dependencies: @@ -5274,10 +5262,6 @@ snapshots: strip-json-comments@3.1.1: {} - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -5296,23 +5280,19 @@ snapshots: tinybench@2.9.0: {} - tinyexec@0.3.2: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + tinyexec@1.2.4: {} tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@1.1.1: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - tinyrainbow@2.0.0: {} - - tinyspy@4.0.4: {} + tinyrainbow@3.1.0: {} tldts-core@7.0.28: {} @@ -5384,27 +5364,6 @@ snapshots: dependencies: react: 19.2.4 - vite-node@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite@6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.25.12 @@ -5419,47 +5378,33 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 - vitest@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(jsdom@29.0.1)(lightningcss@1.32.0): + vitest@4.1.0(@types/node@25.5.0)(jsdom@29.0.1)(vite@6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)): dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 3.10.0 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 vite: 6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0) - vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 jsdom: 29.0.1 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml w3c-xmlserializer@5.0.0: dependencies: From 54a04958b7e8e57a7de75a77f3c5ed36b1f1ecf9 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Tue, 2 Jun 2026 20:28:39 +0200 Subject: [PATCH 166/201] fix(web): vitest 4 compatible fetch spy in recipes.test (unblocks build after vitest 4.1.0 bump) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/lib/recipes.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/lib/recipes.test.ts b/web/src/lib/recipes.test.ts index c997248b..9264fb95 100644 --- a/web/src/lib/recipes.test.ts +++ b/web/src/lib/recipes.test.ts @@ -2,12 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fetchRecipeCatalog, fetchCandidates, previewSection } from "./recipes"; beforeEach(() => { - vi.spyOn(global, "fetch" as never).mockReset(); + vi.spyOn(globalThis, "fetch").mockReset(); }); describe("recipes API client", () => { it("fetchRecipeCatalog returns categories", async () => { - vi.spyOn(global, "fetch" as never).mockResolvedValue({ + vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 200, text: async () => @@ -19,7 +19,7 @@ describe("recipes API client", () => { }); it("fetchCandidates returns candidate list", async () => { - vi.spyOn(global, "fetch" as never).mockResolvedValue({ + vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 200, text: async () => @@ -33,7 +33,7 @@ describe("recipes API client", () => { }); it("previewSection POSTs body and returns items", async () => { - vi.spyOn(global, "fetch" as never).mockResolvedValue({ + vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ items: [{ content_id: "x" }], total_count: 1 }), From dc0392dee024848c45325050c9ee42f9d9f6ea8a Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:23:21 -0400 Subject: [PATCH 167/201] Add PostgreSQL auto-tuning via pgtune --- .env.example | 13 + README.md | 122 ++-- cmd/silo/main.go | 65 ++ docker-compose.yml | 7 +- internal/database/postgres_tune.go | 806 ++++++++++++++++++++++++ internal/database/postgres_tune_test.go | 173 +++++ postgres/postgresql.conf | 38 -- 7 files changed, 1104 insertions(+), 120 deletions(-) create mode 100644 internal/database/postgres_tune.go create mode 100644 internal/database/postgres_tune_test.go delete mode 100644 postgres/postgresql.conf diff --git a/.env.example b/.env.example index e62ea990..85894ae2 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,19 @@ SILO_IMAGE=ghcr.io/silo-server/silo-server:latest POSTGRES_USER=silo POSTGRES_PASSWORD=silo POSTGRES_DB=silo +POSTGRES_SHM_SIZE=8gb + +# PostgreSQL auto-tuning is enabled by default in docker-compose.yml. Silo +# applies pgtune-style OLTP recommendations through ALTER SYSTEM at startup. +# Set POSTGRES_TUNE=off if you manage PostgreSQL tuning yourself. +# POSTGRES_TUNE=off +# POSTGRES_TUNE_PROFILE=oltp +# POSTGRES_TUNE_MEMORY=auto +# POSTGRES_TUNE_MEMORY_BUDGET_PERCENT=75 +# POSTGRES_TUNE_CPUS=auto +# POSTGRES_TUNE_STORAGE=ssd +# POSTGRES_TUNE_DB_SIZE=auto +# POSTGRES_TUNE_CONNECTIONS=100 # Optional host port overrides for docker-compose.yml. # PORT=8090 diff --git a/README.md b/README.md index 892acfd4..930e532b 100644 --- a/README.md +++ b/README.md @@ -218,93 +218,57 @@ Silo requires only a `DATABASE_URL` when running from source or against external | `proxy` | Stream proxy node that connects to the shared deployment database and Redis | | `transcode` | HLS transcode worker node that connects to the shared deployment database and Redis | -## PostgreSQL Performance Tuning +## PostgreSQL Auto-Tuning -The default PostgreSQL configuration is conservative and not optimized for any particular workload. For better performance, use [PGTune](https://pgtune.leopard.in.ua/) to generate settings tailored to your hardware. - -**Recommended PGTune settings:** - -| Setting | Value | -|---|---| -| DB Type | **Web application** | -| Total Memory | Your server's RAM | -| Number of CPUs | Your server's CPU count | -| Number of Connections | 20 (Silo's default pool size) | -| Data Storage | SSD or HDD depending on your setup | - -Silo's workload is predominantly read-heavy API queries (browse, search, metadata lookups) with the database small enough to fit in RAM for most libraries. The **Web application** profile optimizes for this: CPU-bound simple queries served from cached pages, high concurrent reads, and low write volume. - -If you have a very large library (50,000+ items) or heavy activity logging, consider switching to the **OLTP** profile instead. - -### Recommended Defaults for Modern Hardware - -Assuming you're using modern hardware with fast NVMe storage this is what I'm currently running. - -```ini -# Connections -max_connections = 100 - -# Memory (tuned to keep PG under ~8GB total) -shared_buffers = 4GB # holds the entire DB in cache (typical DB is 1-4GB) -effective_cache_size = 6GB # planner hint only, not an allocation -work_mem = 32MB # per-operation sort/hash memory -maintenance_work_mem = 512MB # VACUUM, CREATE INDEX, catalog imports - -# WAL / Write Performance -wal_buffers = 64MB -min_wal_size = 512MB -max_wal_size = 2GB -checkpoint_completion_target = 0.9 - -# Storage (NVMe/Optane) -random_page_cost = 1.1 # default 4.0; NVMe random ≈ sequential -effective_io_concurrency = 200 # NVMe handles many concurrent requests - -# Parallelism -max_worker_processes = 8 -max_parallel_workers_per_gather = 4 -max_parallel_workers = 8 -max_parallel_maintenance_workers = 4 - -# Huge Pages -huge_pages = try -``` - -These settings keep PostgreSQL under ~8GB of RAM while still caching the entire database. For HDD storage, change `random_page_cost` to `4.0` and `effective_io_concurrency` to `2`. - -### Applying Settings - -**Docker (recommended):** Save the settings above to a `postgresql.conf` file and mount it into your container: +The default Docker Compose stack does not require a checked-in `postgresql.conf`. +It enables Silo's [pgtune](https://github.com/le0pard/pgtune)-style OLTP tuning +by default: ```yaml -# docker-compose.yml -postgres: - image: pgvector/pgvector:pg18 - volumes: - - ./postgresql.conf:/etc/postgresql/postgresql.conf:ro - command: postgres -c config_file=/etc/postgresql/postgresql.conf +POSTGRES_TUNE: auto ``` -Then restart: `docker compose restart postgres` +When enabled, Silo connects with `DATABASE_URL` and applies recommendations with +`ALTER SYSTEM`, which writes to PostgreSQL's `postgresql.auto.conf` inside the +database data directory. Reloadable settings are applied immediately with +`pg_reload_conf()`. Settings that PostgreSQL marks as restart-only are written +too, and Silo logs the setting names so you can restart PostgreSQL once: -**Existing PostgreSQL:** You can apply settings without editing config files using `ALTER SYSTEM`, which writes to `postgresql.auto.conf` inside the data directory: - -```sql -ALTER SYSTEM SET work_mem = '32MB'; -ALTER SYSTEM SET effective_cache_size = '6GB'; -ALTER SYSTEM SET random_page_cost = 1.1; -ALTER SYSTEM SET effective_io_concurrency = 200; --- Apply without restart: -SELECT pg_reload_conf(); - --- These require a full restart: -ALTER SYSTEM SET shared_buffers = '4GB'; -ALTER SYSTEM SET max_connections = 100; -ALTER SYSTEM SET max_worker_processes = 8; -ALTER SYSTEM SET huge_pages = 'try'; +```sh +docker compose restart postgres ``` -Settings like `shared_buffers`, `max_connections`, `max_worker_processes`, and `huge_pages` require a PostgreSQL restart. Memory/planner settings (`work_mem`, `effective_cache_size`, `random_page_cost`) can be reloaded live. +The default Compose database user has the required PostgreSQL permissions. If +you use an external PostgreSQL server, make sure the configured `DATABASE_URL` +user can run `ALTER SYSTEM`, or set `POSTGRES_TUNE=off` and manage +PostgreSQL yourself. + +For `POSTGRES_TUNE_MEMORY=auto`, Silo uses the first trustworthy memory source: +a finite Docker cgroup limit, the read-only `/host/proc/meminfo` mount supplied +by the bundled Compose file, then `/proc/meminfo` with container safety guards. +Auto-detected memory is treated as a PostgreSQL budget, defaulting to 75% of +detected RAM so Silo, Redis, plugins, transcodes, and the OS retain headroom. +`POSTGRES_TUNE_DB_SIZE=auto` queries `pg_database_size(current_database())` and +classifies the workload by comparing the database size to that memory budget. + +Optional tuning overrides: + +| Variable | Default | Description | +|---|---:|---| +| `POSTGRES_TUNE_PROFILE` | `oltp` | Tuning profile. Only `oltp` is currently supported. | +| `POSTGRES_TUNE_MEMORY` | `auto` | Server/container RAM, such as `8GB` or `32GB`; explicit values are used as-is. | +| `POSTGRES_TUNE_MEMORY_BUDGET_PERCENT` | `75` | Percent of auto-detected RAM used for PostgreSQL recommendations. | +| `POSTGRES_TUNE_CPUS` | `auto` | CPU count used for worker recommendations. | +| `POSTGRES_TUNE_STORAGE` | `ssd` | One of `hdd`, `ssd`, `san`, or `nvme`. | +| `POSTGRES_TUNE_DB_SIZE` | `auto` | Use `less_ram` when the database comfortably fits in RAM, `mid_ram`, or `greater_ram` for very large databases. | +| `POSTGRES_TUNE_CONNECTIONS` | `100` | PostgreSQL `max_connections`; automatically raised if Silo's app pool is configured higher. | +| `POSTGRES_SHM_SIZE` | `8gb` | Docker `/dev/shm` size for the bundled PostgreSQL container. | + +Advanced operators can still supply their own PostgreSQL configuration or +override these env vars. Set `POSTGRES_TUNE=off` when you do not want Silo to +change PostgreSQL server settings. Settings already written with `ALTER SYSTEM` +remain in `postgresql.auto.conf`; reset those PostgreSQL parameters if you later +move fully to a custom `postgresql.conf`. ## Project Structure diff --git a/cmd/silo/main.go b/cmd/silo/main.go index cde16fc4..c99ff364 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -184,6 +184,70 @@ func configureOperationalLogging( return operationalWriter, opslog.NewRepo(pool), opsPM } +func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxConnections int, mode string) { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "integrated", "api": + default: + return + } + + opts, err := database.LoadPostgresTuneOptionsFromEnv(appMaxConnections) + if err != nil { + slog.Warn("postgres auto-tuning disabled", "error", err) + return + } + if !opts.Enabled { + return + } + + tuneCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + result, err := database.ApplyPostgresTuning(tuneCtx, pool, opts) + for _, failure := range result.Failures { + slog.Warn("postgres auto-tuning setting failed", + "name", failure.Name, + "value", failure.Value, + "error", failure.Err, + ) + } + if err != nil { + slog.Warn("postgres auto-tuning failed", + "error", err, + "applied", result.Applied, + "failures", len(result.Failures), + ) + return + } + + slog.Info("postgres auto-tuning applied", + "profile", opts.Profile, + "postgres_major", result.PostgresMajorVersion, + "settings", result.Applied, + "resets", len(result.Reset), + "failures", len(result.Failures), + "memory_budget_bytes", opts.MemoryBudgetBytes, + "detected_memory_bytes", opts.DetectedMemoryBytes, + "memory_source", opts.MemorySource, + "memory_budget_percent", opts.MemoryBudgetPercent, + "cpus", opts.CPUs, + "connections", opts.Connections, + "storage", opts.Storage, + "db_size", result.DBSize, + "database_size_bytes", result.DatabaseSizeBytes, + ) + if len(result.RestartRequired) > 0 { + slog.Warn("postgres restart required to finish applying auto-tuned settings", + "settings", strings.Join(result.RestartRequired, ","), + ) + } + if len(result.Reset) > 0 { + slog.Info("postgres auto-tuning reset stale settings", + "settings", strings.Join(result.Reset, ","), + ) + } +} + func main() { envFile := flag.String("env", ".env", "path to .env bootstrap file") flag.Parse() @@ -314,6 +378,7 @@ func main() { slog.SetDefault(slog.New(logfilter.New(baseHandler, cfg.Server.LogQuiet))) mode := cfg.Server.Mode + maybeApplyPostgresTuning(ctx, pool, cfg.Database.MaxConnections, mode) slog.Info("silo starting", "mode", mode, "listen", cfg.Server.Listen, "log_level", cfg.Server.LogLevel, "node_id", nodeID) appCtx, appCancel := context.WithCancel(ctx) diff --git a/docker-compose.yml b/docker-compose.yml index f995e672..8eab93af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: postgres: image: pgvector/pgvector:pg18 restart: unless-stopped - shm_size: 1gb + shm_size: ${POSTGRES_SHM_SIZE:-8gb} environment: POSTGRES_USER: ${POSTGRES_USER:-silo} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-silo} @@ -11,8 +11,7 @@ services: - "5432:5432" volumes: - ${SILO_DATA_ROOT:-/opt/silo}/postgres:/var/lib/postgresql - - ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro - command: postgres -c config_file=/etc/postgresql/postgresql.conf + command: ["postgres", "-c", "listen_addresses=*"] healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-silo}"] interval: 5s @@ -40,6 +39,7 @@ services: DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable REDIS_URL: redis://redis:6379 SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins + POSTGRES_TUNE: ${POSTGRES_TUNE:-auto} ports: - "${PORT:-8090}:8080" - "${JF_PORT:-8096}:8096" @@ -48,6 +48,7 @@ services: - ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins - ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode - ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro + - /proc/meminfo:/host/proc/meminfo:ro depends_on: postgres: condition: service_healthy diff --git a/internal/database/postgres_tune.go b/internal/database/postgres_tune.go new file mode 100644 index 00000000..fa695165 --- /dev/null +++ b/internal/database/postgres_tune.go @@ -0,0 +1,806 @@ +package database + +import ( + "bufio" + "context" + "fmt" + "math" + "os" + "runtime" + "strconv" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + postgresTuneProfileOLTP = "oltp" + + postgresTuneStorageHDD = "hdd" + postgresTuneStorageSSD = "ssd" + postgresTuneStorageSAN = "san" + postgresTuneStorageNVMe = "nvme" + + postgresTuneDBSizeLessRAM = "less_ram" + postgresTuneDBSizeMidRAM = "mid_ram" + postgresTuneDBSizeGreaterRAM = "greater_ram" + postgresTuneDBSizeAuto = "auto" + + defaultPostgresTuneMemoryBudgetPercent = 75 + + sizeKB = int64(1024) + sizeMB = 1024 * sizeKB + sizeGB = 1024 * sizeMB + sizeTB = 1024 * sizeGB +) + +var managedPostgresTuneSettings = []string{ + "max_connections", + "shared_buffers", + "effective_cache_size", + "maintenance_work_mem", + "checkpoint_completion_target", + "wal_buffers", + "default_statistics_target", + "random_page_cost", + "effective_io_concurrency", + "work_mem", + "huge_pages", + "jit", + "wal_compression", + "autovacuum_max_workers", + "autovacuum_work_mem", + "min_wal_size", + "max_wal_size", + "max_worker_processes", + "max_parallel_workers_per_gather", + "max_parallel_workers", + "max_parallel_maintenance_workers", + "io_method", + "io_workers", +} + +// PostgresTuneOptions controls automatic PostgreSQL tuning through ALTER SYSTEM. +type PostgresTuneOptions struct { + Enabled bool + Profile string + MemoryBudgetBytes int64 + DetectedMemoryBytes int64 + MemorySource string + MemoryBudgetPercent int + CPUs int + Connections int + Storage string + DBSize string + OSType string + IOMethod string +} + +// PostgresTuneSetting is one recommended PostgreSQL parameter. +type PostgresTuneSetting struct { + Name string + Value string + RequiresRestart bool +} + +// PostgresTuneFailure records a setting that PostgreSQL rejected. +type PostgresTuneFailure struct { + Name string + Value string + Err error +} + +// PostgresTuneResult summarizes one tuning run. +type PostgresTuneResult struct { + PostgresMajorVersion int + DatabaseSizeBytes int64 + DBSize string + Settings []PostgresTuneSetting + Reset []string + Applied int + Failures []PostgresTuneFailure + Reloaded bool + RestartRequired []string +} + +type postgresTuneMemoryBudget struct { + BudgetBytes int64 + DetectedBytes int64 + Source string + Percent int +} + +// LoadPostgresTuneOptionsFromEnv reads the opt-in PostgreSQL tuning environment. +func LoadPostgresTuneOptionsFromEnv(appMaxConnections int) (PostgresTuneOptions, error) { + rawEnabled := firstNonEmptyEnv("POSTGRES_TUNE", "SILO_POSTGRES_TUNE") + if rawEnabled == "" { + return PostgresTuneOptions{}, nil + } + + enabled, profileFromMode, err := parsePostgresTuneMode(rawEnabled) + if err != nil { + return PostgresTuneOptions{}, err + } + if !enabled { + return PostgresTuneOptions{}, nil + } + + profile := firstNonEmptyEnv("POSTGRES_TUNE_PROFILE", "SILO_POSTGRES_TUNE_PROFILE") + if profile == "" { + profile = profileFromMode + } + if profile == "" { + profile = postgresTuneProfileOLTP + } + profile = strings.ToLower(profile) + if profile != postgresTuneProfileOLTP { + return PostgresTuneOptions{}, fmt.Errorf("unsupported POSTGRES_TUNE_PROFILE %q; only %q is supported", profile, postgresTuneProfileOLTP) + } + + memory, err := loadPostgresTuneMemory() + if err != nil { + return PostgresTuneOptions{}, err + } + + cpus, err := loadPostgresTuneCPUs() + if err != nil { + return PostgresTuneOptions{}, err + } + + connections, err := loadPostgresTuneConnections(appMaxConnections) + if err != nil { + return PostgresTuneOptions{}, err + } + + storage, err := normalizePostgresTuneStorage(firstNonEmptyEnv("POSTGRES_TUNE_STORAGE", "SILO_POSTGRES_TUNE_STORAGE")) + if err != nil { + return PostgresTuneOptions{}, err + } + + dbSize, err := normalizePostgresTuneDBSize(firstNonEmptyEnv("POSTGRES_TUNE_DB_SIZE", "SILO_POSTGRES_TUNE_DB_SIZE")) + if err != nil { + return PostgresTuneOptions{}, err + } + + osType := strings.ToLower(firstNonEmptyEnv("POSTGRES_TUNE_OS", "SILO_POSTGRES_TUNE_OS")) + if osType == "" { + osType = "linux" + } + + ioMethod := strings.ToLower(firstNonEmptyEnv("POSTGRES_TUNE_IO_METHOD", "SILO_POSTGRES_TUNE_IO_METHOD")) + + return PostgresTuneOptions{ + Enabled: true, + Profile: profile, + MemoryBudgetBytes: memory.BudgetBytes, + DetectedMemoryBytes: memory.DetectedBytes, + MemorySource: memory.Source, + MemoryBudgetPercent: memory.Percent, + CPUs: cpus, + Connections: connections, + Storage: storage, + DBSize: dbSize, + OSType: osType, + IOMethod: ioMethod, + }, nil +} + +// RecommendPostgresOLTPSettings returns pgtune-style OLTP recommendations. +func RecommendPostgresOLTPSettings(opts PostgresTuneOptions, postgresMajorVersion int) []PostgresTuneSetting { + memoryBudgetKB := opts.MemoryBudgetBytes / sizeKB + if memoryBudgetKB <= 0 { + return nil + } + + connections := opts.Connections + if connections <= 0 { + connections = 100 + } + + settings := make([]PostgresTuneSetting, 0, 24) + sharedBuffers := memoryBudgetKB / 4 + effectiveCacheSize := (memoryBudgetKB * 3) / 4 + maintenanceWorkMem := memoryBudgetKB / 16 + if maintenanceWorkMem > 8*sizeGB/sizeKB { + maintenanceWorkMem = 8 * sizeGB / sizeKB + } + + settings = append(settings, + PostgresTuneSetting{Name: "max_connections", Value: strconv.Itoa(connections)}, + PostgresTuneSetting{Name: "shared_buffers", Value: formatPostgresTuneKB(sharedBuffers)}, + PostgresTuneSetting{Name: "effective_cache_size", Value: formatPostgresTuneKB(effectiveCacheSize)}, + PostgresTuneSetting{Name: "maintenance_work_mem", Value: formatPostgresTuneKB(maintenanceWorkMem)}, + PostgresTuneSetting{Name: "checkpoint_completion_target", Value: "0.9"}, + PostgresTuneSetting{Name: "wal_buffers", Value: formatPostgresTuneKB(walBuffersForSharedBuffers(sharedBuffers))}, + PostgresTuneSetting{Name: "default_statistics_target", Value: "100"}, + PostgresTuneSetting{Name: "random_page_cost", Value: randomPageCost(opts.Storage, opts.DBSize)}, + ) + + if effectiveIO := effectiveIOConcurrency(opts.OSType, opts.Storage); effectiveIO != "" { + settings = append(settings, PostgresTuneSetting{Name: "effective_io_concurrency", Value: effectiveIO}) + } + + settings = append(settings, + PostgresTuneSetting{Name: "work_mem", Value: formatPostgresTuneKB(workMem(memoryBudgetKB, sharedBuffers, connections, opts.CPUs, opts.DBSize))}, + PostgresTuneSetting{Name: "huge_pages", Value: hugePages(sharedBuffers, opts.OSType)}, + ) + + if postgresMajorVersion >= 12 { + settings = append(settings, PostgresTuneSetting{Name: "jit", Value: "off"}) + } + if postgresMajorVersion >= 15 { + settings = append(settings, PostgresTuneSetting{Name: "wal_compression", Value: "lz4"}) + } else if postgresMajorVersion >= 10 { + settings = append(settings, PostgresTuneSetting{Name: "wal_compression", Value: "on"}) + } + + if autovacuumWorkers := autovacuumMaxWorkers(opts.CPUs); autovacuumWorkers != "" { + settings = append(settings, PostgresTuneSetting{Name: "autovacuum_max_workers", Value: autovacuumWorkers}) + } + if maintenanceWorkMem >= 2*sizeGB/sizeKB { + settings = append(settings, PostgresTuneSetting{Name: "autovacuum_work_mem", Value: "2GB"}) + } + + settings = append(settings, + PostgresTuneSetting{Name: "min_wal_size", Value: "2GB"}, + PostgresTuneSetting{Name: "max_wal_size", Value: "8GB"}, + ) + settings = append(settings, parallelWorkerSettings(opts.CPUs, postgresMajorVersion)...) + + if postgresMajorVersion >= 18 { + if ioWorkers := ioWorkers(opts.CPUs); ioWorkers != "" { + settings = append(settings, PostgresTuneSetting{Name: "io_workers", Value: ioWorkers}) + } + if opts.IOMethod != "" && opts.IOMethod != "auto" { + settings = append(settings, PostgresTuneSetting{Name: "io_method", Value: opts.IOMethod}) + } + } + + return settings +} + +// ApplyPostgresTuning applies pgtune-style OLTP recommendations with ALTER SYSTEM. +func ApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, opts PostgresTuneOptions) (*PostgresTuneResult, error) { + result := &PostgresTuneResult{} + if !opts.Enabled { + return result, nil + } + + if opts.DBSize == postgresTuneDBSizeAuto { + databaseSize, err := currentDatabaseSize(ctx, pool) + if err != nil { + return result, err + } + opts.DBSize = classifyPostgresTuneDBSize(databaseSize, opts.MemoryBudgetBytes) + result.DatabaseSizeBytes = databaseSize + } + result.DBSize = opts.DBSize + + majorVersion, err := postgresMajorVersion(ctx, pool) + if err != nil { + return result, err + } + result.PostgresMajorVersion = majorVersion + + settings := RecommendPostgresOLTPSettings(opts, majorVersion) + recommended := make(map[string]struct{}, len(settings)) + for _, setting := range settings { + recommended[setting.Name] = struct{}{} + } + + for _, name := range managedPostgresTuneSettings { + if _, ok := recommended[name]; ok { + continue + } + exists, err := postgresSettingExists(ctx, pool, name) + if err != nil || !exists { + continue + } + requiresRestart, _ := postgresSettingRequiresRestart(ctx, pool, name) + if _, err := pool.Exec(ctx, fmt.Sprintf("ALTER SYSTEM RESET %s", name)); err != nil { + result.Failures = append(result.Failures, PostgresTuneFailure{Name: name, Err: err}) + continue + } + result.Reset = append(result.Reset, name) + result.Applied++ + if requiresRestart { + result.RestartRequired = append(result.RestartRequired, name) + } + } + + for _, setting := range settings { + requiresRestart, _ := postgresSettingRequiresRestart(ctx, pool, setting.Name) + setting.RequiresRestart = requiresRestart + result.Settings = append(result.Settings, setting) + + if _, err := pool.Exec(ctx, fmt.Sprintf("ALTER SYSTEM SET %s = %s", setting.Name, quotePostgresLiteral(setting.Value))); err != nil { + result.Failures = append(result.Failures, PostgresTuneFailure{Name: setting.Name, Value: setting.Value, Err: err}) + continue + } + + result.Applied++ + if requiresRestart { + result.RestartRequired = append(result.RestartRequired, setting.Name) + } + } + + if result.Applied == 0 { + return result, nil + } + + if err := pool.QueryRow(ctx, "SELECT pg_reload_conf()").Scan(&result.Reloaded); err != nil { + return result, err + } + if !result.Reloaded { + return result, fmt.Errorf("pg_reload_conf returned false") + } + + return result, nil +} + +func parsePostgresTuneMode(raw string) (bool, string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "on", "auto": + return true, postgresTuneProfileOLTP, nil + case postgresTuneProfileOLTP: + return true, postgresTuneProfileOLTP, nil + case "0", "false", "no", "off", "none", "disabled": + return false, "", nil + default: + return false, "", fmt.Errorf("invalid POSTGRES_TUNE value %q", raw) + } +} + +func loadPostgresTuneMemory() (postgresTuneMemoryBudget, error) { + raw := firstNonEmptyEnv("POSTGRES_TUNE_MEMORY", "SILO_POSTGRES_TUNE_MEMORY") + if raw == "" || strings.EqualFold(raw, "auto") { + mem, source, err := detectTotalMemoryBytes() + if err != nil { + return postgresTuneMemoryBudget{}, fmt.Errorf("POSTGRES_TUNE_MEMORY=auto: %w", err) + } + budgetPercent, err := loadPostgresTuneMemoryBudgetPercent() + if err != nil { + return postgresTuneMemoryBudget{}, err + } + budget := (mem * int64(budgetPercent)) / 100 + return postgresTuneMemoryBudget{ + BudgetBytes: budget, + DetectedBytes: mem, + Source: source, + Percent: budgetPercent, + }, nil + } + + mem, err := parsePostgresTuneByteSize(raw) + if err != nil { + return postgresTuneMemoryBudget{}, fmt.Errorf("invalid POSTGRES_TUNE_MEMORY: %w", err) + } + return postgresTuneMemoryBudget{ + BudgetBytes: mem, + DetectedBytes: mem, + Source: "env", + Percent: 100, + }, nil +} + +func loadPostgresTuneMemoryBudgetPercent() (int, error) { + raw := firstNonEmptyEnv("POSTGRES_TUNE_MEMORY_BUDGET_PERCENT", "SILO_POSTGRES_TUNE_MEMORY_BUDGET_PERCENT") + if raw == "" { + return defaultPostgresTuneMemoryBudgetPercent, nil + } + percent, err := strconv.Atoi(raw) + if err != nil || percent < 1 || percent > 100 { + return 0, fmt.Errorf("invalid POSTGRES_TUNE_MEMORY_BUDGET_PERCENT %q", raw) + } + return percent, nil +} + +func loadPostgresTuneCPUs() (int, error) { + raw := firstNonEmptyEnv("POSTGRES_TUNE_CPUS", "SILO_POSTGRES_TUNE_CPUS") + if raw == "" || strings.EqualFold(raw, "auto") { + cpus := runtime.NumCPU() + if cpus < 1 { + cpus = 1 + } + return cpus, nil + } + + cpus, err := strconv.Atoi(raw) + if err != nil || cpus < 1 { + return 0, fmt.Errorf("invalid POSTGRES_TUNE_CPUS %q", raw) + } + return cpus, nil +} + +func loadPostgresTuneConnections(appMaxConnections int) (int, error) { + minConnections := 1 + if appMaxConnections > 0 { + minConnections = appMaxConnections + 20 + } + + raw := firstNonEmptyEnv("POSTGRES_TUNE_CONNECTIONS", "SILO_POSTGRES_TUNE_CONNECTIONS") + if raw != "" { + connections, err := strconv.Atoi(raw) + if err != nil || connections < 1 { + return 0, fmt.Errorf("invalid POSTGRES_TUNE_CONNECTIONS %q", raw) + } + if connections < minConnections { + connections = minConnections + } + return connections, nil + } + + connections := 100 + if minConnections > connections { + connections = minConnections + } + return connections, nil +} + +func normalizePostgresTuneStorage(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "ssd": + return postgresTuneStorageSSD, nil + case "hdd", "hard_drive_hdd": + return postgresTuneStorageHDD, nil + case "san": + return postgresTuneStorageSAN, nil + case "nvme", "optane": + return postgresTuneStorageNVMe, nil + default: + return "", fmt.Errorf("invalid POSTGRES_TUNE_STORAGE %q", raw) + } +} + +func normalizePostgresTuneDBSize(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "auto": + return postgresTuneDBSizeAuto, nil + case "mid", "medium", "mid_ram": + return postgresTuneDBSizeMidRAM, nil + case "less", "less_ram", "fits_ram", "fits-in-ram": + return postgresTuneDBSizeLessRAM, nil + case "greater", "greater_ram", "larger_than_ram": + return postgresTuneDBSizeGreaterRAM, nil + default: + return "", fmt.Errorf("invalid POSTGRES_TUNE_DB_SIZE %q", raw) + } +} + +func detectTotalMemoryBytes() (int64, string, error) { + for _, path := range []string{ + "/sys/fs/cgroup/memory.max", + "/sys/fs/cgroup/memory/memory.limit_in_bytes", + } { + if mem, err := readCgroupMemoryLimit(path); err == nil && mem > 0 { + return mem, path, nil + } + } + + if mem, err := readMeminfoTotalBytes("/host/proc/meminfo"); err == nil && mem > 0 { + return mem, "/host/proc/meminfo", nil + } + + mem, err := readMeminfoTotalBytes("/proc/meminfo") + if err != nil { + return 0, "", err + } + if runningInContainer() && mem > 128*sizeGB { + return 0, "", fmt.Errorf("/proc/meminfo reports %s inside a container without a finite cgroup memory limit or /host/proc/meminfo mount; set POSTGRES_TUNE_MEMORY explicitly or mount /proc/meminfo:/host/proc/meminfo:ro", formatByteSize(mem)) + } + return mem, "/proc/meminfo", nil +} + +func readMeminfoTotalBytes(path string) (int64, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "MemTotal:") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + return 0, fmt.Errorf("malformed MemTotal line") + } + kb, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return 0, err + } + return kb * sizeKB, nil + } + if err := scanner.Err(); err != nil { + return 0, err + } + return 0, fmt.Errorf("MemTotal not found") +} + +func runningInContainer() bool { + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + raw, err := os.ReadFile("/proc/1/cgroup") + return err == nil && (strings.Contains(string(raw), "docker") || strings.Contains(string(raw), "kubepods")) +} + +func readCgroupMemoryLimit(path string) (int64, error) { + raw, err := os.ReadFile(path) + if err != nil { + return 0, err + } + value := strings.TrimSpace(string(raw)) + if value == "" || value == "max" { + return 0, fmt.Errorf("no cgroup memory limit") + } + mem, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, err + } + // Docker may expose a huge sentinel when no concrete memory limit is set. + if mem <= 0 || mem > 1<<60 { + return 0, fmt.Errorf("no concrete cgroup memory limit") + } + return mem, nil +} + +func parsePostgresTuneByteSize(raw string) (int64, error) { + normalized := strings.ToUpper(strings.TrimSpace(raw)) + normalized = strings.ReplaceAll(normalized, " ", "") + if normalized == "" { + return 0, fmt.Errorf("empty size") + } + + units := []struct { + suffix string + value int64 + }{ + {"TIB", sizeTB}, + {"TB", sizeTB}, + {"GIB", sizeGB}, + {"GB", sizeGB}, + {"MIB", sizeMB}, + {"MB", sizeMB}, + {"KIB", sizeKB}, + {"KB", sizeKB}, + {"T", sizeTB}, + {"G", sizeGB}, + {"M", sizeMB}, + {"K", sizeKB}, + {"B", 1}, + } + + for _, unit := range units { + if !strings.HasSuffix(normalized, unit.suffix) { + continue + } + number := strings.TrimSuffix(normalized, unit.suffix) + if number == "" { + return 0, fmt.Errorf("missing numeric size in %q", raw) + } + parsed, err := strconv.ParseFloat(number, 64) + if err != nil { + return 0, err + } + if parsed <= 0 { + return 0, fmt.Errorf("size must be positive") + } + return int64(parsed * float64(unit.value)), nil + } + + return 0, fmt.Errorf("size %q must include a unit such as GB or MB", raw) +} + +func formatPostgresTuneKB(kb int64) string { + if kb%(sizeGB/sizeKB) == 0 { + return fmt.Sprintf("%dGB", kb/(sizeGB/sizeKB)) + } + if kb%(sizeMB/sizeKB) == 0 { + return fmt.Sprintf("%dMB", kb/(sizeMB/sizeKB)) + } + return fmt.Sprintf("%dkB", kb) +} + +func formatByteSize(bytes int64) string { + switch { + case bytes%sizeTB == 0: + return fmt.Sprintf("%dTB", bytes/sizeTB) + case bytes%sizeGB == 0: + return fmt.Sprintf("%dGB", bytes/sizeGB) + case bytes%sizeMB == 0: + return fmt.Sprintf("%dMB", bytes/sizeMB) + default: + return fmt.Sprintf("%d bytes", bytes) + } +} + +func walBuffersForSharedBuffers(sharedBuffersKB int64) int64 { + walBuffers := (sharedBuffersKB * 3) / 100 + maxWalBuffers := 16 * sizeMB / sizeKB + if walBuffers > maxWalBuffers { + walBuffers = maxWalBuffers + } + if walBuffers > 14*sizeMB/sizeKB && walBuffers < maxWalBuffers { + walBuffers = maxWalBuffers + } + if walBuffers < 32 { + walBuffers = 32 + } + return walBuffers +} + +func randomPageCost(storage, dbSize string) string { + if dbSize == postgresTuneDBSizeLessRAM { + return "1.1" + } + if storage == postgresTuneStorageHDD { + return "4" + } + return "1.1" +} + +func effectiveIOConcurrency(osType, storage string) string { + if osType != "linux" { + return "" + } + switch storage { + case postgresTuneStorageHDD: + return "2" + case postgresTuneStorageSSD: + return "200" + case postgresTuneStorageSAN: + return "300" + case postgresTuneStorageNVMe: + return "1000" + default: + return "" + } +} + +func workMem(memoryBudgetKB, sharedBuffersKB int64, connections, cpus int, dbSize string) int64 { + parallelForWorkMem := cpus + if parallelForWorkMem < 1 { + parallelForWorkMem = 8 + } + + workMem := (memoryBudgetKB - sharedBuffersKB) / int64((connections+parallelForWorkMem)*3) + switch dbSize { + case postgresTuneDBSizeLessRAM: + workMem = int64(math.Floor(float64(workMem) * 1.3)) + case postgresTuneDBSizeGreaterRAM: + workMem = int64(math.Floor(float64(workMem) * 0.9)) + } + + minWorkMem := 4 * sizeMB / sizeKB + if workMem < minWorkMem { + return minWorkMem + } + return workMem +} + +func currentDatabaseSize(ctx context.Context, pool *pgxpool.Pool) (int64, error) { + var size int64 + if err := pool.QueryRow(ctx, "SELECT pg_database_size(current_database())").Scan(&size); err != nil { + return 0, err + } + return size, nil +} + +func classifyPostgresTuneDBSize(databaseSizeBytes, memoryBudgetBytes int64) string { + if databaseSizeBytes <= 0 || memoryBudgetBytes <= 0 { + return postgresTuneDBSizeMidRAM + } + if databaseSizeBytes <= memoryBudgetBytes/2 { + return postgresTuneDBSizeLessRAM + } + if databaseSizeBytes > memoryBudgetBytes { + return postgresTuneDBSizeGreaterRAM + } + return postgresTuneDBSizeMidRAM +} + +func hugePages(sharedBuffersKB int64, osType string) string { + if osType == "mac" || osType == "darwin" { + return "off" + } + if sharedBuffersKB >= 2*sizeGB/sizeKB { + return "try" + } + return "off" +} + +func autovacuumMaxWorkers(cpus int) string { + if cpus >= 32 { + return "5" + } + if cpus >= 16 { + return "4" + } + return "" +} + +func parallelWorkerSettings(cpus, postgresMajorVersion int) []PostgresTuneSetting { + if cpus < 4 { + return nil + } + + workersPerGather := int(math.Ceil(float64(cpus) / 2)) + if workersPerGather > 4 { + workersPerGather = 4 + } + + settings := []PostgresTuneSetting{ + {Name: "max_worker_processes", Value: strconv.Itoa(cpus)}, + {Name: "max_parallel_workers_per_gather", Value: strconv.Itoa(workersPerGather)}, + } + if postgresMajorVersion >= 10 { + settings = append(settings, PostgresTuneSetting{Name: "max_parallel_workers", Value: strconv.Itoa(cpus)}) + } + if postgresMajorVersion >= 11 { + parallelMaintenanceWorkers := int(math.Ceil(float64(cpus) / 2)) + if parallelMaintenanceWorkers > 4 { + parallelMaintenanceWorkers = 4 + } + settings = append(settings, PostgresTuneSetting{Name: "max_parallel_maintenance_workers", Value: strconv.Itoa(parallelMaintenanceWorkers)}) + } + return settings +} + +func ioWorkers(cpus int) string { + if cpus < 1 { + return "" + } + workers := cpus / 4 + if workers < 3 { + workers = 3 + } + if workers > 32 { + workers = 32 + } + if workers <= 3 { + return "" + } + return strconv.Itoa(workers) +} + +func postgresMajorVersion(ctx context.Context, pool *pgxpool.Pool) (int, error) { + var raw string + if err := pool.QueryRow(ctx, "SHOW server_version_num").Scan(&raw); err != nil { + return 0, err + } + versionNum, err := strconv.Atoi(raw) + if err != nil { + return 0, err + } + return versionNum / 10000, nil +} + +func postgresSettingRequiresRestart(ctx context.Context, pool *pgxpool.Pool, name string) (bool, error) { + var requiresRestart bool + err := pool.QueryRow(ctx, "SELECT context = 'postmaster' FROM pg_settings WHERE name = $1", name).Scan(&requiresRestart) + return requiresRestart, err +} + +func postgresSettingExists(ctx context.Context, pool *pgxpool.Pool, name string) (bool, error) { + var exists bool + err := pool.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM pg_settings WHERE name = $1)", name).Scan(&exists) + return exists, err +} + +func quotePostgresLiteral(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func firstNonEmptyEnv(keys ...string) string { + for _, key := range keys { + value := strings.TrimSpace(os.Getenv(key)) + if value != "" { + return value + } + } + return "" +} diff --git a/internal/database/postgres_tune_test.go b/internal/database/postgres_tune_test.go new file mode 100644 index 00000000..4e1cfd4a --- /dev/null +++ b/internal/database/postgres_tune_test.go @@ -0,0 +1,173 @@ +package database + +import "testing" + +func TestRecommendPostgresOLTPSettings(t *testing.T) { + opts := PostgresTuneOptions{ + Enabled: true, + Profile: postgresTuneProfileOLTP, + MemoryBudgetBytes: 16 * sizeGB, + CPUs: 8, + Connections: 100, + Storage: postgresTuneStorageNVMe, + DBSize: postgresTuneDBSizeMidRAM, + OSType: "linux", + } + + settings := RecommendPostgresOLTPSettings(opts, 18) + byName := map[string]string{} + for _, setting := range settings { + byName[setting.Name] = setting.Value + } + + expected := map[string]string{ + "max_connections": "100", + "shared_buffers": "4GB", + "effective_cache_size": "12GB", + "maintenance_work_mem": "1GB", + "wal_buffers": "16MB", + "min_wal_size": "2GB", + "max_wal_size": "8GB", + "random_page_cost": "1.1", + "effective_io_concurrency": "1000", + "work_mem": "38836kB", + "huge_pages": "try", + "jit": "off", + "wal_compression": "lz4", + "max_worker_processes": "8", + "max_parallel_workers_per_gather": "4", + "max_parallel_workers": "8", + "max_parallel_maintenance_workers": "4", + } + + for key, value := range expected { + if byName[key] != value { + t.Fatalf("%s = %q, want %q", key, byName[key], value) + } + } + + if _, ok := byName["io_method"]; ok { + t.Fatal("io_method should only be emitted when explicitly configured") + } +} + +func TestLoadPostgresTuneOptionsFromEnv(t *testing.T) { + t.Setenv("POSTGRES_TUNE", "auto") + t.Setenv("POSTGRES_TUNE_MEMORY", "8GB") + t.Setenv("POSTGRES_TUNE_CPUS", "4") + t.Setenv("POSTGRES_TUNE_STORAGE", "hdd") + t.Setenv("POSTGRES_TUNE_DB_SIZE", "greater_ram") + + opts, err := LoadPostgresTuneOptionsFromEnv(20) + if err != nil { + t.Fatal(err) + } + + if !opts.Enabled { + t.Fatal("expected tuning to be enabled") + } + if opts.MemoryBudgetBytes != 8*sizeGB { + t.Fatalf("MemoryBudgetBytes = %d, want %d", opts.MemoryBudgetBytes, 8*sizeGB) + } + if opts.DetectedMemoryBytes != 8*sizeGB { + t.Fatalf("DetectedMemoryBytes = %d, want %d", opts.DetectedMemoryBytes, 8*sizeGB) + } + if opts.MemorySource != "env" { + t.Fatalf("MemorySource = %q, want env", opts.MemorySource) + } + if opts.MemoryBudgetPercent != 100 { + t.Fatalf("MemoryBudgetPercent = %d, want 100", opts.MemoryBudgetPercent) + } + if opts.CPUs != 4 { + t.Fatalf("CPUs = %d, want 4", opts.CPUs) + } + if opts.Connections != 100 { + t.Fatalf("Connections = %d, want 100", opts.Connections) + } + if opts.Storage != postgresTuneStorageHDD { + t.Fatalf("Storage = %q, want %q", opts.Storage, postgresTuneStorageHDD) + } + if opts.DBSize != postgresTuneDBSizeGreaterRAM { + t.Fatalf("DBSize = %q, want %q", opts.DBSize, postgresTuneDBSizeGreaterRAM) + } +} + +func TestLoadPostgresTuneOptionsUsesApplicationPoolForHighConnectionCounts(t *testing.T) { + t.Setenv("POSTGRES_TUNE", "oltp") + t.Setenv("POSTGRES_TUNE_MEMORY", "8GB") + t.Setenv("POSTGRES_TUNE_CPUS", "4") + + opts, err := LoadPostgresTuneOptionsFromEnv(120) + if err != nil { + t.Fatal(err) + } + if opts.Connections != 140 { + t.Fatalf("Connections = %d, want 140", opts.Connections) + } +} + +func TestLoadPostgresTuneOptionsFloorsExplicitConnectionsAtApplicationPoolHeadroom(t *testing.T) { + t.Setenv("POSTGRES_TUNE", "oltp") + t.Setenv("POSTGRES_TUNE_MEMORY", "8GB") + t.Setenv("POSTGRES_TUNE_CPUS", "4") + t.Setenv("POSTGRES_TUNE_CONNECTIONS", "80") + + opts, err := LoadPostgresTuneOptionsFromEnv(90) + if err != nil { + t.Fatal(err) + } + if opts.Connections != 110 { + t.Fatalf("Connections = %d, want 110", opts.Connections) + } +} + +func TestPostgresTuneDisabledByDefault(t *testing.T) { + opts, err := LoadPostgresTuneOptionsFromEnv(20) + if err != nil { + t.Fatal(err) + } + if opts.Enabled { + t.Fatal("expected tuning to be disabled by default") + } +} + +func TestPostgresTuneDBSizeDefaultsToAuto(t *testing.T) { + t.Setenv("POSTGRES_TUNE", "oltp") + t.Setenv("POSTGRES_TUNE_MEMORY", "8GB") + t.Setenv("POSTGRES_TUNE_CPUS", "4") + + opts, err := LoadPostgresTuneOptionsFromEnv(20) + if err != nil { + t.Fatal(err) + } + if opts.DBSize != postgresTuneDBSizeAuto { + t.Fatalf("DBSize = %q, want %q", opts.DBSize, postgresTuneDBSizeAuto) + } +} + +func TestClassifyPostgresTuneDBSize(t *testing.T) { + tests := []struct { + name string + dbSize int64 + memBudget int64 + wantDBSize string + }{ + {name: "comfortably fits", dbSize: 8 * sizeGB, memBudget: 24 * sizeGB, wantDBSize: postgresTuneDBSizeLessRAM}, + {name: "fits without headroom", dbSize: 18 * sizeGB, memBudget: 24 * sizeGB, wantDBSize: postgresTuneDBSizeMidRAM}, + {name: "larger than budget", dbSize: 35 * sizeGB, memBudget: 24 * sizeGB, wantDBSize: postgresTuneDBSizeGreaterRAM}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyPostgresTuneDBSize(tt.dbSize, tt.memBudget); got != tt.wantDBSize { + t.Fatalf("classifyPostgresTuneDBSize() = %q, want %q", got, tt.wantDBSize) + } + }) + } +} + +func TestParsePostgresTuneByteSizeRequiresUnit(t *testing.T) { + if _, err := parsePostgresTuneByteSize("16"); err == nil { + t.Fatal("expected bare memory value to be rejected") + } +} diff --git a/postgres/postgresql.conf b/postgres/postgresql.conf deleted file mode 100644 index 0dddf1d9..00000000 --- a/postgres/postgresql.conf +++ /dev/null @@ -1,38 +0,0 @@ -# Silo PostgreSQL Performance Configuration -# Tuned for: modern multi-core CPU, NVMe/Optane storage, ~8GB max RAM usage -# DB Type: Web application (read-heavy, DB fits in RAM) -# -# Adjust values for your hardware, or use https://pgtune.leopard.in.ua/ -# with DB Type "Web application" to generate settings. - -# Connections -listen_addresses = '*' -max_connections = 100 - -# Memory (tuned to keep PG under ~8GB total) -shared_buffers = 4GB # holds the entire DB in cache (typical DB is 1-4GB) -effective_cache_size = 6GB # planner hint only, not an allocation -work_mem = 32MB # per-operation sort/hash memory -maintenance_work_mem = 512MB # VACUUM, CREATE INDEX, catalog imports - -# WAL / Write Performance -wal_buffers = 64MB -min_wal_size = 512MB -max_wal_size = 2GB -checkpoint_completion_target = 0.9 - -# Storage (NVMe/Optane) -random_page_cost = 1.1 # default 4.0; NVMe random ≈ sequential -effective_io_concurrency = 200 # NVMe handles many concurrent requests - -# Parallelism -max_worker_processes = 8 -max_parallel_workers_per_gather = 4 -max_parallel_workers = 8 -max_parallel_maintenance_workers = 4 - -# Huge Pages (Linux only, no effect on macOS) -huge_pages = try - -# Logging (uncomment to log slow queries) -# log_min_duration_statement = 250 From 08a0704a11d1801f899cd3eab609e99fb121243a Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:22:42 -0400 Subject: [PATCH 168/201] fix(jellycompat): fill large browse pages --- internal/catalog/browse.go | 146 +++++------------- internal/catalog/browse_orderby_test.go | 23 +++ internal/catalog/window_count_test.go | 37 ++++- internal/jellycompat/content_direct.go | 50 ++++-- internal/jellycompat/content_direct_test.go | 113 +++++++++++++- internal/jellycompat/query.go | 1 + internal/jellycompat/query_test.go | 11 ++ ...174_movie_release_date_desc_index.down.sql | 1 + .../174_movie_release_date_desc_index.up.sql | 3 + 9 files changed, 260 insertions(+), 125 deletions(-) create mode 100644 migrations/174_movie_release_date_desc_index.down.sql create mode 100644 migrations/174_movie_release_date_desc_index.up.sql diff --git a/internal/catalog/browse.go b/internal/catalog/browse.go index b5407114..24ae0402 100644 --- a/internal/catalog/browse.go +++ b/internal/catalog/browse.go @@ -33,6 +33,7 @@ type BrowseFilters struct { Sort string // created_at, release_date, rating_imdb, rating_tmdb, year, sort_title, recently_added Order string // asc, desc Limit int + MaxLimit int // optional caller-specific cap; zero keeps the default browse cap Offset int SnapshotAt *time.Time // pagination fence: exclude items created after this timestamp } @@ -77,7 +78,7 @@ func (r *BrowseRepository) browse(ctx context.Context, filters BrowseFilters, in return &BrowseResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false}, nil } - dataQuery, queryArgs := plan.pagedSQL(includeTotal) + dataQuery, queryArgs := plan.pagedSQL(false) rows, err := r.pool.Query(ctx, dataQuery, queryArgs...) if err != nil { return nil, fmt.Errorf("browsing media items: %w", err) @@ -88,32 +89,31 @@ func (r *BrowseRepository) browse(ctx context.Context, filters BrowseFilters, in items []*models.MediaItem total int ) - if includeTotal { - items, total, err = scanBrowseItemsWithTotal(rows) - } else { - items, err = scanBrowseItems(rows) - } + items, err = scanBrowseItems(rows) if err != nil { return nil, err } hasMore := false + hasExtraRow := len(items) > plan.limit + if hasExtraRow { + items = items[:plan.limit] + } if includeTotal { - // COUNT(*) OVER () emits no rows when the data SELECT is empty, so - // total stays 0 even when the broader result set has matching rows - // (e.g. OFFSET past the last page). Re-query the count to give - // callers the real total. Skip when offset == 0 because in that - // case an empty page genuinely means total = 0. - if len(items) == 0 && plan.offset > 0 { + switch { + case plan.offset == 0 && len(items) == 0: + total = 0 + case !hasExtraRow && len(items) > 0: + total = plan.offset + len(items) + default: countSQL, countArgs := plan.countSQL() if err := r.pool.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil { - return nil, fmt.Errorf("count fallback for empty browse page: %w", err) + return nil, fmt.Errorf("count browse page: %w", err) } } hasMore = total > plan.offset+plan.limit - } else if len(items) > plan.limit { + } else if hasExtraRow { hasMore = true - items = items[:plan.limit] } return &BrowseResult{ @@ -151,12 +151,9 @@ type browseQueryPlan struct { // countSQL renders a count-only query that returns the total number of rows // matching the plan's FROM/WHERE/GROUP BY, ignoring LIMIT/OFFSET/ORDER BY. -// Used as a fallback when pagedSQL(true) returned an empty page past offset 0: -// COUNT(*) OVER () emits no rows when the data SELECT is empty, so the -// caller would otherwise see total=0 even when the broader result set has -// matching rows. Wraps the inner query in `SELECT COUNT(*) FROM (...) sub` -// so any GROUP BY in the inner query is preserved (we count groups, matching -// what COUNT(*) OVER () would compute). +// Used by exact-total callers so the data SELECT can remain a pure page query. +// Wraps the inner query in `SELECT COUNT(*) FROM (...) sub` so any GROUP BY in +// the inner query is preserved. // // Binds only p.args (FROM/WHERE/GROUP BY values). orderArgs are deliberately // excluded — the count SQL has no ORDER BY clause to reference them, and @@ -172,16 +169,12 @@ func (p browseQueryPlan) countSQL() (string, []any) { } // pagedSQL renders the final paged SELECT and returns it together with the -// fully-bound arg list. When includeTotal is true the SELECT list is appended -// with COUNT(*) OVER () AS total_count so the caller can read the total from -// the first scanned row in a single round trip. When includeTotal is false the -// caller has already requested a single-pass page, so we ask for one extra row -// to detect whether more pages exist without an exact count. -func (p browseQueryPlan) pagedSQL(includeTotal bool) (string, []any) { +// fully-bound arg list. The query always asks for one extra row so callers can +// detect whether more pages exist without coupling the sorted page fetch to an +// exact count. +func (p browseQueryPlan) pagedSQL(_ bool) (string, []any) { queryLimit := p.limit - if !includeTotal { - queryLimit++ - } + queryLimit++ // Bind order: where/from args, then order-by args, then LIMIT, then OFFSET. // limitArgIdx is computed by buildBrowsePlan after consuming order args, so // it correctly points at LIMIT after the orderArgs are bound below. @@ -191,9 +184,6 @@ func (p browseQueryPlan) pagedSQL(includeTotal bool) (string, []any) { offsetArgIdx := p.limitArgIdx + 1 selectList := p.selectClause - if includeTotal { - selectList += ", COUNT(*) OVER () AS total_count" - } sql := fmt.Sprintf( "SELECT %s FROM %s %s %s %s LIMIT $%d OFFSET $%d", selectList, p.fromClause, p.whereClause, p.groupByClause, p.orderBy, @@ -212,8 +202,12 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl if filters.Limit <= 0 { filters.Limit = 20 } - if filters.Limit > 100 { - filters.Limit = 100 + maxLimit := filters.MaxLimit + if maxLimit <= 0 { + maxLimit = 100 + } + if filters.Limit > maxLimit { + filters.Limit = maxLimit } if filters.Offset < 0 { filters.Offset = 0 @@ -367,7 +361,7 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl // bind only its FROM/WHERE/GROUP BY values; pagedSQL binds them together. // argIdx still advances past them so limitArgIdx points at the right // LIMIT placeholder once orderArgs are bound positionally before LIMIT. - orderBy, orderArgs := buildOrderByPlan(filters.Sort, filters.Order, filters.SnapshotAt, argIdx, singleLibraryNoDedup) + orderBy, orderArgs := buildOrderByPlan(filters.Sort, filters.Order, filters.SnapshotAt, argIdx, singleLibraryNoDedup, browseFiltersAreMovieOnly(filters)) argIdx += len(orderArgs) selectClause := browseItemColumns("mi") @@ -932,81 +926,13 @@ func scanBrowseItems(rows pgx.Rows) ([]*models.MediaItem, error) { return items, nil } -// scanBrowseItemsWithTotal scans rows that include a trailing total_count column -// emitted by COUNT(*) OVER (). Used by browse() in the includeTotal path so we -// don't fire a separate count query. -func scanBrowseItemsWithTotal(rows pgx.Rows) ([]*models.MediaItem, int, error) { - var ( - items []*models.MediaItem - total int - ) - for rows.Next() { - var item models.MediaItem - var rowTotal int - err := rows.Scan( - &item.ContentID, - &item.Type, - &item.Title, - &item.SortTitle, - &item.OriginalTitle, - &item.Year, - &item.Genres, - &item.ContentRating, - &item.Runtime, - &item.Overview, - &item.Tagline, - &item.RatingIMDB, - &item.RatingTMDB, - &item.RatingRTCritic, - &item.RatingRTAudience, - &item.ImdbID, - &item.TmdbID, - &item.TvdbID, - &item.PosterPath, - &item.PosterThumbhash, - &item.BackdropPath, - &item.BackdropThumbhash, - &item.LogoPath, - &item.MetadataS3Path, - &item.MetadataEtag, - &item.SeasonCount, - &item.Studios, - &item.Networks, - &item.Countries, - &item.Keywords, - &item.OriginalLanguage, - &item.ReleaseDate, - &item.FirstAirDate, - &item.LastAirDate, - &item.ShowStatus, - &item.MatchedAt, - &item.EpisodeMetadataIncomplete, - &item.EpisodeMetadataLastCheckedAt, - &item.Status, - &item.CreatedAt, - &item.UpdatedAt, - &item.AddedAt, - &rowTotal, - ) - if err != nil { - return nil, 0, fmt.Errorf("scanning browse item row with total: %w", err) - } - items = append(items, &item) - total = rowTotal - } - if err := rows.Err(); err != nil { - return nil, 0, fmt.Errorf("iterating browse item rows: %w", err) - } - return items, total, nil -} - // buildOrderBy constructs the ORDER BY clause from sort and order parameters. func buildOrderBy(sort, order string) string { - clause, _ := buildOrderByPlan(sort, order, nil, 0, false) + clause, _ := buildOrderByPlan(sort, order, nil, 0, false, false) return clause } -func buildOrderByPlan(sort, order string, snapshot *time.Time, argIdx int, singleLibraryNoDedup bool) (string, []any) { +func buildOrderByPlan(sort, order string, snapshot *time.Time, argIdx int, singleLibraryNoDedup bool, movieOnly bool) (string, []any) { direction := "DESC" if strings.EqualFold(order, "asc") { direction = "ASC" @@ -1027,6 +953,9 @@ func buildOrderByPlan(sort, order string, snapshot *time.Time, argIdx int, singl } return "ORDER BY RANDOM()", nil case "release_date": + if movieOnly { + return fmt.Sprintf("ORDER BY mi.release_date %s%s, mi.content_id ASC", direction, nullsClause), nil + } return fmt.Sprintf( "ORDER BY COALESCE(mi.release_date::text, NULLIF(BTRIM(mi.first_air_date), '')) %s%s, mi.content_id ASC", direction, @@ -1067,6 +996,11 @@ func buildOrderByPlan(sort, order string, snapshot *time.Time, argIdx int, singl } } +func browseFiltersAreMovieOnly(filters BrowseFilters) bool { + types := splitTypes(filters.Type) + return len(types) == 1 && types[0] == "movie" +} + // ParseContentRatings splits a comma-separated content rating string into // a slice. Empty input returns nil. func ParseContentRatings(s string) []string { diff --git a/internal/catalog/browse_orderby_test.go b/internal/catalog/browse_orderby_test.go index 39a8ecf7..b65c5998 100644 --- a/internal/catalog/browse_orderby_test.go +++ b/internal/catalog/browse_orderby_test.go @@ -42,3 +42,26 @@ func TestBuildOrderBy_LastAirDateReadsDenormColumn(t *testing.T) { t.Fatalf("expected sparse last_air_date sort to push missing items last, got %q", got) } } + +func TestBrowsePlan_ReleaseDateMovieOnlyUsesIndexedDateColumn(t *testing.T) { + repo := &BrowseRepository{} + plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{ + Type: "movie", + Sort: "release_date", + Order: "desc", + Limit: 1000, + }) + if err != nil { + t.Fatalf("buildBrowsePlan error: %v", err) + } + if earlyEmpty { + t.Fatal("did not expect early empty result") + } + if strings.Contains(plan.orderBy, "COALESCE(") || strings.Contains(plan.orderBy, "::text") { + t.Fatalf("movie-only release_date sort must use the indexed date column, got %q", plan.orderBy) + } + want := "ORDER BY mi.release_date DESC NULLS LAST, mi.content_id ASC" + if plan.orderBy != want { + t.Fatalf("orderBy = %q, want %q", plan.orderBy, want) + } +} diff --git a/internal/catalog/window_count_test.go b/internal/catalog/window_count_test.go index 3ac01402..bb41e4df 100644 --- a/internal/catalog/window_count_test.go +++ b/internal/catalog/window_count_test.go @@ -41,9 +41,11 @@ func TestQueryExecutor_PreviewPage_SkipTotal_OmitsWindowCount(t *testing.T) { } } -// TestBrowseRepository_browse_UsesWindowCount asserts that buildBrowsePlan + -// pagedSQL emit COUNT(*) OVER () in the data SELECT when includeTotal is true. -func TestBrowseRepository_browse_UsesWindowCount(t *testing.T) { +// TestBrowseRepository_browse_ExactTotalOmitsWindowCount asserts that +// buildBrowsePlan + pagedSQL keep the data SELECT free of COUNT(*) OVER (). +// BrowsePage runs the exact count separately when needed so large ordered +// catalogs can use top-N/index plans for the page fetch. +func TestBrowseRepository_browse_ExactTotalOmitsWindowCount(t *testing.T) { repo := &BrowseRepository{} plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Limit: 20}) if err != nil { @@ -53,8 +55,8 @@ func TestBrowseRepository_browse_UsesWindowCount(t *testing.T) { t.Fatalf("did not expect early empty result for default filters") } sql, _ := plan.pagedSQL(true) - if !strings.Contains(sql, "COUNT(*) OVER ()") { - t.Fatalf("expected COUNT(*) OVER () for single-pass browse count; got:\n%s", sql) + if strings.Contains(sql, "COUNT(*) OVER ()") { + t.Fatalf("exact browse totals must omit COUNT(*) OVER (); got:\n%s", sql) } } @@ -75,6 +77,31 @@ func TestBrowseRepository_browse_SkipTotal_OmitsWindowCount(t *testing.T) { } } +func TestBrowseRepository_browse_MaxLimitAllowsCallerSpecificLargePages(t *testing.T) { + repo := &BrowseRepository{} + plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Limit: 1000, MaxLimit: 1000}) + if err != nil { + t.Fatalf("buildBrowsePlan error: %v", err) + } + if earlyEmpty { + t.Fatalf("did not expect early empty result") + } + if plan.limit != 1000 { + t.Fatalf("plan limit = %d, want 1000", plan.limit) + } + + defaultPlan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Limit: 1000}) + if err != nil { + t.Fatalf("buildBrowsePlan default cap error: %v", err) + } + if earlyEmpty { + t.Fatalf("did not expect early empty result for default cap") + } + if defaultPlan.limit != 100 { + t.Fatalf("default plan limit = %d, want 100", defaultPlan.limit) + } +} + // TestQueryExecutor_PreviewPage_CountSQL_OmitsSortOnlyJoins pins that exact // totals do not carry ORDER BY-only joins. The page query still needs those // joins for sorts such as added_at, but the count query only needs the base diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index 05e0bd4d..516946b8 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -27,10 +27,15 @@ type LibraryPosterPresigner interface { // directContentService relies on. Defined as an interface so tests can // substitute a stub without standing up a Postgres pool. type browseSource interface { - Browse(ctx context.Context, filters catalog.BrowseFilters) (*catalog.BrowseResult, error) + BrowsePage(ctx context.Context, filters catalog.BrowseFilters, includeTotal bool) (*catalog.BrowseResult, error) ListGenres(ctx context.Context, filters catalog.BrowseFilters) ([]string, error) } +const ( + compatBrowseChunkLimit = 100 + compatBrowseMaxLimit = 1000 +) + // itemAccessSource is the subset of *catalog.ItemRepository that // directContentService season/episode handlers rely on. Defined as an // interface so tests can substitute a stub without standing up a Postgres @@ -165,17 +170,23 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session filter := s.resolveFilter(ctx, session) isPlayedFilter := params.Get("is_played") // "", "true", or "false" contentIDs := parseContentIDParam(params.Get("content_ids")) + includeTotal := parseBool(params.Get("include_total"), true) requestedLimit := catalog.ParseIntParam(params.Get("limit")) if requestedLimit <= 0 { requestedLimit = 24 } - requestedOffset := catalog.ParseIntParam(params.Get("offset")) + if requestedLimit > compatBrowseMaxLimit { + requestedLimit = compatBrowseMaxLimit + } + requestedOffset := max(catalog.ParseIntParam(params.Get("offset")), 0) - // When filtering by played status, over-fetch to compensate for filtered-out items. fetchLimit := requestedLimit if isPlayedFilter != "" { - fetchLimit = min(requestedLimit*3, 100) + // Played status is a profile overlay, so browse over-fetches bounded + // chunks and filters them locally instead of asking catalog for every + // row in a large library. + fetchLimit = min(max(requestedLimit*3, compatBrowseChunkLimit), compatBrowseMaxLimit) } filters := catalog.BrowseFilters{ @@ -191,19 +202,31 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session Sort: params.Get("sort"), Order: params.Get("order"), Limit: fetchLimit, + MaxLimit: compatBrowseMaxLimit, Offset: requestedOffset, } var collected []upstreamListItem totalFromCatalog := 0 - maxIterations := 5 + totalKnown := false + hasMore := false + scannedRows := 0 + maxScannedRows := requestedLimit + if isPlayedFilter != "" { + maxScannedRows = max(requestedLimit*5, compatBrowseChunkLimit) + } - for range maxIterations { - result, err := s.browseRepo.Browse(ctx, filters) + for len(collected) < requestedLimit && scannedRows < maxScannedRows { + wantTotal := includeTotal && !totalKnown + result, err := s.browseRepo.BrowsePage(ctx, filters, wantTotal) if err != nil { return nil, fmt.Errorf("browse items: %w", err) } - totalFromCatalog = result.Total + if wantTotal { + totalFromCatalog = result.Total + totalKnown = true + } + hasMore = result.HasMore batch := make([]upstreamListItem, 0, len(result.Items)) for _, mi := range result.Items { @@ -232,21 +255,24 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session collected = append(collected, batch...) } - // Stop if we have enough or catalog is exhausted. - if len(collected) >= requestedLimit || len(result.Items) < filters.Limit { + scannedRows += len(result.Items) + if len(result.Items) == 0 || !result.HasMore { break } - filters.Offset += filters.Limit + filters.Offset += len(result.Items) } // Trim to requested limit. if len(collected) > requestedLimit { collected = collected[:requestedLimit] } + if totalKnown { + hasMore = totalFromCatalog > requestedOffset+requestedLimit + } return &upstreamBrowseResponse{ Total: totalFromCatalog, - HasMore: totalFromCatalog > requestedOffset+requestedLimit, + HasMore: hasMore, Items: collected, }, nil } diff --git a/internal/jellycompat/content_direct_test.go b/internal/jellycompat/content_direct_test.go index d3ca23de..990521e0 100644 --- a/internal/jellycompat/content_direct_test.go +++ b/internal/jellycompat/content_direct_test.go @@ -2,6 +2,7 @@ package jellycompat import ( "context" + "fmt" "net/url" "testing" "time" @@ -329,10 +330,28 @@ func (s *progressCountingStore) DeleteLibraryPlaybackPreference(context.Context, type stubBrowseSource struct { items []*models.MediaItem total int + calls []stubBrowseCall } -func (s *stubBrowseSource) Browse(_ context.Context, _ catalog.BrowseFilters) (*catalog.BrowseResult, error) { - return &catalog.BrowseResult{Items: s.items, Total: s.total}, nil +type stubBrowseCall struct { + filters catalog.BrowseFilters + includeTotal bool +} + +func (s *stubBrowseSource) BrowsePage(_ context.Context, filters catalog.BrowseFilters, includeTotal bool) (*catalog.BrowseResult, error) { + s.calls = append(s.calls, stubBrowseCall{filters: filters, includeTotal: includeTotal}) + + start := min(max(filters.Offset, 0), len(s.items)) + end := min(start+max(filters.Limit, 0), len(s.items)) + total := 0 + if includeTotal { + total = s.total + } + return &catalog.BrowseResult{ + Items: append([]*models.MediaItem(nil), s.items[start:end]...), + Total: total, + HasMore: end < len(s.items), + }, nil } func (s *stubBrowseSource) ListGenres(_ context.Context, _ catalog.BrowseFilters) ([]string, error) { @@ -413,6 +432,84 @@ func TestBrowseItems_FetchesProgressWhenPlayedFilterSet(t *testing.T) { } } +func TestBrowseItems_FillsLargeJellyfinLimitInSingleCatalogPage(t *testing.T) { + provider := newProgressCountingStoreProvider() + browse := &stubBrowseSource{ + items: makeBrowseTestMediaItems(400), + total: 400, + } + svc := newDirectContentServiceForTest(browse, provider) + + session := &Session{StreamAppUserID: 1, ProfileID: "profile-1"} + params := url.Values{} + params.Set("limit", "250") + params.Set("offset", "10") + params.Set("include_total", "true") + + result, err := svc.BrowseItems(context.Background(), session, params) + if err != nil { + t.Fatalf("BrowseItems returned error: %v", err) + } + if got, want := len(result.Items), 250; got != want { + t.Fatalf("items length = %d, want %d", got, want) + } + if result.Total != 400 { + t.Fatalf("Total = %d, want 400", result.Total) + } + if !result.HasMore { + t.Fatal("HasMore = false, want true") + } + if got, want := len(browse.calls), 1; got != want { + t.Fatalf("BrowsePage calls = %d, want %d", got, want) + } + + call := browse.calls[0] + if call.filters.Limit != 250 { + t.Fatalf("Limit = %d, want 250", call.filters.Limit) + } + if call.filters.MaxLimit != compatBrowseMaxLimit { + t.Fatalf("MaxLimit = %d, want %d", call.filters.MaxLimit, compatBrowseMaxLimit) + } + if call.filters.Offset != 10 { + t.Fatalf("Offset = %d, want 10", call.filters.Offset) + } + if !call.includeTotal { + t.Fatal("includeTotal = false, want true") + } +} + +func TestBrowseItems_HonorsIncludeTotalFalse(t *testing.T) { + browse := &stubBrowseSource{ + items: makeBrowseTestMediaItems(300), + total: 300, + } + svc := newDirectContentServiceForTest(browse, nil) + + session := &Session{StreamAppUserID: 1, ProfileID: "profile-1"} + params := url.Values{} + params.Set("limit", "150") + params.Set("include_total", "false") + + result, err := svc.BrowseItems(context.Background(), session, params) + if err != nil { + t.Fatalf("BrowseItems returned error: %v", err) + } + if got, want := len(result.Items), 150; got != want { + t.Fatalf("items length = %d, want %d", got, want) + } + if result.Total != 0 { + t.Fatalf("Total = %d, want 0 when include_total=false", result.Total) + } + if got, want := len(browse.calls), 1; got != want { + t.Fatalf("BrowsePage calls = %d, want %d", got, want) + } + for i, call := range browse.calls { + if call.includeTotal { + t.Fatalf("call %d includeTotal = true, want false", i) + } + } +} + // TestEnrichListItemsUserData_BatchesIntoSingleFetch verifies that // enrichListItemsUserData makes exactly one batched ListProgressByMediaItems // call regardless of batch size — not N+1 per-item fetches. @@ -437,3 +534,15 @@ func TestEnrichListItemsUserData_BatchesIntoSingleFetch(t *testing.T) { want, got) } } + +func makeBrowseTestMediaItems(count int) []*models.MediaItem { + items := make([]*models.MediaItem, 0, count) + for i := range count { + items = append(items, &models.MediaItem{ + ContentID: fmt.Sprintf("movie-%03d", i), + Type: "movie", + Title: fmt.Sprintf("Movie %03d", i), + }) + } + return items +} diff --git a/internal/jellycompat/query.go b/internal/jellycompat/query.go index c300524e..0e89c8be 100644 --- a/internal/jellycompat/query.go +++ b/internal/jellycompat/query.go @@ -197,6 +197,7 @@ func buildBrowseParams(query itemsQuery) url.Values { if query.order != "" { params.Set("order", query.order) } + params.Set("include_total", strconv.FormatBool(query.enableTotalRecordCount)) if query.personID > 0 { params.Set("person_id", strconv.FormatInt(query.personID, 10)) } diff --git a/internal/jellycompat/query_test.go b/internal/jellycompat/query_test.go index 1a9e3610..780386c3 100644 --- a/internal/jellycompat/query_test.go +++ b/internal/jellycompat/query_test.go @@ -29,6 +29,17 @@ func TestParseItemsQueryAcceptsIsFavoriteParam(t *testing.T) { } } +func TestBuildBrowseParamsPropagatesEnableTotalRecordCount(t *testing.T) { + req := httptest.NewRequest("GET", "/Items?EnableTotalRecordCount=false", nil) + + query := parseItemsQuery(req, NewResourceIDCodec()) + params := buildBrowseParams(query) + + if got := params.Get("include_total"); got != "false" { + t.Fatalf("include_total = %q, want false", got) + } +} + func TestMapSortByReleaseDate(t *testing.T) { tests := []string{ "PremiereDate", diff --git a/migrations/174_movie_release_date_desc_index.down.sql b/migrations/174_movie_release_date_desc_index.down.sql new file mode 100644 index 00000000..0dac3912 --- /dev/null +++ b/migrations/174_movie_release_date_desc_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS public.idx_media_items_movie_release_date_desc_content; diff --git a/migrations/174_movie_release_date_desc_index.up.sql b/migrations/174_movie_release_date_desc_index.up.sql new file mode 100644 index 00000000..b734e0f8 --- /dev/null +++ b/migrations/174_movie_release_date_desc_index.up.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS idx_media_items_movie_release_date_desc_content +ON public.media_items USING btree (release_date DESC NULLS LAST, content_id ASC) +WHERE type = 'movie'; From de89f0136c78ac5ccf7ea9ced485e4887d373231 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:47:36 -0400 Subject: [PATCH 169/201] perf(jellycompat): compress and batch list image work --- internal/jellycompat/content_direct.go | 74 ++++++++++++++--- internal/jellycompat/content_direct_test.go | 90 +++++++++++++++++++++ internal/jellycompat/router.go | 1 + internal/jellycompat/router_test.go | 46 +++++++++++ 4 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 internal/jellycompat/router_test.go diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index 516946b8..79bfb9a2 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -235,10 +235,9 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session mi = localized } } - item := mediaItemToListItem(mi) - s.presignListItem(ctx, &item) - batch = append(batch, item) + batch = append(batch, mediaItemToListItem(mi)) } + s.presignListItems(ctx, batch) if isPlayedFilter != "" { // Need user data to filter by played status. The handler's // resolveUserStateForContentIDs call covers UserData on the wire @@ -313,10 +312,9 @@ func (s *directContentService) SearchItems(ctx context.Context, session *Session mi = localized } } - item := mediaItemToListItem(mi) - s.presignListItem(ctx, &item) - listItems = append(listItems, item) + listItems = append(listItems, mediaItemToListItem(mi)) } + s.presignListItems(ctx, listItems) s.enrichListItemsUserData(ctx, session, listItems) return &upstreamBrowseResponse{ @@ -652,6 +650,39 @@ func seasonUserDataFromProgress(progress userstore.WatchProgress) *catalog.Seaso // --- Image URL presigning helpers --- func (s *directContentService) presignListItem(ctx context.Context, item *upstreamListItem) { + if item == nil { + return + } + items := []upstreamListItem{*item} + s.presignListItems(ctx, items) + *item = items[0] +} + +func (s *directContentService) presignListItems(ctx context.Context, items []upstreamListItem) { + if len(items) == 0 { + return + } + for i := range items { + ensureListItemImagePaths(&items[i]) + } + if s.detailSvc == nil { + return + } + + posterURLs := s.detailSvc.PresignImageURLsWithExpiry(ctx, collectListImagePaths(items, func(item upstreamListItem) string { return item.PosterURL }), "poster", compatCardImageSize) + backdropURLs := s.detailSvc.PresignImageURLsWithExpiry(ctx, collectListImagePaths(items, func(item upstreamListItem) string { return item.BackdropURL }), "backdrop", compatCardImageSize) + logoURLs := s.detailSvc.PresignImageURLsWithExpiry(ctx, collectListImagePaths(items, func(item upstreamListItem) string { return item.LogoURL }), "logo", compatCardImageSize) + stillURLs := s.detailSvc.PresignImageURLsWithExpiry(ctx, collectListImagePaths(items, func(item upstreamListItem) string { return item.StillURL }), "still", compatCardImageSize) + + for i := range items { + items[i].PosterURL = resolvedListImageURL(posterURLs, items[i].PosterURL) + items[i].BackdropURL = resolvedListImageURL(backdropURLs, items[i].BackdropURL) + items[i].LogoURL = resolvedListImageURL(logoURLs, items[i].LogoURL) + items[i].StillURL = resolvedListImageURL(stillURLs, items[i].StillURL) + } +} + +func ensureListItemImagePaths(item *upstreamListItem) { if item.PosterPath == "" { item.PosterPath = item.PosterURL } @@ -664,10 +695,33 @@ func (s *directContentService) presignListItem(ctx context.Context, item *upstre if item.StillPath == "" { item.StillPath = item.StillURL } - item.PosterURL = compatPresignImage(s.detailSvc, ctx, item.PosterURL, "poster", compatCardImageSize) - item.BackdropURL = compatPresignImage(s.detailSvc, ctx, item.BackdropURL, "backdrop", compatCardImageSize) - item.LogoURL = compatPresignImage(s.detailSvc, ctx, item.LogoURL, "logo", compatCardImageSize) - item.StillURL = compatPresignImage(s.detailSvc, ctx, item.StillURL, "still", compatCardImageSize) +} + +func collectListImagePaths(items []upstreamListItem, pick func(upstreamListItem) string) []string { + paths := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + path := pick(item) + if path == "" { + continue + } + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + paths = append(paths, path) + } + return paths +} + +func resolvedListImageURL(resolved map[string]catalog.ResolvedImageURL, path string) string { + if path == "" { + return "" + } + if value, ok := resolved[path]; ok { + return value.URL + } + return "" } func (s *directContentService) presignSeason(ctx context.Context, season *upstreamSeason) { diff --git a/internal/jellycompat/content_direct_test.go b/internal/jellycompat/content_direct_test.go index 990521e0..d8f79cde 100644 --- a/internal/jellycompat/content_direct_test.go +++ b/internal/jellycompat/content_direct_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "slices" "testing" "time" @@ -57,6 +58,95 @@ func TestItemEtagIncludesPremiereDate(t *testing.T) { } } +type countingCompatImageResolver struct { + singleCalls int + batchCalls int + variants []string + paths []string +} + +func (r *countingCompatImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string { + r.singleCalls++ + return "single:" + variant + ":" + path +} + +func (r *countingCompatImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string { + resolved := r.ResolveImageURLsWithExpiry(context.Background(), paths, variant) + urls := make(map[string]string, len(resolved)) + for path, value := range resolved { + urls[path] = value.URL + } + return urls +} + +func (r *countingCompatImageResolver) ResolveImageURLWithExpiry(_ context.Context, path string, variant string) catalog.ResolvedImageURL { + r.singleCalls++ + return catalog.ResolvedImageURL{URL: "single:" + variant + ":" + path} +} + +func (r *countingCompatImageResolver) ResolveImageURLsWithExpiry(_ context.Context, paths []string, variant string) map[string]catalog.ResolvedImageURL { + r.batchCalls++ + r.variants = append(r.variants, variant) + r.paths = append(r.paths, paths...) + resolved := make(map[string]catalog.ResolvedImageURL, len(paths)) + for _, path := range paths { + resolved[path] = catalog.ResolvedImageURL{URL: "batch:" + variant + ":" + path} + } + return resolved +} + +func TestPresignListItemsBatchResolvesImages(t *testing.T) { + resolver := &countingCompatImageResolver{} + detailSvc := &catalog.DetailService{} + detailSvc.SetImageResolver(resolver) + svc := &directContentService{detailSvc: detailSvc} + + items := []upstreamListItem{ + { + ContentID: "movie-1", + PosterURL: "plug://poster-1", + BackdropURL: "plug://backdrop-1", + LogoURL: "plug://logo-1", + }, + { + ContentID: "movie-2", + PosterURL: "plug://poster-2", + StillURL: "plug://still-2", + }, + } + + svc.presignListItems(context.Background(), items) + + if resolver.singleCalls != 0 { + t.Fatalf("single image resolver calls = %d, want 0", resolver.singleCalls) + } + if resolver.batchCalls != 4 { + t.Fatalf("batch image resolver calls = %d, want 4", resolver.batchCalls) + } + for _, variant := range resolver.variants { + if variant != "card" { + t.Fatalf("batch variant = %q, want card", variant) + } + } + for _, path := range []string{"plug://poster-1", "plug://poster-2", "plug://backdrop-1", "plug://logo-1", "plug://still-2"} { + if !slices.Contains(resolver.paths, path) { + t.Fatalf("batch paths %v missing %q", resolver.paths, path) + } + } + if items[0].PosterPath != "plug://poster-1" { + t.Fatalf("PosterPath = %q, want original path", items[0].PosterPath) + } + if got := items[0].PosterURL; got != "batch:card:plug://poster-1" { + t.Fatalf("PosterURL = %q", got) + } + if got := items[0].BackdropURL; got != "batch:card:plug://backdrop-1" { + t.Fatalf("BackdropURL = %q", got) + } + if got := items[1].StillURL; got != "batch:card:plug://still-2" { + t.Fatalf("StillURL = %q", got) + } +} + // progressCountingStoreProvider is a test double that records calls to // ForUser and ListProgressByMediaItems. Used to assert that BrowseItems // does not duplicate the handler-level user-data fetch. diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index ae6bf322..97e9622e 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -39,6 +39,7 @@ func NewRouter(deps Dependencies) chi.Router { MaxAge: 86400, })) r.Use(normalizeCompatPathMiddleware) + r.Use(middleware.Compress(5, "application/json")) if debugPath := os.Getenv("JELLYCOMPAT_DEBUG_LOG"); debugPath != "" { rotator := &lumberjack.Logger{ Filename: debugPath, diff --git a/internal/jellycompat/router_test.go b/internal/jellycompat/router_test.go new file mode 100644 index 00000000..1b94950e --- /dev/null +++ b/internal/jellycompat/router_test.go @@ -0,0 +1,46 @@ +package jellycompat + +import ( + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/config" +) + +func TestRouterCompressesJSONResponses(t *testing.T) { + cfg, err := config.LoadFromDB(map[string]string{}) + if err != nil { + t.Fatalf("LoadFromDB: %v", err) + } + router := NewRouter(Dependencies{Config: cfg}) + + req := httptest.NewRequest(http.MethodGet, "/System/Info/Public", nil) + req.Header.Set("Accept-Encoding", "gzip") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("Content-Encoding = %q, want gzip", got) + } + + reader, err := gzip.NewReader(rec.Body) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + defer reader.Close() + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read compressed body: %v", err) + } + if !strings.Contains(string(body), `"ProductName":"Jellyfin Server"`) { + t.Fatalf("unexpected response body %q", string(body)) + } +} From 177cfdc4853626bdc1100b289e4d31e0ef8d44d2 Mon Sep 17 00:00:00 2001 From: fluxis Date: Fri, 5 Jun 2026 22:19:38 -0400 Subject: [PATCH 170/201] feat(autoscan): pluggable scan-source autoscan category (Sonarr/Radarr) (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: design spec for autoscan arr polling Periodic poller over autoscan-enabled Radarr/Sonarr instances (reusing request_integrations) that maps import paths to Silo media folders and enqueues targeted scans via the existing scantrigger + scanqueue. Lean single-service model: no cross-node fan-out guard or retry queue. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for autoscan arr polling Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): settings and sources schema Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): core types Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): path rewrite helper Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): dedupe imported paths to parent folders Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): arr import-history client Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): settings + sources repository Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): redis scan-suppression seam Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): PollOnce poll cycle * feat(autoscan): poll task and wiring * feat(autoscan): admin API endpoints * feat(autoscan): admin API endpoints Adds ErrIntegrationNotFound sentinel (errors.Is) instead of string matching. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): autoscan types and hooks Co-Authored-By: Claude Sonnet 4.6 * feat(web): autoscan admin tab * fix(autoscan): release suppression claim on enqueue failure; reconfigure trigger on interval change; skip source on key-resolution error Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): update handler test for 3-arg NewAutoscanHandler * fix(autoscan): per-path suppression key, bounded poll window + overlap, boundary-safe rewrites, GREATEST cursor guard, async trigger, quiet unresolved-path skip, FK->404 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): normalize Windows path separators, surface status errors, re-seed source editor on save Addresses minor code-review findings: Windows backslash paths now normalized before rewrite/dedupe; HandleStatus returns repository errors instead of 200; the per-source editor re-seeds from server data after a save. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: design spec for autoscan rewrite-sync from arr root folders Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for autoscan rewrite-sync Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): suffix-match rewrite suggester Add suggestRewrites / commonSuffixLen for Task 1 of the autoscan arr-polling feature. Pure function: matches arr root-folder paths to Silo media folder paths by longest common trailing segment count, adjusted for depth-delta so coincidental same-named segments at different structural levels don't inflate confidence. Categorises each arr root as Proposed, Ambiguous, Unmatched, or Covered by an existing PathRewrite rule. TDD: test file written first, verified failing, then implementation added. Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): GetSource single-source lookup * feat(autoscan): arr root-folder client + Silo folder lister * feat(autoscan): Service.SuggestRewrites Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): rewrite-suggestions endpoint Add GET /autoscan/sources/{id}/rewrite-suggestions admin endpoint: extend the autoscanTriggerer interface with SuggestRewrites, wire SetRewriteResolvers in the router, and add handler + test. Co-Authored-By: Claude Sonnet 4.6 * feat(web): autoscan rewrite-suggestions types and hook * feat(web): autoscan sync-rewrites preview * fix(autoscan): normalize covered-rule paths, dedup roots/folders, skip no-op suggestions Addresses final-review edge cases: coveredBy normalizes the existing rewrite's From (so a stored Windows/dup-slash rule still covers a root); duplicate arr roots and duplicate Silo folder paths are de-duplicated; an arr path that already equals its Silo path is not proposed as a no-op rewrite. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): vitest 4 compatible fetch spy in recipes.test (unblocks build after vitest 4.1.0 bump) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): non-null suggestion slices + move Sync into rewrites card - suggestRewrites initializes Proposed/Unmatched/Ambiguous/Covered to empty slices so the JSON response is [] not null — fixes the 'Something went wrong' crash when every root is already covered (frontend mapped over null). - Move the sync button into the Path rewrites card beside 'Add rewrite' and rename it 'Sync rewrites'; guard the proposed map with ?? []. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): long root-folder timeout + sync spinner + collapse rewrites on load - Root-folder fetch for sync uses a 2-min timeout: Radarr/Sonarr compute unmappedFolders by scanning all roots, so a large library's /rootfolder takes 20-30s+ and tripped arrclient's 30s default (Sonarr 502'd at exactly 30s). - Spin the sync icon + show 'Syncing…' while the request is in flight. - Path rewrites card starts collapsed on page load. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): rescan on Sonarr/Radarr file renames History polling previously only tracked downloadFolderImported events. A rename in Sonarr/Radarr (episodeFileRenamed / movieFileRenamed) moves a file without an import event, leaving the library folder stale until the next full scan. Extend the history client to also surface renamed paths: both the new path and the old sourcePath, since a rename can move a file between folders and both parents may need rescanning. Delete events are still skipped — upgrade-deletes are covered by the paired import, and standalone deletes carry no file path in arr history. Renames the interface method ImportedPaths -> ChangedPaths to reflect the broader scope. Co-Authored-By: Claude Opus 4.8 (1M context) * test(autoscan): synchronize trigger test with detached PollOnce goroutine HandleTrigger dispatches PollOnce on a detached goroutine and responds 202 immediately. The test read trig.called straight after the handler returned, racing the goroutine (usually 'PollOnce was not invoked') and reading the field without synchronization (a data race under -race). Signal completion through a channel the fake sends on when PollOnce runs; the test waits on it (bounded) before asserting. The channel send happens-before the receive, so the subsequent read of called is race-free. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: design spec for autoscan as a pluggable scan-source category Reframes autoscan from a Requests-coupled, arr-only feature into a standalone Autoscan category. Change-detection providers become out-of-process plugins via a new additive scan_source.v1 capability (client-pull, opaque marker); Sonarr/Radarr is the first provider. Host keeps a provider-agnostic resolve/suppress/enqueue engine; all arr-specific logic (and path rewrites) move into the plugin. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for scan_source.v1 SDK capability First of the per-repo plans from the autoscan-plugin-architecture spec. Adds the additive scan_source.v1 capability to silo-plugin-sdk (proto + codegen + capability allowlist + runtime wiring), TDD per task, tagged as v0.5.0 so the host and arr-plugin plans can build against it. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for autoscan host backend (part 1 of 2) Backend for the standalone Autoscan category: scan_source.v1 plugin plumbing (pluginhost client + plugins.Service resolver), generalized engine driven by a provider seam, autoscan_connections + autoscan_sources schema (decoupled from Requests), connection resolution (own or Requests-linked), admin API. Depends on silo-plugin-sdk v0.5.0. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plans for autoscan arr plugin + host UI arr plugin: new installable scan_source.v1 plugin (history imports+renames, rewrites, Silo-native paths), structured like silo-plugin-tmdb; ports the arr-specific logic from the closed PR #43. host UI (part 2 of 2): standalone Autoscan admin category (connections, sources, settings) extracted out of Requests. Co-Authored-By: Claude Opus 4.8 (1M context) * build(autoscan): replace silo-plugin-sdk with local scan_source.v1 checkout Temporary dev replace so the host backend can build against the unreleased scan_source.v1 capability (silo-plugin-sdk PR #2). Finalize to v0.5.0 once the SDK is tagged. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(pluginhost): scan_source.v1 capability client wrapper Adds ScanSourceClient struct, the Client.ScanSource() accessor (mirrors ScheduledTask pattern), and a PollChanges method. Also introduces client_test.go with capability-gate tests for both scheduled_task.v1 and scan_source.v1 using a lazy gRPC ClientConn. Co-Authored-By: Claude Sonnet 4.6 * test+fix(pluginhost): cover capability-id gate, dedicated scan_source timeout Adds a "wrong id returns error" subtest to both capability-gate tests so the capability-ID component is exercised independently of the type. Introduces DefaultScanSourceTimeout (2m) for PollChanges, which polls an external arr API that can be slow, instead of the generic 10s control timeout. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(plugins): expose scan_source.v1 client resolver Co-Authored-By: Claude Sonnet 4.6 * feat(migrations): autoscan v2 schema (connections + sources, no requests FK) Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): v2 types and repository Replace the request_integrations-coupled model with the decoupled v2 schema (autoscan_settings + autoscan_connections + autoscan_sources). Connection CRUD, source upsert/list/get, and AdvanceMarker/RecordError for opaque marker bookkeeping. ErrIntegrationNotFound becomes ErrNotFound. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): resolve connections (own credentials or Requests-linked) ConnectionResolver turns a stored Connection into concrete credentials, reading a soft-linked Requests integration's live base URL/key when RequestIntegrationID is set, then resolving the api-key ref to plaintext. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): scan-source provider seam over the plugin resolver ScanSourceProvider lets the engine poll changed paths without a live plugin; pluginProvider adapts plugins.Service.ScanSourceClient in production. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): generic engine drives sources via scan_source provider Rewrite PollOnce to iterate enabled sources, resolve each connection, poll the provider for changed paths, and run the salvaged resolve→suppress→enqueue loop (uniqueParentDirs, (folder,path) suppression key, RequestError quiet-skip, release-claims-on-enqueue-fail) verbatim. Store the opaque next marker via AdvanceMarker only after a successful enqueue; RecordError + keep marker on provider failure. Tests reworked onto a fakeProvider/fakeStore with an added opaque-marker-verbatim assertion. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): drop conflicting connection CHECK, add connection resolver tests - migration 172: remove the autoscan_connections_source_present CHECK. It conflicted with request_integration_id ON DELETE SET NULL: deleting a Requests integration that a linked-only connection (base_url NULL) points at would null the FK and trip the CHECK, blocking the delete. The intended behavior is for the connection to survive as an orphaned 'needs attention' row. Creation-time validity is now enforced at the application layer. Verified on a throwaway DB: full chain applies and the delete-cascade leaves an orphaned (both-null) connection. - connection.go: TrimSpace the api key ref + resolved secret before the empty-string checks, matching requests.resolveAPIKey parity. - connection_test.go: fake-based tests for ConnectionResolver.Resolve (own creds, linked, linked-missing error, lookup error, trim/fallback). - repository.go: bound RecordError's stored last_error to 2048 chars. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(api): autoscan v2 admin endpoints Rewrite the autoscan admin HTTP handler against the v2 model: settings, connection CRUD, source update, manual trigger (detached PollOnce), and status. Connection/source responses omit api_key_ref and resolved keys (has_api_key flag only); unknown connection/source ids map to 404 via autoscan.ErrNotFound. Retire the host-side rewrite-suggestions endpoint (now lives in the arr plugin). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): wire v2 service, routes, retire rewrite-suggestions Export PollChangesClient/ScanSourceResolver from the autoscan provider so the api package can declare a structurally-conformant plugin adapter (Go has no return-type covariance, so the adapter must name the interface as its return type). Add api.BuildAutoscanService with the requests-integration lookup and plugin scan-source adapters, shared by the router (manual trigger) and the background poll task. Re-wire router routes to the v2 connections/sources/settings/trigger/status surface and drop the rewrite-suggestions route. Update cmd/silo to build the v2 poll task, seeding its interval from Settings.DefaultPollIntervalSeconds. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(api): enforce connection requires own URL or a Requests link Migration 172 dropped the DB CHECK that required an autoscan connection to carry either its own base_url or a request_integration_id, delegating that invariant to the application layer — but the enforcement was never added, so HandleCreateConnection/HandleUpdateConnection accepted both-NULL orphans that ConnectionResolver.Resolve would hand a plugin as an empty base URL. Add a shared validateConnectionInput helper (whitespace-only request_integration_id counts as absent) and reject both-empty payloads with HTTP 400 on both the create and update paths. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): deliver resolved connection to plugin PollChanges now populates PollChangesRequest.Connection with the resolved {base_url, api_key} instead of dropping the conn param on the floor. Drops the stale doc comment claiming the connection was delivered out-of-band at upsert time -- that mechanism never existed. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): auto-discover sources from installed scan_source plugins Auto-discovery seeds a disabled, connection-less source row per installed scan_source.v1 capability before an operator binds a connection, so connection_id is now nullable end to end: - migration 172: connection_id drops NOT NULL (still ON DELETE RESTRICT) - Source.ConnectionID becomes *string; repository scans/writes it as nullable and adds idempotent EnsureSource (INSERT ... ON CONFLICT DO NOTHING) - new ScanSourceLister seam + Service.DiscoverSources, called at the start of PollOnce (errors logged, non-fatal); production adapter enumerates ListEnabled -> ListCapabilities filtered to scan_source.v1 - PollOnce skips an enabled source with no connection bound, recording 'no connection bound' so the UI can surface it - HandleUpdateSource rejects enabling a source with no effective connection (400); source DTOs expose connection_id as nullable - BuildAutoscanService / NewService thread the installation store at both wiring sites (router + poll task) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): honor per-source poll interval PollOnce now skips an enabled source that ran too recently: the floor is source.PollIntervalSeconds when set, else settings.DefaultPollIntervalSeconds. The global poll task fires at the default cadence, so this makes the per-source interval a 'poll at most every N seconds' floor. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(autoscan): reconcile spec + arr-plugin plan with credential-in-request + auto-discovery The credential-delivery mechanism changed during execution: the host now passes resolved {base_url, api_key} in PollChangesRequest.connection each poll (not plugin runtime config). Also records source auto-discovery, nullable connection_id, and the per-source interval floor decided at the final integration review. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): autoscan v2 types and query hooks Replace v1 autoscan types and hooks with v2 DTOs matching the backend handler (autoscan.go): settings, connection (with has_api_key, no raw key), source (installation_id/capability_id/connection_id), status. Add connections CRUD hooks, useAutoscanStatus, update sources hook to v2 input shape. Retain deprecated shims for AutoscanPathRewrite, AutoscanRewriteSuggestions, and useAutoscanRewriteSuggestions so AdminRequests.tsx continues to compile until Task 6 removes that tab. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): autoscan connections panel (reuse or own) Card+Table listing connections with "Reused from Requests" / "Own" badges. Add/edit dialog with two modes: reuse a Sonarr/Radarr Requests integration or enter own name/URL/API-key credentials. Delete with alert-dialog confirm. Never renders key material — only has_api_key is sent by the backend. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): autoscan sources panel Table of auto-discovered scan sources (one row per installed scan_source plugin capability). Operator can bind a connection via inline Select (auto-saved on change), set a per-source poll interval (saved on blur), and toggle enabled. Shows a "Needs connection" badge for unbound sources; attempting to enable without a connection lets the backend 400 surface via the existing toast in useUpdateAutoscanSource.onError. Status column shows last_run_at relative time or last_error with icon. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): standalone Autoscan admin page Tabs page (Sources | Connections | Settings) mirroring AdminRequests header/layout. Settings tab exposes global enable switch, default poll interval, and debounce — all auto-saved on blur or toggle. "Run now" button calls useTriggerAutoscan and toasts "Autoscan triggered" on 202. Route and sidebar nav are intentionally deferred to Task 5. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): route and sidebar nav for Autoscan category Add /admin/autoscan route pointing to AdminAutoscan and a matching "Autoscan" item in the Content group of the admin sidebar (with RefreshCw icon), so the new standalone page is reachable from the nav. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(web): move Autoscan out of Requests into its own category Remove the Autoscan tab, AutoscanTab/AutoscanSourceEditor component definitions, and AutoscanSettingsFormState from AdminRequests.tsx. Delete the Task-1 compatibility stubs: AutoscanPathRewrite and AutoscanRewriteSuggestions types from api/types.ts, and the useAutoscanRewriteSuggestions no-op shim from useAutoscan.ts. The build confirms zero dangling references. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): allow unbinding a source connection (full-state source update) Change the source-update input struct's connection_id from string to *string so the UI can send null to unbind, a UUID to bind, or omit (null) to clear. Remove the fall-back-to-existing logic; the handler now sets the source's ConnectionID directly from the input. The enable-guard fires when the resulting connection is nil regardless of cause. Frontend sends the complete triple (connection_id, enabled, poll_interval_seconds) on every mutation site; selecting "— No connection —" sends null for a real unbind. Adds aria-label to connection Select and interval Input for accessibility. Backend tests cover bind, unbind, unbind while enabled → 400, and enable without connection → 400. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(migrations): backfill autoscan v1 settings+connections instead of dropping Migration 172 unconditionally DROPped the shipped v1 autoscan_settings/ autoscan_sources (migration 171), losing an upgraded operator's enable flag, poll cadence, debounce, and arr server list — autoscan came back OFF. Rewrite 172 up to be non-destructive of what can be carried: rename the v1 tables aside, create the v2 schema, backfill settings (poll minutes -> seconds) and seed a reusable LINKED connection per distinct v1 source integration, then drop the renamed v1 tables. v2 sources are keyed on a plugin (installation_id, capability_id) that did not exist in v1, so they are left to runtime discovery; path rewrites move to plugin config and are intentionally not carried. Verified against a throwaway DB: after 171 + v1 seed data, applying 172 yields enabled=true, default_poll_interval_seconds=300, debounce_seconds=30, and one autoscan_connections row linked to the v1 integration. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): preserve api key on metadata-only connection edit UpdateConnection unconditionally wrote api_key_ref = nullable(c.APIKeyRef), so a metadata-only edit (the UI omits the key when left blank — "leave blank to keep existing") NULLed the stored key and broke the next poll. Mirror requests' UpdateIntegration: api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END, passing the raw trimmed string so a blank incoming ref keeps the existing value. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): skip orphaned sources + add source delete endpoint An enabled source whose scan_source plugin was uninstalled/disabled kept its autoscan_sources row, which errored every poll cycle, and there was no way to remove it. DiscoverSources now returns the set of currently-discovered (installation_id, capability_id) pairs; PollOnce skips any enabled source not in that set quietly (no RecordError), stopping the per-cycle error spam for orphans. A nil set (no lister / discovery failed) disables pruning so a transient discovery failure does not silence live sources. Adds DELETE /admin/autoscan/sources/{id} -> HandleDeleteSource -> repo.DeleteSource so an operator can clear orphans (unknown id -> 404). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): reject reused connection when Requests integration is disabled RequestIntegrationLookup.Get returned a linked integration's base_url/api_key even when the integration was disabled or had a blank base_url (the v1 poll gate `WHERE ri.enabled = true` was dropped in v2). Now Get surfaces a disabled or unconfigured linked integration as an error, which the engine turns into a logged skip / RecordError instead of polling an unusable target. The gating is extracted into a pure checkRequestIntegrationUsable helper so it is unit-testable without a DB-backed repo. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): reschedule poll task on settings change HandleUpdateSettings no longer rescheduled the poll task (the v1 triggerUpdater / UpdateTriggers wiring was dropped in v2), so a default_poll_interval_seconds change only applied after a restart. Re-add an optional triggerUpdater (taskmanager.UpdateTriggers) on AutoscanHandler, wired via SetTriggerUpdater from the router when a task manager is available. On a successful settings update the handler recomputes the interval trigger from default_poll_interval_seconds and calls UpdateTriggers("autoscan_poll", ...). The dependency is optional: a nil updater skips rescheduling so tests need no task manager, and a reschedule failure is non-fatal (the interval is persisted). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): disable enable toggle for unbound sources, add source delete + interval hint - Disable the Enable switch when a source has no effective bound connection (connection_id null and no pending edit selection), re-enabling once bound. - Add useDeleteAutoscanSource hook mirroring useDeleteAutoscanConnection pattern. - Add per-row delete button (Trash2 icon → AlertDialog confirm) to let operators remove orphaned/unwanted source rows. - Add interval floor helper text showing the global default poll interval so operators know values below it have no effect. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): consume source_paths from merged scan_source contract The merged plugin SDK renamed PollChangesResponse.changed_paths to source_paths and the plugin now returns RAW source-namespace paths. pluginProvider.PollChanges reads GetSourcePaths(); the host applies per-source path rewrites before resolving/enqueueing (separate commit). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(migrations): add path_rewrites to autoscan_sources Add path_rewrites jsonb NOT NULL DEFAULT '[]' to the autoscan_sources CREATE in migration 172 (unreleased/branch-only, so amended in place). The host now owns per-source prefix rewrites. v1 path_rewrites cannot be backfilled (v2 sources key on a plugin installation/capability with no v1 mapping); documented that operators must re-enter rewrites post-upgrade. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): host-owned per-source path rewrites Rewrite ownership moved from the scan_source plugin to the host. The plugin returns raw source-namespace paths; the host now normalizes separators and applies the source's per-source prefix rewrites before dedupe/resolve/enqueue. - types: add PathRewrite{From,To} and Source.PathRewrites - rewrite: re-add applyRewrites/normalizeSeparators; apply the MOST-SPECIFIC (longest From) match, not first-match, so a broad rule can't shadow a nested one regardless of ordering - service.PollOnce: rewrite raw provider paths before resolveAndClaim - repository: marshal/unmarshal path_rewrites jsonb in UpsertSource and all source scans (EnsureSource discovery rows take the DB default []) - handlers: autoscanSourceInput/response + status DTO carry path_rewrites (full-state like connection_id); reject blank from/to with 400 - tests: rewrite unit tests, engine applies rewrites before enqueue, handler round-trips path_rewrites and 400s on a blank rewrite Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): discover installed scan_source plugins on sources-list view A scan_source plugin installed via the normal /admin/plugins flow must show up in the Autoscan component immediately, not only after a poll cycle (which runs only when autoscan is enabled). HandleListSources now runs discovery (seeding a disabled, connection-less source row per installed scan_source capability) before listing. Best-effort: discovery failure does not block listing. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): per-source path rewrites editor + plugins-page install hint Add AutoscanPathRewrite type and path_rewrites fields to AutoscanSource/ AutoscanSourceInput. SourcesPanel gains an expandable rewrite editor per source row (from→to pairs, Add/Remove/Save) threaded into the full-state body so connection, interval, and rewrite changes always carry all fields. Adds a Plugins-page install hint in both the empty state and above the table for discoverability. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(autoscan): host-owned path rewrites + install/discovery flow Reconcile the spec with the merged SDK decision (rewrites moved host-side; PollChangesResponse.source_paths carries raw provider paths). Document that scan-source plugins install via the normal /admin/plugins page and surface in Autoscan via discovery (run on poll cycles and on sources-list view). Co-Authored-By: Claude Opus 4.8 (1M context) * build: depend on merged silo-plugin-sdk via pseudo-version (drop local replace) PR #2 (scan_source.v1 + source_paths) is merged to silo-plugin-sdk main, so the host can resolve the canonical module at the merged commit (v0.4.1-0.20260603030807-807b07e785b2) instead of a local-path replace. The branch now builds off-machine (CI/Docker). Bump to a clean v0.5.0 once tagged. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(migrations): single clean autoscan v2 migration (v1 never shipped) The v1 in-process autoscan (migration 171) was never released to origin/main, so no live system has v1 autoscan data to preserve. Collapse the v1-create + v2-rename/backfill/drop dance into one clean 171 that creates the v2 connections-based schema directly. Removes 172 entirely. The runner applies by version set-difference with no checksum validation, so the already-migrated test instance (171+172 recorded) skips both and is unaffected; fresh installs get the clean v2 schema in one step. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): allow many sources per plugin + add-source enumeration Drop the one-source-per-(installation, capability) model. A single installed scan_source plugin capability can now back many sources, each bound to a different connection (e.g. one Sonarr plugin fronting four arr servers). - migration 171: remove the autoscan_sources UNIQUE(installation_id, capability_id) constraint; sources are operator-created, not auto-seeded. - repository: replace UpsertSource (relied on the unique conflict) with a plain CreateSource (fresh uuid) + a by-id UpdateSource; remove EnsureSource. - discovery: replace auto-seeding (DiscoverSources/RefreshDiscovered) with ListAvailableScanSources (the Add-source picker list, enriched with plugin id + display name) and an installedScanSources set used only for orphan-skip. - service: PollOnce stops seeding and instead fetches the installed-capability set for orphan detection; Store gains GetSource and drops EnsureSource. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): connection test endpoint (engine) Add Service.TestConnection / TestConnectionByID: resolve a connection (ad-hoc input or an existing stored connection) to concrete credentials and probe the arr GET /api/v3/system/status with a short timeout. A reachable/authorized target yields OK=true plus the reported version; an unreachable / 401 / non-200 target yields OK=false with a human-readable error (the probe failure is part of the result payload, never an error from the method itself). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): host-side rewrite suggester + admin API for new endpoints Port the path-rewrite suggester back host-side (it had moved into the plugin): suggestRewrites suffix-matches arr root folders against Silo media folders to propose path rewrites, reporting proposed / unmatched / ambiguous / covered. Service.SuggestRewrites resolves the source's bound connection, lists arr roots (GET /api/v3/rootfolder) and Silo folder paths, and runs the matcher; a source with no bound connection returns ErrNoConnection (400). Admin API (all admin-gated): - POST /admin/autoscan/sources create a source - GET /admin/autoscan/scan-source-plugins Add-source picker list - POST /admin/autoscan/connections/test probe a connection - GET /admin/autoscan/sources/{id}/rewrite-suggestions sync rewrites HandleListSources no longer auto-seeds; create validates the capability is currently installed and that enabling requires a connection. Wiring threads the arr root-folder/status client and the catalog folder lister through BuildAutoscanService; the lister now surfaces plugin id + display name. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): autoscan hooks + types for sources, connection test, rewrites Add types and React Query hooks backing the autoscan admin UI batch: - AutoscanAvailableSource / useAvailableScanSources (scan-source plugins) - AutoscanSourceCreateInput / useCreateAutoscanSource (POST sources) - AutoscanConnectionTestResult / useTestAutoscanConnection (advisory test) - AutoscanRewriteSuggestions / useAutoscanRewriteSuggestions (on-demand) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): add-source dialog + sync-from-arr rewrites in SourcesPanel Add a "+ Add source" header action opening a dialog that creates a scan source from any installed scan-source plugin bound to an arr connection, so operators can add one source per connection (e.g. four arr instances). Empty state links to /admin/plugins when no plugins are installed. Add a "Sync from arr" button to each source's rewrite editor that fetches root-folder rewrite suggestions and renders a preview: checkbox-selectable Proposed rewrites plus collapsed Unmatched / Ambiguous / Already-mapped sections. "Apply selected" merges the checked rewrites (dedupe by `from`) and persists via the normal full-state source PUT. Sync is disabled until the source has a bound connection. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): test-connection button in autoscan ConnectionsPanel dialog Add an advisory "Test connection" button to the add/edit connection dialog. It probes the current dialog input — connection_id when editing, request_integration_id in reuse mode, or base_url/api_key_ref for own credentials — and renders the result inline: green "Connected (vX.Y)" on success, red error on failure. Never blocks save; stale results clear when credential fields change. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): autoscan page polish + global enable toggle in header Surface a global Autoscan enable toggle and an enabled/disabled status badge next to the page title, alongside the existing "Run now" header action so primary controls are reachable without opening a tab. Remove the now-redundant enable switch from the Settings tab (it points at the header toggle instead). Tighten header layout for wrap on narrow widths. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): right-align autoscan enable toggle + Run now in the page header Drop the redundant nested justify-between wrapper so the header actions sit directly under .page-header (space-between + bottom-align), matching the /admin/libraries header layout. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): hold poll marker when paths return but none resolve A freshly-enabled source whose path_rewrites aren't configured yet returns provider paths that resolve to zero library folders. PollOnce previously advanced the marker unconditionally on any successful poll, permanently skipping those imports. Now the marker advances only when there is nothing to do (zero paths) or at least one path resolved+enqueued; when paths come back but none resolve, the marker is held and an explaining error recorded so the operator can fix the rewrites and a later poll re-reads the same window. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): don't prune sources of disabled-but-installed plugins PluginScanSourceLister used the installation store's ListEnabled, so a temporarily-disabled plugin dropped out of the discovered set and PollOnce treated its sources as orphaned, skipping them with no last_error (silent vanish). Switch to List so only a fully-uninstalled plugin counts as orphaned; a disabled-but-installed plugin's sources are still attempted and surface a visible RecordError when the client fails to load. The Add-source picker shares the same all-installed set. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): treat empty request_integration_id as no link ConnectionResolver.Resolve gated the linked-integration path on a non-nil RequestIntegrationID pointer, so a pointer-to-empty-string (from a both-NULL orphan or a stripped link) called requests.Get(""). Guard on a non-empty trimmed value so it falls back to the connection's own fields instead. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): align startup poll interval with reschedule computation Startup seeded the poll task by integer-dividing default_poll_interval_seconds by 60 (minutes), while HandleUpdateSettings reschedules with seconds*1000 ms; the two diverged for sub-minute and non-60-multiple intervals. NewAutoscanPollTask now takes the interval in milliseconds and main.go seeds it as seconds*1000, matching the reschedule path so both agree. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): normalize stored rewrite From at poll time applyRewrites matched the stored From after only TrimSpace/TrimSuffix, while suggest.go coveredBy normalizes via normalizePath (backslash->slash, collapse '//'). A Windows-style or dup-slash stored rewrite was thus reported 'covered' at suggest time yet never matched at poll time. applyRewrites now normalizes From through normalizePath so poll-time and suggest-time agree. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): don't corrupt source poll interval on enable/connection change Add a `parseInterval` helper that maps empty input to null (use global default), valid positive integers to the integer, and any other mid-edit-invalid value to the source's currently-persisted `poll_interval_seconds` — so toggling the enable switch or changing the connection cannot silently overwrite the interval with 0 or NaN. Wire the helper through `fullBody()` (the single source of truth for PUT payloads) and remove the two inline duplications in `handleConnectionChange` and `handleRewriteSave` that both previously used raw `Number()`. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): make the source connection optional (provider-agnostic) A host connection is the credential/endpoint for server-based providers (Sonarr/Radarr); other scan_source providers (e.g. a CephFS/filesystem watcher that reads ceph.dir.r* xattrs) need none. PollOnce now polls connection-less sources, passing an empty ResolvedConnection the plugin may ignore; a plugin that requires credentials surfaces the error at poll time. Drops the enable-requires-connection 400s. Provider-specific config lives in the plugin's own global_config_schema, not a host connection. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): provider-agnostic autoscan copy + optional source connection Replace arr-hardcoded framing in AdminAutoscan, SourcesPanel, and ConnectionsPanel with neutral scan-source language. Remove the connection-required gate on the source enable toggle so connectionless providers (e.g. filesystem watchers) can be enabled; soften the badge from "Needs connection" to "No connection". Sync-from-server button remains gated on a bound connection (it needs a server to query). Co-Authored-By: Claude Opus 4.8 (1M context) * docs: repo-relative paths in autoscan plans Replace local absolute filesystem paths (/opt/silo, sibling checkouts, /tmp/go/bin) in docs/superpowers/plans with repository-relative wording per CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): rune-safe last_error truncation Truncate RecordError messages on a UTF-8 rune boundary so a byte-bounded cut can't split a multi-byte rune and store invalid UTF-8. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): advance marker when resolved-but-suppressed (not unresolved) resolveAndClaim now reports resolvedAny (whether any path mapped to a Silo library folder, independent of suppression). PollOnce gates the "none matched a Silo library folder" hold+RecordError on !resolvedAny instead of len(targets)==0, so a poll whose paths resolved but were all debounced/suppressed advances the marker instead of being treated as a misconfiguration. Adds a regression test for the suppressed case. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(api): normalize request_integration_id Trim whitespace and collapse empty-after-trim request_integration_id to nil on connection create and update, so a pointer-to-"" or " " is never persisted as a bogus Requests link. Also corrects a stale migration-172 comment to 171 (the collapsed migration number). Co-Authored-By: Claude Opus 4.8 (1M context) * chore(autoscan): provider-agnostic poll-task copy Rename the poll task to "Autoscan poll" with a provider-agnostic description and progress message; drop Sonarr/Radarr/arr wording. Key() (autoscan_poll) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * test(autoscan): fix typo in connectionless-source test name Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): add scan source management * chore(deps): bump silo-plugin-sdk for structured scan source changes Pins silo-plugin-sdk to 0d78651, which adds source_config on PollChangesRequest plus the structured changes / ScanSourceChangeScope fields on PollChangesResponse that internal/autoscan/provider.go already consumes. Without this the branch fails to compile against the prior pin (807b07e). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): label scan sources by connection name in admin UI arr-plugin sources fan out one-per-connection under a single generic "arr" capability, so every row in the Sources and Activity panels rendered an identical "arr (plugin #N)" label. Lead with the bound connection name (Radarr/Sonarr/...) instead, demoting capability + plugin to a subtitle. Sources without a connection (e.g. cephfs) keep the capability fallback. Activity threads a source_id -> connection name lookup (built from the existing sources + connections queries) through the scan/poll tables the same way librariesByID is threaded. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(autoscan): spec for generic + operator-editable source labels Design for a shared label-resolution helper (operator label -> connection name -> manifest display_name -> capability_id) consumed by the Sources and Activity panels, plus an operator-editable per-source label backed by a new autoscan_sources.label column. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(autoscan): implementation plan for source labels Task-by-task TDD plan: migration 174 (label column), Go domain/repo/handler wiring with server-side normalization, shared frontend label helper, and Sources/Activity panel integration. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): migration for source label column * feat(autoscan): source label domain field + normalizer * feat(autoscan): persist source label in repository Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): accept, normalize, and return source label Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): add label to source API types * feat(autoscan): shared source-label resolution helper Co-Authored-By: Claude Sonnet 4.6 * refactor(autoscan): polish source-label helper per review Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): label sources via shared helper + operator label input Co-Authored-By: Claude Sonnet 4.6 * refactor(autoscan): clarify source label naming per review * feat(autoscan): resolve activity source labels via shared helper Replace the sourceNames Map plumbing in ActivityPanel with SourceLabelLookups and delegate both name functions to resolveEventSourceName from @/lib/autoscanLabels, enabling the full label chain (operator label → connection name → manifest display_name → capability_id) for all Scan History and Poll log rows. * fix(autoscan): carry label on status source + guard poll label Final-review follow-ups: add the label field to the autoscanStatusSource response (and AutoscanStatusSource type) so the status view matches the source response per spec, and give pollSourceName a non-empty fallback for symmetry with scanSourceName. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(autoscan): resolve source aria-labels through the label chain Replace the legacy capability-only sourceLabel() helper with resolveSourceName() (operator label -> connection -> display_name -> capability). Row controls now announce the row's resolvedLabel (reflecting in-progress edits) and the delete dialog announces the resolved name, so screen readers hear "4K Movies" instead of "arr (plugin #4)". Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): paginate queue + history with a shared table pager Replace the card/table hybrid and 200-row "Load more" cap on the autoscan Activity panel with proper tables and real pagination. Backend: add offset + total-count to the scans/events list endpoints so history pages through the full set instead of a capped window. Extract shared event/scan WHERE-clause builders so list and count filter identically, and add CountEvents / CountAutoscanScans. Frontend: add a reusable TablePagination component (rows-per-page, "showing X-Y of Z", numbered window with ellipses, responsive) and reuse it for the server-paginated history (scans + polls) and the client-paginated live queue. Unify all three tables behind one DataTable shell so they read as one family. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> --- cmd/silo/main.go | 25 + .../plans/2026-06-02-autoscan-arr-polling.md | 1198 ++++++++++++++ .../plans/2026-06-02-autoscan-host-backend.md | 779 +++++++++ .../plans/2026-06-02-autoscan-rewrite-sync.md | 694 ++++++++ ...-02-autoscan-scan-source-sdk-capability.md | 375 +++++ .../plans/2026-06-03-autoscan-arr-plugin.md | 265 +++ .../plans/2026-06-03-autoscan-host-ui.md | 209 +++ .../2026-06-05-autoscan-source-labels.md | 1031 ++++++++++++ .../2026-06-02-autoscan-arr-polling-design.md | 222 +++ ...-02-autoscan-plugin-architecture-design.md | 262 +++ ...2026-06-02-autoscan-rewrite-sync-design.md | 173 ++ ...026-06-05-autoscan-source-labels-design.md | 161 ++ go.mod | 2 +- go.sum | 4 +- internal/api/autoscan_wiring.go | 175 ++ internal/api/autoscan_wiring_test.go | 24 + internal/api/handlers/autoscan.go | 1007 ++++++++++++ internal/api/handlers/autoscan_test.go | 1302 +++++++++++++++ internal/api/router.go | 45 +- internal/autoscan/connection.go | 66 + internal/autoscan/connection_test.go | 129 ++ internal/autoscan/dedupe.go | 22 + internal/autoscan/dedupe_test.go | 22 + internal/autoscan/discovery.go | 88 + internal/autoscan/discovery_test.go | 72 + internal/autoscan/errors.go | 7 + internal/autoscan/labels.go | 20 + internal/autoscan/labels_test.go | 39 + internal/autoscan/probe.go | 54 + internal/autoscan/probe_test.go | 97 ++ internal/autoscan/provider.go | 83 + internal/autoscan/provider_test.go | 99 ++ internal/autoscan/repository.go | 861 ++++++++++ internal/autoscan/rewrite.go | 48 + internal/autoscan/rewrite_test.go | 97 ++ internal/autoscan/service.go | 464 ++++++ internal/autoscan/service_test.go | 746 +++++++++ internal/autoscan/suggest.go | 186 +++ internal/autoscan/suggest_deps.go | 81 + internal/autoscan/suppress.go | 43 + internal/autoscan/types.go | 167 ++ internal/models/scan_run.go | 27 +- internal/pluginhost/client.go | 21 + internal/pluginhost/client_test.go | 107 ++ internal/pluginhost/handshake.go | 11 +- internal/plugins/service.go | 13 + internal/plugins/service_hot_reload_test.go | 4 + internal/scanner/scanner.go | 88 +- internal/scanner/scanner_test.go | 33 +- internal/scanner/subtitles.go | 53 +- internal/scanqueue/repository.go | 22 +- internal/scanqueue/service.go | 31 +- internal/scantrigger/scantrigger.go | 31 + internal/scantrigger/scantrigger_test.go | 38 + internal/taskmanager/tasks/autoscan_poll.go | 62 + migrations/171_autoscan.down.sql | 3 + migrations/171_autoscan.up.sql | 62 + .../172_autoscan_source_config.down.sql | 2 + migrations/172_autoscan_source_config.up.sql | 23 + migrations/173_autoscan_events.down.sql | 8 + migrations/173_autoscan_events.up.sql | 33 + migrations/174_autoscan_source_label.down.sql | 1 + migrations/174_autoscan_source_label.up.sql | 3 + ...75_movie_release_date_desc_index.down.sql} | 0 ... 175_movie_release_date_desc_index.up.sql} | 0 web/src/App.tsx | 2 + web/src/api/types.ts | 202 +++ web/src/components/AdminSidebar.tsx | 6 + web/src/components/ui/pagination.test.ts | 40 + web/src/components/ui/pagination.tsx | 166 ++ web/src/components/ui/pagination.utils.ts | 21 + web/src/hooks/queries/keys.ts | 9 + web/src/hooks/queries/useAutoscan.ts | 327 ++++ web/src/lib/autoscanLabels.test.ts | 114 ++ web/src/lib/autoscanLabels.ts | 85 + web/src/lib/scanRuns.ts | 88 + web/src/pages/AdminAutoscan.tsx | 220 +++ web/src/pages/AdminLibraries.tsx | 92 +- .../pages/admin/autoscan/ActivityPanel.tsx | 924 +++++++++++ .../pages/admin/autoscan/ConnectionsPanel.tsx | 532 ++++++ web/src/pages/admin/autoscan/SourcesPanel.tsx | 1426 +++++++++++++++++ 81 files changed, 16236 insertions(+), 138 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-02-autoscan-arr-polling.md create mode 100644 docs/superpowers/plans/2026-06-02-autoscan-host-backend.md create mode 100644 docs/superpowers/plans/2026-06-02-autoscan-rewrite-sync.md create mode 100644 docs/superpowers/plans/2026-06-02-autoscan-scan-source-sdk-capability.md create mode 100644 docs/superpowers/plans/2026-06-03-autoscan-arr-plugin.md create mode 100644 docs/superpowers/plans/2026-06-03-autoscan-host-ui.md create mode 100644 docs/superpowers/plans/2026-06-05-autoscan-source-labels.md create mode 100644 docs/superpowers/specs/2026-06-02-autoscan-arr-polling-design.md create mode 100644 docs/superpowers/specs/2026-06-02-autoscan-plugin-architecture-design.md create mode 100644 docs/superpowers/specs/2026-06-02-autoscan-rewrite-sync-design.md create mode 100644 docs/superpowers/specs/2026-06-05-autoscan-source-labels-design.md create mode 100644 internal/api/autoscan_wiring.go create mode 100644 internal/api/autoscan_wiring_test.go create mode 100644 internal/api/handlers/autoscan.go create mode 100644 internal/api/handlers/autoscan_test.go create mode 100644 internal/autoscan/connection.go create mode 100644 internal/autoscan/connection_test.go create mode 100644 internal/autoscan/dedupe.go create mode 100644 internal/autoscan/dedupe_test.go create mode 100644 internal/autoscan/discovery.go create mode 100644 internal/autoscan/discovery_test.go create mode 100644 internal/autoscan/errors.go create mode 100644 internal/autoscan/labels.go create mode 100644 internal/autoscan/labels_test.go create mode 100644 internal/autoscan/probe.go create mode 100644 internal/autoscan/probe_test.go create mode 100644 internal/autoscan/provider.go create mode 100644 internal/autoscan/provider_test.go create mode 100644 internal/autoscan/repository.go create mode 100644 internal/autoscan/rewrite.go create mode 100644 internal/autoscan/rewrite_test.go create mode 100644 internal/autoscan/service.go create mode 100644 internal/autoscan/service_test.go create mode 100644 internal/autoscan/suggest.go create mode 100644 internal/autoscan/suggest_deps.go create mode 100644 internal/autoscan/suppress.go create mode 100644 internal/autoscan/types.go create mode 100644 internal/pluginhost/client_test.go create mode 100644 internal/taskmanager/tasks/autoscan_poll.go create mode 100644 migrations/171_autoscan.down.sql create mode 100644 migrations/171_autoscan.up.sql create mode 100644 migrations/172_autoscan_source_config.down.sql create mode 100644 migrations/172_autoscan_source_config.up.sql create mode 100644 migrations/173_autoscan_events.down.sql create mode 100644 migrations/173_autoscan_events.up.sql create mode 100644 migrations/174_autoscan_source_label.down.sql create mode 100644 migrations/174_autoscan_source_label.up.sql rename migrations/{174_movie_release_date_desc_index.down.sql => 175_movie_release_date_desc_index.down.sql} (100%) rename migrations/{174_movie_release_date_desc_index.up.sql => 175_movie_release_date_desc_index.up.sql} (100%) create mode 100644 web/src/components/ui/pagination.test.ts create mode 100644 web/src/components/ui/pagination.tsx create mode 100644 web/src/components/ui/pagination.utils.ts create mode 100644 web/src/hooks/queries/useAutoscan.ts create mode 100644 web/src/lib/autoscanLabels.test.ts create mode 100644 web/src/lib/autoscanLabels.ts create mode 100644 web/src/lib/scanRuns.ts create mode 100644 web/src/pages/AdminAutoscan.tsx create mode 100644 web/src/pages/admin/autoscan/ActivityPanel.tsx create mode 100644 web/src/pages/admin/autoscan/ConnectionsPanel.tsx create mode 100644 web/src/pages/admin/autoscan/SourcesPanel.tsx diff --git a/cmd/silo/main.go b/cmd/silo/main.go index c99ff364..9ef8fe2f 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -35,6 +35,7 @@ import ( "github.com/Silo-Server/silo-server/internal/api" "github.com/Silo-Server/silo-server/internal/api/handlers" "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/autoscan" "github.com/Silo-Server/silo-server/internal/cache" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/catalogseed" @@ -1395,6 +1396,30 @@ func main() { requestReconcileSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(reconcileResolver)) } taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100)) + if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil { + autoscanRepo := autoscan.NewRepository(deps.DB) + autoscanSvc := api.BuildAutoscanService( + autoscanRepo, + pluginService, + pluginInstallationStore, + mediarequests.NewRepository(deps.DB), + settingsRepo, + deps.FolderRepo, + deps.LibraryScanQueue, + deps.RedisClient, + ) + // The poll task's default interval seeds the schedule from the stored + // settings (DefaultPollIntervalSeconds); per-cycle gating still runs + // off the live settings inside PollOnce. Seed in MILLISECONDS as + // seconds*1000 — the SAME computation HandleUpdateSettings uses to + // reschedule — so startup and reschedule agree for sub-minute and + // non-60-multiple intervals (the old seconds/60 minutes path diverged). + var intervalMs int64 = 10 * 60 * 1000 + if settings, serr := autoscanRepo.GetSettings(appCtx); serr == nil && settings.DefaultPollIntervalSeconds > 0 { + intervalMs = int64(settings.DefaultPollIntervalSeconds) * 1000 + } + taskMgr.Register(tasks.NewAutoscanPollTask(autoscanSvc, intervalMs)) + } reconcileProviderIDRepo := catalog.NewProviderIDRepository(deps.DB) reconcileEpisodeRepo := catalog.NewEpisodeRepository(deps.DB) historyResolver := watchstate.NewStableIdentityResolver(nil, reconcileEpisodeRepo, reconcileProviderIDRepo) diff --git a/docs/superpowers/plans/2026-06-02-autoscan-arr-polling.md b/docs/superpowers/plans/2026-06-02-autoscan-arr-polling.md new file mode 100644 index 00000000..e8667068 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-autoscan-arr-polling.md @@ -0,0 +1,1198 @@ +# Autoscan Arr Polling 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:** A periodic task that polls autoscan-enabled Radarr/Sonarr instances for imported files and enqueues targeted Silo library scans for the affected folders. + +**Architecture:** A new `internal/autoscan` package: a `Repository` (settings + sources, joining `request_integrations` for URL/key/kind), a `HistoryClient` (reuses `arrclient` to read `/api/v3/history/since`), pure path-rewrite/dedupe helpers, and a `Service.PollOnce` that resolves imported paths via the existing `scantrigger.Resolver` and enqueues into the existing `scanqueue`, with a Redis suppression key to avoid re-scanning a folder back-to-back. Runs as a `taskmanager.Task`. + +**Tech Stack:** Go (pgx, `github.com/redis/go-redis/v9`, standard `testing`/`httptest`), PostgreSQL (paired numbered migrations), React/TypeScript. + +**Spec:** `docs/superpowers/specs/2026-06-02-autoscan-arr-polling-design.md` + +**Commands assume the repository root is the cwd.** Go tests: `go test ./internal/autoscan/...`. Full lint: `make lint`. Frontend: `cd web && pnpm run lint`. Ensure the Go toolchain is on `PATH` (prepend its `bin` directory if `go` is not found). Use the project's disposable test DB for migrations; never touch a live database. + +--- + +## Phase 0 — Branch + +- [ ] **Step 0.1: Confirm feature branch** + +```bash +git rev-parse --abbrev-ref HEAD # expect: feat/autoscan-arr-polling +``` +If not on it: `git checkout main && git pull && git checkout -b feat/autoscan-arr-polling`. + +--- + +## Phase 1 — Data model + +### Task 1: Migration + +**Files:** +- Create: `migrations/_autoscan.up.sql` +- Create: `migrations/_autoscan.down.sql` + +`` is the next sequential migration number. Find it with: +```bash +ls migrations/ | grep -oE '^[0-9]+' | sort -n | tail -1 +``` +Use that number + 1 (zero-padded to the same width as neighbors). + +- [ ] **Step 1.1: Write the up migration** + +```sql +CREATE TABLE IF NOT EXISTS public.autoscan_settings ( + id boolean PRIMARY KEY DEFAULT true, + enabled boolean NOT NULL DEFAULT false, + poll_interval_minutes integer NOT NULL DEFAULT 10, + debounce_seconds integer NOT NULL DEFAULT 60, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT autoscan_settings_singleton CHECK (id), + CONSTRAINT autoscan_settings_interval_positive CHECK (poll_interval_minutes > 0), + CONSTRAINT autoscan_settings_debounce_nonneg CHECK (debounce_seconds >= 0) +); + +INSERT INTO public.autoscan_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS public.autoscan_sources ( + integration_id text PRIMARY KEY + REFERENCES public.request_integrations(id) ON DELETE CASCADE, + enabled boolean NOT NULL DEFAULT false, + path_rewrites jsonb NOT NULL DEFAULT '[]'::jsonb, + last_poll_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); +``` + +- [ ] **Step 1.2: Write the down migration** + +```sql +DROP TABLE IF EXISTS public.autoscan_sources; +DROP TABLE IF EXISTS public.autoscan_settings; +``` + +- [ ] **Step 1.3: Verify on the disposable DB** + +Apply all migrations (including this one) to the throwaway DB used in prior plans, then: +```bash +# (reset throwaway DB to baseline + run migrations via the project's migrate path) +psql "$CI_DATABASE_URL" -c "\d public.autoscan_sources" +psql "$CI_DATABASE_URL" -c "\d public.autoscan_settings" +``` +Expected: both tables exist; `autoscan_sources.integration_id` FKs `request_integrations` with `ON DELETE CASCADE`. Also apply the `.down.sql` and confirm it drops cleanly. + +- [ ] **Step 1.4: Commit** + +```bash +git add migrations/_autoscan.up.sql migrations/_autoscan.down.sql +git commit -m "feat(autoscan): settings and sources schema" +``` + +### Task 2: Go types + +**Files:** +- Create: `internal/autoscan/types.go` + +- [ ] **Step 2.1: Write the types** + +```go +package autoscan + +import "time" + +// Settings is the global autoscan configuration (singleton row). +type Settings struct { + Enabled bool `json:"enabled"` + PollIntervalMinutes int `json:"poll_interval_minutes"` + DebounceSeconds int `json:"debounce_seconds"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PathRewrite is an optional prefix translation from an arr path to a Silo path. +type PathRewrite struct { + From string `json:"from"` + To string `json:"to"` +} + +// Source is an autoscan-enabled Radarr/Sonarr instance. Kind/BaseURL/APIKeyRef/Name +// are read from request_integrations; the rest live in autoscan_sources. +type Source struct { + IntegrationID string `json:"integration_id"` + Kind string `json:"kind"` + Name string `json:"name"` + BaseURL string `json:"-"` + APIKeyRef string `json:"-"` + Enabled bool `json:"enabled"` + PathRewrites []PathRewrite `json:"path_rewrites"` + LastPollAt *time.Time `json:"last_poll_at,omitempty"` +} + +// SourceUpdate is the admin-editable subset of a source. +type SourceUpdate struct { + Enabled bool `json:"enabled"` + PathRewrites []PathRewrite `json:"path_rewrites"` +} +``` + +- [ ] **Step 2.2: Build + commit** + +```bash +go build ./internal/autoscan/... && git add internal/autoscan/types.go && git commit -m "feat(autoscan): core types" +``` + +--- + +## Phase 2 — Pure helpers (path rewrite + dedupe) + +### Task 3: Path rewrite + +**Files:** +- Create: `internal/autoscan/rewrite.go` +- Create: `internal/autoscan/rewrite_test.go` + +- [ ] **Step 3.1: Write the failing test** + +```go +package autoscan + +import "testing" + +func TestApplyRewrites(t *testing.T) { + rw := []PathRewrite{{From: "/data/media", To: "/mnt/media"}} + cases := []struct{ in, want string }{ + {"/data/media/Movies/Dune/Dune.mkv", "/mnt/media/Movies/Dune/Dune.mkv"}, // prefix match + {"/other/path/file.mkv", "/other/path/file.mkv"}, // no match -> passthrough + } + for _, tc := range cases { + if got := applyRewrites(tc.in, rw); got != tc.want { + t.Fatalf("applyRewrites(%q) = %q, want %q", tc.in, got, tc.want) + } + } + // first match wins + multi := []PathRewrite{{From: "/data", To: "/A"}, {From: "/data/media", To: "/B"}} + if got := applyRewrites("/data/media/x", multi); got != "/A/media/x" { + t.Fatalf("first-match: got %q", got) + } + // empty rewrites -> passthrough + if got := applyRewrites("/data/media/x", nil); got != "/data/media/x" { + t.Fatalf("nil rewrites: got %q", got) + } +} +``` + +- [ ] **Step 3.2: Run it — expect failure** + +```bash +go test ./internal/autoscan/ -run TestApplyRewrites -v +``` +Expected: FAIL (`applyRewrites` undefined). + +- [ ] **Step 3.3: Implement** + +`internal/autoscan/rewrite.go`: +```go +package autoscan + +import "strings" + +// applyRewrites returns path with the first matching prefix rewrite applied, +// or path unchanged when none match. +func applyRewrites(path string, rewrites []PathRewrite) string { + for _, rw := range rewrites { + from := strings.TrimSpace(rw.From) + if from == "" { + continue + } + if strings.HasPrefix(path, from) { + return strings.TrimSpace(rw.To) + strings.TrimPrefix(path, from) + } + } + return path +} +``` + +- [ ] **Step 3.4: Run it — expect pass; commit** + +```bash +go test ./internal/autoscan/ -run TestApplyRewrites -v +git add internal/autoscan/rewrite.go internal/autoscan/rewrite_test.go +git commit -m "feat(autoscan): path rewrite helper" +``` + +### Task 4: Dedupe imported paths to unique parent folders + +**Files:** +- Create: `internal/autoscan/dedupe.go` +- Create: `internal/autoscan/dedupe_test.go` + +- [ ] **Step 4.1: Write the failing test** + +```go +package autoscan + +import ( + "reflect" + "sort" + "testing" +) + +func TestUniqueParentDirs(t *testing.T) { + in := []string{ + "/mnt/media/Show/Season 01/E01.mkv", + "/mnt/media/Show/Season 01/E02.mkv", // same dir -> collapse + "/mnt/media/Movie/Movie.mkv", + "", // empty -> ignored + } + got := uniqueParentDirs(in) + sort.Strings(got) + want := []string{"/mnt/media/Movie", "/mnt/media/Show/Season 01"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("uniqueParentDirs = %v, want %v", got, want) + } +} +``` + +- [ ] **Step 4.2: Run it — expect failure** + +```bash +go test ./internal/autoscan/ -run TestUniqueParentDirs -v +``` +Expected: FAIL (`uniqueParentDirs` undefined). + +- [ ] **Step 4.3: Implement** + +`internal/autoscan/dedupe.go`: +```go +package autoscan + +import "path/filepath" + +// uniqueParentDirs maps imported file paths to their distinct parent directories, +// dropping empties. A season's episodes in one folder collapse to one entry. +func uniqueParentDirs(paths []string) []string { + seen := make(map[string]struct{}) + var out []string + for _, p := range paths { + if p == "" { + continue + } + dir := filepath.Dir(p) + if _, ok := seen[dir]; ok { + continue + } + seen[dir] = struct{}{} + out = append(out, dir) + } + return out +} +``` + +- [ ] **Step 4.4: Run it — expect pass; commit** + +```bash +go test ./internal/autoscan/ -run TestUniqueParentDirs -v +git add internal/autoscan/dedupe.go internal/autoscan/dedupe_test.go +git commit -m "feat(autoscan): dedupe imported paths to parent folders" +``` + +--- + +## Phase 3 — Arr history client + +### Task 5: ImportedPaths from /api/v3/history/since + +**Files:** +- Create: `internal/autoscan/history.go` +- Create: `internal/autoscan/history_test.go` + +- [ ] **Step 5.1: Write the failing test (httptest)** + +```go +package autoscan + +import ( + "context" + "net/http" + "net/http/httptest" + "sort" + "testing" + "time" +) + +func TestArrHistoryImportedPaths(t *testing.T) { + // Both Radarr and Sonarr return history records with a string eventType and + // data.importedPath on downloadFolderImported events. + body := `[ + {"eventType":"downloadFolderImported","data":{"importedPath":"/mnt/media/Movies/Dune (2021)/Dune.mkv"}}, + {"eventType":"grabbed","data":{"importedPath":"/should/be/ignored"}}, + {"eventType":"downloadFolderImported","data":{"importedPath":"/mnt/media/Show/S01/E01.mkv"}} + ]` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/history/since" { + t.Errorf("unexpected path %s", r.URL.Path) + } + if r.URL.Query().Get("date") == "" { + t.Errorf("missing date param") + } + if r.Header.Get("X-Api-Key") != "k" { + t.Errorf("missing api key header") + } + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + c := NewArrHistoryClient(nil) + paths, err := c.ImportedPaths(context.Background(), srv.URL, "k", time.Unix(0, 0).UTC()) + if err != nil { + t.Fatalf("ImportedPaths: %v", err) + } + sort.Strings(paths) + want := []string{"/mnt/media/Movies/Dune (2021)/Dune.mkv", "/mnt/media/Show/S01/E01.mkv"} + if len(paths) != 2 || paths[0] != want[0] || paths[1] != want[1] { + t.Fatalf("ImportedPaths = %v, want %v", paths, want) + } +} +``` + +> Verify the `arrclient` sets the API key as the `X-Api-Key` header (read `internal/requests/arrclient/client.go` `DoJSON`). If it uses a query param instead, adjust the test's assertion to match — do not change the client. + +- [ ] **Step 5.2: Run it — expect failure** + +```bash +go test ./internal/autoscan/ -run TestArrHistoryImportedPaths -v +``` +Expected: FAIL (`NewArrHistoryClient` undefined). + +- [ ] **Step 5.3: Implement** + +`internal/autoscan/history.go`: +```go +package autoscan + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/Silo-Server/silo-server/internal/requests/arrclient" +) + +// HistoryClient reads recently-imported file paths from a Radarr/Sonarr instance. +type HistoryClient interface { + ImportedPaths(ctx context.Context, baseURL, apiKey string, since time.Time) ([]string, error) +} + +type historyRecord struct { + EventType string `json:"eventType"` + Data struct { + ImportedPath string `json:"importedPath"` + } `json:"data"` +} + +const importedEventType = "downloadFolderImported" + +type arrHistoryClient struct { + httpClient *http.Client +} + +// NewArrHistoryClient returns a HistoryClient backed by the shared arrclient. +func NewArrHistoryClient(httpClient *http.Client) HistoryClient { + return &arrHistoryClient{httpClient: httpClient} +} + +func (c *arrHistoryClient) ImportedPaths(ctx context.Context, baseURL, apiKey string, since time.Time) ([]string, error) { + client := arrclient.New(baseURL, apiKey, c.httpClient) + q := url.Values{} + q.Set("date", since.UTC().Format(time.RFC3339)) + var records []historyRecord + if err := client.GetJSON(ctx, "/api/v3/history/since?"+q.Encode(), &records); err != nil { + return nil, fmt.Errorf("autoscan: poll history: %w", err) + } + var paths []string + for _, rec := range records { + if rec.EventType != importedEventType { + continue + } + if rec.Data.ImportedPath != "" { + paths = append(paths, rec.Data.ImportedPath) + } + } + return paths, nil +} +``` + +- [ ] **Step 5.4: Run it — expect pass; commit** + +```bash +go test ./internal/autoscan/ -run TestArrHistoryImportedPaths -v +git add internal/autoscan/history.go internal/autoscan/history_test.go +git commit -m "feat(autoscan): arr import-history client" +``` + +--- + +## Phase 4 — Repository + +### Task 6: autoscan repository + +**Files:** +- Create: `internal/autoscan/repository.go` + +- [ ] **Step 6.1: Implement the repository** + +```go +package autoscan + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository struct{ pool *pgxpool.Pool } + +func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +func (r *Repository) GetSettings(ctx context.Context) (Settings, error) { + var s Settings + err := r.pool.QueryRow(ctx, ` + SELECT enabled, poll_interval_minutes, debounce_seconds, updated_at + FROM autoscan_settings WHERE id = true`). + Scan(&s.Enabled, &s.PollIntervalMinutes, &s.DebounceSeconds, &s.UpdatedAt) + if err != nil { + return Settings{}, fmt.Errorf("get autoscan settings: %w", err) + } + return s, nil +} + +func (r *Repository) UpdateSettings(ctx context.Context, s Settings) (Settings, error) { + var out Settings + err := r.pool.QueryRow(ctx, ` + UPDATE autoscan_settings + SET enabled = $1, poll_interval_minutes = $2, debounce_seconds = $3, updated_at = now() + WHERE id = true + RETURNING enabled, poll_interval_minutes, debounce_seconds, updated_at`, + s.Enabled, s.PollIntervalMinutes, s.DebounceSeconds). + Scan(&out.Enabled, &out.PollIntervalMinutes, &out.DebounceSeconds, &out.UpdatedAt) + if err != nil { + return Settings{}, fmt.Errorf("update autoscan settings: %w", err) + } + return out, nil +} + +// sourceColumns selects an autoscan source joined with its request_integrations row. +// LEFT JOIN so a source row whose instance was deleted still surfaces (it will be +// pruned by the FK cascade in practice, but the join stays defensive). +const sourceSelect = ` + SELECT ri.id, ri.kind, ri.name, ri.base_url, ri.api_key_ref, + COALESCE(s.enabled, false), COALESCE(s.path_rewrites, '[]'::jsonb), s.last_poll_at + FROM request_integrations ri + LEFT JOIN autoscan_sources s ON s.integration_id = ri.id` + +func scanSource(row interface{ Scan(...any) error }) (Source, error) { + var src Source + var rewritesRaw []byte + var lastPoll *time.Time + if err := row.Scan(&src.IntegrationID, &src.Kind, &src.Name, &src.BaseURL, &src.APIKeyRef, + &src.Enabled, &rewritesRaw, &lastPoll); err != nil { + return Source{}, err + } + if len(rewritesRaw) > 0 { + if err := json.Unmarshal(rewritesRaw, &src.PathRewrites); err != nil { + return Source{}, fmt.Errorf("unmarshal path_rewrites for %s: %w", src.IntegrationID, err) + } + } + src.LastPollAt = lastPoll + return src, nil +} + +// ListAllSources returns every Radarr/Sonarr instance with its autoscan state +// (for the admin UI — includes disabled). +func (r *Repository) ListAllSources(ctx context.Context) ([]Source, error) { + rows, err := r.pool.Query(ctx, sourceSelect+` ORDER BY ri.kind, ri.name`) + if err != nil { + return nil, fmt.Errorf("list autoscan sources: %w", err) + } + defer rows.Close() + var out []Source + for rows.Next() { + src, err := scanSource(rows) + if err != nil { + return nil, err + } + out = append(out, src) + } + return out, rows.Err() +} + +// ListEnabledSources returns only autoscan-enabled instances whose underlying +// integration is itself enabled (the poll set). +func (r *Repository) ListEnabledSources(ctx context.Context) ([]Source, error) { + rows, err := r.pool.Query(ctx, sourceSelect+ + ` WHERE s.enabled = true AND ri.enabled = true ORDER BY ri.kind, ri.name`) + if err != nil { + return nil, fmt.Errorf("list enabled autoscan sources: %w", err) + } + defer rows.Close() + var out []Source + for rows.Next() { + src, err := scanSource(rows) + if err != nil { + return nil, err + } + out = append(out, src) + } + return out, rows.Err() +} + +// UpsertSource sets the per-instance autoscan toggle + rewrites. +func (r *Repository) UpsertSource(ctx context.Context, integrationID string, u SourceUpdate) (*Source, error) { + rewrites, err := json.Marshal(u.PathRewrites) + if err != nil { + return nil, fmt.Errorf("marshal path_rewrites: %w", err) + } + if u.PathRewrites == nil { + rewrites = []byte("[]") + } + if _, err := r.pool.Exec(ctx, ` + INSERT INTO autoscan_sources (integration_id, enabled, path_rewrites, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (integration_id) DO UPDATE SET + enabled = EXCLUDED.enabled, + path_rewrites = EXCLUDED.path_rewrites, + updated_at = now()`, + integrationID, u.Enabled, rewrites); err != nil { + return nil, fmt.Errorf("upsert autoscan source: %w", err) + } + row := r.pool.QueryRow(ctx, sourceSelect+` WHERE ri.id = $1`, integrationID) + src, err := scanSource(row) + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("integration not found: %s", integrationID) + } + return nil, err + } + return &src, nil +} + +// AdvanceLastPoll sets last_poll_at for a source (creating the row if needed). +func (r *Repository) AdvanceLastPoll(ctx context.Context, integrationID string, at time.Time) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO autoscan_sources (integration_id, last_poll_at, updated_at) + VALUES ($1, $2, now()) + ON CONFLICT (integration_id) DO UPDATE SET last_poll_at = $2, updated_at = now()`, + integrationID, at) + if err != nil { + return fmt.Errorf("advance autoscan last_poll: %w", err) + } + return nil +} +``` + +- [ ] **Step 6.2: Build + commit** + +```bash +go build ./internal/autoscan/... +git add internal/autoscan/repository.go +git commit -m "feat(autoscan): settings + sources repository" +``` + +> The repository is exercised end-to-end by the `PollOnce` service tests (Task 8) via a fake store, and by the migration check (Task 1). A DB-backed repo test is optional; if the project has a Postgres test harness, add `repository_test.go` mirroring the requests repo tests. + +--- + +## Phase 5 — Service + +### Task 7: Suppression (Redis) seam + +**Files:** +- Create: `internal/autoscan/suppress.go` + +- [ ] **Step 7.1: Implement the suppressor** + +```go +package autoscan + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// Suppressor prevents re-enqueuing a scan for the same folder within a window. +type Suppressor interface { + // ShouldScan atomically claims the folder for scanning: returns true and sets + // a TTL key if no claim exists, false if a recent claim is still live. + ShouldScan(ctx context.Context, folderID int, ttl time.Duration) (bool, error) +} + +type redisSuppressor struct{ client *redis.Client } + +func NewRedisSuppressor(client *redis.Client) Suppressor { return &redisSuppressor{client: client} } + +func (s *redisSuppressor) ShouldScan(ctx context.Context, folderID int, ttl time.Duration) (bool, error) { + if s.client == nil || ttl <= 0 { + return true, nil // no suppression configured -> always scan + } + key := fmt.Sprintf("autoscan:scanned:%d", folderID) + ok, err := s.client.SetNX(ctx, key, "1", ttl).Result() + if err != nil { + return true, nil // fail open: a Redis hiccup should not block scanning + } + return ok, nil +} +``` + +- [ ] **Step 7.2: Build + commit** + +```bash +go build ./internal/autoscan/... +git add internal/autoscan/suppress.go +git commit -m "feat(autoscan): redis scan-suppression seam" +``` + +### Task 8: Service.PollOnce + +**Files:** +- Create: `internal/autoscan/service.go` +- Create: `internal/autoscan/service_test.go` + +- [ ] **Step 8.1: Write the failing test** + +```go +package autoscan + +import ( + "context" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scantrigger" +) + +type fakeStore struct { + settings Settings + sources []Source + advanced map[string]time.Time +} + +func (f *fakeStore) GetSettings(context.Context) (Settings, error) { return f.settings, nil } +func (f *fakeStore) ListEnabledSources(context.Context) ([]Source, error) { return f.sources, nil } +func (f *fakeStore) AdvanceLastPoll(_ context.Context, id string, at time.Time) error { + if f.advanced == nil { + f.advanced = map[string]time.Time{} + } + f.advanced[id] = at + return nil +} + +type fakeHistory struct { + paths map[string][]string // baseURL -> imported paths + err error +} + +func (f *fakeHistory) ImportedPaths(_ context.Context, baseURL, _ string, _ time.Time) ([]string, error) { + if f.err != nil { + return nil, f.err + } + return f.paths[baseURL], nil +} + +type fakeResolver struct{} + +func (fakeResolver) Resolve(_ context.Context, req scantrigger.Request) (*scantrigger.Target, error) { + // Resolve any /mnt/media path to folder 7; anything else is unresolvable. + if len(req.Path) >= 11 && req.Path[:11] == "/mnt/media/" { + return &scantrigger.Target{Folder: &models.MediaFolder{ID: 7}, Mode: scantrigger.ModeSubtree, Path: req.Path, Trigger: req.Trigger}, nil + } + return nil, nil +} + +type recordingQueuer struct{ enqueued []scantrigger.Target } + +func (q *recordingQueuer) EnqueueScans(_ context.Context, targets []scantrigger.Target) error { + q.enqueued = append(q.enqueued, targets...) + return nil +} + +type allowSuppressor struct{} + +func (allowSuppressor) ShouldScan(context.Context, int, time.Duration) (bool, error) { return true, nil } + +func TestPollOnceEnqueuesDedupedFolders(t *testing.T) { + store := &fakeStore{ + settings: Settings{Enabled: true, PollIntervalMinutes: 10, DebounceSeconds: 60}, + sources: []Source{{ + IntegrationID: "i1", Kind: "sonarr", BaseURL: "http://sonarr", APIKeyRef: "k", Enabled: true, + }}, + } + hist := &fakeHistory{paths: map[string][]string{ + "http://sonarr": { + "/mnt/media/Show/S01/E01.mkv", + "/mnt/media/Show/S01/E02.mkv", // same folder -> dedup + "/outside/lib/x.mkv", // unresolvable -> skipped + }, + }} + q := &recordingQueuer{} + svc := NewService(store, hist, fakeResolver{}, q, allowSuppressor{}, nil) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 1 { + t.Fatalf("expected 1 deduped folder scan, got %d: %+v", len(q.enqueued), q.enqueued) + } + if q.enqueued[0].Trigger != "autoscan" || q.enqueued[0].Folder.ID != 7 { + t.Fatalf("unexpected target: %+v", q.enqueued[0]) + } + if _, ok := store.advanced["i1"]; !ok { + t.Fatalf("expected last_poll advanced for i1") + } +} + +func TestPollOnceDisabledNoop(t *testing.T) { + store := &fakeStore{settings: Settings{Enabled: false}} + q := &recordingQueuer{} + svc := NewService(store, &fakeHistory{}, fakeResolver{}, q, allowSuppressor{}, nil) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 0 { + t.Fatalf("disabled autoscan should enqueue nothing, got %d", len(q.enqueued)) + } +} +``` + +- [ ] **Step 8.2: Run it — expect failure** + +```bash +go test ./internal/autoscan/ -run TestPollOnce -v +``` +Expected: FAIL (`NewService` undefined). + +- [ ] **Step 8.3: Implement** + +`internal/autoscan/service.go`: +```go +package autoscan + +import ( + "context" + "log/slog" + "time" + + "github.com/Silo-Server/silo-server/internal/scantrigger" +) + +const scanTrigger = "autoscan" + +// Store is the persistence the service needs. +type Store interface { + GetSettings(ctx context.Context) (Settings, error) + ListEnabledSources(ctx context.Context) ([]Source, error) + AdvanceLastPoll(ctx context.Context, integrationID string, at time.Time) error +} + +// Resolver maps a filesystem path to a Silo scan target. +type Resolver interface { + Resolve(ctx context.Context, req scantrigger.Request) (*scantrigger.Target, error) +} + +// Queuer enqueues resolved scan targets. +type Queuer interface { + EnqueueScans(ctx context.Context, targets []scantrigger.Target) error +} + +// SecretResolver decrypts an api_key_ref into a usable key. +type SecretResolver interface { + Get(ctx context.Context, key string) (string, error) +} + +type Service struct { + store Store + history HistoryClient + resolver Resolver + queue Queuer + suppress Suppressor + secrets SecretResolver + now func() time.Time +} + +func NewService(store Store, history HistoryClient, resolver Resolver, queue Queuer, suppress Suppressor, secrets SecretResolver) *Service { + return &Service{ + store: store, history: history, resolver: resolver, queue: queue, + suppress: suppress, secrets: secrets, + now: func() time.Time { return time.Now().UTC() }, + } +} + +// PollOnce runs one autoscan cycle. Per-source failures are logged and skipped; +// only the overall settings/listing errors propagate. +func (s *Service) PollOnce(ctx context.Context) error { + settings, err := s.store.GetSettings(ctx) + if err != nil { + return err + } + if !settings.Enabled { + return nil + } + sources, err := s.store.ListEnabledSources(ctx) + if err != nil { + return err + } + ttl := time.Duration(settings.DebounceSeconds) * time.Second + + for _, src := range sources { + cycleStart := s.now() + // First enable: last_poll_at null -> floor at cycleStart (don't replay history). + since := cycleStart + if src.LastPollAt != nil { + since = *src.LastPollAt + } + + apiKey := src.APIKeyRef + if s.secrets != nil && apiKey != "" { + if resolved, rerr := s.secrets.Get(ctx, apiKey); rerr == nil && resolved != "" { + apiKey = resolved + } + } + + paths, perr := s.history.ImportedPaths(ctx, src.BaseURL, apiKey, since) + if perr != nil { + slog.WarnContext(ctx, "autoscan: source poll failed", "integration_id", src.IntegrationID, "err", perr) + continue // do not advance last_poll -> retry window next cycle + } + + // rewrite -> dedupe -> resolve -> suppress -> enqueue + rewritten := make([]string, 0, len(paths)) + for _, p := range paths { + rewritten = append(rewritten, applyRewrites(p, src.PathRewrites)) + } + var targets []scantrigger.Target + for _, dir := range uniqueParentDirs(rewritten) { + target, rerr := s.resolver.Resolve(ctx, scantrigger.Request{Path: dir, Trigger: scanTrigger}) + if rerr != nil { + slog.WarnContext(ctx, "autoscan: resolve failed", "path", dir, "err", rerr) + continue + } + if target == nil || target.Folder == nil { + continue // outside Silo's media folders + } + ok, serr := s.suppress.ShouldScan(ctx, target.Folder.ID, ttl) + if serr != nil || !ok { + continue + } + target.Trigger = scanTrigger + targets = append(targets, *target) + } + if len(targets) > 0 { + if eerr := s.queue.EnqueueScans(ctx, targets); eerr != nil { + slog.WarnContext(ctx, "autoscan: enqueue failed", "integration_id", src.IntegrationID, "err", eerr) + continue // do not advance -> retry + } + } + if aerr := s.store.AdvanceLastPoll(ctx, src.IntegrationID, cycleStart); aerr != nil { + slog.WarnContext(ctx, "autoscan: advance last_poll failed", "integration_id", src.IntegrationID, "err", aerr) + } + } + return nil +} +``` + +- [ ] **Step 8.4: Run tests — expect pass** + +```bash +go test ./internal/autoscan/... -v +``` +Expected: all PASS. (`Service.PollOnce`, rewrite, dedupe, history.) + +- [ ] **Step 8.5: Commit** + +```bash +git add internal/autoscan/service.go internal/autoscan/service_test.go +git commit -m "feat(autoscan): PollOnce poll cycle" +``` + +--- + +## Phase 6 — Task + wiring + +### Task 9: Poll task + main.go wiring + +**Files:** +- Create: `internal/taskmanager/tasks/autoscan_poll.go` +- Modify: `cmd/silo/main.go` (construct the service + register the task) +- Modify: `internal/api/router.go` (if the service/handler is built there instead) + +- [ ] **Step 9.1: Implement the task** (mirror `reconcile_requests.go`) + +```go +package tasks + +import ( + "context" + "fmt" + + "github.com/Silo-Server/silo-server/internal/taskmanager" +) + +type AutoscanPoller interface { + PollOnce(ctx context.Context) error + PollIntervalMinutes(ctx context.Context) int +} + +type AutoscanPollTask struct { + poller AutoscanPoller + intervalMinutes int +} + +func NewAutoscanPollTask(poller AutoscanPoller, intervalMinutes int) *AutoscanPollTask { + if intervalMinutes <= 0 { + intervalMinutes = 10 + } + return &AutoscanPollTask{poller: poller, intervalMinutes: intervalMinutes} +} + +func (t *AutoscanPollTask) Key() string { return "autoscan_poll" } +func (t *AutoscanPollTask) Name() string { return "Autoscan Poll" } +func (t *AutoscanPollTask) Description() string { + return "Polls autoscan-enabled Radarr/Sonarr instances for imported files and scans the affected folders" +} +func (t *AutoscanPollTask) Category() taskmanager.TaskCategory { return taskmanager.TaskCategoryLibrary } +func (t *AutoscanPollTask) IsHidden() bool { return false } + +func (t *AutoscanPollTask) DefaultTriggers() []taskmanager.TriggerConfig { + return []taskmanager.TriggerConfig{ + {Type: taskmanager.TriggerTypeInterval, IntervalMs: int64(t.intervalMinutes) * 60 * 1000}, + } +} + +func (t *AutoscanPollTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { + progress.Report(0, "Polling arr instances") + if t.poller == nil { + progress.Report(100, "Autoscan unavailable") + return nil + } + if err := t.poller.PollOnce(ctx); err != nil { + return fmt.Errorf("autoscan poll: %w", err) + } + progress.Report(100, "Autoscan poll complete") + return nil +} +``` + +> Add a `PollIntervalMinutes(ctx)` helper to the autoscan `Service` (reads `GetSettings`, falls back to 10) so `main.go` can seed `DefaultTriggers`. Keep it tolerant of errors (return 10 on failure). + +- [ ] **Step 9.2: Construct the service and register the task in `main.go`** + +Find where `libraryScanQueue` (`deps.LibraryScanQueue`), `deps.FolderRepo`, `deps.RedisClient`, `settingsRepo`, and `taskMgr.Register(...)` are in scope (around the other `taskMgr.Register` calls). Build: + +```go +autoscanResolver := scantrigger.NewResolver(deps.FolderRepo) // FolderRepo satisfies scantrigger.FolderRepository +autoscanSvc := autoscan.NewService( + autoscan.NewRepository(deps.DB), + autoscan.NewArrHistoryClient(nil), + autoscanResolver, + deps.LibraryScanQueue, // *scanqueue.Service satisfies autoscan.Queuer (EnqueueScans) + autoscan.NewRedisSuppressor(deps.RedisClient), + settingsRepo, // ServerSettingsRepo satisfies autoscan.SecretResolver (Get) +) +taskMgr.Register(tasks.NewAutoscanPollTask(autoscanSvc, autoscanSvc.PollIntervalMinutes(ctx))) +``` + +Guard each dependency for nil exactly like neighboring registrations (e.g. only register when `deps.FolderRepo != nil && deps.LibraryScanQueue != nil`). If `scantrigger.NewResolver` needs a concrete repo type, pass `deps.FolderRepo` (it already satisfies `GetByID`/`List`). Confirm `*scanqueue.Service` has `EnqueueScans(ctx, []scantrigger.Target) error` (it does) so it satisfies `autoscan.Queuer`. + +- [ ] **Step 9.3: Build the whole server** + +```bash +go build ./... # requires libvips locally; if unavailable, build ./internal/... ./cmd/... minus the bimg-dependent packages, and rely on CI/Docker for the full build +go vet ./internal/autoscan/... ./internal/taskmanager/... +``` +Expected: autoscan + taskmanager packages build/vet clean. + +- [ ] **Step 9.4: Commit** + +```bash +git add internal/taskmanager/tasks/autoscan_poll.go cmd/silo/main.go +git commit -m "feat(autoscan): poll task and wiring" +``` + +--- + +## Phase 7 — Admin API + +### Task 10: Autoscan handlers + routes + +**Files:** +- Create: `internal/api/handlers/autoscan.go` +- Create: `internal/api/handlers/autoscan_test.go` +- Modify: `internal/api/router.go` (mount routes) + +- [ ] **Step 10.1: Service methods the handler needs** + +Add to `internal/autoscan/service.go` (admin-facing, thin wrappers over the repo; the service already holds the store as the narrow `Store` interface — widen the concrete service to also expose these by holding the `*Repository` or adding methods to `Store`). Simplest: have the handler take the `*autoscan.Repository` directly for reads/writes and the `*autoscan.Service` for `PollOnce`. Implement handler methods: +- `GET /autoscan/settings` → `repo.GetSettings` +- `PUT /autoscan/settings` → validate (`poll_interval_minutes > 0`, `debounce_seconds >= 0`) → `repo.UpdateSettings` +- `GET /autoscan/sources` → `repo.ListAllSources` (map to a response that omits `BaseURL`/`APIKeyRef` — they have `json:"-"` already, so the `Source` struct is safe to return directly) +- `PUT /autoscan/sources/{id}` → decode `SourceUpdate` → `repo.UpsertSource` +- `POST /autoscan/trigger` → `service.PollOnce` (run in a short-lived goroutine or inline; return 202) +- `GET /autoscan/status` → `{enabled, sources:[{integration_id, name, last_poll_at}]}` from `repo.GetSettings` + `repo.ListAllSources` + +Follow the existing admin handler style in `internal/api/handlers/requests.go` (viewer/admin extraction, `writeJSON`, error mapping helper, chi `URLParam`). + +- [ ] **Step 10.2: Mount routes in `router.go`** + +Near the request-integration routes (admin group), add: +```go +autoscanHandler := handlers.NewAutoscanHandler(autoscanRepo, autoscanSvc) +r.Route("/autoscan", func(r chi.Router) { + r.Get("/settings", autoscanHandler.HandleGetSettings) + r.Put("/settings", autoscanHandler.HandleUpdateSettings) + r.Get("/sources", autoscanHandler.HandleListSources) + r.Put("/sources/{id}", autoscanHandler.HandleUpsertSource) + r.Post("/trigger", autoscanHandler.HandleTrigger) + r.Get("/status", autoscanHandler.HandleStatus) +}) +``` +Apply the same admin auth + a rate limiter on `/trigger` (tight per-admin cap) consistent with other admin mutation routes. + +- [ ] **Step 10.3: Handler test** + +Add `autoscan_test.go` with a fake repo/service asserting: GET settings returns JSON; PUT validates a non-positive interval (400); `/sources` response never contains `base_url`/`api_key_ref`; `/trigger` invokes `PollOnce`. Mirror `requests_test.go`'s fake-service pattern. + +- [ ] **Step 10.4: Build (handlers pkg needs libvips — verify in Docker/CI), gofmt, commit** + +```bash +gofmt -l internal/api/handlers/autoscan.go internal/api/router.go +git add internal/api/handlers/autoscan.go internal/api/handlers/autoscan_test.go internal/api/router.go +git commit -m "feat(autoscan): admin API endpoints" +``` + +--- + +## Phase 8 — Frontend + +> Follow existing patterns in `web/src/pages/AdminRequests.tsx`, `web/src/api/types.ts`, and `web/src/hooks/queries/useRequests.ts`. Each task ends with `cd web && pnpm exec tsc -b` (the REAL typecheck — `tsc --noEmit` checks nothing), `pnpm run lint`, `pnpm exec prettier --check `. + +### Task 11: Types + hooks + +**Files:** +- Modify: `web/src/api/types.ts` +- Create: `web/src/hooks/queries/useAutoscan.ts` + +- [ ] **Step 11.1: Types** + +Add to `types.ts`: +```ts +export interface AutoscanSettings { + enabled: boolean; + poll_interval_minutes: number; + debounce_seconds: number; + updated_at?: string; +} +export interface AutoscanPathRewrite { + from: string; + to: string; +} +export interface AutoscanSource { + integration_id: string; + kind: string; + name: string; + enabled: boolean; + path_rewrites: AutoscanPathRewrite[]; + last_poll_at?: string | null; +} +``` + +- [ ] **Step 11.2: Hooks** + +`useAutoscan.ts`: `useAutoscanSettings()` (GET `/admin/autoscan/settings`), `useUpdateAutoscanSettings()` (PUT), `useAutoscanSources()` (GET `/admin/autoscan/sources`), `useUpdateAutoscanSource()` (PUT `/admin/autoscan/sources/{id}`), `useTriggerAutoscan()` (POST `/admin/autoscan/trigger`). Match the mutation/query/invalidation/toast conventions in `useRequests.ts`. + +- [ ] **Step 11.3: Lint + commit** + +```bash +cd web && pnpm exec tsc -b && pnpm run lint && pnpm exec prettier --check src/api/types.ts src/hooks/queries/useAutoscan.ts +git add web/src/api/types.ts web/src/hooks/queries/useAutoscan.ts +git commit -m "feat(web): autoscan types and hooks" +``` + +### Task 12: Autoscan tab + +**Files:** +- Modify: `web/src/pages/AdminRequests.tsx` + +- [ ] **Step 12.1: Add the tab** + +Add `"autoscan"` to `ADMIN_REQUEST_TABS`, a `Autoscan`, and a ``. Implement `AutoscanTab`: +- Global settings card: enable `SwitchField`, poll-interval `Input` (number, min 1), debounce-seconds `Input` (number, min 0), Save button (`useUpdateAutoscanSettings`). +- Per-source list (`useAutoscanSources`): one row per instance — name + kind badge, an autoscan `SwitchField`, a collapsible path-rewrite editor (add/remove `from → to` rows), read-only "Last polled" (`last_poll_at`), and a Save button (`useUpdateAutoscanSource`). +- A "Poll now" button (`useTriggerAutoscan`) with a success toast. + +Reuse `Field`, `SwitchField`, `Input`, `Button`, `Badge` already in the file. + +- [ ] **Step 12.2: Lint + commit** + +```bash +cd web && pnpm exec tsc -b && pnpm run lint && pnpm exec prettier --check src/pages/AdminRequests.tsx +git add web/src/pages/AdminRequests.tsx +git commit -m "feat(web): autoscan admin tab" +``` + +--- + +## Phase 9 — Verification + +### Task 13: Full verification + +- [ ] **Step 13.1: Go** + +```bash +go test ./internal/autoscan/... ./internal/taskmanager/... 2>&1 | tail +go vet ./internal/autoscan/... +gofmt -l internal/autoscan internal/taskmanager/tasks/autoscan_poll.go +``` +Expected: all tests pass; vet + gofmt clean. + +- [ ] **Step 13.2: Frontend** + +```bash +cd web && pnpm exec tsc -b && pnpm run lint && pnpm run format:check +``` + +- [ ] **Step 13.3: Docker build (closes the libvips gap)** + +```bash +docker build --build-arg BUILD_REVISION=$(git rev-parse HEAD) --build-arg BUILD_DIRTY=false -t silo-server:autoscan . +``` +Expected: exit 0 (proves the handlers package + full vite build compile). + +- [ ] **Step 13.4: Smoke (optional, local deploy)** + +Enable autoscan in the Autoscan tab, toggle a Radarr/Sonarr source on, import something in that arr (or "Poll now"), and confirm a scan with `trigger=autoscan` appears in the scan history and the folder is scanned. + +- [ ] **Step 13.5: `make verify-local-paths`** + +```bash +make verify-local-paths +``` + +--- + +## Self-review notes (resolved) + +- **Spec §1 data model** → Tasks 1–2, 6. **§2 poll cycle** → Task 8. **§3 path resolution** → Tasks 3, 4, 8 (reuses `scantrigger`/`scanqueue` unchanged). **§4 scheduling** → Task 9. **§5 API/UI** → Tasks 10–12. **§6 testing** → Tasks 3,4,5,8,10,11,12 + Task 13. +- **First-enable floor** (spec §2.3.3): `since = cycleStart` when `LastPollAt == nil` (Task 8.3) — does not replay history. +- **Reuse, not duplicate**: history client uses `arrclient`; resolution uses `scantrigger.Resolver`; enqueue uses `scanqueue` (`autoscan.Queuer` is satisfied by `*scanqueue.Service`). Credentials come from `request_integrations` via the join (Task 6) + the shared `SecretResolver` (Task 8). +- **No fan-out guard / retry queue** by design — per-source failure skips advancing `last_poll_at` so the window retries (Task 8.3). +- **Type consistency**: `Source`, `Settings`, `SourceUpdate`, `PathRewrite`, `HistoryClient.ImportedPaths`, `Suppressor.ShouldScan`, `Service.PollOnce`, `Store`/`Resolver`/`Queuer`/`SecretResolver` interfaces are used consistently across Tasks 2–10. diff --git a/docs/superpowers/plans/2026-06-02-autoscan-host-backend.md b/docs/superpowers/plans/2026-06-02-autoscan-host-backend.md new file mode 100644 index 00000000..803f4560 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-autoscan-host-backend.md @@ -0,0 +1,779 @@ +# Autoscan Host Backend Implementation Plan (Part 1 of 2) + +> **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:** Build the silo-server backend for the standalone Autoscan category: poll `scan_source.v1` plugins on a timer, resolve their Silo-native paths to library folders, and enqueue rescans — with connections decoupled from Requests. + +**Architecture:** Mirror the existing `scheduled_task.v1` host dispatch (`pluginhost.Client` capability wrapper + `plugins.Service` resolver + a `taskmanager` task). Generalize the closed PR #43 autoscan engine (resolve → suppress → enqueue) so its input is "Silo-native paths from a scan-source plugin" instead of "arr history." Replace the old `request_integrations`-coupled schema with `autoscan_connections` + `autoscan_sources`. + +**Tech Stack:** Go, pgx/v5, the `internal/plugins`/`internal/pluginhost` runtime, `internal/scantrigger`/`internal/scanqueue`, `internal/taskmanager`, numbered SQL migrations, `github.com/Silo-Server/silo-plugin-sdk` (the new `scan_source.v1` capability). + +Commands assume the silo-server repository root is the cwd. This is **Part 1 (backend)**; the Autoscan admin UI is Part 2. + +--- + +## Prerequisites + +1. **SDK dependency.** This plan needs the SDK's `scan_source.v1` symbols (`runtime.Client.ScanSource()`, `pluginv1.ScanSourceClient`, `pluginv1.PollChangesRequest/Response`, `capability.ScanSource`). They are in `silo-plugin-sdk` PR #2 but **not yet released**. Until `v0.5.0` is tagged, add a local replace directive so the host builds against the working checkout: + + ```bash + # from the repository root (cwd) + go mod edit -replace github.com/Silo-Server/silo-plugin-sdk=../silo-plugin-sdk + go mod tidy + ``` + + This points the replace at the `silo-plugin-sdk` checkout (a sibling of this repo). + + The final task removes the replace and bumps to the tagged version. + +2. **Closed PR #43 is on `main`.** The in-process arr autoscan (`internal/autoscan/*`, `internal/api/handlers/autoscan.go`, `internal/taskmanager/tasks/autoscan_poll.go`, router routes, migration `171_autoscan`, the `AdminRequests.tsx` tab) is the salvage source. This plan **generalizes the engine and replaces the schema/wiring**; it does not preserve the arr-specific history client (that moves to the arr plugin in a separate plan). + +3. **Tests that touch `internal/api/handlers` or `internal/api` link libvips via CGO.** Run the suite in the libvips-equipped container (see project memory `silo-test-libvips-gotcha`), not the bare host, or those packages report `[build failed]` and skip silently. + +--- + +## Pattern references (read before starting) + +- Host capability client wrapper: `internal/pluginhost/client.go` — `ScheduledTaskClient` type (l.42), `Client.ScheduledTask` accessor (l.111), `requireCapability` (l.157), `Run` (l.226). +- Host resolver: `internal/plugins/service.go:413` `ScheduledTaskClient(ctx, installationID, capabilityID)` → `ensureClient` → `client.ScheduledTask(capabilityID)`. +- Timer task over plugin capabilities: `internal/plugins/task_registry.go` (iterate enabled installations, filter capability type, build a `taskmanager.Task`). +- Salvageable engine (generalize, don't rewrite): `internal/autoscan/service.go` (resolve/suppress/enqueue loop), `dedupe.go` (`uniqueParentDirs`), `suppress.go` (`Suppressor`). Discard `history.go`, `rewrite.go`, `suggest*.go` (arr-specific → arr plugin). +- Migration mechanics: paired `migrations/NNN_*.up.sql`/`.down.sql`, embedded via `migrations/embed.go`, auto-applied on boot. +- Credential decryption for the reuse-from-Requests case: `internal/requests/repository.go` (`integrationColumns` has `base_url, api_key_ref`); Fernet resolution via `catalog.ServerSettingsRepo.Get` (the same path PR #43's `SecretResolver` used). + +--- + +## File Structure + +| File | Responsibility | Action | +|---|---|---| +| `internal/pluginhost/client.go` | `ScanSourceClient` wrapper + `Client.ScanSource()` accessor | Modify | +| `internal/pluginhost/client_test.go` | `requireCapability` gate for `scan_source.v1` | Modify | +| `internal/plugins/service.go` | `ScanSourceClient(ctx, installationID, capabilityID)` resolver | Modify | +| `migrations/172_autoscan_v2.up.sql` / `.down.sql` | `autoscan_connections` + `autoscan_sources`; drop the `171` tables | Create | +| `internal/autoscan/types.go` | `Source`, `Connection`, `Settings`, `PollChanges` provider seam | Rewrite | +| `internal/autoscan/repository.go` | CRUD for connections + sources; `AdvanceMarker` | Rewrite | +| `internal/autoscan/provider.go` | `ScanSourceProvider` interface + adapter over the plugins resolver | Create | +| `internal/autoscan/connection.go` | Resolve a `Connection` (own or Requests-linked) → `{base_url, api_key}` | Create | +| `internal/autoscan/service.go` | Generic engine: per source → `PollChanges(marker)` → resolve → suppress → enqueue → store marker | Rewrite | +| `internal/autoscan/dedupe.go`, `suppress.go` | Unchanged salvage | Keep | +| `internal/autoscan/history.go`, `rewrite.go`, `suggest*.go` | Arr-specific | Delete | +| `internal/taskmanager/tasks/autoscan_poll.go` | Poll task → `Service.PollOnce` | Keep (retarget) | +| `internal/api/handlers/autoscan.go` | Admin API (settings/connections/sources/trigger/status) | Rewrite | +| `internal/api/router.go`, `cmd/silo/main.go` | Wire the rebuilt service + connection resolver; drop request-coupled wiring | Modify | +| `go.mod` | SDK replace (dev) → `v0.5.0` (final) | Modify | + +--- + +## Task 1: Host `pluginhost` client wrapper for `scan_source.v1` + +**Files:** +- Modify: `internal/pluginhost/client.go` +- Modify: `internal/pluginhost/client_test.go` + +- [ ] **Step 1: Write the failing test** + +In `internal/pluginhost/client_test.go`, add a test mirroring the existing `scheduled_task` capability-gate test (find it first; it constructs a `Client` with a manifest declaring a capability and asserts the accessor errors when the capability is absent and succeeds when present): + +```go +func TestClientScanSourceRequiresCapability(t *testing.T) { + // Client whose manifest does NOT declare scan_source.v1 + c := newTestClientWithCapabilities(t, "metadata_provider.v1") + if _, err := c.ScanSource("missing"); err == nil { + t.Fatal("ScanSource: expected error when capability absent") + } + // Client WHOSE manifest declares scan_source.v1 with id "arr" + c2 := newTestClientWithCapabilities(t, "scan_source.v1:arr") + if _, err := c2.ScanSource("arr"); err != nil { + t.Fatalf("ScanSource: unexpected error when capability present: %v", err) + } +} +``` + +Use whatever helper the sibling `scheduled_task` test uses to construct a `Client` with declared capabilities; mirror it exactly (do not invent a helper that does not exist — read `client_test.go` and copy the construction). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/pluginhost/ -run TestClientScanSourceRequiresCapability` +Expected: FAIL — `c.ScanSource undefined`. + +- [ ] **Step 3: Add the wrapper type and accessor** + +In `internal/pluginhost/client.go`, after the `ScheduledTaskClient` type (l.42), add: + +```go +type ScanSourceClient struct { + client pluginv1.ScanSourceClient + timeout time.Duration +} +``` + +After the `Client.ScheduledTask` accessor (l.117), add: + +```go +func (c *Client) ScanSource(capabilityID string) (*ScanSourceClient, error) { + if err := c.requireCapability("scan_source.v1", capabilityID); err != nil { + return nil, err + } + return &ScanSourceClient{ + client: c.rpc.ScanSource(), + timeout: DefaultControlTimeout, + }, nil +} + +func (c *ScanSourceClient) PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) { + callCtx, cancel := ensureDeadline(ctx, c.timeout) + defer cancel() + return c.client.PollChanges(callCtx, req) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/pluginhost/ -run TestClientScanSourceRequiresCapability` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/pluginhost/client.go internal/pluginhost/client_test.go +git commit -m "feat(pluginhost): scan_source.v1 capability client wrapper" +``` + +--- + +## Task 2: `plugins.Service` resolver for `scan_source.v1` + +**Files:** +- Modify: `internal/plugins/service.go` + +- [ ] **Step 1: Add the resolver method** + +After `ScheduledTaskClient` (l.413–423), add the identical shape for scan source: + +```go +func (s *Service) ScanSourceClient( + ctx context.Context, + installationID int, + capabilityID string, +) (*pluginhost.ScanSourceClient, error) { + client, err := s.ensureClient(ctx, installationID) + if err != nil { + return nil, err + } + return client.ScanSource(capabilityID) +} +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `go build ./internal/plugins/` +Expected: exits 0. + +- [ ] **Step 3: Commit** + +```bash +git add internal/plugins/service.go +git commit -m "feat(plugins): expose scan_source.v1 client resolver" +``` + +--- + +## Task 3: Schema — `autoscan_connections` + `autoscan_sources` + +**Files:** +- Create: `migrations/172_autoscan_v2.up.sql`, `migrations/172_autoscan_v2.down.sql` + +- [ ] **Step 1: Write the up migration** + +Create `migrations/172_autoscan_v2.up.sql`. It drops PR #43's `171` tables and creates the decoupled schema (no FK into `request_integrations`; the optional reuse link is `ON DELETE SET NULL`): + +```sql +DROP TABLE IF EXISTS public.autoscan_sources; +DROP TABLE IF EXISTS public.autoscan_settings; + +CREATE TABLE public.autoscan_settings ( + id boolean PRIMARY KEY DEFAULT true, + enabled boolean NOT NULL DEFAULT false, + default_poll_interval_seconds integer NOT NULL DEFAULT 600, + debounce_seconds integer NOT NULL DEFAULT 60, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT autoscan_settings_singleton CHECK (id), + CONSTRAINT autoscan_settings_interval_pos CHECK (default_poll_interval_seconds > 0), + CONSTRAINT autoscan_settings_debounce_nonneg CHECK (debounce_seconds >= 0) +); +INSERT INTO public.autoscan_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING; + +CREATE TABLE public.autoscan_connections ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + kind text NOT NULL, + -- Own credentials (used when request_integration_id IS NULL): + base_url text, + api_key_ref text, + -- Optional soft reuse of a Requests arr server (live link; SET NULL on delete): + request_integration_id text REFERENCES public.request_integrations(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT autoscan_connections_source_present + CHECK (request_integration_id IS NOT NULL OR base_url IS NOT NULL) +); + +CREATE TABLE public.autoscan_sources ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + installation_id integer NOT NULL, + capability_id text NOT NULL, + connection_id uuid NOT NULL REFERENCES public.autoscan_connections(id) ON DELETE RESTRICT, + enabled boolean NOT NULL DEFAULT false, + poll_interval_seconds integer, + marker text, + last_run_at timestamptz, + last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT autoscan_sources_interval_pos CHECK (poll_interval_seconds IS NULL OR poll_interval_seconds > 0), + CONSTRAINT autoscan_sources_capability_unique UNIQUE (installation_id, capability_id) +); +``` + +- [ ] **Step 2: Write the down migration** + +Create `migrations/172_autoscan_v2.down.sql`: + +```sql +DROP TABLE IF EXISTS public.autoscan_sources; +DROP TABLE IF EXISTS public.autoscan_connections; +DROP TABLE IF EXISTS public.autoscan_settings; +``` + +(Recreating the `171` tables on down-migration is unnecessary: `171` is unreleased and superseded; the down simply removes `172`'s tables.) + +- [ ] **Step 3: Apply and verify against a disposable DB** + +Run the migrations against a throwaway Postgres (the project's disposable-DB pattern) and confirm boot applies cleanly: +```bash +docker compose up -d postgres +go run ./cmd/silo --migrate-only 2>&1 | grep -i "migrations applied" || echo "check migration output" +``` +Expected: migrations apply without error; `\d autoscan_connections` shows the soft FK `request_integration_id`. + +- [ ] **Step 4: Commit** + +```bash +git add migrations/172_autoscan_v2.up.sql migrations/172_autoscan_v2.down.sql +git commit -m "feat(migrations): autoscan v2 schema (connections + sources, no requests FK)" +``` + +--- + +## Task 4: Types and repository + +**Files:** +- Rewrite: `internal/autoscan/types.go` +- Rewrite: `internal/autoscan/repository.go` + +- [ ] **Step 1: Define the types** + +Replace `internal/autoscan/types.go` with the v2 model: + +```go +package autoscan + +import "time" + +type Settings struct { + Enabled bool + DefaultPollIntervalSeconds int + DebounceSeconds int +} + +// Connection is an arr server the host can reach: either own credentials, or a +// live reference to a Requests integration (RequestIntegrationID set). +type Connection struct { + ID string + Name string + Kind string + BaseURL string // own; empty when linked + APIKeyRef string // own; empty when linked + RequestIntegrationID *string +} + +// Source ties a scan_source plugin capability instance to a connection plus the +// host-owned scheduling/bookkeeping state. +type Source struct { + ID string + InstallationID int + CapabilityID string + ConnectionID string + Enabled bool + PollIntervalSeconds *int // nil => use settings default + Marker *string // opaque; nil on first run + LastRunAt *time.Time + LastError *string +} +``` + +- [ ] **Step 2: Write a failing repository test** + +Add `internal/autoscan/repository_test.go` exercising round-trip + `AdvanceMarker` against a disposable DB (gate with the project's DB-test build tag/helper if one exists; otherwise a `//go:build integration` tag matching sibling DB tests). Minimal: + +```go +func TestRepositoryMarkerRoundTrip(t *testing.T) { + repo := newTestRepo(t) // disposable-DB helper used by sibling repo tests + connID := repo.mustCreateConnection(t, Connection{Name: "Sonarr", Kind: "sonarr", BaseURL: "http://x", APIKeyRef: "k"}) + srcID := repo.mustCreateSource(t, Source{InstallationID: 1, CapabilityID: "arr", ConnectionID: connID, Enabled: true}) + + if err := repo.AdvanceMarker(ctx, srcID, "2026-06-02T14:10:00Z"); err != nil { + t.Fatalf("AdvanceMarker: %v", err) + } + got, _ := repo.ListEnabledSources(ctx) + if len(got) != 1 || got[0].Marker == nil || *got[0].Marker != "2026-06-02T14:10:00Z" { + t.Fatalf("marker not persisted: %+v", got) + } +} +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `go test ./internal/autoscan/ -run TestRepositoryMarkerRoundTrip` (with the DB helper available) +Expected: FAIL — undefined repository methods. + +- [ ] **Step 4: Implement the repository** + +Rewrite `internal/autoscan/repository.go` with: `GetSettings`, `UpdateSettings`, connection CRUD (`CreateConnection`, `UpdateConnection`, `DeleteConnection`, `ListConnections`, `GetConnection`), source CRUD (`UpsertSource`, `ListEnabledSources`, `ListSources`, `GetSource`), and `AdvanceMarker(ctx, sourceID, marker string)` (sets `marker`, `last_run_at = now()`, clears `last_error`). Use pgx; follow the column/scan style of the existing (pre-rewrite) `repository.go` and `internal/requests/repository.go`. + +- [ ] **Step 5: Run to verify it passes** + +Run: `go test ./internal/autoscan/ -run TestRepositoryMarkerRoundTrip` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/autoscan/types.go internal/autoscan/repository.go internal/autoscan/repository_test.go +git commit -m "feat(autoscan): v2 types and repository (connections, sources, markers)" +``` + +--- + +## Task 5: Connection resolution (own or Requests-linked) + +**Files:** +- Create: `internal/autoscan/connection.go` +- Create: `internal/autoscan/connection_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +func TestResolveConnectionOwnVsLinked(t *testing.T) { + reqLookup := fakeRequestIntegrationLookup{"req-1": creds{BaseURL: "http://req:7878", APIKeyRef: "rk"}} + secrets := fakeSecrets{"rk": "REQKEY", "ownref": "OWNKEY"} + r := NewConnectionResolver(reqLookup, secrets) + + own, err := r.Resolve(ctx, Connection{BaseURL: "http://own:8989", APIKeyRef: "ownref"}) + if err != nil || own.BaseURL != "http://own:8989" || own.APIKey != "OWNKEY" { + t.Fatalf("own resolve = %+v, err=%v", own, err) + } + id := "req-1" + linked, err := r.Resolve(ctx, Connection{RequestIntegrationID: &id}) + if err != nil || linked.BaseURL != "http://req:7878" || linked.APIKey != "REQKEY" { + t.Fatalf("linked resolve = %+v, err=%v", linked, err) + } +} + +func TestResolveConnectionLinkedMissingFallsBack(t *testing.T) { + r := NewConnectionResolver(fakeRequestIntegrationLookup{}, fakeSecrets{}) + id := "gone" + if _, err := r.Resolve(ctx, Connection{RequestIntegrationID: &id}); err == nil { + t.Fatal("expected error when linked Requests integration is missing") + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `go test ./internal/autoscan/ -run TestResolveConnection` +Expected: FAIL — undefined `NewConnectionResolver`. + +- [ ] **Step 3: Implement** + +Create `internal/autoscan/connection.go`: + +```go +package autoscan + +import ( + "context" + "fmt" +) + +// ResolvedConnection is concrete credentials handed to the plugin. +type ResolvedConnection struct { + BaseURL string + APIKey string +} + +type RequestIntegrationLookup interface { + // Returns base URL + encrypted api key ref for a Requests integration. + Get(ctx context.Context, integrationID string) (baseURL, apiKeyRef string, err error) +} + +type SecretResolver interface { + Get(ctx context.Context, ref string) (string, error) +} + +type ConnectionResolver struct { + requests RequestIntegrationLookup + secrets SecretResolver +} + +func NewConnectionResolver(r RequestIntegrationLookup, s SecretResolver) *ConnectionResolver { + return &ConnectionResolver{requests: r, secrets: s} +} + +func (cr *ConnectionResolver) Resolve(ctx context.Context, c Connection) (ResolvedConnection, error) { + baseURL, apiKeyRef := c.BaseURL, c.APIKeyRef + if c.RequestIntegrationID != nil { + u, ref, err := cr.requests.Get(ctx, *c.RequestIntegrationID) + if err != nil { + return ResolvedConnection{}, fmt.Errorf("autoscan: linked requests integration %q: %w", *c.RequestIntegrationID, err) + } + baseURL, apiKeyRef = u, ref + } + apiKey := apiKeyRef + if cr.secrets != nil && apiKeyRef != "" { + resolved, err := cr.secrets.Get(ctx, apiKeyRef) + if err != nil { + return ResolvedConnection{}, fmt.Errorf("autoscan: resolve api key: %w", err) + } + if resolved != "" { + apiKey = resolved + } + } + return ResolvedConnection{BaseURL: baseURL, APIKey: apiKey}, nil +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `go test ./internal/autoscan/ -run TestResolveConnection` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/autoscan/connection.go internal/autoscan/connection_test.go +git commit -m "feat(autoscan): resolve connections (own credentials or Requests-linked)" +``` + +--- + +## Task 6: Provider seam over the plugin resolver + +**Files:** +- Create: `internal/autoscan/provider.go` + +- [ ] **Step 1: Define the seam and adapter** + +The engine must be testable without a live plugin, so it depends on a narrow interface, with a production adapter over `plugins.Service.ScanSourceClient`. Create `internal/autoscan/provider.go`: + +```go +package autoscan + +import ( + "context" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" +) + +// ScanSourceProvider yields changed paths for one source. The engine calls +// PollChanges; production wraps the plugins.Service scan_source resolver. +type ScanSourceProvider interface { + PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection) (paths []string, nextMarker string, err error) +} + +// pluginScanSourceClient is the slice of *pluginhost.ScanSourceClient used here. +type pluginScanSourceClient interface { + PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) +} + +type scanSourceResolver interface { + ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (pluginScanSourceClient, error) +} + +type pluginProvider struct{ resolver scanSourceResolver } + +func NewPluginProvider(resolver scanSourceResolver) ScanSourceProvider { + return &pluginProvider{resolver: resolver} +} + +func (p *pluginProvider) PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection) ([]string, string, error) { + client, err := p.resolver.ScanSourceClient(ctx, installationID, capabilityID) + if err != nil { + return nil, "", err + } + resp, err := client.PollChanges(ctx, &pluginv1.PollChangesRequest{CapabilityId: capabilityID, Marker: marker}) + if err != nil { + return nil, "", err + } + return resp.GetChangedPaths(), resp.GetNextMarker(), nil +} +``` + +Note: the connection (`conn`) is delivered to the plugin out-of-band as runtime config when the source is configured (the plugin reads its own `{base_url, api_key}`); it is passed here so a future provider variant could inject per-call. For v1 the production path configures the plugin instance with the resolved connection at upsert time; document this in `NewPluginProvider`'s comment. + +- [ ] **Step 2: Build** + +Run: `go build ./internal/autoscan/` +Expected: exits 0. + +- [ ] **Step 3: Commit** + +```bash +git add internal/autoscan/provider.go +git commit -m "feat(autoscan): scan-source provider seam over the plugin resolver" +``` + +--- + +## Task 7: Generalize the engine (`Service.PollOnce`) + +**Files:** +- Rewrite: `internal/autoscan/service.go` +- Modify: `internal/autoscan/service_test.go` (generalize the salvaged tests) + +- [ ] **Step 1: Adapt the salvaged tests to the provider seam** + +The PR #43 `service_test.go` used a `fakeHistory`; replace it with a `fakeProvider` implementing `ScanSourceProvider`, keeping the same assertions (dedupe to one folder; distinct paths same folder → two scans; disabled no-op; provider error keeps marker; enqueue failure releases claims + keeps marker). Add an opaque-marker assertion: the provider's returned `nextMarker` is stored verbatim via `AdvanceMarker`. + +```go +type fakeProvider struct { + paths map[string][]string // key: capabilityID + nextMarker string + err error +} + +func (f *fakeProvider) PollChanges(_ context.Context, _ int, capabilityID, _ string, _ ResolvedConnection) ([]string, string, error) { + if f.err != nil { + return nil, "", f.err + } + return f.paths[capabilityID], f.nextMarker, nil +} +``` + +- [ ] **Step 2: Run to verify the tests fail** + +Run: `go test ./internal/autoscan/ -run TestPollOnce` +Expected: FAIL — `Service` signature/`fakeProvider` not yet wired. + +- [ ] **Step 3: Rewrite the engine** + +Rewrite `internal/autoscan/service.go`. Keep the salvaged resolve→suppress→enqueue loop verbatim (it is provider-agnostic already), but drive it from sources + the provider seam: + +```go +func (s *Service) PollOnce(ctx context.Context) error { + settings, err := s.store.GetSettings(ctx) + if err != nil { + return err + } + if !settings.Enabled { + return nil + } + sources, err := s.store.ListEnabledSources(ctx) + if err != nil { + return err + } + ttl := time.Duration(settings.DebounceSeconds) * time.Second + + for _, src := range sources { + conn, cerr := s.resolveConnection(ctx, src.ConnectionID) + if cerr != nil { + slog.WarnContext(ctx, "autoscan: resolve connection failed", "source_id", src.ID, "err", cerr) + continue + } + marker := "" + if src.Marker != nil { + marker = *src.Marker + } + paths, next, perr := s.provider.PollChanges(ctx, src.InstallationID, src.CapabilityID, marker, conn) + if perr != nil { + slog.WarnContext(ctx, "autoscan: poll changes failed", "source_id", src.ID, "err", perr) + _ = s.store.RecordError(ctx, src.ID, perr.Error()) + continue + } + + targets, claimed := s.resolveAndClaim(ctx, paths, ttl) // salvaged dedupe→resolve→suppress + if len(targets) > 0 { + if eerr := s.queue.EnqueueScans(ctx, targets); eerr != nil { + s.releaseClaims(ctx, claimed) + slog.WarnContext(ctx, "autoscan: enqueue failed", "source_id", src.ID, "err", eerr) + continue // do NOT advance marker + } + } + if aerr := s.store.AdvanceMarker(ctx, src.ID, next); aerr != nil { + slog.WarnContext(ctx, "autoscan: advance marker failed", "source_id", src.ID, "err", aerr) + } + } + return nil +} +``` + +Extract the salvaged loop body into `resolveAndClaim`/`releaseClaims` helpers (lifted verbatim from PR #43's `service.go` lines that build `targets`/`claimed`). Keep `uniqueParentDirs`, `Suppressor`, the `fmt.Sprintf("%d|%s", folderID, target.Path)` suppression key, and the `scantrigger.RequestError` quiet-skip. + +- [ ] **Step 4: Run to verify the tests pass** + +Run: `go test ./internal/autoscan/ -run TestPollOnce` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/autoscan/service.go internal/autoscan/service_test.go +git commit -m "feat(autoscan): generic engine drives sources via scan_source provider" +``` + +--- + +## Task 8: Delete arr-specific salvage residue + +**Files:** +- Delete: `internal/autoscan/history.go`, `history_test.go`, `rewrite.go`, `rewrite_test.go`, `suggest.go`, `suggest_deps.go`, `suggest_test.go` + +- [ ] **Step 1: Remove the files** + +```bash +git rm internal/autoscan/history.go internal/autoscan/history_test.go \ + internal/autoscan/rewrite.go internal/autoscan/rewrite_test.go \ + internal/autoscan/suggest.go internal/autoscan/suggest_deps.go internal/autoscan/suggest_test.go +``` + +- [ ] **Step 2: Build to confirm no remaining references** + +Run: `go build ./internal/autoscan/...` +Expected: exits 0 (the engine no longer references the deleted arr logic). If it fails, a reference was missed — remove it. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "refactor(autoscan): drop arr-specific history/rewrite/suggest (moved to plugin)" +``` + +--- + +## Task 9: Admin API rewrite + +**Files:** +- Rewrite: `internal/api/handlers/autoscan.go` +- Modify: `internal/api/handlers/autoscan_test.go` + +- [ ] **Step 1: Define the endpoints** + +Rewrite the handler to back the new model. Endpoints (admin-gated, mounted in Task 10): +- `GET/PUT /admin/autoscan/settings` +- `GET/POST /admin/autoscan/connections`, `PUT/DELETE /admin/autoscan/connections/{id}` +- `GET /admin/autoscan/sources`, `PUT /admin/autoscan/sources/{id}` (enable, interval, connection binding) +- `POST /admin/autoscan/trigger` (async `PollOnce`, 202 — keep the detached-goroutine + channel-tested pattern from the closed PR's `autoscan_test.go`) +- `GET /admin/autoscan/status` + +Strip credentials from responses (never emit `api_key_ref`/resolved keys), matching the `_SENSITIVE_METADATA_KEYS` defense-in-depth posture. + +- [ ] **Step 2: Port + extend the handler tests** + +Carry over the trigger test (the `done`-channel synchronization fix from `d3ee373`), the FK→404 test (now: unknown source/connection → 404), and a secrets-not-leaked test for the connections list. + +- [ ] **Step 3: Run the handler tests (libvips container)** + +Run (in the libvips-equipped container per Prerequisites #3): +`go test ./internal/api/handlers/ -run Autoscan` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/api/handlers/autoscan.go internal/api/handlers/autoscan_test.go +git commit -m "feat(api): autoscan v2 admin endpoints (settings, connections, sources)" +``` + +--- + +## Task 10: Wiring + rate limits + retire old coupling + +**Files:** +- Modify: `internal/api/router.go` +- Modify: `cmd/silo/main.go` + +- [ ] **Step 1: Rebuild the service wiring** + +In `internal/api/router.go` and `cmd/silo/main.go`, construct the v2 service: + +```go +autoscanRepo := autoscan.NewRepository(deps.DB) +provider := autoscan.NewPluginProvider(pluginServiceScanSourceAdapter{pluginService}) +connResolver := autoscan.NewConnectionResolver(requestIntegrationLookup{requestsRepo}, serverSettingsSecretResolver{settingsRepo}) +autoscanSvc := autoscan.NewService(autoscanRepo, provider, connResolver, deps.Resolver, deps.ScanQueue, autoscan.NewRedisSuppressor(deps.RedisClient)) +autoscanHandler = handlers.NewAutoscanHandler(autoscanRepo, autoscanSvc) +``` + +Replace the old route block with the Task 9 routes. Keep the `catalog_*`/admin rate-limit conventions consistent with the security standards (per-user/admin caps on the new endpoints; mirror the request-integrations admin tier). + +- [ ] **Step 2: Retarget the poll task** + +`internal/taskmanager/tasks/autoscan_poll.go` calls `Service.PollOnce` — unchanged behavior; confirm it compiles against the rebuilt service and uses `Settings.DefaultPollIntervalSeconds` for the interval (per-source overrides are honored inside `PollOnce`). + +- [ ] **Step 3: Build the whole tree** + +Run (libvips container): `go build ./...` +Expected: exits 0. + +- [ ] **Step 4: Commit** + +```bash +git add internal/api/router.go cmd/silo/main.go internal/taskmanager/tasks/autoscan_poll.go +git commit -m "feat(autoscan): wire v2 service, routes, and poll task" +``` + +--- + +## Task 11: Full sweep + finalize SDK dependency + +**Files:** +- Modify: `go.mod`, `go.sum` + +- [ ] **Step 1: Full build + test (libvips container)** + +Run in the libvips-equipped container (Prerequisites #3): +```bash +go build ./... && go test ./... && go vet ./... +``` +Expected: build OK; all packages `ok`; vet clean. `gofmt -l internal migrations` prints nothing. + +- [ ] **Step 2: Finalize the SDK version (after `v0.5.0` is tagged upstream)** + +Once `silo-plugin-sdk` PR #2 is merged and `v0.5.0` tagged: +```bash +go mod edit -dropreplace github.com/Silo-Server/silo-plugin-sdk +go get github.com/Silo-Server/silo-plugin-sdk@v0.5.0 +go mod tidy +go build ./... +``` +Expected: builds against the released tag with no replace directive. + +If `v0.5.0` is not yet tagged when this plan is otherwise complete, leave the replace directive in place, commit it, and note in the PR that the dependency must be finalized before merge (do not merge a `replace` pointing at a local path). + +- [ ] **Step 3: Commit** + +```bash +git add go.mod go.sum +git commit -m "build: depend on silo-plugin-sdk v0.5.0 (scan_source.v1)" +``` + +--- + +## Self-Review notes + +- **Spec coverage:** §7 engine (Tasks 6–7), §8 connections incl. reuse-or-own + soft Requests link (Tasks 3,5), §9 data model decoupled from Requests + Autoscan-owned state (Tasks 3–4,9–10), §5/§11 host consumes `scan_source.v1` (Tasks 1–2). §10 (arr plugin) and §9 UI are separate plans. +- **Salvage map honored (§13):** engine/dedupe/suppress kept and generalized (Tasks 6–8); arr history/rewrite/suggest deleted (Task 8); `last_poll_at` → opaque `marker` (Tasks 3–4,7). +- **Out of scope here:** the Autoscan admin **UI** category (Part 2 plan) and the **arr plugin** (separate repo plan). This backend is testable on its own via the repository/engine/handler tests. +- **Risk carried from spec §14:** plugin multiplicity (one installation per arr server vs. many connections per installation) is modeled as one `autoscan_sources` row per `(installation_id, capability_id)`; if the runtime supports multiple capability instances per installation this maps cleanly, otherwise it implies one installation per server. Confirm during Task 1–2 by inspecting `ensureClient`/installation handling. +- **Type consistency:** `ResolvedConnection{BaseURL,APIKey}`, `Connection{BaseURL,APIKeyRef,RequestIntegrationID}`, `Source{InstallationID,CapabilityID,ConnectionID,Marker}`, `ScanSourceProvider.PollChanges(...)`, `Service.PollOnce` used consistently across Tasks 4–10. diff --git a/docs/superpowers/plans/2026-06-02-autoscan-rewrite-sync.md b/docs/superpowers/plans/2026-06-02-autoscan-rewrite-sync.md new file mode 100644 index 00000000..cbeb532d --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-autoscan-rewrite-sync.md @@ -0,0 +1,694 @@ +# Autoscan Rewrite-Sync 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:** A "Sync rewrites from arr" action that suggests autoscan `path_rewrites` for an instance by matching its arr root folders to Silo media folders on shared trailing path segments, previewed and committed by the admin. + +**Architecture:** A pure `suggestRewrites` matcher in `internal/autoscan`, a `Service.SuggestRewrites` method backed by two new injected deps (an arr root-folder client and a Silo folder lister), one read-only admin endpoint returning a preview, and a "Sync from arr" button in the existing Autoscan tab that merges proposals into the editor (admin saves via the existing per-source PUT). Manual rewrites stay the source of truth. + +**Tech Stack:** Go (standard `testing`), React/TypeScript. + +**Spec:** `docs/superpowers/specs/2026-06-02-autoscan-rewrite-sync-design.md` + +**Commands assume the repository root is the cwd.** Go tests: `go test ./internal/autoscan/...`. Frontend: `cd web && pnpm exec tsc -b && pnpm run lint`. Ensure the Go toolchain is on `PATH` (prepend its `bin` directory if `go` is missing). `internal/api`/`internal/api/handlers` cannot be compiled without libvips; verify those via `gofmt` + the Docker build. + +--- + +## Phase 0 — Branch + +- [ ] **Step 0.1: Confirm branch** + +```bash +git rev-parse --abbrev-ref HEAD # expect: feat/autoscan-arr-polling +``` +This feature extends the autoscan branch. If you're elsewhere: `git checkout feat/autoscan-arr-polling`. + +--- + +## Phase 1 — Core matcher (pure, TDD) + +### Task 1: suggestRewrites + types + +**Files:** +- Create: `internal/autoscan/suggest.go` +- Create: `internal/autoscan/suggest_test.go` + +- [ ] **Step 1.1: Write the failing test** + +`internal/autoscan/suggest_test.go`: +```go +package autoscan + +import ( + "reflect" + "testing" +) + +func TestSuggestRewrites(t *testing.T) { + silo := []string{ + "/mnt/media/happy/storage2/tvshows1", + "/mnt/media/happy4k/4ktv7", + "/mnt/media/storage/Anime/Subs", + "/mnt/media/storage/Anime2/Subs", + "/library/Films", + "/tank/television/Show", + } + + t.Run("multi-segment unique match", func(t *testing.T) { + got := suggestRewrites([]string{"/mnt/happy/storage2/tvshows1"}, silo, nil) + if len(got.Proposed) != 1 || got.Proposed[0].To != "/mnt/media/happy/storage2/tvshows1" || got.Proposed[0].MatchDepth != 2 { + t.Fatalf("proposed=%+v", got.Proposed) + } + }) + + t.Run("single-segment unique match (different parents)", func(t *testing.T) { + got := suggestRewrites([]string{"/mnt/kodama/storage2/4ktv7"}, silo, nil) + if len(got.Proposed) != 1 || got.Proposed[0].To != "/mnt/media/happy4k/4ktv7" || got.Proposed[0].MatchDepth != 1 { + t.Fatalf("proposed=%+v", got.Proposed) + } + }) + + t.Run("longest-suffix disambiguation", func(t *testing.T) { + got := suggestRewrites([]string{"/mnt/kodama/storage1/Anime/Subs"}, silo, nil) + if len(got.Proposed) != 1 || got.Proposed[0].To != "/mnt/media/storage/Anime/Subs" || got.Proposed[0].MatchDepth != 2 { + t.Fatalf("expected Anime/Subs (depth 2), got %+v", got.Proposed) + } + if len(got.Ambiguous) != 0 { + t.Fatalf("should not be ambiguous: %+v", got.Ambiguous) + } + }) + + t.Run("unmatched when no shared segment", func(t *testing.T) { + got := suggestRewrites([]string{"/data/Movies"}, silo, nil) + if len(got.Unmatched) != 1 || got.Unmatched[0] != "/data/Movies" || len(got.Proposed) != 0 { + t.Fatalf("got=%+v", got) + } + }) + + t.Run("leaf match across unlike layouts", func(t *testing.T) { + got := suggestRewrites([]string{"/srv/tv/Show"}, silo, nil) + if len(got.Proposed) != 1 || got.Proposed[0].To != "/tank/television/Show" { + t.Fatalf("got=%+v", got) + } + }) + + t.Run("ambiguous tie", func(t *testing.T) { + // a root whose only shared segment is "Subs" ties two folders + got := suggestRewrites([]string{"/foo/bar/Subs"}, silo, nil) + if len(got.Ambiguous) != 1 || len(got.Ambiguous[0].Candidates) != 2 { + t.Fatalf("expected ambiguous with 2 candidates, got %+v", got.Ambiguous) + } + }) + + t.Run("covered by existing rule", func(t *testing.T) { + existing := []PathRewrite{{From: "/mnt/happy", To: "/mnt/media/happy"}} + got := suggestRewrites([]string{"/mnt/happy/storage2/tvshows1"}, silo, existing) + if len(got.Covered) != 1 || len(got.Proposed) != 0 { + t.Fatalf("expected covered, got %+v", got) + } + }) + + t.Run("normalization: trailing slash, backslashes, dup slashes", func(t *testing.T) { + got := suggestRewrites([]string{`\mnt\happy\\storage2\tvshows1\`}, silo, nil) + if len(got.Proposed) != 1 || got.Proposed[0].From != "/mnt/happy/storage2/tvshows1" || got.Proposed[0].To != "/mnt/media/happy/storage2/tvshows1" { + t.Fatalf("normalization failed: %+v", got.Proposed) + } + }) +} + +func TestCommonSuffixLen(t *testing.T) { + cases := []struct { + a, b []string + want int + }{ + {[]string{"a", "b", "c"}, []string{"x", "b", "c"}, 2}, + {[]string{"4ktv7"}, []string{"happy4k", "4ktv7"}, 1}, + {[]string{"4ktv7"}, []string{"4ktv70"}, 0}, // full-segment, not substring + {[]string{"a"}, []string{"b"}, 0}, + } + for _, tc := range cases { + if got := commonSuffixLen(tc.a, tc.b); got != tc.want { + t.Fatalf("commonSuffixLen(%v,%v)=%d want %d", tc.a, tc.b, got, tc.want) + } + } +} +``` + +- [ ] **Step 1.2: Run it — expect failure** + +```bash +go test ./internal/autoscan/ -run 'TestSuggestRewrites|TestCommonSuffixLen' -v +``` +Expected: FAIL (`suggestRewrites` undefined). + +- [ ] **Step 1.3: Implement** `internal/autoscan/suggest.go`: +```go +package autoscan + +import "strings" + +// RewriteSuggestions is the result of matching arr root folders to Silo folders. +type RewriteSuggestions struct { + Proposed []ProposedRewrite `json:"proposed"` + Unmatched []string `json:"unmatched"` + Ambiguous []AmbiguousRoot `json:"ambiguous"` + Covered []string `json:"covered"` +} + +// ProposedRewrite is a suggested rewrite plus its confidence (shared trailing segments). +type ProposedRewrite struct { + From string `json:"from"` + To string `json:"to"` + MatchDepth int `json:"match_depth"` +} + +// AmbiguousRoot is an arr root that tied across multiple Silo folders. +type AmbiguousRoot struct { + Root string `json:"root"` + Candidates []string `json:"candidates"` +} + +// normalizePath makes a path comparable: backslashes -> '/', collapse duplicate +// slashes, strip a trailing slash (but keep a bare "/"). +func normalizePath(p string) string { + p = normalizeSeparators(strings.TrimSpace(p)) + for strings.Contains(p, "//") { + p = strings.ReplaceAll(p, "//", "/") + } + if len(p) > 1 { + p = strings.TrimRight(p, "/") + } + return p +} + +func segments(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + return strings.Split(p, "/") +} + +// commonSuffixLen counts equal trailing segments (full-segment equality). +func commonSuffixLen(a, b []string) int { + i, j, n := len(a)-1, len(b)-1, 0 + for i >= 0 && j >= 0 && a[i] == b[j] { + n++ + i-- + j-- + } + return n +} + +// coveredBy reports whether an existing rewrite already matches root (same +// boundary rule as applyRewrites). +func coveredBy(root string, existing []PathRewrite) bool { + for _, rw := range existing { + from := strings.TrimRight(strings.TrimSpace(rw.From), "/") + if from == "" { + continue + } + if root == from || strings.HasPrefix(root, from+"/") { + return true + } + } + return false +} + +// suggestRewrites matches each arr root to the Silo folder sharing the most +// trailing path segments (unique winner). Roots already handled by an existing +// rewrite are reported as Covered; roots with no shared segment as Unmatched; +// ties as Ambiguous. Pure: no I/O, no deployment constants. +func suggestRewrites(arrRoots, siloFolderPaths []string, existing []PathRewrite) RewriteSuggestions { + siloNorm := make([]string, 0, len(siloFolderPaths)) + siloSegs := make([][]string, 0, len(siloFolderPaths)) + for _, p := range siloFolderPaths { + n := normalizePath(p) + if n == "" { + continue + } + siloNorm = append(siloNorm, n) + siloSegs = append(siloSegs, segments(n)) + } + + var out RewriteSuggestions + for _, raw := range arrRoots { + root := normalizePath(raw) + if root == "" { + continue + } + if coveredBy(root, existing) { + out.Covered = append(out.Covered, root) + continue + } + rootSegs := segments(root) + best := 0 + var winners []string + for i, segs := range siloSegs { + n := commonSuffixLen(rootSegs, segs) + if n == 0 { + continue + } + if n > best { + best, winners = n, []string{siloNorm[i]} + } else if n == best { + winners = append(winners, siloNorm[i]) + } + } + switch { + case best == 0: + out.Unmatched = append(out.Unmatched, root) + case len(winners) == 1: + out.Proposed = append(out.Proposed, ProposedRewrite{From: root, To: winners[0], MatchDepth: best}) + default: + out.Ambiguous = append(out.Ambiguous, AmbiguousRoot{Root: root, Candidates: winners}) + } + } + return out +} +``` + +- [ ] **Step 1.4: Run it — expect pass; commit** + +```bash +go test ./internal/autoscan/ -run 'TestSuggestRewrites|TestCommonSuffixLen' -v +gofmt -l internal/autoscan/suggest.go internal/autoscan/suggest_test.go +git add internal/autoscan/suggest.go internal/autoscan/suggest_test.go +git commit -m "feat(autoscan): suffix-match rewrite suggester" +``` + +--- + +## Phase 2 — Service support + +### Task 2: Repository.GetSource + +**Files:** +- Modify: `internal/autoscan/repository.go` + +- [ ] **Step 2.1: Implement** `GetSource` (single-source join, includes disabled). Add after `ListEnabledSources`: +```go +// GetSource returns one instance's autoscan state joined with its +// request_integrations row (kind/base_url/api_key_ref), regardless of enabled. +func (r *Repository) GetSource(ctx context.Context, integrationID string) (*Source, error) { + row := r.pool.QueryRow(ctx, sourceSelect+` WHERE ri.id = $1`, integrationID) + src, err := scanSource(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("%w: %s", ErrIntegrationNotFound, integrationID) + } + return nil, fmt.Errorf("get autoscan source: %w", err) + } + return &src, nil +} +``` +(`errors`, `pgx`, `fmt`, `ErrIntegrationNotFound`, and `sourceSelect`/`scanSource` already exist in the file.) + +- [ ] **Step 2.2: Build + commit** + +```bash +go build ./internal/autoscan/... && go test ./internal/autoscan/... +git add internal/autoscan/repository.go +git commit -m "feat(autoscan): GetSource single-source lookup" +``` + +### Task 3: RootFolderClient + FolderLister adapters + +**Files:** +- Create: `internal/autoscan/suggest_deps.go` + +- [ ] **Step 3.1: Implement the two adapters + interfaces** +```go +package autoscan + +import ( + "context" + "net/http" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/requests/arrclient" +) + +// RootFolderClient lists a Radarr/Sonarr instance's configured root folder paths. +type RootFolderClient interface { + RootFolders(ctx context.Context, baseURL, apiKey string) ([]string, error) +} + +// FolderLister lists every Silo media-folder path. +type FolderLister interface { + ListFolderPaths(ctx context.Context) ([]string, error) +} + +type arrRootFolderClient struct{ httpClient *http.Client } + +// NewArrRootFolderClient returns a RootFolderClient backed by the shared arrclient. +func NewArrRootFolderClient(httpClient *http.Client) RootFolderClient { + return &arrRootFolderClient{httpClient: httpClient} +} + +func (c *arrRootFolderClient) RootFolders(ctx context.Context, baseURL, apiKey string) ([]string, error) { + client := arrclient.New(baseURL, apiKey, c.httpClient) + folders, err := arrclient.ListRootFolders(ctx, client) + if err != nil { + return nil, err + } + paths := make([]string, 0, len(folders)) + for _, f := range folders { + if f.Path != "" { + paths = append(paths, f.Path) + } + } + return paths, nil +} + +type catalogFolderLister struct{ repo *catalog.FolderRepository } + +// NewCatalogFolderLister adapts catalog.FolderRepository to FolderLister. +func NewCatalogFolderLister(repo *catalog.FolderRepository) FolderLister { + return &catalogFolderLister{repo: repo} +} + +func (l *catalogFolderLister) ListFolderPaths(ctx context.Context) ([]string, error) { + folders, err := l.repo.List(ctx) + if err != nil { + return nil, err + } + var paths []string + for _, f := range folders { + paths = append(paths, f.Paths...) + } + return paths, nil +} +``` +Note: `autoscan` already depends on `catalog` (via `scantrigger`) and on `requests/arrclient` (via `history.go`), so these imports add no new module-level coupling and no cycle (`catalog` does not import `autoscan`). + +- [ ] **Step 3.2: Build + commit** + +```bash +go build ./internal/autoscan/... +git add internal/autoscan/suggest_deps.go +git commit -m "feat(autoscan): arr root-folder client + Silo folder lister" +``` + +### Task 4: Service.SuggestRewrites + +**Files:** +- Modify: `internal/autoscan/service.go` +- Modify: `internal/autoscan/service_test.go` + +- [ ] **Step 4.1: Write the failing test** (append to `service_test.go`): +```go +type fakeRootFolders struct { + paths []string + err error +} + +func (f fakeRootFolders) RootFolders(context.Context, string, string) ([]string, error) { + return f.paths, f.err +} + +type fakeFolderLister struct{ paths []string } + +func (f fakeFolderLister) ListFolderPaths(context.Context) ([]string, error) { return f.paths, nil } + +type sourceGetterStore struct { + fakeStore + src *Source +} + +func (s *sourceGetterStore) GetSource(context.Context, string) (*Source, error) { return s.src, nil } + +func TestSuggestRewritesService(t *testing.T) { + store := &sourceGetterStore{src: &Source{IntegrationID: "i1", Kind: "sonarr", BaseURL: "http://x", APIKeyRef: "k"}} + svc := NewService(store, &fakeHistory{}, fakeResolver{}, &recordingQueuer{}, allowSuppressor{}, nil) + svc.SetRewriteResolvers( + fakeRootFolders{paths: []string{"/mnt/happy/storage2/tvshows1", "/data/Movies"}}, + fakeFolderLister{paths: []string{"/mnt/media/happy/storage2/tvshows1"}}, + ) + got, err := svc.SuggestRewrites(context.Background(), "i1") + if err != nil { + t.Fatalf("SuggestRewrites: %v", err) + } + if len(got.Proposed) != 1 || got.Proposed[0].To != "/mnt/media/happy/storage2/tvshows1" { + t.Fatalf("proposed=%+v", got.Proposed) + } + if len(got.Unmatched) != 1 || got.Unmatched[0] != "/data/Movies" { + t.Fatalf("unmatched=%+v", got.Unmatched) + } +} +``` +> The `Store` interface needs a `GetSource` method so the service can call it. Add `GetSource(ctx context.Context, integrationID string) (*Source, error)` to the `Store` interface in `service.go`. `*Repository` already implements it (Task 2). The test's `sourceGetterStore` embeds the existing `fakeStore` and adds `GetSource`. If `fakeStore` does not satisfy the widened interface on its own, that's fine — only `sourceGetterStore` is used here; but ensure the package still compiles (other tests construct `fakeStore` directly and pass it where `Store` is required — add a `GetSource` method to `fakeStore` returning `(nil, nil)` so it still satisfies `Store`). + +- [ ] **Step 4.2: Run it — expect failure** + +```bash +go test ./internal/autoscan/ -run TestSuggestRewritesService -v +``` +Expected: FAIL (`SetRewriteResolvers`/`SuggestRewrites` undefined). + +- [ ] **Step 4.3: Implement** in `service.go`. Add fields + setter + method, and add `GetSource` to `Store`: +```go +// add to the Store interface: +// GetSource(ctx context.Context, integrationID string) (*Source, error) + +// add fields to Service struct: +// rootFolders RootFolderClient +// folders FolderLister + +// SetRewriteResolvers wires the deps used by SuggestRewrites (optional; only the +// admin-facing service needs them). +func (s *Service) SetRewriteResolvers(rootFolders RootFolderClient, folders FolderLister) { + s.rootFolders = rootFolders + s.folders = folders +} + +// SuggestRewrites matches an instance's arr root folders to Silo media folders. +func (s *Service) SuggestRewrites(ctx context.Context, integrationID string) (RewriteSuggestions, error) { + if s.rootFolders == nil || s.folders == nil { + return RewriteSuggestions{}, fmt.Errorf("autoscan: rewrite suggestion not configured") + } + src, err := s.store.GetSource(ctx, integrationID) + if err != nil { + return RewriteSuggestions{}, err + } + apiKey := src.APIKeyRef + if s.secrets != nil && apiKey != "" { + if resolved, rerr := s.secrets.Get(ctx, apiKey); rerr == nil && resolved != "" { + apiKey = resolved + } + } + arrRoots, err := s.rootFolders.RootFolders(ctx, src.BaseURL, apiKey) + if err != nil { + return RewriteSuggestions{}, fmt.Errorf("autoscan: list arr root folders: %w", err) + } + siloPaths, err := s.folders.ListFolderPaths(ctx) + if err != nil { + return RewriteSuggestions{}, fmt.Errorf("autoscan: list silo folders: %w", err) + } + return suggestRewrites(arrRoots, siloPaths, src.PathRewrites), nil +} +``` +Add `GetSource` to `fakeStore` in `service_test.go` (`func (f *fakeStore) GetSource(context.Context, string) (*Source, error) { return nil, nil }`). + +- [ ] **Step 4.4: Run tests — expect pass; commit** + +```bash +go test ./internal/autoscan/... +gofmt -l internal/autoscan/service.go internal/autoscan/service_test.go +git add internal/autoscan/service.go internal/autoscan/service_test.go +git commit -m "feat(autoscan): Service.SuggestRewrites" +``` + +--- + +## Phase 3 — API endpoint + +### Task 5: Handler + route + +**Files:** +- Modify: `internal/api/handlers/autoscan.go` +- Modify: `internal/api/router.go` +- Modify: `internal/api/handlers/autoscan_test.go` + +- [ ] **Step 5.1: Extend the handler's service interface + add the handler method** + +In `autoscan.go`, add `SuggestRewrites` to the `autoscanTriggerer` interface: +```go +type autoscanTriggerer interface { + PollOnce(ctx context.Context) error + SuggestRewrites(ctx context.Context, integrationID string) (autoscan.RewriteSuggestions, error) +} +``` +Add the handler: +```go +func (h *AutoscanHandler) HandleRewriteSuggestions(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + suggestions, err := h.svc.SuggestRewrites(r.Context(), id) + if err != nil { + if errors.Is(err, autoscan.ErrIntegrationNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Autoscan source not found") + return + } + // arr unreachable / bad key / folder list failure + writeError(w, http.StatusBadGateway, "arr_unreachable", "Could not load root folders from the arr instance") + return + } + writeJSON(w, http.StatusOK, suggestions) +} +``` +(`errors`, `autoscan`, `chi`, `strings`, `writeError`, `writeJSON` already imported.) + +- [ ] **Step 5.2: Mount the route** in `router.go`, inside the autoscan route block: +```go +r.Get("/autoscan/sources/{id}/rewrite-suggestions", autoscanHandler.HandleRewriteSuggestions) +``` + +- [ ] **Step 5.3: Wire the service deps** in `router.go` where `autoscanSvc` is built (after `autoscan.NewService(...)`): +```go +autoscanSvc.SetRewriteResolvers( + autoscan.NewArrRootFolderClient(nil), + autoscan.NewCatalogFolderLister(deps.FolderRepo), +) +``` +(This is inside the existing `if deps.FolderRepo != nil && deps.LibraryScanQueue != nil` block, so `deps.FolderRepo` is non-nil here.) + +- [ ] **Step 5.4: Update the handler test fake** + +In `autoscan_test.go`, the fake satisfying `autoscanTriggerer` (e.g. `fakeAutoscanTriggerer`) gains: +```go +func (f *fakeAutoscanTriggerer) SuggestRewrites(context.Context, string) (autoscan.RewriteSuggestions, error) { + return autoscan.RewriteSuggestions{}, nil +} +``` +Add a test asserting `HandleRewriteSuggestions` returns 200 with JSON for a known id (mirror the existing handler tests). + +- [ ] **Step 5.5: Verify (limited) + commit** + +```bash +# ensure the Go toolchain is on PATH +go vet ./internal/autoscan/... +gofmt -l internal/api/handlers/autoscan.go internal/api/router.go internal/api/handlers/autoscan_test.go +# internal/api cannot compile here (libvips) — Docker verifies later. +git add internal/api/handlers/autoscan.go internal/api/router.go internal/api/handlers/autoscan_test.go +git commit -m "feat(autoscan): rewrite-suggestions endpoint" +``` + +--- + +## Phase 4 — Frontend + +### Task 6: Types + hook + +**Files:** +- Modify: `web/src/api/types.ts` +- Modify: `web/src/hooks/queries/useAutoscan.ts` + +- [ ] **Step 6.1: Types** — add to `types.ts`: +```ts +export interface AutoscanProposedRewrite { + from: string; + to: string; + match_depth: number; +} +export interface AutoscanAmbiguousRoot { + root: string; + candidates: string[]; +} +export interface AutoscanRewriteSuggestions { + proposed: AutoscanProposedRewrite[]; + unmatched: string[]; + ambiguous: AutoscanAmbiguousRoot[]; + covered: string[]; +} +``` + +- [ ] **Step 6.2: Hook** — add to `useAutoscan.ts` a mutation that fetches suggestions for an id: +```ts +export function useAutoscanRewriteSuggestions() { + return useMutation({ + mutationFn: (id: string) => + api(`/admin/autoscan/sources/${id}/rewrite-suggestions`), + onError: (err) => + toast.error(err instanceof Error ? err.message : "Could not sync rewrites from the arr instance"), + }); +} +``` +Match the import/`api`/`toast`/`useMutation` conventions already in the file (it imports `api` and `toast` already; reuse them; import the new type). + +- [ ] **Step 6.3: Lint + commit** + +```bash +cd web && pnpm exec tsc -b && pnpm run lint && pnpm exec prettier --check src/api/types.ts src/hooks/queries/useAutoscan.ts +git add web/src/api/types.ts web/src/hooks/queries/useAutoscan.ts +git commit -m "feat(web): autoscan rewrite-suggestions types and hook" +``` + +### Task 7: Sync button + preview in AutoscanSourceEditor + +**Files:** +- Modify: `web/src/pages/AdminRequests.tsx` + +- [ ] **Step 7.1: Add the button + preview** + +In `AutoscanSourceEditor`: +- Call `const suggest = useAutoscanRewriteSuggestions();` and add local state `const [preview, setPreview] = useState(null);` and `const [selected, setSelected] = useState>(new Set());`. +- Add a **"Sync from arr"** `Button` beside Save: `onClick={async () => { const s = await suggest.mutateAsync(source.integration_id); setPreview(s); setSelected(new Set(s.proposed.map((p) => p.from))); }}` (disabled while `suggest.isPending`). +- When `preview` is set, render a panel (reuse the file's `Dialog` or an inline bordered panel) with three sections: + - **Proposed:** each `proposed[]` row — a checkbox bound to `selected.has(p.from)` (toggle updates the set), `from → to`, and a `Badge` showing confidence: `match_depth >= 2 ? "${match_depth} segments" : "1 segment — weak"` (destructive/secondary variant for weak). + - **Unmatched:** muted list of `unmatched[]` ("no Silo match — add manually if needed"). + - **Ambiguous:** list of `ambiguous[]` (`root` + its `candidates` joined) — info only. + - An **"Add selected to rewrites"** button that merges the checked proposals into the editor's existing rewrites state, deduped by `from` (skip any whose `from` already exists), then `setPreview(null)`. Do NOT auto-save — the admin then clicks the existing **Save**. +- Keep the existing manual path-rewrite editor unchanged; sync only appends rows to it. + +Reuse existing `Button`/`Badge`/`Field`/`Dialog` primitives and the editor's existing rewrites-state setter. + +- [ ] **Step 7.2: Lint + commit** + +```bash +cd web && pnpm exec tsc -b && pnpm run lint && pnpm exec prettier --check src/pages/AdminRequests.tsx +git add web/src/pages/AdminRequests.tsx +git commit -m "feat(web): autoscan sync-rewrites preview" +``` + +--- + +## Phase 5 — Verification + +### Task 8: Full verification + +- [ ] **Step 8.1: Go** + +```bash +# ensure the Go toolchain is on PATH +go test ./internal/autoscan/... 2>&1 | tail +go vet ./internal/autoscan/... +gofmt -l internal/autoscan internal/api/handlers/autoscan.go internal/api/router.go +``` +Expected: all pass; vet/gofmt clean. + +- [ ] **Step 8.2: Frontend** + +```bash +cd web && pnpm exec tsc -b && pnpm run lint && pnpm run format:check +``` + +- [ ] **Step 8.3: Docker build (closes the libvips gap)** + +```bash +docker build --build-arg BUILD_REVISION=$(git rev-parse HEAD) --build-arg BUILD_DIRTY=false -t silo-server:autoscan . +``` +Expected: exit 0 (handlers + router + frontend compile). + +- [ ] **Step 8.4: Smoke (optional)** + +On a deploy with arr instances configured: open an instance in the Autoscan tab, click **Sync from arr**, confirm the preview shows proposed (with confidence) + unmatched + ambiguous, "Add selected" appends deduped rows, and Save persists them. + +--- + +## Self-review notes (resolved) + +- **Spec §1 matcher** → Task 1 (incl. normalization, covered-first, suffix depth, unlike-deployment fixtures). **§2 endpoint/service** → Tasks 2–5 (`GetSource`, `RootFolderClient`/`FolderLister`, `SuggestRewrites`, endpoint, 404/502 mapping). **§3 frontend** → Tasks 6–7. **§4 testing** → Tasks 1,4,5 + Task 8. +- **Manual-preserving**: sync only *appends* deduped rows to the editor; the admin saves via the existing `PUT` (Task 7). Never auto-writes. +- **Generic**: `suggestRewrites` takes plain string slices, no deployment constants; tests include `/data/Movies` and `/srv/tv` fixtures (Task 1). +- **Optional deps**: `SetRewriteResolvers` keeps `main.go`'s poll service untouched; `SuggestRewrites` errors if unset (Task 4). +- **Type consistency**: `RewriteSuggestions`/`ProposedRewrite`/`AmbiguousRoot` (Go) ↔ `AutoscanRewriteSuggestions`/`AutoscanProposedRewrite`/`AutoscanAmbiguousRoot` (TS); `match_depth` JSON tag matches. diff --git a/docs/superpowers/plans/2026-06-02-autoscan-scan-source-sdk-capability.md b/docs/superpowers/plans/2026-06-02-autoscan-scan-source-sdk-capability.md new file mode 100644 index 00000000..2f02b11a --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-autoscan-scan-source-sdk-capability.md @@ -0,0 +1,375 @@ +# `scan_source.v1` SDK Capability — 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 new, additive `scan_source.v1` plugin capability to `silo-plugin-sdk` so the Silo host can pull changed paths from a scan-source plugin on a timer. + +**Architecture:** Mirror the existing `scheduled_task.v1` capability exactly — a single-RPC, request/response gRPC service defined in a `.proto`, generated into `pkg/pluginproto`, then registered in three Go helper sites (`capability.KnownTypes`, the `runtime.CapabilityServers` struct, and the `runtime.GRPCPlugin` server registration). Nothing existing is modified in shape; only new symbols are added. Tag a new SDK release so the host and the arr plugin can build against it. + +**Tech Stack:** Go, Protocol Buffers (proto3), `buf` codegen (`buf.gen.yaml`), `google.golang.org/protobuf`, `google.golang.org/grpc`, the `hashicorp/go-plugin` runtime the SDK wraps. + +--- + +## Prerequisites (read before starting) + +This plan targets the **`silo-plugin-sdk`** repository (Go module `github.com/Silo-Server/silo-plugin-sdk`), which is **not currently checked out** in this workspace — only `silo-server` and `silo-plugin-tmdb` are. Before executing: + +1. Clone `silo-plugin-sdk` as a writable working copy beside the other repos. Commands below assume **the silo-plugin-sdk repository root is the cwd**. +2. Ensure the proto toolchain is available: the `make proto` target auto-installs `buf`, `protoc-gen-go@v1.36.11`, and `protoc-gen-go-grpc@v1.6.1` into `$(GO_BIN)`, but it requires the **`protoc`** binary to already be on `PATH` (it errors `protoc is required` otherwise). Install `protoc` if absent. +3. This SDK is the **long pole**: the silo-server host plan and the arr-plugin plan both depend on the version tagged at the end of this plan (Task 6). Do not start those until this is tagged. + +**Pattern reference (current `scheduled_task.v1`, the template this mirrors):** +- Proto: `proto/silo/plugin/v1/scheduled_task.proto` +- Generated: `pkg/pluginproto/silo/plugin/v1/scheduled_task.pb.go` + `scheduled_task_grpc.pb.go` +- Capability const + allowlist: `pkg/pluginsdk/capability/capability.go` +- Runtime wiring: `pkg/pluginsdk/runtime/runtime.go` (struct field, `Client` accessor, `GRPCServer` registration) +- Manifest acceptance test pattern: `pkg/pluginsdk/manifest/manifest_test.go` + +--- + +## File Structure + +| File | Responsibility | Action | +|---|---|---| +| `proto/silo/plugin/v1/scan_source.proto` | The `ScanSource` service + `PollChanges` request/response messages | Create | +| `pkg/pluginproto/silo/plugin/v1/scan_source.pb.go` | Generated message types | Create (codegen) | +| `pkg/pluginproto/silo/plugin/v1/scan_source_grpc.pb.go` | Generated client/server stubs | Create (codegen) | +| `pkg/pluginsdk/capability/capability.go` | `ScanSource` const + add to `KnownTypes` allowlist | Modify | +| `pkg/pluginsdk/capability/capability_test.go` | Assert `ScanSource` is a known type | Create or Modify | +| `pkg/pluginsdk/runtime/runtime.go` | `CapabilityServers.ScanSource` field, `Client.ScanSource()` accessor, `GRPCServer` registration | Modify | +| `pkg/pluginsdk/runtime/runtime_test.go` | Assert a `ScanSource` server registers without error | Create or Modify | +| `pkg/pluginsdk/manifest/manifest_test.go` | Assert a manifest declaring `scan_source.v1` loads | Modify | + +--- + +## Task 1: Define and generate the `scan_source.v1` proto + +**Files:** +- Create: `proto/silo/plugin/v1/scan_source.proto` +- Create (via codegen): `pkg/pluginproto/silo/plugin/v1/scan_source.pb.go`, `pkg/pluginproto/silo/plugin/v1/scan_source_grpc.pb.go` + +- [ ] **Step 1: Write the proto** + +Create `proto/silo/plugin/v1/scan_source.proto` (the `go_package` option must match the sibling protos exactly so generated code lands in package `pluginv1`): + +```proto +syntax = "proto3"; + +package silo.plugin.v1; + +option go_package = "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1;pluginv1"; + +// ScanSource lets the host pull changed filesystem paths from a provider +// (e.g. Sonarr/Radarr, inotify, Ceph). Pull only: the host owns the timer and +// calls PollChanges; the plugin never initiates. +service ScanSource { + rpc PollChanges(PollChangesRequest) returns (PollChangesResponse); +} + +message PollChangesRequest { + // Which configured scan_source capability instance is being polled. + string capability_id = 1; + // Opaque continuation token from the previous PollChanges. Empty on first + // run, which the provider treats as "start from now" (do not replay history). + string marker = 2; +} + +message PollChangesResponse { + // Absolute paths already translated into Silo's filesystem namespace (the + // plugin has applied its own path rewrites). Files or directories. + repeated string changed_paths = 1; + // Opaque continuation token. The host stores it verbatim and echoes it back + // on the next PollChanges; the host never parses it. + string next_marker = 2; +} +``` + +- [ ] **Step 2: Generate the Go code** + +Run: `make proto` +Expected: exits 0; creates `pkg/pluginproto/silo/plugin/v1/scan_source.pb.go` and `scan_source_grpc.pb.go`. (If it prints `protoc is required`, install `protoc` and re-run.) + +- [ ] **Step 3: Verify the generated symbols exist and compile** + +Run: `go build ./... && grep -l "func NewScanSourceClient" pkg/pluginproto/silo/plugin/v1/scan_source_grpc.pb.go && grep -l "RegisterScanSourceServer" pkg/pluginproto/silo/plugin/v1/scan_source_grpc.pb.go` +Expected: build succeeds; both `grep`s print the filename (confirming `ScanSourceClient`, `ScanSourceServer`, `NewScanSourceClient`, `RegisterScanSourceServer` were generated — `require_unimplemented_servers=false` in `buf.gen.yaml` means no forced `UnimplementedScanSourceServer` embedding). + +- [ ] **Step 4: Commit** + +```bash +git add proto/silo/plugin/v1/scan_source.proto pkg/pluginproto/silo/plugin/v1/scan_source.pb.go pkg/pluginproto/silo/plugin/v1/scan_source_grpc.pb.go +git commit -m "feat(proto): add scan_source.v1 capability service" +``` + +--- + +## Task 2: Register the capability type in the allowlist + +The manifest loader rejects capability types not in `capability.KnownTypes` (see `TestLoadRejectsUnknownCapabilityType`). A manifest declaring `scan_source.v1` cannot load until the type is registered here. + +**Files:** +- Modify: `pkg/pluginsdk/capability/capability.go` +- Create or Modify: `pkg/pluginsdk/capability/capability_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `pkg/pluginsdk/capability/capability_test.go` (create the file with this `package capability` header if it does not exist): + +```go +package capability + +import "testing" + +func TestScanSourceIsKnownType(t *testing.T) { + if ScanSource != "scan_source.v1" { + t.Fatalf("ScanSource const = %q, want %q", ScanSource, "scan_source.v1") + } + found := false + for _, k := range KnownTypes { + if k == ScanSource { + found = true + break + } + } + if !found { + t.Fatalf("ScanSource (%q) missing from KnownTypes %v", ScanSource, KnownTypes) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/pluginsdk/capability/ -run TestScanSourceIsKnownType -v` +Expected: FAIL — `undefined: ScanSource` (the const does not exist yet). + +- [ ] **Step 3: Add the const and allowlist entry** + +In `pkg/pluginsdk/capability/capability.go`, add the const inside the existing `const (...)` block (after `EbookBackend`): + +```go + EbookBackend = "ebook_backend.v1" + ScanSource = "scan_source.v1" +``` + +and add it to the `KnownTypes` slice (append `ScanSource` to the existing list): + +```go + ScanSource, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./pkg/pluginsdk/capability/ -run TestScanSourceIsKnownType -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/pluginsdk/capability/capability.go pkg/pluginsdk/capability/capability_test.go +git commit -m "feat(capability): register scan_source.v1 as a known capability type" +``` + +--- + +## Task 3: Wire the capability into the runtime + +Add the `ScanSource` server to `CapabilityServers`, a `Client.ScanSource()` accessor, and the conditional registration in `GRPCServer` — mirroring `ScheduledTask` in `pkg/pluginsdk/runtime/runtime.go`. + +**Files:** +- Modify: `pkg/pluginsdk/runtime/runtime.go` +- Create or Modify: `pkg/pluginsdk/runtime/runtime_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `pkg/pluginsdk/runtime/runtime_test.go` (create with the `package runtime` header if absent). This test constructs a real `*grpc.Server`, supplies a minimal `ScanSource` server, and asserts `GRPCServer` registers it without error: + +```go +package runtime + +import ( + "context" + "testing" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + "google.golang.org/grpc" +) + +type stubScanSource struct{} + +func (stubScanSource) PollChanges(context.Context, *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) { + return &pluginv1.PollChangesResponse{}, nil +} + +func TestGRPCServerRegistersScanSource(t *testing.T) { + p := &GRPCPlugin{Servers: CapabilityServers{ + Runtime: stubRuntime{}, // existing stub used by sibling runtime tests + ScanSource: stubScanSource{}, + }} + srv := grpc.NewServer() + if err := p.GRPCServer(nil, srv); err != nil { + t.Fatalf("GRPCServer with ScanSource = %v, want nil", err) + } + if _, ok := srv.GetServiceInfo()["silo.plugin.v1.ScanSource"]; !ok { + t.Fatalf("ScanSource service not registered; got %v", srv.GetServiceInfo()) + } +} +``` + +Note: `GRPCServer` requires `Servers.Runtime` to be non-nil (it errors otherwise). Reuse the existing runtime stub the sibling tests use. If no such stub exists in `runtime_test.go`, add a minimal one: + +```go +type stubRuntime struct{ pluginv1.RuntimeServer } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/pluginsdk/runtime/ -run TestGRPCServerRegistersScanSource -v` +Expected: FAIL — `unknown field 'ScanSource' in struct literal of type CapabilityServers` (the field does not exist yet). + +- [ ] **Step 3: Add the struct field** + +In `pkg/pluginsdk/runtime/runtime.go`, add to the `CapabilityServers` struct (after the `ScheduledTask` field): + +```go + ScheduledTask pluginv1.ScheduledTaskServer + ScanSource pluginv1.ScanSourceServer +``` + +- [ ] **Step 4: Add the `Client` accessor** + +After the existing `func (c *Client) ScheduledTask() ...` method, add: + +```go +func (c *Client) ScanSource() pluginv1.ScanSourceClient { + return pluginv1.NewScanSourceClient(c.conn) +} +``` + +- [ ] **Step 5: Add the conditional registration in `GRPCServer`** + +In `func (p *GRPCPlugin) GRPCServer(...)`, after the `ScheduledTask` registration block, add: + +```go + if p.Servers.ScheduledTask != nil { + pluginv1.RegisterScheduledTaskServer(server, p.Servers.ScheduledTask) + } + if p.Servers.ScanSource != nil { + pluginv1.RegisterScanSourceServer(server, p.Servers.ScanSource) + } +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `go test ./pkg/pluginsdk/runtime/ -run TestGRPCServerRegistersScanSource -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add pkg/pluginsdk/runtime/runtime.go pkg/pluginsdk/runtime/runtime_test.go +git commit -m "feat(runtime): serve and dial scan_source.v1 capability" +``` + +--- + +## Task 4: Prove a `scan_source.v1` manifest loads + +Guards the end-to-end path: a plugin manifest declaring the new capability must pass the loader (it would not before Task 2's allowlist entry). + +**Files:** +- Modify: `pkg/pluginsdk/manifest/manifest_test.go` + +- [ ] **Step 1: Write the failing test** + +The test file is package `manifest_test` (external) and loads from a JSON literal via `manifest.Load([]byte)`. Append this test, mirroring `TestLoadAcceptsRequestRouterCapability` exactly with the type swapped to `scan_source.v1`: + +```go +func TestLoadAcceptsScanSourceCapability(t *testing.T) { + raw := []byte(`{ + "plugin_id": "silo.example", + "version": "1.0.0", + "silo_api_version": "v1", + "capabilities": [ + {"type": "scan_source.v1", "id": "arr", "display_name": "X", "description": "Y"} + ] + }`) + m, err := manifest.Load(raw) + if err != nil { + t.Fatalf("Load returned unexpected error: %v", err) + } + if len(m.GetCapabilities()) != 1 { + t.Fatalf("expected 1 capability, got %d", len(m.GetCapabilities())) + } + if got := m.GetCapabilities()[0].GetType(); got != "scan_source.v1" { + t.Fatalf("capability type = %q, want scan_source.v1", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (before this task's dependency was in place) / passes now** + +Run: `go test ./pkg/pluginsdk/manifest/ -run TestLoadAcceptsScanSourceCapability -v` +Expected: PASS (Task 2 already added `scan_source.v1` to `KnownTypes`). If it FAILS with an unknown-capability-type error, Task 2 was not completed correctly — fix that first. + +- [ ] **Step 3: Commit** + +```bash +git add pkg/pluginsdk/manifest/manifest_test.go +git commit -m "test(manifest): accept scan_source.v1 capability in manifests" +``` + +--- + +## Task 5: Full build + test sweep + +**Files:** none (verification only) + +- [ ] **Step 1: Build everything** + +Run: `go build ./...` +Expected: exits 0. + +- [ ] **Step 2: Run the full SDK test suite** + +Run: `go test ./...` +Expected: all packages `ok`. Confirms the additive change broke no existing capability, manifest, or runtime test. + +- [ ] **Step 3: Vet + format** + +Run: `go vet ./... && gofmt -l pkg proto 2>/dev/null; test -z "$(gofmt -l pkg)"` +Expected: vet clean; `gofmt -l pkg` prints nothing. + +--- + +## Task 6: Tag the release + +Downstream repos (silo-server host, arr plugin) consume the SDK as a tagged module version. + +**Files:** none (release action) + +- [ ] **Step 1: Confirm the version to tag** + +Check the latest tag: `git tag --list 'v*' | sort -V | tail -3`. The current consumed version is `v0.4.0`; this additive feature warrants a minor bump to **`v0.5.0`** (no breaking changes). + +- [ ] **Step 2: Tag and push** + +```bash +git tag v0.5.0 +git push origin v0.5.0 +``` + +- [ ] **Step 3: Record the contract for downstream plans** + +Note in the host and arr-plugin plans that they must require `github.com/Silo-Server/silo-plugin-sdk v0.5.0` and use: +- `capability.ScanSource` (`"scan_source.v1"`) +- `runtime.CapabilityServers{ ScanSource: }` (plugin side) +- `client.ScanSource()` → `pluginv1.ScanSourceClient.PollChanges(ctx, &pluginv1.PollChangesRequest{CapabilityId, Marker})` (host side) +- response fields `PollChangesResponse.ChangedPaths []string`, `PollChangesResponse.NextMarker string` + +--- + +## Self-Review notes + +- **Spec coverage:** Implements spec §5 (the `scan_source.v1` contract: `PollChanges`, opaque marker, Silo-native paths) and §11 (additive-only; registered in `KnownTypes`; no existing capability modified). Spec §12 build-order step 1 (tag the SDK) is Task 6. +- **Out of scope here:** the host engine (spec §7), connections (§8), the Autoscan category (§9), and the arr plugin (§10) are separate per-repo plans built against the `v0.5.0` tag produced by Task 6. +- **Marker is opaque end-to-end:** the proto carries `marker`/`next_marker` as plain strings with no server-side interpretation, satisfying the "any provider's bookmark" requirement. diff --git a/docs/superpowers/plans/2026-06-03-autoscan-arr-plugin.md b/docs/superpowers/plans/2026-06-03-autoscan-arr-plugin.md new file mode 100644 index 00000000..baf19355 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-autoscan-arr-plugin.md @@ -0,0 +1,265 @@ +# Sonarr/Radarr Scan-Source Plugin 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:** Build a new installable Silo plugin that implements `scan_source.v1` for Sonarr/Radarr: when the host pulls, it polls arr `/history`, extracts imported + renamed paths, applies its own path rewrites, and returns Silo-native paths + a marker. + +**Architecture:** A standalone Go module structured like `silo-plugin-tmdb` — a `manifest.json` declaring `scan_source.v1`, a `main.go` serving `runtime.CapabilityServers{Runtime, ScanSource}` via `runtime.Serve`, and the arr-specific logic ported from the closed silo-server PR #43 (`internal/autoscan/history.go`, `rewrite.go`, `suggest.go`). + +**Tech Stack:** Go, `github.com/Silo-Server/silo-plugin-sdk` (`v0.5.0`, the `scan_source.v1` capability), `hashicorp/go-plugin` runtime (via the SDK), arr HTTP API (`X-Api-Key`). + +Commands assume the **new plugin repository root** is the cwd (the `silo-plugin-autoscan-arr` checkout, a sibling of this repo like the `silo-plugin-sdk` checkout). + +--- + +## Prerequisites + +1. **SDK `v0.5.0`.** Needs `scan_source.v1` from `silo-plugin-sdk` PR #2. Until tagged, depend on the local checkout via replace: + ```bash + go mod edit -replace github.com/Silo-Server/silo-plugin-sdk=../silo-plugin-sdk + ``` + This points at the `silo-plugin-sdk` checkout (a sibling of this repo). Task 8 finalizes to `v0.5.0`. +2. **Template:** the `silo-plugin-tmdb` checkout (a sibling of this repo) — copy its `main.go` runtime scaffold, `Makefile`, and `manifest.json` shape. +3. **Salvage source:** the arr logic is on silo-server `main` at `internal/autoscan/history.go` (imports + renames extraction, bounded window), `rewrite.go` (`applyRewrites`, `normalizeSeparators`), `suggest.go`/`suggest_deps.go` (root-folder suffix-match suggester). Port these verbatim where possible. +4. **New repo:** create `Silo-Server/silo-plugin-autoscan-arr` (or fork pattern). The plugin is registered in the `silo-plugins` catalog in a separate follow-up (Task 9 notes it). + +--- + +## File Structure + +| File | Responsibility | Action | +|---|---|---| +| `go.mod` | module `github.com/Silo-Server/silo-plugin-autoscan-arr`; SDK dep | Create | +| `manifest.json` | declares `scan_source.v1` capability + `global_config_schema` (path rewrites only; connection arrives per request) | Create | +| `main.go` | `runtimeServer` (GetManifest/Configure) + `scanSourceServer` (PollChanges); `runtime.Serve` | Create | +| `internal/arr/history.go` | poll `/history`, imports + renames, bounded window | Create (port) | +| `internal/arr/rewrite.go` | `applyRewrites`, `normalizeSeparators` | Create (port) | +| `internal/arr/client.go` | arr HTTP client (`X-Api-Key`, response cap, timeout) | Create | +| `internal/config/config.go` | parse Configure payload → `{rewrites}` (connection comes per request, not config) | Create | +| `Makefile` | build (cross-platform), like tmdb | Create | + +--- + +## Task 1: Scaffold the plugin (serves an empty `scan_source.v1`) + +**Files:** Create `go.mod`, `manifest.json`, `main.go`, `Makefile`. + +- [ ] **Step 1: Module + SDK dep** + +```bash +go mod init github.com/Silo-Server/silo-plugin-autoscan-arr +go mod edit -replace github.com/Silo-Server/silo-plugin-sdk=../silo-plugin-sdk # the silo-plugin-sdk checkout, a sibling of this repo +go get github.com/Silo-Server/silo-plugin-sdk@v0.4.0 # replace points at the scan_source branch checkout +``` + +- [ ] **Step 2: manifest.json** + +Mirror tmdb's shape, declaring the scan source: + +```json +{ + "plugin_id": "silo.autoscan.arr", + "version": "0.1.0", + "checksum": "__CHECKSUM__", + "silo_api_version": "v1", + "supported_platforms": [ + {"os": "linux", "arch": "amd64"}, + {"os": "linux", "arch": "arm64"}, + {"os": "darwin", "arch": "arm64"} + ], + "capabilities": [ + { + "type": "scan_source.v1", + "id": "arr", + "display_name": "Sonarr / Radarr", + "description": "Triggers Silo rescans from Sonarr/Radarr import and rename history." + } + ], + "global_config_schema": [ + {"key": "path_rewrites", "type": "json", "required": false, "display_name": "Path rewrites"} + ] +} +``` + +> **Connection is NOT plugin config.** The arr base URL + API key are delivered by the host in each `PollChangesRequest.connection` (resolved from the operator's Autoscan connection — own creds or a reused Requests link). The plugin reads them from the request and stores no credentials. Only path rewrites (and other provider-specific behaviour) live in plugin config. + +(Confirm the exact `global_config_schema` entry shape against `silo-plugin-sdk` `pkg/pluginsdk/config` — adapt field names to the SDK's config schema type.) + +- [ ] **Step 3: main.go scaffold** + +Copy tmdb's `runtimeServer` (GetManifest from embedded manifest; Configure stores config) and add a `scanSourceServer` returning empty for now: + +```go +type scanSourceServer struct{ cfg *config.Config } + +func (s *scanSourceServer) PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) { + return &pluginv1.PollChangesResponse{}, nil +} + +func main() { + manifest := mustLoadManifest() + rt := &runtimeServer{manifest: manifest} + runtime.Serve(runtime.ServeConfig{ + Servers: runtime.CapabilityServers{ + Runtime: rt, + ScanSource: &scanSourceServer{cfg: rt.cfg}, + }, + }) +} +``` + +- [ ] **Step 4: Build** + +Run: `go build ./...` +Expected: exits 0. + +- [ ] **Step 5: Commit** + +```bash +git init && git add -A +git commit -m "feat: scaffold autoscan-arr scan_source.v1 plugin" +``` + +--- + +## Task 2: Config parsing (Configure → path rewrites only) + +**Files:** Create `internal/config/config.go`, `internal/config/config_test.go`. + +> Connection (base_url/api_key) is NOT config — it arrives per poll in `PollChangesRequest.connection` (Task 5). Plugin config carries only path rewrites. + +- [ ] **Step 1: Failing test** + +```go +func TestParseConfig(t *testing.T) { + cfg, err := Parse(map[string]string{ + "path_rewrites": `[{"from":"/mnt/arr/tv","to":"/mnt/media/tv"}]`, + }) + if err != nil { t.Fatalf("Parse: %v", err) } + if len(cfg.Rewrites) != 1 || cfg.Rewrites[0].From != "/mnt/arr/tv" { t.Fatalf("bad rewrites: %+v", cfg.Rewrites) } +} +``` + +- [ ] **Step 2: Run → fail.** `go test ./internal/config/` → undefined `Parse`. + +- [ ] **Step 3: Implement** `Parse(map[string]string) (*Config, error)` with `Config{Rewrites []Rewrite}` where `Rewrite{From, To string}`; unmarshal `path_rewrites` JSON. Wire `runtimeServer.Configure` to store the parsed config so `scanSourceServer` reads the rewrites. + +- [ ] **Step 4: Run → pass.** + +- [ ] **Step 5: Commit** `feat(config): parse arr connection and path rewrites`. + +--- + +## Task 3: arr history client (imports + renames, bounded window) + +**Files:** Create `internal/arr/client.go`, `internal/arr/history.go`, `internal/arr/history_test.go`. + +Port from silo-server `main:internal/autoscan/history.go` (the `ChangedPaths` logic: `downloadFolderImported.importedPath`; `episodeFileRenamed`/`movieFileRenamed` → `path` + `sourcePath`; deletes ignored) and the client (`X-Api-Key`, 1 MiB cap, timeout). The marker is an RFC3339 timestamp; empty marker ⇒ "now". + +- [ ] **Step 1: Failing test** — copy the salvaged `history_test.go` (`TestArrHistoryChangedPaths`: imports + both rename paths returned, `grabbed`/`episodeFileDeleted` ignored), renamed to this package, asserting the returned `(paths, nextMarker)`. + +- [ ] **Step 2: Run → fail.** + +- [ ] **Step 3: Implement** `ChangedPaths(ctx, baseURL, apiKey, since time.Time) (paths []string, newest time.Time, err error)` — port `history.go`, additionally returning the newest history timestamp as the next marker. Apply the bounded window (24h max-lookback floor + overlap buffer) here. + +- [ ] **Step 4: Run → pass.** + +- [ ] **Step 5: Commit** `feat(arr): poll history for imports and renames`. + +--- + +## Task 4: Path rewrites (→ Silo-native paths) + +**Files:** Create `internal/arr/rewrite.go`, `internal/arr/rewrite_test.go`. + +Port `applyRewrites` (boundary-safe prefix: `path==trimmed || HasPrefix(path, trimmed+"/")`) and `normalizeSeparators` from silo-server `main:internal/autoscan/rewrite.go`. + +- [ ] **Step 1: Failing test** — copy salvaged `rewrite_test.go` cases. +- [ ] **Step 2: Run → fail.** +- [ ] **Step 3: Implement** the port. +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Commit** `feat(arr): boundary-safe path rewrites`. + +--- + +## Task 5: Wire `PollChanges` end-to-end + +**Files:** Modify `main.go` (`scanSourceServer.PollChanges`), add `main_test.go`. + +- [ ] **Step 1: Failing test** — a `scanSourceServer` (rewrites configured) called with a `PollChangesRequest` whose `Connection` points at a stub arr server (httptest); assert `PollChanges` with empty marker returns rewritten Silo-native paths and a non-empty `next_marker`, and that the request actually hit the stub at `connection.base_url` with `X-Api-Key: connection.api_key`. A second call with the returned marker queries arr with the right `since`. Also assert a request with a nil/empty `Connection` returns an error (no credentials to poll with). + +- [ ] **Step 2: Run → fail.** + +- [ ] **Step 3: Implement** — read the connection from the request, not from config: + +```go +func (s *scanSourceServer) PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) { + conn := req.GetConnection() + if conn == nil || conn.GetBaseUrl() == "" { + return nil, fmt.Errorf("scan_source: no connection supplied") + } + since := time.Time{} // empty marker => now (history client floors to now) + if m := req.GetMarker(); m != "" { + if t, err := time.Parse(time.RFC3339, m); err == nil { since = t } + } + raw, newest, err := arr.ChangedPaths(ctx, conn.GetBaseUrl(), conn.GetApiKey(), since) + if err != nil { return nil, err } + out := make([]string, 0, len(raw)) + for _, p := range raw { + out = append(out, arr.ApplyRewrites(arr.NormalizeSeparators(p), s.cfg.Rewrites)) + } + return &pluginv1.PollChangesResponse{ChangedPaths: out, NextMarker: newest.UTC().Format(time.RFC3339)}, nil +} +``` + +- [ ] **Step 4: Run → pass.** + +- [ ] **Step 5: Commit** `feat: PollChanges returns Silo-native paths from arr history`. + +--- + +## Task 6: Rewrite suggester ("Sync from arr") — root folders + +**Files:** Create `internal/arr/suggest.go`, `suggest_test.go`. + +Port `suggestRewrites` (suffix-match arr root folders → Silo folders) from `main:internal/autoscan/suggest.go`. **Open question (spec §14 risk #2):** how the operator triggers "Sync" and how the plugin reaches Silo's media-folder list. + +- [ ] **Step 1:** Decide the delivery mechanism — recommended: the plugin reads Silo's folder list via the host library-listing service (`pluginhost` library lister, reachable through the SDK `runtime.Host()` client). Confirm that path exists in `v0.5.0`; if not, scope "Sync" to a follow-up and ship Tasks 1–5 (manual rewrites only) first. +- [ ] **Step 2:** Port the suffix-match suggester + its tests (unique/ambiguous/covered/no-op/normalization cases from `suggest_test.go`). +- [ ] **Step 3:** Expose it — either via an `http_routes.v1` capability the admin UI calls, or as part of the plugin config flow. Document the chosen mechanism. +- [ ] **Step 4:** Tests pass; commit `feat(arr): suffix-match rewrite suggester`. + +> If §14 risk #2 is unresolved at execution time, **skip this task**, ship manual rewrites (Tasks 1–5), and file the suggester as a follow-up. The plugin is fully functional without it. + +--- + +## Task 7: Cross-repo smoke test against the host + +**Files:** none (verification). + +- [ ] **Step 1:** Build the plugin binary; install it into a dev silo-server (with the Part-1 backend) via the plugin installer. +- [ ] **Step 2:** Configure a source pointing at a real/stub arr; trigger an autoscan poll; confirm the host enqueues a scan for an imported path. (This exercises the full SDK contract end-to-end.) +- [ ] **Step 3:** Note results; no commit unless fixes are needed. + +--- + +## Task 8: Finalize SDK dependency + +- [ ] After `silo-plugin-sdk v0.5.0` is tagged: `go mod edit -dropreplace ...; go get .../silo-plugin-sdk@v0.5.0; go mod tidy; go build ./...`. +- [ ] Commit `build: depend on silo-plugin-sdk v0.5.0`. +- [ ] Do not publish/tag the plugin with a `replace` directive in `go.mod`. + +--- + +## Task 9: Catalog registration (follow-up, separate repo) + +- [ ] Add `silo.autoscan.arr` to the `silo-plugins` catalog manifest so it is installable. This is a separate PR in the `silo-plugins` repo (not this module) — note it; out of scope for this plan's repo. + +--- + +## Self-Review notes + +- **Spec coverage:** §10 (arr plugin: history imports + renames, bounded window, rewrites, Silo-native paths) Tasks 3–5; §5 contract (`PollChanges`, opaque marker = RFC3339 timestamp, first-run "now") Task 5; "Sync from arr" §10/§14-risk-2 Task 6 (with an explicit skip path). +- **Salvage:** history/rewrite/suggest ported from the closed PR (Tasks 3,4,6) — exactly the files the host backend plan deletes (Part-1 Task 8). +- **Risk:** §14 risk #1 (one plugin installation per arr server vs. many) — this plugin holds one connection per installation/config; multiple arr servers ⇒ multiple installs unless the runtime supports multiple capability instances. §14 risk #2 (Sync UI) gated in Task 6 with a clean skip. +- **Type consistency:** `Config{Rewrites}` (no creds — connection arrives in `PollChangesRequest.connection`), `Rewrite{From,To}`, `arr.ChangedPaths(ctx, baseURL, apiKey, since) ([]string, time.Time, error)`, `arr.ApplyRewrites`/`NormalizeSeparators`, `scanSourceServer.PollChanges` consistent across Tasks 2–6. diff --git a/docs/superpowers/plans/2026-06-03-autoscan-host-ui.md b/docs/superpowers/plans/2026-06-03-autoscan-host-ui.md new file mode 100644 index 00000000..209b2176 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-autoscan-host-ui.md @@ -0,0 +1,209 @@ +# Autoscan Admin UI Implementation Plan (Part 2 of 2) + +> **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:** Give Autoscan its own admin category (page + sidebar nav), separate from Requests: manage Connections (reuse a Requests arr server or enter own), Sources (per scan-source plugin: enable, interval, connection), and global settings — backed by the Part-1 v2 API. + +**Architecture:** Extract the Autoscan tab out of `AdminRequests.tsx` into a standalone `AdminAutoscan.tsx` page; register a route and a sidebar item; rebuild `useAutoscan.ts` hooks + `types.ts` against the v2 endpoints (`/admin/autoscan/{settings,connections,sources,trigger,status}`). + +**Tech Stack:** React + TypeScript, React Query (`@tanstack/react-query`), the project's `ui/` component library (`Tabs`, `Table`, `Card`, etc.), `react-router` routes in `App.tsx`, 2-space indent / double quotes / 100-col (`web/.prettierrc`). + +Commands assume the silo-server repository root is the cwd. **Depends on Part 1 (backend)** being implemented (the v2 endpoints must exist). + +--- + +## Prerequisites + +1. **Part-1 backend merged/available** — the v2 endpoints and response shapes (connections with `request_integration_id`, sources with `marker`/`last_error`, settings with `default_poll_interval_seconds`). +2. **Reference the existing autoscan tab** at `web/src/pages/AdminRequests.tsx` (the `autoscan` `TabsContent`, the `AutoscanTab`/`AutoscanSourceEditor` components) and hooks at `web/src/hooks/queries/useAutoscan.ts` — these are the starting material; they are reshaped, not discarded. +3. **Run before MR:** `cd web && pnpm run lint && pnpm run format:check`. Note the 20 pre-existing failing frontend tests are unrelated (project memory); do not be alarmed if they appear — only newly-introduced failures matter. + +--- + +## File Structure + +| File | Responsibility | Action | +|---|---|---| +| `web/src/api/types.ts` | `AutoscanConnection`, `AutoscanSource`, `AutoscanSettings` (v2 shapes) | Modify | +| `web/src/hooks/queries/useAutoscan.ts` | hooks for settings/connections/sources/trigger/status | Rewrite | +| `web/src/hooks/queries/keys.ts` | query keys for connections/sources | Modify | +| `web/src/pages/AdminAutoscan.tsx` | the new standalone Autoscan page (Connections + Sources + Settings tabs) | Create | +| `web/src/pages/admin/autoscan/ConnectionsPanel.tsx` | list/add/edit/delete connections (own or Requests-linked) | Create | +| `web/src/pages/admin/autoscan/SourcesPanel.tsx` | per-source enable, interval, connection binding, status | Create | +| `web/src/pages/AdminRequests.tsx` | remove the Autoscan tab | Modify | +| `web/src/App.tsx` | add `} />` | Modify | +| `web/src/components/AdminSidebar.tsx` | add the Autoscan nav item under "Content" | Modify | + +--- + +## Task 1: v2 types + hooks + +**Files:** Modify `web/src/api/types.ts`, `web/src/hooks/queries/keys.ts`; rewrite `web/src/hooks/queries/useAutoscan.ts`. + +- [ ] **Step 1: Types** + +Add to `web/src/api/types.ts`: + +```ts +export interface AutoscanSettings { + enabled: boolean; + default_poll_interval_seconds: number; + debounce_seconds: number; +} + +export interface AutoscanConnection { + id: string; + name: string; + kind: string; + // present when the connection has its own credentials: + base_url?: string; + // present when linked to a Requests integration (live reuse): + request_integration_id?: string | null; + // NOTE: api_key_ref / resolved keys are never sent by the backend. +} + +export interface AutoscanSource { + id: string; + installation_id: number; + capability_id: string; + connection_id: string; + enabled: boolean; + poll_interval_seconds: number | null; + last_run_at: string | null; + last_error: string | null; +} +``` + +- [ ] **Step 2: Hooks** + +Rewrite `useAutoscan.ts` with: `useAutoscanSettings`/`useUpdateAutoscanSettings`, `useAutoscanConnections`/`useCreateAutoscanConnection`/`useUpdateAutoscanConnection`/`useDeleteAutoscanConnection`, `useAutoscanSources`/`useUpdateAutoscanSource`, `useAutoscanStatus`, `useTriggerAutoscan`. Follow the existing hook style (the current `useAutoscan.ts` is the template for query/mutation + invalidation). Add query keys in `keys.ts`. + +- [ ] **Step 3: Typecheck** + +Run: `cd web && pnpm exec tsc --noEmit` +Expected: no type errors in the autoscan files. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/api/types.ts web/src/hooks/queries/useAutoscan.ts web/src/hooks/queries/keys.ts +git commit -m "feat(web): autoscan v2 types and query hooks" +``` + +--- + +## Task 2: Connections panel + +**Files:** Create `web/src/pages/admin/autoscan/ConnectionsPanel.tsx`. + +- [ ] **Step 1: Build the panel** + +A `Card` + `Table` listing connections (name, kind, and a "Reused from Requests" / "Own" badge derived from `request_integration_id`). An "Add connection" dialog offers two modes: +- **Reuse from Requests** — a select populated from the existing request-integrations hook (filter to arr kinds), storing `request_integration_id`. +- **Enter own** — name + URL + API key fields, posting own credentials. + +Edit + delete actions per row. Never render any key material (the backend doesn't send it). + +- [ ] **Step 2: Lint/format/typecheck** + +Run: `cd web && pnpm run lint && pnpm exec tsc --noEmit` +Expected: clean. + +- [ ] **Step 3: Commit** `feat(web): autoscan connections panel (reuse or own)`. + +--- + +## Task 3: Sources panel + +**Files:** Create `web/src/pages/admin/autoscan/SourcesPanel.tsx`. + +- [ ] **Step 1: Build the panel** + +A `Table` of sources, one row per installed `scan_source` capability instance: plugin/capability label, a connection ` setEdit((ed) => ({ ...ed, label: e.target.value }))} + onBlur={handleLabelBlur} + /> +
    + ); +``` + +- [ ] **Step 7: Build and pass `pluginDisplayNames` from the main panel** + +In `SourcesPanel` (the default export), add the available-sources query and the memoized map near the other hooks (after `const settings = useAutoscanSettings();`): + +```ts + const available = useAvailableScanSources(); + const pluginDisplayNames = useMemo( + () => buildPluginDisplayNames(available.data ?? []), + [available.data], + ); +``` + +`useAvailableScanSources` is already imported in this file. Then pass the prop to **both** `` instances (the card-layout map and the table-layout map), adding this line to each: + +```tsx + source={source} + connectionOptions={connectionOptions} + pluginDisplayNames={pluginDisplayNames} + globalPollInterval={globalPollInterval} +``` + +- [ ] **Step 8: Typecheck, lint, format** + +```bash +cd web && pnpm exec tsc -b && pnpm exec eslint src/pages/admin/autoscan/SourcesPanel.tsx && pnpm exec prettier --write src/pages/admin/autoscan/SourcesPanel.tsx +``` + +Expected: tsc exit 0, eslint exit 0, prettier rewrites/confirms formatting. + +- [ ] **Step 9: Commit** + +```bash +git add web/src/pages/admin/autoscan/SourcesPanel.tsx +git commit -m "feat(autoscan): label sources via shared helper + operator label input" +``` + +--- + +## Task 8: Activity panel — resolve labels via `SourceLabelLookups` + +**Files:** +- Modify: `web/src/pages/admin/autoscan/ActivityPanel.tsx` + +This task replaces the existing `sourceNames: Map` plumbing (connection-name-only) with the full `SourceLabelLookups` and the shared resolver. + +- [ ] **Step 1: Update imports** + +Add `useAvailableScanSources` to the existing `@/hooks/queries/useAutoscan` import block: + +```ts +import { + useAutoscanConnections, + useAutoscanEvents, + useAutoscanScans, + useAutoscanSources, + useAutoscanStatus, + useAvailableScanSources, +} from "@/hooks/queries/useAutoscan"; +``` + +Add a helper import (next to the other `@/lib` imports such as `@/lib/scanRuns`): + +```ts +import { + buildPluginDisplayNames, + resolveEventSourceName, + type SourceLabelLookups, +} from "@/lib/autoscanLabels"; +``` + +- [ ] **Step 2: Rewrite the two name functions** + +Replace the existing `pollSourceName` and `scanSourceName` functions (and the comment block above them) with: + +```ts +// arr-plugin sources fan out one-per-connection under a single generic +// capability, so resolve every Activity row through the shared label chain: +// operator label -> connection name -> manifest display_name -> capability_id. +function pollSourceName(event: AutoscanEvent, lookups: SourceLabelLookups): string { + return resolveEventSourceName(event, lookups); +} + +function scanSourceName(scan: AutoscanScan, lookups: SourceLabelLookups): string { + return resolveEventSourceName(scan, lookups) || "Autoscan"; +} +``` + +- [ ] **Step 3: Swap the prop type on the four presentational components** + +In each of `ScanHistoryCard`, `ScanHistoryTable`, `PollEventCard`, and `PollEventTable`, rename the `sourceNames` prop to `lookups` and change its type from `Map` to `SourceLabelLookups`. For example, `ScanHistoryCard` becomes: + +```ts +function ScanHistoryCard({ + scan, + librariesByID, + lookups, +}: { + scan: AutoscanScan; + librariesByID: Map; + lookups: SourceLabelLookups; +}) { +``` + +Apply the identical rename (`sourceNames` → `lookups`, `Map` → `SourceLabelLookups`) to `ScanHistoryTable`, `PollEventCard`, and `PollEventTable` prop lists. + +- [ ] **Step 4: Update the call sites inside those components** + +Within those four components, update every call and pass-through: +- `scanSourceName(scan, sourceNames)` → `scanSourceName(scan, lookups)` (in `ScanHistoryCard` and the `ScanHistoryTable` table row). +- `pollSourceName(event, sourceNames)` → `pollSourceName(event, lookups)` (in `PollEventCard` and the `PollEventTable` table row). +- In `ScanHistoryTable`, the `` becomes ``. +- In `PollEventTable`, the `` becomes ``. + +- [ ] **Step 5: Build `labelLookups` in the main component** + +Add the available-sources query alongside the existing `useAutoscanSources()` / `useAutoscanConnections()` calls: + +```ts + const available = useAvailableScanSources(); +``` + +Replace the existing `sourceNames` `useMemo` with: + +```ts + const labelLookups: SourceLabelLookups = useMemo( + () => ({ + sourceByID: new Map(autoscanSources.map((s) => [s.id, s])), + connectionByID: new Map(autoscanConnections.map((c) => [c.id, c.name])), + displayNames: buildPluginDisplayNames(available.data ?? []), + }), + [autoscanSources, autoscanConnections, available.data], + ); +``` + +- [ ] **Step 6: Pass `labelLookups` to the two tables** + +Update the `` and `` render sites: replace `sourceNames={sourceNames}` with `lookups={labelLookups}`. + +- [ ] **Step 7: Typecheck, lint, format** + +```bash +cd web && pnpm exec tsc -b && pnpm exec eslint src/pages/admin/autoscan/ActivityPanel.tsx && pnpm exec prettier --write src/pages/admin/autoscan/ActivityPanel.tsx +``` + +Expected: tsc exit 0, eslint exit 0, prettier confirms. + +- [ ] **Step 8: Run the full frontend test + lint suite** + +```bash +cd web && pnpm exec vitest run src/lib/autoscanLabels.test.ts && pnpm run lint && pnpm run format:check +``` + +Expected: tests pass, lint clean, format clean. + +- [ ] **Step 9: Commit** + +```bash +git add web/src/pages/admin/autoscan/ActivityPanel.tsx +git commit -m "feat(autoscan): resolve activity source labels via shared helper" +``` + +--- + +## Task 9: Integration — rebuild, migrate, and verify end-to-end + +**Files:** none (build + runtime verification) + +- [ ] **Step 1: Build the image (compiles backend + frontend)** + +```bash +docker build \ + --build-arg BUILD_REVISION=$(git rev-parse --short HEAD) \ + --build-arg BUILD_DIRTY=false \ + -t silo-server:autoscan-test . +``` + +Expected: build completes, exit 0. + +- [ ] **Step 2: Recreate the container (applies migration 174 on startup)** + +```bash +docker compose up -d silo +``` + +Then wait for health: + +```bash +for i in $(seq 1 24); do s=$(docker inspect -f '{{.State.Health.Status}}' silo-silo-1); echo "$s"; [ "$s" = healthy ] && break; sleep 5; done +``` + +Expected: `healthy`. + +- [ ] **Step 3: Verify migration 174 applied and the column exists** + +```bash +docker compose exec -T postgres psql -U silo -d silo -c \ + "SELECT version, filename FROM schema_versions WHERE version=174;" +docker compose exec -T postgres psql -U silo -d silo -c \ + "SELECT column_name FROM information_schema.columns WHERE table_name='autoscan_sources' AND column_name='label';" +``` + +Expected: version 174 row present; `label` column listed. + +- [ ] **Step 4: Verify the API round-trips a label** + +Pick a source id, set a label, and confirm it persists. Run: + +```bash +SID=$(docker compose exec -T postgres psql -U silo -d silo -t -A -c "SELECT id FROM autoscan_sources LIMIT 1;") +docker compose exec -T postgres psql -U silo -d silo -c \ + "UPDATE autoscan_sources SET label='4K Movies' WHERE id='${SID}';" +docker compose exec -T postgres psql -U silo -d silo -c \ + "SELECT id, capability_id, label FROM autoscan_sources WHERE id='${SID}';" +``` + +Expected: the row shows `label = 4K Movies`. (This seeds data so the next step shows the label in the UI; the operator path is the inline input.) + +- [ ] **Step 5: Manual UI verification** + +In the admin UI, open **Admin → Autoscan → Sources**. Confirm: +- The seeded source's row title shows `4K Movies` with subtitle ` · plugin #N`. +- Each row has a "Custom label (optional)" input; editing one and clicking away (blur) persists it (reload the page — the label remains). +- Open the **Activity** tab; the Scans/Polls "Source" column shows the operator label for the labeled source and the connection/display name for the others. + +- [ ] **Step 6: Revert the seed (optional)** + +```bash +docker compose exec -T postgres psql -U silo -d silo -c \ + "UPDATE autoscan_sources SET label='' WHERE label='4K Movies';" +``` + +- [ ] **Step 7: Final commit (if any working-tree changes remain)** + +```bash +git status +``` + +Expected: clean (all changes committed in earlier tasks). + +--- + +## Self-review notes + +- **Spec coverage:** resolution chain (Task 6), operator label storage (Tasks 1–4), shared helper consumed by both panels (Tasks 6–8), inline on-blur edit (Task 7), Activity deleted-source fallback (Task 6 test + helper), testing (Tasks 2, 4, 6). All spec sections map to a task. +- **Type consistency:** `composeSourceLabel`, `SourceLabelParts`, `SourceLabel`, `SourceLabelLookups`, `resolveEventSourceName`, `pluginDisplayNameKey`, `buildPluginDisplayNames` are defined in Task 6 and used unchanged in Tasks 7–8. Backend `NormalizeSourceLabel` / `MaxSourceLabelLen` defined in Task 2, used in Task 4. `Source.Label` (Task 2) used in Task 3; `autoscanSourceResponse.Label` / input `Label` (Task 4) consistent with `AutoscanSource.label` / `AutoscanSourceInput.label` (Task 5). +- **No placeholders:** every code step shows complete code; every run step shows the command and expected result. diff --git a/docs/superpowers/specs/2026-06-02-autoscan-arr-polling-design.md b/docs/superpowers/specs/2026-06-02-autoscan-arr-polling-design.md new file mode 100644 index 00000000..f11cb849 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-autoscan-arr-polling-design.md @@ -0,0 +1,222 @@ +# Autoscan: Polling Radarr/Sonarr to Trigger Targeted Library Scans + +**Status:** Design approved, ready for implementation plan +**Date:** 2026-06-02 +**Area:** new `internal/autoscan`, `internal/taskmanager/tasks`, `migrations/`, `internal/api`, `web/src/pages/AdminRequests.tsx` + +## Summary + +Silo currently learns about new media via scheduled/manual library scans. When +Radarr/Sonarr import a download, the new file only appears in Silo on the next +scan. This adds an **autoscan poller**: a periodic background task that polls the +configured Radarr/Sonarr instances for newly-imported files and enqueues +**targeted folder scans** into Silo's existing scan queue, so requested (and +non-requested) content shows up promptly. + +The design follows the established autoscan pattern — poll arr import history, +translate the imported paths, and trigger targeted scans — but kept deliberately +simple. That pattern is normally built to orchestrate scans across many separate +media-server boxes, which needs a bounded fan-out guard and a retry queue. Silo +is a single service scanning *itself*, so the cross-node fan-out machinery is +unnecessary — imported paths become folder jobs in Silo's own worker-bounded +`scanqueue`. + +## Goals + +- Poll autoscan-enabled Radarr/Sonarr instances on an interval for import events. +- Translate arr import paths into Silo media folders and enqueue targeted scans. +- Reuse the Radarr/Sonarr instances already configured for requests + (`request_integrations`) — no duplicate URLs/keys. +- Coalesce bursts (a season's episodes) into minimal redundant scans. +- Admin-configurable: global enable, interval, debounce, per-instance toggle, + optional path rewrites, on-demand "poll now". + +## Non-Goals + +- No cross-node scan fan-out or bounded-concurrency semaphore (single service; + the `scanqueue` workers are the bound). +- No separate retry queue (a failed source poll simply does not advance its + high-water mark, so the next cycle retries the window). +- No arr webhook/"Connect" push integration (polling only, as requested; webhook + push is a possible future add-on). +- No separate autoscan source credential store — sources are existing + `request_integrations`. + +## Background + +- **`internal/scantrigger`** already maps a filesystem path to a Silo + `MediaFolder` and scan path: `Resolver.Resolve(Request{Path}) -> Target{Folder, + Mode, Path}` (via `LongestMatchingRoot` / `MatchFolderForPath`). +- **`internal/scanqueue`** executes scans through a worker-bounded queue: + `Queuer.EnqueueScan(ctx, folderID, mode, path, trigger)` / + `EnqueueScans(targets)`. +- **`internal/requests/arrclient`** is a thin Radarr/Sonarr HTTP client + (`New(baseURL, apiKey, httpClient)`, `GetJSON`, `DoJSON`) — reused for the new + history call. +- **`request_integrations`** holds each instance's `id`, `kind`, `base_url`, and + Fernet-encrypted `api_key_ref`, resolved via the same secret resolver the + request service uses. +- The **task manager** runs periodic tasks via the `taskmanager.Task` interface + with interval `DefaultTriggers()` (e.g. `ReconcileRequestsTask`). + +## Design + +### 1. Data model & config + +A new `internal/autoscan` package owns its own schema; `request_integrations` +stays focused on fulfillment. + +**`autoscan_settings`** (singleton, mirrors `request_settings`): + +| column | type / default | purpose | +|---|---|---| +| `id` | bool PK default true, singleton CHECK | | +| `enabled` | bool, default false | master on/off | +| `poll_interval_minutes` | int, default 10 | poll task interval | +| `debounce_seconds` | int, default 60 | folder re-scan suppression window | +| `created_at`, `updated_at` | timestamptz | | + +**`autoscan_sources`** (one row per autoscan-enabled instance): + +| column | type | purpose | +|---|---|---| +| `integration_id` | text PK → `request_integrations(id)` ON DELETE CASCADE | the source instance | +| `enabled` | bool, default false | per-instance autoscan toggle | +| `path_rewrites` | jsonb, default `[]` | `[{from, to}]` prefix overrides | +| `last_poll_at` | timestamptz, null | `history/since` high-water mark | +| `created_at`, `updated_at` | timestamptz | | + +**Reused, not duplicated:** `base_url` / `api_key_ref` come from +`request_integrations`. Debounce/suppression state lives in **Redis** +(`autoscan:scanned:{folder_id}`, TTL = `debounce_seconds`), not Postgres. + +### 2. Poll cycle + +`autoscan.Service.PollOnce(ctx)` (invoked by the periodic task): + +1. Load `autoscan_settings`; if `!enabled`, return immediately (no-op). +2. Load `autoscan_sources WHERE enabled`, joined to `request_integrations` for + `kind` / `base_url` / `api_key_ref`. +3. For each source (sequential — a handful of instances): + 1. Decrypt the API key via the secret resolver. + 2. Record `cycleStart := now`. + 3. `arrclient.GetJSON("/api/v3/history/since?date={last_poll_at}&eventType=downloadFolderImported")` + — both Radarr and Sonarr expose this. `last_poll_at` null → a sane floor + (do not scan all history on first enable; floor at `cycleStart`). + 4. Extract the imported **file path** per event (Radarr: movie file path; + Sonarr: episode file `outputPath`); take its **parent directory**. + 5. On success, advance that source's `last_poll_at = cycleStart`. On failure + (arr unreachable, decode error), log and **do not** advance — next cycle + retries the window. +4. For each extracted directory: apply path rewrites (§3), resolve via + `scantrigger.Resolver` → `Target{Folder, Mode, Path}`. Unresolvable paths + (outside Silo's media folders) are logged and skipped. +5. **Dedupe to unique folders** for this cycle; for each, check the Redis + suppression key — if absent, `EnqueueScan(..., trigger="autoscan")` and set + the key with TTL `debounce_seconds`; if present, skip (already scanned this + window). + +**Debounce rationale:** because the poller batches a whole interval of imports +per `history/since` call, within-cycle folder dedup does most of the work. The +Redis suppression key only prevents re-enqueuing the same folder on back-to-back +cycles. Silo's scanner is idempotent, so a season importing across two cycles +simply gets a second (cheap) scan that picks up the rest. This is simpler than a +"collect-expired" debounce plus a retry queue, which the polling + idempotent +re-scan model makes unnecessary. + +### 3. Path resolution & scan enqueue + +A small pipeline in front of the existing scan plumbing — no new scan logic: + +1. **Rewrite** (per source, optional): apply the first matching `path_rewrites` + entry as a prefix replacement (e.g. `/data/media -> /mnt/media`). No rewrites + → path used as-is (shared-mount common case). +2. **Resolve** via `scantrigger.Resolver.Resolve(Request{Path: rewrittenDir})` + → `Target{Folder, Mode, Path}`. No matching media folder → log + skip. +3. **Enqueue** deduped targets via `scanqueue` `EnqueueScans` with + `trigger="autoscan"` (attributable in scan history, flows through the same + worker-bounded queue). + +### 4. Scheduling & concurrency + +- **`internal/taskmanager/tasks/autoscan_poll.go`** implements `taskmanager.Task` + (`Key: "autoscan_poll"`, name/description/category, `DefaultTriggers` = interval + trigger seeded from `poll_interval_minutes`). Its run body calls + `autoscan.Service.PollOnce(ctx)`. +- **Disabled = cheap no-op:** the task stays registered; `PollOnce` returns early + when `enabled` is false. Toggling autoscan never (de)registers tasks. +- **Interval changes:** updating `poll_interval_minutes` reconfigures the task's + interval trigger via the task manager's existing trigger-config path; if a live + update is unavailable it applies on next start. +- **No overlap, no semaphore:** the task manager serializes a task's executions; + sources are polled sequentially; scan execution is bounded by `scanqueue`. The + single-service simplification: no bounded-fan-out semaphore is needed. +- **Primary/integrated server only** (same as the request reconcile task), not on + standalone transcode/proxy worker nodes. + +### 5. Admin API & UI + +**API** (admin-gated, `/api/v1/admin/autoscan`, mirroring the request-settings +handlers; never echoes API keys): + +- `GET/PUT /autoscan/settings` — `{enabled, poll_interval_minutes, debounce_seconds}` +- `GET /autoscan/sources` — per autoscan-eligible instance (joined from + `request_integrations`): `{integration_id, name, kind, enabled, path_rewrites, + last_poll_at}` +- `PUT /autoscan/sources/{integration_id}` — set `{enabled, path_rewrites}` +- `POST /autoscan/trigger` — run `PollOnce` once on demand; tight per-admin rate + limit so it cannot hammer the arr instances +- `GET /autoscan/status` — `{enabled, per-source last_poll_at, last scan summary}` + +**UI:** a new **"Autoscan" tab** in `web/src/pages/AdminRequests.tsx` (alongside +Queue / Settings / Integrations / User Overrides): + +- Global controls: enable switch, poll interval, debounce window. +- Per-source list — one row per Radarr/Sonarr instance with an autoscan toggle, + an optional `from → to` path-rewrite editor, and a read-only "last polled". +- A **"Poll now"** button → `POST /autoscan/trigger`. + +Rate limits and admin-auth follow Silo's existing request-admin conventions. + +### 6. Testing + +- **Path rewrite:** prefix replacement, first-match ordering, no-match + passthrough, trailing-slash handling. +- **arr history client:** table-driven `httptest` fixtures for both Radarr and + Sonarr import-event JSON shapes; asserts the correct file path is extracted and + its parent folder taken. +- **Dedupe + suppression:** many paths in one folder → one target; folder with a + live suppression key skipped; absent/expired key enqueues. +- **`PollOnce` (integration seam):** fakes for source store, arr history client, + `scantrigger.Resolver`, `scanqueue.Queuer`, Redis — asserts deduped folder + scans enqueued with `trigger="autoscan"`, `last_poll_at` advanced on success, + **not** advanced on per-source failure, clean no-op when disabled. +- **Unresolvable path:** logged + skipped, never enqueued or fatal. +- **Repository / migration:** `autoscan_settings` singleton + `autoscan_sources` + CRUD and the `request_integrations` join; migration applies + rolls back + (sidecar table, `ON DELETE CASCADE`). +- **Frontend:** Autoscan tab renders, per-source toggle / path-rewrite editor + round-trips, "Poll now" calls the trigger endpoint. + +## Risks & open considerations + +- **First-enable floor:** `last_poll_at` null must floor at "now" (not epoch) so + enabling autoscan doesn't enqueue a scan for every historical import. +- **history/since shape differences:** Radarr vs Sonarr import-event payloads + differ; the path extraction is per-kind and covered by fixtures. +- **Path rewrites vs scantrigger roots:** if neither rewrites nor Silo's media + folders match the arr path, the import is silently skipped (logged) — this is + the expected "content outside Silo's libraries" behavior, but a misconfigured + rewrite will look like "autoscan does nothing"; the `last_poll_at` + status + endpoint and skip logs are the debugging surface. +- **Idempotent re-scan:** relies on Silo's scanner being safe to run repeatedly + on a folder (it is — re-scan finds new files). + +## References + +- Existing Silo plumbing reused by this design: `internal/scantrigger/scantrigger.go`, + `internal/scanqueue/`, `internal/requests/arrclient/`, + `internal/taskmanager/tasks/reconcile_requests.go` +- Prior art: the general autoscan pattern (poll arr import history → translate + paths → trigger targeted scans). Silo's variant drops the cross-node fan-out + guard and retry queue, which only a multi-box orchestrator needs. diff --git a/docs/superpowers/specs/2026-06-02-autoscan-plugin-architecture-design.md b/docs/superpowers/specs/2026-06-02-autoscan-plugin-architecture-design.md new file mode 100644 index 00000000..be405018 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-autoscan-plugin-architecture-design.md @@ -0,0 +1,262 @@ +# Autoscan as a Pluggable Scan-Source Category — Design Spec + +**Date:** 2026-06-02 +**Status:** Approved design, pre-implementation +**Goal:** Turn autoscan from a Requests-coupled, arr-only feature into a standalone **Autoscan** category whose change-detection providers are out-of-process Silo plugins, with Sonarr/Radarr as the first provider. + +**Architecture (one sentence):** Silo's host keeps a thin, provider-agnostic engine that resolves changed paths to library folders and enqueues rescans; each *way of noticing change* (arr, later inotify/ceph) is an installable plugin that Silo pulls on a timer through a new additive `scan_source.v1` capability. + +**Tech stack:** Go (silo-server host + plugins), `silo-plugin-sdk` (protobuf capability contract, consumed as a tagged module), existing `internal/plugins` + `internal/pluginhost` runtime, existing `scantrigger`/`scanqueue` scan pipeline, React/TS admin UI. + +Commands assume the repository root is the cwd. + +--- + +## 1. Background & motivation + +The current autoscan implementation (delivered in the now-closed PR #43, still on local `main`) is in-process Go that: + +- polls Sonarr/Radarr `/api/v3/history/since` on a host timer, +- rewrites arr-side paths to Silo paths, dedups to parent dirs, +- resolves each to a `MediaFolder` and enqueues a targeted scan, +- is configured against `request_integrations` rows (hard FK, `ON DELETE CASCADE`), living as a tab inside the **Requests** admin page. + +Two structural problems: + +1. **It is arr-only and not extensible.** Adding inotify, Ceph notifications, or any other change source means more bespoke host code. +2. **It is subordinate to Requests.** A source cannot exist without a Requests integration row; deleting the row destroys the source. + +Silo already ships a full out-of-process plugin runtime (`internal/plugins`, `internal/pluginhost`): manifests, an installer, typed capability descriptors (`metadata_provider.v1`, `request_router.v1`, `media_analyzer.v1`, `scheduled_task.v1`, `event_consumer.v1`, …), a host-driven scheduled-task mechanism, and a rate-limited plugin→host event path. This design uses that runtime to make change-detection pluggable. + +## 2. Goals / non-goals + +**Goals** + +- A new **Autoscan** admin category, independent of Requests. +- A new additive `scan_source.v1` plugin capability — **breaks no existing plugin**. +- A **client-pull** contract: Silo asks each source "what changed since ``?" on a timer. +- Sonarr/Radarr re-implemented as the first scan-source plugin; the closed PR's arr logic relocates into it. +- A provider-agnostic host engine reusable by every future scan source. +- Optional reuse of Requests' arr connection details, *or* standalone connection entry. + +**Non-goals (this design)** + +- inotify / Ceph / other providers (the contract must *accommodate* them; we do not build them). +- A push (plugin-initiated) delivery mode. Pull only; push sources buffer internally and answer on pull (see §6). +- Refactoring the Requests subsystem itself. + +## 3. Architecture overview + +``` +┌──────────────────────────── Silo (host) ────────────────────────────┐ +│ Autoscan category (new; not under Requests) │ +│ • Sources: which scan_source plugin instance, on/off, interval │ +│ • Connections: reuse a Requests arr server, or enter own │ +│ • Per-source state: opaque marker, last-run status │ +│ │ │ +│ ▼ │ +│ AutoscanStore — generic engine (provider-agnostic) │ +│ per tick, per enabled source: │ +│ call plugin PollChanges(marker) │ +│ → resolve each Silo-native path → MediaFolder │ +│ → suppress duplicates → enqueue scan (existing scanner) │ +│ → store returned next_marker │ +│ │ scan_source.v1 (pull) │ +└──────────────────────────────┼───────────────────────────────────────┘ + ▼ + ┌──────── Sonarr/Radarr scan-source plugin ────────┐ + │ (new installable repo, like silo-plugin-tmdb) │ + │ receives resolved arr address + key │ + │ polls /history (imports + renames) │ + │ returns RAW source paths + next marker │ + │ (host applies the source's rewrites) │ + └──────────────────────────────────────────────────┘ +``` + +**Five components** + +1. **`scan_source.v1`** (in `silo-plugin-sdk`) — the pull contract. §5. +2. **AutoscanStore** (host) — generic resolve→suppress→enqueue→marker engine. §7. +3. **Connection resolution** (host) — reference-or-inline → concrete credentials. §8. +4. **Autoscan category + data model** (host) — sources, connections, decoupled from Requests. §9. +5. **arr scan-source plugin** (new repo) — first provider. §10. + +## 4. Responsibility split (the dividing line) + +| Concern | Owner | Rationale | +|---|---|---| +| Talk to arr; poll `/history`; imports + renames; bounded window; first-run | **Plugin** | Provider-specific I/O. | +| **Path rewrites** (per-source config + application) | **Host** | _(Revised post-merge: the SDK landed host-owned rewrites.)_ The plugin returns RAW source-namespace paths in `source_paths`; the host applies each source's rewrite rules (longest-`From`-first) before resolving. Keeps every provider dumb and rewrite behavior uniform. Rewrites are edited per-source in the Autoscan UI and stored in `autoscan_sources.path_rewrites`. | +| Resolve Silo-native path → `MediaFolder`; dedupe; **enqueue rescan** | **Host** | Touches Silo internals (scan queue, DB, library map) that plugins cannot and must not reach. | +| Opaque per-source marker; poll interval; enabled; last-run status | **Host** | Scheduling and bookkeeping are the engine's job. | +| Connection selection (reuse-from-Requests vs own) and credential resolution | **Host** | Only the host knows about Requests; the plugin receives resolved creds. | + +The plugin's responsibility ends at **"here are the changed paths, in Silo's terms."** Everything past that is the host engine, which is provider-agnostic. + +## 5. The `scan_source.v1` contract (centerpiece) + +A new, additive capability in `silo-plugin-sdk`. Existing plugins never declare it and are unaffected (§11). + +### Protobuf (as implemented in silo-plugin-sdk) + +```proto +// scan_source.v1 — host pulls changed paths from a provider on a timer. +service ScanSource { + // Host calls this on each poll tick for a configured scan_source capability. + rpc PollChanges(PollChangesRequest) returns (PollChangesResponse); +} + +message PollChangesRequest { + string capability_id = 1; // which configured scan_source instance + string marker = 2; // opaque; empty string on first run + ResolvedConnection connection = 3; // resolved upstream credentials (see §8) +} + +// Concrete upstream credentials the host resolves and hands to the plugin each +// poll, so the plugin stays credential-source-agnostic and stores no secrets. +message ResolvedConnection { + string base_url = 1; + string api_key = 2; +} + +message PollChangesResponse { + // Absolute changed paths in the provider/source namespace (RAW — NOT yet + // rewritten). Files or directories. The host applies the source's rewrite + // rules and validates before enqueueing. + repeated string source_paths = 1; + // Opaque continuation token. Host stores verbatim and echoes it back + // on the next PollChanges. The plugin never assumes the host parses it. + string next_marker = 2; +} +``` + +### Contract rules + +- **Pull only.** The host owns the timer and calls `PollChanges`. The plugin never initiates. +- **Opaque marker.** The host stores and echoes `next_marker` without interpreting it. arr uses an RFC3339 timestamp; a future provider may use a sequence number or event id — no host change required. +- **First run.** `marker == ""` ⇒ the plugin starts from "now" (do not replay full history). +- **Idempotent, at-least-once.** The host may re-issue the same `marker` if it failed to persist `next_marker`; providers must tolerate returning overlapping paths. The host's suppression layer (§7) absorbs duplicates. +- **Error semantics.** A failed `PollChanges` (e.g. arr unreachable) ⇒ the host keeps the *old* marker and retries next tick; nothing is skipped or advanced. +- **Raw source paths.** `source_paths` are in the provider's namespace, NOT rewritten. The host applies each source's path-rewrite rules (host-owned, per-source) before resolving to a library folder. +- **Credentials per call.** The host resolves the source's connection (§8) and passes the concrete `{base_url, api_key}` in `PollChangesRequest.connection` on every poll. The plugin reads them from the request — it stores no credentials and never knows whether they came from its own config or a reused Requests link. Delivered over the local host↔plugin gRPC channel only. + +The capability needs **only** `PollChanges` for v1. A `TestConnection`/validate RPC is explicitly deferred (YAGNI; connection validity surfaces as a failed poll). + +## 6. Marker & delivery semantics + +- One opaque marker stored per source, host-side. +- Pull-only does **not** exclude push-style providers: an inotify/Ceph plugin maintains its own internal buffer of observed changes and flushes it (and advances its marker) on each `PollChanges`. Cost: up to one poll-interval of latency. This keeps a single uniform contract. + +## 7. Host: AutoscanStore engine + +Provider-agnostic. Per enabled source, per tick: + +1. `paths, next := plugin.PollChanges(marker)`; on error, log + keep marker + continue. +2. For each `dir` in `uniqueParentDirs(paths)`: + - `target := scantrigger.Resolve(dir)`. Unresolvable (outside Silo's media folders) ⇒ quiet skip. + - Suppression key `fmt.Sprintf("%d|%s", folderID, target.Path)` with debounce TTL; already-claimed ⇒ skip. + - Accumulate `target`; record claimed key. +3. `scanqueue.EnqueueScans(targets)`. On failure, release claims and **do not** advance the marker. +4. On success, persist `next_marker`. + +This is the salvaged core of PR #43 (`internal/autoscan/service.go` resolve/suppress/enqueue, `dedupe.go`, `suppress.go`), generalized so its input is "paths from a provider" rather than "paths from arr." Path-rewrite helpers (`rewrite.go`, `suggest.go`, `history.go`, the arr history client) leave the host and move into the plugin (§10). + +## 8. Host: Connections (reuse or own) + +A **connection** is "an arr server Silo can reach," created two ways: + +- **Own:** name + base URL + API key, entered in the Autoscan category; the key is Fernet-encrypted at rest (same mechanism as existing service credentials — never plaintext in JSONB). +- **Linked (reuse):** a live reference to an existing Requests `request_integrations` arr server. Resolved at use-time, so edits in Requests propagate. + +**Resolution:** on each poll the host resolves the chosen connection (own or linked) into concrete `{base_url, api_key}` and passes them in `PollChangesRequest.connection` (§5). The plugin is credential-source-agnostic — it only ever sees resolved values, fresh each poll, so a change to a reused Requests credential propagates immediately with no re-push. This "reuse or own" behaviour is therefore free for every future provider. (Resolving per-call rather than caching in plugin config also means no decrypted secret is ever stored at rest in the plugin.) + +**Decoupling from Requests:** the link is **soft and optional** — `SET NULL` / surfaced-as-"needs attention", never cascading. Requests has no knowledge of Autoscan. Autoscan and Requests are peers; Autoscan merely *offers* to borrow a Requests connection. + +## 9. Host: Autoscan category & data model + +New top-level **Autoscan** admin section (its own nav entry, not a Requests tab). Manages **Connections** (§8) and **Sources**. + +A **source** corresponds to one configured `scan_source.v1` capability instance and carries: + +- **Host-owned per-source config:** path rewrites (`autoscan_sources.path_rewrites`, edited in the Autoscan Sources UI) and any provider-specific settings. _(Revised post-merge: rewrites moved host-side; the plugin has no config.)_ +- **Host-owned per-source state:** the chosen connection, enabled on/off, poll interval (per-source, not one global timer), the opaque marker, and last-run status/error. + +**Install flow + source auto-discovery (resolves §14 risk #1).** A scan-source plugin (Sonarr/Radarr, etc.) is installed through the **normal `/admin/plugins`** page — there is no separate install path; the installer doesn't special-case the capability type. Source rows are then never created by hand: the host enumerates every installed `scan_source.v1` capability (via the plugin installation store) and *seeds a disabled, connection-less row* per capability (`INSERT … ON CONFLICT (installation_id, capability_id) DO NOTHING`, never disturbing existing config). Discovery runs **both on each poll cycle AND when the Autoscan sources list is viewed** (so a freshly-installed plugin appears in Autoscan immediately, even when autoscan is disabled and nothing is polling). The operator then binds a connection and enables it. Consequently **`connection_id` is nullable**: a freshly-discovered source has none, and the engine skips any enabled source with no connection (recording a "no connection bound" status). The admin API rejects enabling a source that has no effective connection. + +**Per-source interval** is honoured as a floor: the global poll task runs at `default_poll_interval_seconds`, and within a cycle the engine skips a source whose `last_run_at` is newer than its own `poll_interval_seconds` (falling back to the default). A source therefore polls *at most* every N seconds, never more often than the global cadence. + +### Schema changes (host) + +Rework the PR #43 tables; **no FK into `request_integrations`**: + +- `autoscan_connections` — `id`, `name`, `kind` (e.g. `sonarr`/`radarr`), and *either* own credentials (`base_url`, `api_key_ref` encrypted) *or* a nullable soft link `request_integration_id` (`ON DELETE SET NULL`). +- `autoscan_sources` — `id`, `(installation_id, capability_id)` (unique; the discovered identity), **nullable** `connection_id → autoscan_connections` (`ON DELETE RESTRICT`; null until the operator binds one), `enabled`, `poll_interval_seconds` (nullable; null = use default), `marker` (opaque text, nullable), `last_run_at`, `last_error`. Creation-time validity for connections (own creds OR a link) is enforced at the application layer (the DB CHECK would conflict with `ON DELETE SET NULL` on the reuse link). +- `autoscan_settings` (optional) — retain a global enable + default interval; per-source interval overrides it. + +A migration supersedes PR #43's `171_autoscan` schema (which is not yet upstream, so this is a forward redefinition, not a production migration of live data on `origin/main`). + +## 10. Plugin: Sonarr/Radarr scan-source + +A new installable repo structured like `silo-plugin-tmdb` (standalone Go module, `manifest.json` declaring `scan_source.v1`, gRPC `main.go`, depends on the new tagged `silo-plugin-sdk`). Receives resolved arr `{base_url, api_key}` as config. Implements `PollChanges`: + +1. Treat `marker` as the "since" timestamp (empty ⇒ now). +2. Call arr `/api/v3/history/since`, capped by a bounded window (24h max-lookback floor + overlap buffer — relocated from PR #43) so a long outage cannot pull an oversized response. +3. Extract paths: **imports** (`downloadFolderImported.importedPath`) and **renames** (`episodeFileRenamed`/`movieFileRenamed`: both new `path` and old `sourcePath`). Deletes ignored (upgrade-deletes are covered by the paired import; standalone deletes carry no path). +4. Return `{source_paths = the RAW arr paths, next_marker = newest history timestamp}`. The plugin does NOT rewrite — the host normalizes separators and applies the source's rewrite rules. _(Revised post-merge.)_ + +**Rewrite config + "Sync from arr":** the rewrite list and the suffix-match suggester (which matches arr root folders to Silo media folders) live in the plugin. The suggester needs Silo's media-folder list, obtained via the existing host library-listing service (`internal/pluginhost` library lister) exposed to plugins. The relocated logic is PR #43's `suggest.go`/`suggest_deps.go`/`rewrite.go`/`history.go`. + +## 11. Backward compatibility (hard requirement) + +Adding `scan_source.v1` **must not break existing plugins.** It doesn't, by construction: + +- Capabilities are independent opt-in declarations. Each host subsystem **filters for its own type and ignores the rest** (verified: `metadata_provider.v1`, `scheduled_task.v1`, `event_consumer.v1`, … each use `if type != mine { skip }`). There is **no global allowlist that rejects unknown capability types.** +- **Additive-only rule:** the SDK change *adds* new proto messages/service + a new capability string. It never modifies the shape of an existing capability. (Mutating an existing capability is the only thing that would break plugins; we forbid it.) +- **No forced upgrades:** existing plugin repos stay pinned to their current SDK version and build unchanged. Only a plugin that *wants* to be a scan source adopts the new SDK version. + +## 12. Build order & repo decomposition + +One shared contract, three repos (+ catalog), in dependency order: + +1. **`silo-plugin-sdk`** (long pole): define `scan_source.v1` (proto + generated code + manifest helper + runtime server scaffold), tag a new version (e.g. `v0.5.0`). The tagged release is a hard serialization point — host and plugin cannot build against the contract until it exists. +2. **silo-server (host)** — bump SDK dep; build AutoscanStore (generalized), the `scan_source.v1` caller (timer → `PollChanges`), connections + resolution, the Autoscan category UI, retire the in-process arr autoscan, add the reworked migration. *Parallelizable with (3) once (1) is tagged.* +3. **arr plugin (new repo)** — depends on SDK `v0.5.0`; implement `PollChanges`, rewrites, "Sync from arr", bounded window. +4. **`silo-plugins`** (catalog) — add the entry so the arr plugin is installable. + +Each repo gets its **own implementation plan** (separate `writing-plans` pass); this single spec defines the shared contract and per-side responsibilities they all build against. + +**Local logistics:** only `silo-server` and `silo-plugin-tmdb` are currently checked out. Implementation will require `silo-plugin-sdk` and a new arr-plugin repo (and later `silo-plugins`) available locally. + +## 13. Salvage map (from closed PR #43) + +| PR #43 code | Destination | +|---|---| +| arr `/history` client, imports + renames, bounded window (`history.go`) | arr plugin | +| path rewrite apply (`rewrite.go`) + "Sync from arr" suggester (`suggest.go`, `suggest_deps.go`) | arr plugin | +| `last_poll_at` cursor | host, generalized to opaque per-source `marker` | +| resolve + dedupe + suppress + enqueue (`service.go`, `dedupe.go`, `suppress.go`) | host AutoscanStore (generic) | +| `171_autoscan` schema (FK → `request_integrations`) | host, reworked: `autoscan_connections` + `autoscan_sources`, no requests FK | +| Autoscan admin tab under Requests (`AdminRequests.tsx`) | host, as its own Autoscan category | + +## 14. Risks & open questions + +1. **Plugin multiplicity for multiple arr servers.** Does the runtime support multiple configured instances of one plugin (one per arr server), or is config per-installation only? If the latter, "two Sonarrs + one Radarr" implies either multiple installations or a plugin that manages several connections internally. *Resolve before the host/plugin plans by reading `internal/plugins` installation + capability-instance handling.* +2. **Plugin config UI richness.** The rewrite table + a "Sync from arr" action are richer than typical key-value plugin config. Verify the plugin config-schema/admin-config UI can host a structured list and an action button; if not, design a minimal affordance (e.g. suggester runs on first configure, or a dedicated plugin HTTP route via `http_routes.v1`). +3. **Library-folder list exposure to plugins.** Confirm the `internal/pluginhost` library lister is reachable by a `scan_source` plugin for the suggester. If not, add a host service method. +4. **Marker persistence vs at-least-once.** Ensure marker write happens only after successful enqueue, matching PR #43's "don't advance on failure" guarantee. +5. **Closed PR disposition.** The in-process autoscan on local `main` (34 commits) should be removed/replaced rather than shipped; sequence the host plan to retire it cleanly. + +## 15. Testing strategy + +- **SDK:** proto compiles; manifest helper round-trips `scan_source.v1`; runtime scaffold serves `PollChanges`. +- **Host engine:** table-driven tests for resolve→suppress→enqueue→marker (port PR #43's `service_test.go`, generalized to a fake `ScanSource` client): dedupe, distinct-paths-same-folder, disabled no-op, poll failure keeps marker, enqueue failure releases claims + keeps marker, opaque-marker pass-through. Run in the libvips-equipped environment (host handler tests link libvips via CGO — see workspace note). +- **Connections:** resolution of own vs linked; linked falls back gracefully when the Requests row is deleted (`SET NULL`). +- **arr plugin:** history parsing (imports + renames, ignores unrelated events), rewrite application, bounded window, first-run "from now"; suffix-match suggester cases (unique/ambiguous/covered/no-op/normalization) ported from PR #43. +- **Compat:** an existing-style plugin manifest with no `scan_source.v1` is unaffected by host scan-source dispatch. + +## 16. Out of scope / future + +- inotify and Ceph-notify scan-source plugins (the contract accommodates them via the buffer-and-flush pattern of §6). +- A push/event delivery mode, if pull latency ever proves insufficient. +- A `TestConnection` RPC on the capability. +- Migrating Requests onto a shared connection abstraction (today's reuse is a one-directional soft link only). diff --git a/docs/superpowers/specs/2026-06-02-autoscan-rewrite-sync-design.md b/docs/superpowers/specs/2026-06-02-autoscan-rewrite-sync-design.md new file mode 100644 index 00000000..d40ae061 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-autoscan-rewrite-sync-design.md @@ -0,0 +1,173 @@ +# Autoscan: Sync Path Rewrites from Arr Root Folders + +**Status:** Design approved, ready for implementation plan +**Date:** 2026-06-02 +**Area:** `internal/autoscan`, `internal/api`, `web/src/pages/AdminRequests.tsx` +**Extends:** the autoscan feature (`docs/superpowers/specs/2026-06-02-autoscan-arr-polling-design.md`) + +## Summary + +Autoscan resolves an imported file path against Silo's media folders, but +Radarr/Sonarr often report files under paths that differ from how Silo mounts +the same content — so operators must hand-author per-root `path_rewrites`. For a +multi-instance, many-root deployment this can be dozens of rows. + +This adds a **"Sync rewrites from arr"** convenience: for one instance, query its +root folders, match each root to a Silo media folder by **shared trailing path +segments**, and present the resulting `{from, to}` rewrites as a **preview** the +admin reviews and commits. Manual rewrites remain the source of truth; sync only +*fills the editor* — nothing persists until the admin saves. + +The matcher is purely structural (no assumptions about any particular path +layout) so it helps any deployment whose arr and Silo share folder names at some +depth, and degrades gracefully (no shared names → "unmatched" → manual entry) +when they do not. + +## Goals + +- One-click suggestion of `path_rewrites` for an instance from its arr root folders. +- Correct disambiguation when multiple Silo folders share a leaf name. +- Preview-then-commit: the admin sees proposed/unmatched/ambiguous and saves + through the existing per-source save. Manual rows are never removed or overwritten. +- Deployment-agnostic: no hard-coded paths; works for any structure; surfaces + match confidence so weak matches can be rejected. + +## Non-Goals + +- No automatic/background writing of rewrites — the admin always commits. +- No new persistence path — apply is the existing `PUT /sources/{id}`. +- No case-insensitive matching (case-sensitive segments; correct on Linux). A + toggle can be added later if needed. +- No "sync all instances at once" — per-instance, matching the editor UI. + +## Design + +### 1. Matching function (core, pure) + +A single side-effect-free function in `internal/autoscan`: + +```go +func suggestRewrites(arrRoots []string, siloFolderPaths []string, existing []PathRewrite) RewriteSuggestions +``` + +```go +type RewriteSuggestions struct { + Proposed []ProposedRewrite `json:"proposed"` + Unmatched []string `json:"unmatched"` + Ambiguous []AmbiguousRoot `json:"ambiguous"` + Covered []string `json:"covered"` +} +type ProposedRewrite struct { + From string `json:"from"` // arr root + To string `json:"to"` // matched Silo folder path + MatchDepth int `json:"match_depth"` // trailing segments shared (confidence) +} +type AmbiguousRoot struct { + Root string `json:"root"` + Candidates []string `json:"candidates"` +} +``` + +**Normalization** (applied to every path before comparison): backslashes → `/` +(reuse `normalizeSeparators`), strip a trailing `/`, collapse duplicate slashes. +This makes any path format (POSIX, Windows, single- or many-root) comparable. + +**Per arr root:** +1. **Covered-first:** if any `existing` rewrite's `From` already matches the root + (exact, or root starts with `From + "/"`, using the same boundary rule as + `applyRewrites`), classify as **Covered** and skip — so sync never re-proposes + what a manual or broad rule already handles. +2. Split both the root and each Silo folder path into segments; compute the + **longest common trailing-segment suffix** (full-segment equality, so `4ktv7` + does not match `4ktv70`). +3. Among Silo folders, take the maximum suffix length `> 0` and the folders that + achieve it: + - exactly one → **Proposed** `{From: root, To: folder, MatchDepth: n}` + - more than one → **Ambiguous** `{root, candidates}` + - zero → **Unmatched** + +No deployment constants appear in the function; it operates only on the string +slices it is given. + +### 2. Preview endpoint & service + +**Endpoint:** `GET /api/v1/admin/autoscan/sources/{id}/rewrite-suggestions` → +`RewriteSuggestions` JSON (admin-gated, like the other autoscan routes). + +**Service method** `Service.SuggestRewrites(ctx, integrationID) (RewriteSuggestions, error)`: +1. `Repository.GetSource(id)` — a new single-source variant of the existing join + that returns `kind`/`base_url`/`api_key_ref`/`path_rewrites` even when the + source is disabled. Not found → `ErrIntegrationNotFound`. +2. Decrypt the API key via the existing `SecretResolver`. +3. `RootFolderClient.RootFolders(ctx, baseURL, apiKey)` → arr root paths. +4. `FolderLister.ListFolderPaths(ctx)` → all Silo media-folder paths. +5. Return `suggestRewrites(arrRoots, siloPaths, source.PathRewrites)`. + +**Two new narrow `Service` dependencies** (injected like `history`/`resolver`): +- `RootFolderClient.RootFolders(ctx, baseURL, apiKey string) ([]string, error)` — + an `arrclient`-backed impl reusing `arrclient.ListRootFolders` (returns paths only). +- `FolderLister.ListFolderPaths(ctx) ([]string, error)` — a thin adapter over + `catalog.FolderRepository.List()` flattening each folder's `Paths`. + +**Wiring:** both constructed where the autoscan handler is built in `router.go` +(`deps.FolderRepo` already in scope; the arr client is trivial). The handler's +service interface gains `SuggestRewrites`. + +**Errors:** instance not found → 404; arr unreachable / bad key → 502 with the +arr error surfaced; Silo folder-list failure → 500. + +No new write path — the frontend merges `proposed` into the editor and the admin +saves via the existing `PUT /sources/{id}`. + +### 3. Frontend (preview UI) + +In `AutoscanSourceEditor` (`web/src/pages/AdminRequests.tsx`), add a **"Sync from +arr"** button beside Save. On click, a new hook +(`useAutoscanRewriteSuggestions`) calls the endpoint and opens a preview panel: + +- **Proposed** — each row: `From → To` + a **confidence badge** from `match_depth` + (e.g. "3 segments" strong, "1 segment — weak"), with a checkbox (default checked). +- **Unmatched** — info-only list of arr roots with no Silo match ("add manually"). +- **Ambiguous** — arr root + candidate Silo paths (info-only; admin adds a manual row). + +**"Add selected to rewrites"** merges the checked proposals into the editor's +existing rewrite rows, **deduped by `from`, never removing manual rows**. The +admin then clicks the normal **Save**. New types in `web/src/api/types.ts` +(`AutoscanRewriteSuggestions`, `ProposedRewrite`, `AmbiguousRoot`) and hook in +`web/src/hooks/queries/useAutoscan.ts`. Reuse existing +`Button`/`Badge`/`Dialog`/`Field` primitives. + +### 4. Testing + +- **`suggestRewrites`** — table-driven, fixtures spanning layouts: multi-segment + match, single-segment unique, longest-suffix disambiguation (`Anime/Subs` vs + `Anime2/Subs`), unmatched (no shared segment), already-covered via a broad + prefix rule, an ambiguous tie, plus **unlike-this-deployment** cases + (`/data/Movies` arr vs `/library/Films` Silo → unmatched; `/srv/tv/Show` vs + `/tank/television/Show` → leaf match) and normalization (trailing slash, + backslashes, duplicate slashes). +- **`Service.SuggestRewrites`** — fakes for source store / `RootFolderClient` / + `FolderLister` / secrets: asserts proposed/unmatched/ambiguous; arr-unreachable + surfaces the error. +- **Handler** — returns the JSON; 404 for missing instance; 502 when the arr is + unreachable. +- **Frontend** — the button hits the endpoint; the preview renders all three + buckets with confidence badges; "Add selected" merges deduped rows; Save + persists via the existing mutation. + +## Risks & open considerations + +- **Weak (1-segment) matches** can be coincidental across very different layouts; + mitigated by surfacing `match_depth` so the admin rejects them before saving. +- **Case-sensitivity** means case-mismatched mounts produce Unmatched rather than + a wrong auto-match — a deliberate safe default; revisit only if requested. +- **Arr reachability** is required for sync (unlike the rest of autoscan which + polls on a schedule); a 502 with the arr error tells the admin to check the + instance, and manual entry still works. + +## References + +- Reused: `internal/requests/arrclient.ListRootFolders`, + `internal/catalog/FolderRepository.List`, `internal/autoscan` (`PathRewrite`, + `normalizeSeparators`, `Repository`, `Service`, the existing + `PUT /autoscan/sources/{id}` save). diff --git a/docs/superpowers/specs/2026-06-05-autoscan-source-labels-design.md b/docs/superpowers/specs/2026-06-05-autoscan-source-labels-design.md new file mode 100644 index 00000000..c49d954c --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-autoscan-source-labels-design.md @@ -0,0 +1,161 @@ +# Autoscan source labels — design + +**Date:** 2026-06-05 +**Status:** Approved, pending implementation plan +**Scope:** Admin UI labeling of autoscan scan sources, generic across plugins, plus an operator-editable per-source label. + +Commands assume the repository root is the cwd. + +## Problem + +Autoscan sources are labeled in the admin UI (Sources tab and Activity tab) by their +`capability_id` plus `plugin #`. The `arr` scan-source plugin fans out +one source per connection (Sonarr, Sonarr4k, Radarr, Radarr4k) under a single generic +`arr` capability, so every row rendered an identical "arr (plugin #4)" label. A prior +change made the Sources/Activity labels lead with the bound connection name, which fixes +`arr`, but the fallback for any other plugin is still the cryptic `capability_id` + +`plugin #N`. + +Two gaps remain: + +1. **Other plugins look cryptic.** A connectionless plugin (e.g. cephfs) or any future + plugin still falls back to the raw `capability_id`, ignoring the human-friendly + `display_name` the plugin already declares in its manifest. +2. **No operator control.** An operator cannot name a source themselves. This is the only + fully-general answer for plugin shapes the automatic chain cannot disambiguate (e.g. a + single plugin installation creating several connectionless sources distinguished only + by their `source_config`). + +## Goals + +- Generic, plugin-agnostic labels driven by metadata that already exists + (manifest `display_name`, connection `name`), so future plugins look good with **no + host code per plugin**. +- An optional operator-editable label per source that overrides the automatic chain. +- A single shared label-resolution helper, consumed by both the Sources and Activity + panels, so the logic is not duplicated. + +## Non-goals + +- Plugin-declared label templates in the manifest/SDK (the "option C" from brainstorming). + Deferred: it requires a cross-repo SDK/manifest contract change. The chain below leaves a + clean seam for it. +- Backend-side label resolution returned on API payloads. The resolution stays in the + frontend helper for now; promote it to the backend only if the mobile clients need the + same labels. + +## Resolution chain + +A source's display label resolves through four rungs, most-specific first: + +``` +operator label -> connection name -> manifest display_name -> capability_id +``` + +- **operator label** — the optional per-source text the admin sets (this design's new field). +- **connection name** — the operator-named `autoscan_connections.name` bound to the source. +- **manifest display_name** — from the plugin manifest, surfaced by the + `scan-source-plugins` endpoint (`AutoscanAvailableSource.display_name`). +- **capability_id** — last-resort raw identifier; never worse than today. + +The chosen rung is the **title**. The remaining identity (plugin display name / capability + +`plugin #`) renders as a **subtitle**, so no information is lost when a +higher rung wins. Example: operator label `4K Movies` as title, `Radarr4k · plugin #4` as +subtitle. + +### Per-plugin behavior + +| Source shape | Title | Subtitle | +| --- | --- | --- | +| arr, connection bound, no operator label | `Radarr4k` (connection) | `arr · plugin #4` | +| arr, operator label set | `4K Movies` (operator) | `Radarr4k · plugin #4` | +| cephfs, no connection | `CephFS Watcher` (manifest display_name) | `plugin #5` | +| future plugin, nothing declared | `capability_id` | `plugin #N` | +| Activity event whose source was deleted (`source_id` null) | manifest display_name or `capability_id` from the event's stored fields | `plugin #N` | + +## Components + +### Shared helper — `web/src/lib/autoscanLabels.ts` (new) + +Pure, React-free, unit-tested. + +- `composeSourceLabel({ operatorLabel, connectionName, displayName, capabilityId, installationId })` + `=> { name: string; detail: string }` — implements the four-rung chain and the + subtitle composition. +- `pluginDisplayNameKey(installationId, capabilityId) => string` — stable map key. +- `buildPluginDisplayNames(available: AutoscanAvailableSource[]) => Map` — + builds the `installation_id + capability_id -> display_name` lookup both panels use. + +Future seam: `composeSourceLabel` may later accept an optional `configLabel` rung (for the +deferred plugin-template option) without changing either panel's call shape. + +### Migration — `migrations/174_autoscan_source_label.{up,down}.sql` (new) + +```sql +-- up +ALTER TABLE public.autoscan_sources ADD COLUMN label text NOT NULL DEFAULT ''; +-- down +ALTER TABLE public.autoscan_sources DROP COLUMN label; +``` + +`label` empty string = unset; the resolution chain falls through to the automatic rungs. +174 is a fresh, unused migration number (no collision with the reused-172 history). + +### Backend (Go) + +- `internal/autoscan/types.go` — add `Label string` to the `Source` struct and the source + input type. +- `internal/autoscan/repository.go` — add `label` to `sourceColumns`, `scanSource`, and the + create/update statements. +- `internal/api/handlers/autoscan.go` — accept `label` on source update; include it in the + `autoscanStatusSource` and source responses. Trim whitespace and cap length at 120 + characters server-side. + +### Frontend + +- `web/src/api/types.ts` — add `label` to `AutoscanSource` and `AutoscanSourceInput`. +- `web/src/pages/admin/autoscan/SourcesPanel.tsx` — replace the inline `sourceIdentity` + block with a call to `composeSourceLabel`. Add an optional "Label" text input to the + source row, saved on blur via the existing `fullBody` PATCH path (mirrors the interval + field's `handleIntervalBlur` auto-save). Build the display-name map from the existing + `useAvailableScanSources()` query. +- `web/src/pages/admin/autoscan/ActivityPanel.tsx` — add `useAvailableScanSources()`, build + the display-name map, map `source_id -> source` to obtain the operator label and + connection, and route `pollSourceName` / `scanSourceName` through `composeSourceLabel`, + returning `.name`. Preserve the existing `"Autoscan"` fallback for scans with no + capability. + +## Edit UX + +Inline per-row, reusing the existing on-blur auto-save pattern (no modal, no new +interaction model). The label input is blank by default with placeholder text indicating +it is optional. Saving sends the full row state through the same source-update mutation +used for connection/interval/rewrites. + +## Error handling and edge cases + +- **Deleted source on an Activity event** — `autoscan_events.source_id` is + `ON DELETE SET NULL`; with a null `source_id` there is no operator label or connection, + so the chain resolves from the event row's own stored `capability_id` / `installation_id` + (plus manifest display_name if the plugin is still installed). +- **Uninstalled plugin** — `display_name` does not resolve; the chain falls to + `capability_id`. +- **Bound connection** — `autoscan_sources.connection_id` is `ON DELETE RESTRICT`, so a + bound connection always resolves to a name. +- **Length / whitespace** — the operator label is trimmed and capped at 120 characters + server-side; an all-whitespace label normalizes to empty (unset). + +## Testing + +- `web/src/lib/autoscanLabels.test.ts` — covers each rung of the chain: operator label + wins, connection name wins, manifest display_name wins, capability fallback, and the + subtitle composition. +- Extend the existing autoscan repository/handler Go tests to cover the `label` column + round-trip (create/update/read) and the server-side trim + length cap. + +## Scope summary + +One migration pair, three Go files, four frontend files (one new). No SDK changes, no +multi-repo coordination. Operator-editable labels (this design) and the automatic chain +share the same helper, so the deferred plugin-template option layers on later with no +rework. diff --git a/go.mod b/go.mod index f9c420ce..5aa90d07 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( ) require ( - github.com/Silo-Server/silo-plugin-sdk v0.4.0 + github.com/Silo-Server/silo-plugin-sdk v0.4.1-0.20260604220604-0d7865171220 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect diff --git a/go.sum b/go.sum index 0d513135..31791ea8 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= -github.com/Silo-Server/silo-plugin-sdk v0.4.0 h1:DJkRROQfr/kfwnF5dUdkhmbCso1KDLZc7uK7YfJnaO0= -github.com/Silo-Server/silo-plugin-sdk v0.4.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= +github.com/Silo-Server/silo-plugin-sdk v0.4.1-0.20260604220604-0d7865171220 h1:tiwfULdh9yOW7HO6fYf8v+lXIsrCrGfp3GM8ymhrBME= +github.com/Silo-Server/silo-plugin-sdk v0.4.1-0.20260604220604-0d7865171220/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= diff --git a/internal/api/autoscan_wiring.go b/internal/api/autoscan_wiring.go new file mode 100644 index 00000000..e65639d5 --- /dev/null +++ b/internal/api/autoscan_wiring.go @@ -0,0 +1,175 @@ +package api + +import ( + "context" + "fmt" + "strings" + + "github.com/redis/go-redis/v9" + + "github.com/Silo-Server/silo-server/internal/autoscan" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/plugins" + mediarequests "github.com/Silo-Server/silo-server/internal/requests" + "github.com/Silo-Server/silo-server/internal/scantrigger" +) + +// autoscanQueuer is the scan-enqueue surface BuildAutoscanService needs; it is +// satisfied by *scanqueue.Service (autoscan.Queuer's concrete production impl). +type autoscanQueuer = autoscan.Queuer + +// RequestIntegrationLookup adapts the Requests repository to the autoscan +// connection resolver's RequestIntegrationLookup: it resolves a soft-linked +// Requests integration to its base URL and (write-only) api-key ref. +type RequestIntegrationLookup struct { + Repo *mediarequests.Repository +} + +func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKeyRef string, err error) { + integration, err := l.Repo.GetIntegration(ctx, integrationID) + if err != nil { + return "", "", err + } + // A reused Requests connection must honor the integration's live state. The + // v1 poll gated on `WHERE ri.enabled = true`; here we surface a disabled or + // unconfigured (blank base_url) integration as an error so the engine turns + // it into a logged skip / RecordError rather than polling an unusable target. + if err := checkRequestIntegrationUsable(integrationID, integration.Enabled, integration.BaseURL); err != nil { + return "", "", err + } + return integration.BaseURL, integration.APIKeyRef, nil +} + +// checkRequestIntegrationUsable returns a non-nil error when a linked Requests +// integration cannot be polled: it is disabled, or it has no base_url. Extracted +// as a pure function so the gating is unit-testable without a DB-backed repo. +func checkRequestIntegrationUsable(integrationID string, enabled bool, baseURL string) error { + if !enabled { + return fmt.Errorf("linked requests integration %q is disabled", integrationID) + } + if strings.TrimSpace(baseURL) == "" { + return fmt.Errorf("linked requests integration %q has no base_url configured", integrationID) + } + return nil +} + +// PluginScanSourceAdapter adapts plugins.Service to autoscan.ScanSourceResolver. +// plugins.Service.ScanSourceClient returns the concrete +// *pluginhost.ScanSourceClient; that concrete type satisfies +// autoscan.PollChangesClient (it has the matching PollChanges method), so the +// adapter declares the exported interface as its return type and returns the +// concrete value (Go has no return-type covariance, so the method signature must +// name the interface exactly to satisfy ScanSourceResolver). +type PluginScanSourceAdapter struct { + Svc *plugins.Service +} + +func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (autoscan.PollChangesClient, error) { + return a.Svc.ScanSourceClient(ctx, installationID, capabilityID) +} + +// scanSourceCapabilityType is the plugin capability type autoscan discovery +// enumerates. +const scanSourceCapabilityType = "scan_source.v1" + +// PluginScanSourceLister adapts the plugin installation store to +// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1 +// capability across ALL installed plugins, regardless of enabled state. +// +// Using List (not ListEnabled) is deliberate for orphan detection: a temporarily +// DISABLED-but-installed plugin must NOT be treated as orphaned. If we dropped +// disabled plugins here, PollOnce would prune their sources and skip them with no +// last_error — they'd vanish silently. Keeping them present means PollOnce still +// attempts them; the plugin client fails to load (not running) and the source +// gets a visible RecordError instead. Only a fully UNINSTALLED plugin (gone from +// List entirely) is a true orphan. The Add-source picker shares this all-installed +// set, which is fine — operators can bind sources against an installed capability. +type PluginScanSourceLister struct { + Store *plugins.InstallationStore +} + +func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan.DiscoveredSource, error) { + installations, err := l.Store.List(ctx) + if err != nil { + return nil, err + } + var out []autoscan.DiscoveredSource + for _, inst := range installations { + caps, err := l.Store.ListCapabilities(ctx, inst.ID) + if err != nil { + return nil, err + } + for _, c := range caps { + if c == nil || c.Type != scanSourceCapabilityType { + continue + } + out = append(out, autoscan.DiscoveredSource{ + InstallationID: c.InstallationID, + CapabilityID: c.ID, + PluginID: inst.PluginID, + DisplayName: scanSourceDisplayName(inst.PluginID, c), + }) + } + } + return out, nil +} + +// scanSourceDisplayName derives a human-friendly label for a scan_source +// capability: the capability manifest's display_name when present, else the +// plugin id (with the capability id appended when it adds information). +func scanSourceDisplayName(pluginID string, c *plugins.Capability) string { + if c != nil && c.Metadata != nil { + if name, ok := c.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" { + return strings.TrimSpace(name) + } + } + switch { + case pluginID != "" && c != nil && c.ID != "": + return pluginID + " / " + c.ID + case pluginID != "": + return pluginID + case c != nil: + return c.ID + default: + return "" + } +} + +// AutoscanSecretResolver resolves an encrypted api-key reference to plaintext. +// It is satisfied by the server settings repo (Get(ctx, key) (string, error)). +type AutoscanSecretResolver interface { + Get(ctx context.Context, ref string) (string, error) +} + +// BuildAutoscanService wires the v2 autoscan engine from its concrete +// dependencies. Both the HTTP router (manual trigger) and the background poll +// task share this constructor so the adapter wiring lives in exactly one place. +func BuildAutoscanService( + repo *autoscan.Repository, + pluginService *plugins.Service, + installationStore *plugins.InstallationStore, + requestsRepo *mediarequests.Repository, + secrets AutoscanSecretResolver, + folderRepo *catalog.FolderRepository, + queue autoscanQueuer, + redisClient *redis.Client, +) *autoscan.Service { + provider := autoscan.NewPluginProvider(PluginScanSourceAdapter{pluginService}) + connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo}, secrets) + svc := autoscan.NewService( + repo, + provider, + connRes, + scantrigger.NewResolver(folderRepo), + queue, + autoscan.NewRedisSuppressor(redisClient), + PluginScanSourceLister{installationStore}, + ) + // Wire the connection-test + rewrite-suggester deps: a (long-timeout) + // arr root-folder/status client and a Silo media-folder lister. + svc.SetSuggesterDeps( + autoscan.NewArrRootFolderClient(nil), + autoscan.NewCatalogFolderLister(folderRepo), + ) + return svc +} diff --git a/internal/api/autoscan_wiring_test.go b/internal/api/autoscan_wiring_test.go new file mode 100644 index 00000000..926f2f86 --- /dev/null +++ b/internal/api/autoscan_wiring_test.go @@ -0,0 +1,24 @@ +package api + +import "testing" + +// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests +// connection is rejected when the integration is disabled or has no base_url, so +// the autoscan engine skips it instead of polling an unusable target. +func TestCheckRequestIntegrationUsable(t *testing.T) { + t.Run("enabled with base_url is usable", func(t *testing.T) { + if err := checkRequestIntegrationUsable("req-1", true, "http://radarr:7878"); err != nil { + t.Fatalf("expected usable, got %v", err) + } + }) + t.Run("disabled is rejected", func(t *testing.T) { + if err := checkRequestIntegrationUsable("req-1", false, "http://radarr:7878"); err == nil { + t.Fatal("expected error for disabled integration") + } + }) + t.Run("blank base_url is rejected", func(t *testing.T) { + if err := checkRequestIntegrationUsable("req-1", true, " "); err == nil { + t.Fatal("expected error for blank base_url") + } + }) +} diff --git a/internal/api/handlers/autoscan.go b/internal/api/handlers/autoscan.go new file mode 100644 index 00000000..d8bb727d --- /dev/null +++ b/internal/api/handlers/autoscan.go @@ -0,0 +1,1007 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/autoscan" + "github.com/Silo-Server/silo-server/internal/taskmanager" +) + +// autoscanStore is the subset of *autoscan.Repository the handler needs. The +// repository implements the full CRUD surface; this interface lets tests inject +// a fake. +type autoscanStore interface { + GetSettings(ctx context.Context) (autoscan.Settings, error) + UpdateSettings(ctx context.Context, s autoscan.Settings) (autoscan.Settings, error) + ListConnections(ctx context.Context) ([]autoscan.Connection, error) + CreateConnection(ctx context.Context, c autoscan.Connection) (autoscan.Connection, error) + UpdateConnection(ctx context.Context, c autoscan.Connection) (autoscan.Connection, error) + DeleteConnection(ctx context.Context, id string) error + ListSources(ctx context.Context) ([]autoscan.Source, error) + GetSource(ctx context.Context, id string) (autoscan.Source, error) + CreateSource(ctx context.Context, s autoscan.Source) (autoscan.Source, error) + UpdateSource(ctx context.Context, s autoscan.Source) (autoscan.Source, error) + DeleteSource(ctx context.Context, id string) error + ListAutoscanScans(ctx context.Context, filter autoscan.ScanListFilter) ([]autoscan.ScanWithEvent, error) + CountAutoscanScans(ctx context.Context, filter autoscan.ScanListFilter) (int, error) + ListEvents(ctx context.Context, filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error) + CountEvents(ctx context.Context, filter autoscan.EventListFilter) (int, error) + GetQueueSummary(ctx context.Context) (autoscan.QueueSummary, error) + LatestEventAt(ctx context.Context) (*time.Time, error) +} + +// autoscanTriggerer is the subset of *autoscan.Service the handler needs. +type autoscanTriggerer interface { + PollOnce(ctx context.Context) error + // ListAvailableScanSources enumerates installed scan_source capabilities for + // the Add-source picker, and is also used to validate a create request + // targets a currently-installed capability. + ListAvailableScanSources(ctx context.Context) ([]autoscan.AvailableScanSource, error) + // TestConnection probes an ad-hoc connection input; TestConnectionByID probes + // an existing stored connection. + TestConnection(ctx context.Context, c autoscan.Connection) (autoscan.ConnectionTestResult, error) + TestConnectionByID(ctx context.Context, id string) (autoscan.ConnectionTestResult, error) + // SuggestRewrites proposes path rewrites by matching the source's arr root + // folders against Silo's media folders. + SuggestRewrites(ctx context.Context, sourceID string) (autoscan.RewriteSuggestions, error) +} + +// autoscanTriggerUpdater reconfigures the poll task's schedule when the default +// poll interval changes (satisfied by *taskmanager.TaskManager). It is optional: +// when nil, a settings change still persists but only takes effect on restart. +type autoscanTriggerUpdater interface { + UpdateTriggers(key string, triggerConfigs []taskmanager.TriggerConfig) error +} + +// autoscanPollTaskKey is the task-manager key for the autoscan poll task. It must +// match (*tasks.AutoscanPollTask).Key(). +const autoscanPollTaskKey = "autoscan_poll" + +// AutoscanHandler serves the admin-only autoscan settings/connections/sources/ +// trigger API. +type AutoscanHandler struct { + repo autoscanStore + svc autoscanTriggerer + triggers autoscanTriggerUpdater // optional; nil skips rescheduling +} + +func NewAutoscanHandler(repo autoscanStore, svc autoscanTriggerer) *AutoscanHandler { + return &AutoscanHandler{repo: repo, svc: svc} +} + +// SetTriggerUpdater wires the optional poll-task rescheduler so a settings change +// re-applies the poll interval without a restart. Keeping it a setter (rather +// than a constructor arg) lets tests construct the handler without a task +// manager. +func (h *AutoscanHandler) SetTriggerUpdater(t autoscanTriggerUpdater) { + h.triggers = t +} + +// --- Settings --- + +type autoscanSettingsResponse struct { + Enabled bool `json:"enabled"` + DefaultPollIntervalSeconds int `json:"default_poll_interval_seconds"` + DebounceSeconds int `json:"debounce_seconds"` +} + +func settingsResponse(s autoscan.Settings) autoscanSettingsResponse { + return autoscanSettingsResponse{ + Enabled: s.Enabled, + DefaultPollIntervalSeconds: s.DefaultPollIntervalSeconds, + DebounceSeconds: s.DebounceSeconds, + } +} + +func (h *AutoscanHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) { + settings, err := h.repo.GetSettings(r.Context()) + if err != nil { + writeAutoscanError(w, err) + return + } + writeJSON(w, http.StatusOK, settingsResponse(settings)) +} + +func (h *AutoscanHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { + var req autoscanSettingsResponse + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if req.DefaultPollIntervalSeconds <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "default_poll_interval_seconds must be greater than 0") + return + } + if req.DebounceSeconds < 0 { + writeError(w, http.StatusBadRequest, "bad_request", "debounce_seconds must be 0 or greater") + return + } + updated, err := h.repo.UpdateSettings(r.Context(), autoscan.Settings{ + Enabled: req.Enabled, + DefaultPollIntervalSeconds: req.DefaultPollIntervalSeconds, + DebounceSeconds: req.DebounceSeconds, + }) + if err != nil { + writeAutoscanError(w, err) + return + } + // Reschedule the poll task so a changed default_poll_interval_seconds takes + // effect immediately rather than only after a restart. Optional: skip when no + // task manager is wired (e.g. tests). A reschedule failure is non-fatal — the + // new interval is persisted and will apply on the next restart regardless. + if h.triggers != nil { + intervalMs := int64(updated.DefaultPollIntervalSeconds) * 1000 + if terr := h.triggers.UpdateTriggers(autoscanPollTaskKey, []taskmanager.TriggerConfig{ + {Type: taskmanager.TriggerTypeInterval, IntervalMs: intervalMs}, + }); terr != nil { + slog.WarnContext(r.Context(), "autoscan: reschedule poll task failed", "err", terr) + } + } + writeJSON(w, http.StatusOK, settingsResponse(updated)) +} + +// --- Connections --- + +// autoscanConnectionResponse is the client view of a connection. It NEVER +// includes api_key_ref or any resolved credential: callers manage credentials +// either by setting an api-key ref (write-only) or by linking a Requests +// integration. +type autoscanConnectionResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + BaseURL string `json:"base_url,omitempty"` + RequestIntegrationID *string `json:"request_integration_id,omitempty"` + // HasAPIKey reports whether the connection carries its own (write-only) + // api-key ref, without disclosing the ref itself. + HasAPIKey bool `json:"has_api_key"` +} + +func connectionResponse(c autoscan.Connection) autoscanConnectionResponse { + return autoscanConnectionResponse{ + ID: c.ID, + Name: c.Name, + Kind: c.Kind, + BaseURL: c.BaseURL, + RequestIntegrationID: c.RequestIntegrationID, + HasAPIKey: strings.TrimSpace(c.APIKeyRef) != "", + } +} + +// autoscanConnectionInput is the write payload. api_key_ref is accepted (it is +// an opaque reference into platform settings, never a plaintext secret) but is +// never echoed back in any response. +type autoscanConnectionInput struct { + Name string `json:"name"` + Kind string `json:"kind"` + BaseURL string `json:"base_url"` + APIKeyRef string `json:"api_key_ref"` + RequestIntegrationID *string `json:"request_integration_id"` +} + +// validateConnectionInput enforces the invariant that migration 171 dropped from +// the DB CHECK and delegated to the application layer: a connection must carry +// either its own base_url or a live link to a Requests integration. A connection +// with neither is a both-NULL orphan that ConnectionResolver.Resolve would hand +// a plugin as an empty base URL. A whitespace-only request_integration_id counts +// as absent. Enforced on both create and update (an update can strip a +// connection to both-empty). +func validateConnectionInput(in autoscanConnectionInput) error { + hasOwn := strings.TrimSpace(in.BaseURL) != "" + hasLink := in.RequestIntegrationID != nil && strings.TrimSpace(*in.RequestIntegrationID) != "" + if !hasOwn && !hasLink { + return errors.New("connection requires base_url or request_integration_id") + } + return nil +} + +// normalizeRequestIntegrationID trims whitespace from the optional Requests +// integration link and collapses an empty-after-trim value to nil. This keeps +// the persisted column either a real id or NULL — a pointer to "" or " " must +// never be stored as a bogus link (the resolver guards empty at read time, but +// normalizing on write keeps the data consistent). +func normalizeRequestIntegrationID(ref *string) *string { + if ref == nil { + return nil + } + trimmed := strings.TrimSpace(*ref) + if trimmed == "" { + return nil + } + return &trimmed +} + +func (h *AutoscanHandler) HandleListConnections(w http.ResponseWriter, r *http.Request) { + conns, err := h.repo.ListConnections(r.Context()) + if err != nil { + writeAutoscanError(w, err) + return + } + out := make([]autoscanConnectionResponse, 0, len(conns)) + for _, c := range conns { + out = append(out, connectionResponse(c)) + } + writeJSON(w, http.StatusOK, struct { + Connections []autoscanConnectionResponse `json:"connections"` + }{Connections: out}) +} + +func (h *AutoscanHandler) HandleCreateConnection(w http.ResponseWriter, r *http.Request) { + var in autoscanConnectionInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "name is required") + return + } + if err := validateConnectionInput(in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + created, err := h.repo.CreateConnection(r.Context(), autoscan.Connection{ + Name: strings.TrimSpace(in.Name), + Kind: strings.TrimSpace(in.Kind), + BaseURL: strings.TrimSpace(in.BaseURL), + APIKeyRef: strings.TrimSpace(in.APIKeyRef), + RequestIntegrationID: normalizeRequestIntegrationID(in.RequestIntegrationID), + }) + if err != nil { + writeAutoscanError(w, err) + return + } + writeJSON(w, http.StatusCreated, connectionResponse(created)) +} + +func (h *AutoscanHandler) HandleUpdateConnection(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + var in autoscanConnectionInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "name is required") + return + } + if err := validateConnectionInput(in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + updated, err := h.repo.UpdateConnection(r.Context(), autoscan.Connection{ + ID: id, + Name: strings.TrimSpace(in.Name), + Kind: strings.TrimSpace(in.Kind), + BaseURL: strings.TrimSpace(in.BaseURL), + APIKeyRef: strings.TrimSpace(in.APIKeyRef), + RequestIntegrationID: normalizeRequestIntegrationID(in.RequestIntegrationID), + }) + if err != nil { + writeAutoscanError(w, err) + return + } + writeJSON(w, http.StatusOK, connectionResponse(updated)) +} + +func (h *AutoscanHandler) HandleDeleteConnection(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if err := h.repo.DeleteConnection(r.Context(), id); err != nil { + writeAutoscanError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- Sources --- + +// autoscanSourceResponse is the client view of a source. It carries no +// credentials: the connection link is by id only. +type autoscanSourceResponse struct { + ID string `json:"id"` + InstallationID int `json:"installation_id"` + CapabilityID string `json:"capability_id"` + ConnectionID *string `json:"connection_id"` + Enabled bool `json:"enabled"` + PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"` + PathRewrites []autoscan.PathRewrite `json:"path_rewrites"` + SourceConfig map[string]string `json:"source_config"` + Label string `json:"label"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + LastError *string `json:"last_error,omitempty"` +} + +func sourceResponse(s autoscan.Source) autoscanSourceResponse { + // Always emit a (possibly empty) array, never JSON null, so clients can treat + // path_rewrites as a stable list field. + rewrites := s.PathRewrites + if rewrites == nil { + rewrites = []autoscan.PathRewrite{} + } + config := normalizeSourceConfig(s.SourceConfig) + return autoscanSourceResponse{ + ID: s.ID, + InstallationID: s.InstallationID, + CapabilityID: s.CapabilityID, + ConnectionID: s.ConnectionID, + Enabled: s.Enabled, + PollIntervalSeconds: s.PollIntervalSeconds, + PathRewrites: rewrites, + SourceConfig: config, + Label: s.Label, + LastRunAt: s.LastRunAt, + LastError: s.LastError, + } +} + +func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Request) { + // Sources are operator-created (no auto-seed): just list what exists. Use + // GET /scan-source-plugins for the Add-source picker of installed capabilities. + sources, err := h.repo.ListSources(r.Context()) + if err != nil { + writeAutoscanError(w, err) + return + } + out := make([]autoscanSourceResponse, 0, len(sources)) + for _, s := range sources { + out = append(out, sourceResponse(s)) + } + writeJSON(w, http.StatusOK, struct { + Sources []autoscanSourceResponse `json:"sources"` + }{Sources: out}) +} + +// --- Available scan-source plugins (Add-source picker) --- + +type autoscanScanSourcePluginResponse struct { + InstallationID int `json:"installation_id"` + CapabilityID string `json:"capability_id"` + PluginID string `json:"plugin_id"` + DisplayName string `json:"display_name"` +} + +// HandleListAvailableScanSources returns every installed scan_source capability +// an operator can create a source against (the Add-source picker list). +func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter, r *http.Request) { + available, err := h.svc.ListAvailableScanSources(r.Context()) + if err != nil { + writeAutoscanError(w, err) + return + } + out := make([]autoscanScanSourcePluginResponse, 0, len(available)) + for _, a := range available { + out = append(out, autoscanScanSourcePluginResponse{ + InstallationID: a.InstallationID, + CapabilityID: a.CapabilityID, + PluginID: a.PluginID, + DisplayName: a.DisplayName, + }) + } + writeJSON(w, http.StatusOK, struct { + Plugins []autoscanScanSourcePluginResponse `json:"plugins"` + }{Plugins: out}) +} + +// --- Create source --- + +// autoscanCreateSourceInput is the create payload: it names the installed +// scan_source capability to bind (installation_id + capability_id) plus the +// initial binding/scheduling fields. Unlike the update payload, identity is +// supplied here rather than read from an existing row. +type autoscanCreateSourceInput struct { + InstallationID int `json:"installation_id"` + CapabilityID string `json:"capability_id"` + ConnectionID *string `json:"connection_id"` + Enabled bool `json:"enabled"` + PollIntervalSeconds *int `json:"poll_interval_seconds"` + PathRewrites []autoscan.PathRewrite `json:"path_rewrites"` + SourceConfig map[string]string `json:"source_config"` + Label string `json:"label"` +} + +// HandleCreateSource creates a new source bound to an installed scan_source +// capability. Many sources may share one capability (e.g. one Sonarr plugin +// fronting several arr servers, one source per connection). The capability must +// currently be installed; enabling requires a bound connection. +func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Request) { + var in autoscanCreateSourceInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + capID := strings.TrimSpace(in.CapabilityID) + if capID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required") + return + } + if in.PollIntervalSeconds != nil && *in.PollIntervalSeconds <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "poll_interval_seconds must be greater than 0 when set") + return + } + if err := validatePathRewrites(in.PathRewrites); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + // Validate the (installation_id, capability_id) is a currently-installed + // scan_source capability — a source may only be created against an installed + // plugin capability. + available, err := h.svc.ListAvailableScanSources(r.Context()) + if err != nil { + writeAutoscanError(w, err) + return + } + if !scanSourceInstalled(available, in.InstallationID, capID) { + writeError(w, http.StatusBadRequest, "bad_request", "installation_id + capability_id is not a currently-installed scan_source capability") + return + } + connArg := normalizeConnectionID(in.ConnectionID) + created, err := h.repo.CreateSource(r.Context(), autoscan.Source{ + InstallationID: in.InstallationID, + CapabilityID: capID, + ConnectionID: connArg, + Enabled: in.Enabled, + PollIntervalSeconds: in.PollIntervalSeconds, + PathRewrites: normalizePathRewrites(in.PathRewrites), + SourceConfig: normalizeSourceConfig(in.SourceConfig), + Label: autoscan.NormalizeSourceLabel(in.Label), + }) + if err != nil { + writeAutoscanError(w, err) + return + } + writeJSON(w, http.StatusCreated, sourceResponse(created)) +} + +// scanSourceInstalled reports whether (installationID, capabilityID) is in the +// set of currently-installed scan_source capabilities. +func scanSourceInstalled(available []autoscan.AvailableScanSource, installationID int, capabilityID string) bool { + for _, a := range available { + if a.InstallationID == installationID && a.CapabilityID == capabilityID { + return true + } + } + return false +} + +// autoscanSourceInput is the source write payload. The (installation_id, +// capability_id) identity is read from the existing source row, so only the +// schedulable/binding fields are accepted. +// +// connection_id is a *string so the caller can express three distinct states: +// - field absent / JSON null → nil → unbind (clear the connection) +// - non-empty string UUID → bind to that connection +// +// The UI must always send the full desired state for all three fields. +// path_rewrites is full-state like connection_id: the UI always sends the +// complete desired list. Each entry needs a non-empty from and to. +type autoscanSourceInput struct { + ConnectionID *string `json:"connection_id"` + Enabled bool `json:"enabled"` + PollIntervalSeconds *int `json:"poll_interval_seconds"` + PathRewrites []autoscan.PathRewrite `json:"path_rewrites"` + SourceConfig map[string]string `json:"source_config"` + Label string `json:"label"` +} + +// validatePathRewrites lightly validates a source's rewrite list: each entry +// must carry a non-empty (trimmed) from and to. Returns an error suitable for a +// 400 response. +func validatePathRewrites(rewrites []autoscan.PathRewrite) error { + for i, rw := range rewrites { + if strings.TrimSpace(rw.From) == "" || strings.TrimSpace(rw.To) == "" { + return fmt.Errorf("path_rewrites[%d]: from and to are both required", i) + } + } + return nil +} + +// normalizePathRewrites trims whitespace off each rewrite's from/to so stored +// rules match the same trimming applyRewrites does at poll time. +func normalizePathRewrites(rewrites []autoscan.PathRewrite) []autoscan.PathRewrite { + out := make([]autoscan.PathRewrite, 0, len(rewrites)) + for _, rw := range rewrites { + out = append(out, autoscan.PathRewrite{ + From: strings.TrimSpace(rw.From), + To: strings.TrimSpace(rw.To), + }) + } + return out +} + +func normalizeSourceConfig(config map[string]string) map[string]string { + out := make(map[string]string, len(config)) + for key, value := range config { + key = strings.TrimSpace(key) + if key == "" { + continue + } + out[key] = strings.TrimSpace(value) + } + return out +} + +func (h *AutoscanHandler) HandleUpdateSource(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + var in autoscanSourceInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if in.PollIntervalSeconds != nil && *in.PollIntervalSeconds <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "poll_interval_seconds must be greater than 0 when set") + return + } + if err := validatePathRewrites(in.PathRewrites); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + // connection_id is a full-state field: nil means unbind, a UUID string + // means bind. A whitespace-only string is normalised to nil (unbound). + connArg := normalizeConnectionID(in.ConnectionID) + // Update by id; identity (installation_id, capability_id) is immutable and + // preserved by the repo. A missing source maps to 404. + updated, err := h.repo.UpdateSource(r.Context(), autoscan.Source{ + ID: id, + ConnectionID: connArg, + Enabled: in.Enabled, + PollIntervalSeconds: in.PollIntervalSeconds, + PathRewrites: normalizePathRewrites(in.PathRewrites), + SourceConfig: normalizeSourceConfig(in.SourceConfig), + Label: autoscan.NormalizeSourceLabel(in.Label), + }) + if err != nil { + writeAutoscanError(w, err) + return + } + writeJSON(w, http.StatusOK, sourceResponse(updated)) +} + +// normalizeConnectionID maps a full-state connection_id input to a stored value: +// a nil pointer or a whitespace-only string means unbound (nil); a non-empty +// trimmed UUID binds to that connection. +func normalizeConnectionID(in *string) *string { + if in == nil { + return nil + } + trimmed := strings.TrimSpace(*in) + if trimmed == "" { + return nil + } + return &trimmed +} + +// HandleDeleteSource removes a source row. This is the operator's escape hatch +// for an orphaned source (one whose scan_source plugin was uninstalled): the +// poll loop skips such rows quietly, and this endpoint clears them. An unknown +// id maps to 404 via autoscan.ErrNotFound. +func (h *AutoscanHandler) HandleDeleteSource(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if err := h.repo.DeleteSource(r.Context(), id); err != nil { + writeAutoscanError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- Test connection --- + +// autoscanTestConnectionInput probes either an ad-hoc connection (own base_url + +// api_key_ref, or a linked request_integration_id) OR an existing stored +// connection by id. When connection_id is set, it takes precedence and the +// ad-hoc fields are ignored. +type autoscanTestConnectionInput struct { + ConnectionID *string `json:"connection_id"` + BaseURL string `json:"base_url"` + APIKeyRef string `json:"api_key_ref"` + RequestIntegrationID *string `json:"request_integration_id"` +} + +// autoscanTestConnectionResponse is the probe outcome: ok=true with the reported +// arr version on success, or ok=false with a human-readable error on failure +// (unreachable / 401 / non-200). A failed probe is still a 200 response — the +// failure lives in the payload, not the HTTP status. +type autoscanTestConnectionResponse struct { + OK bool `json:"ok"` + Version string `json:"version,omitempty"` + Error string `json:"error,omitempty"` +} + +// HandleTestConnection probes an arr connection's /api/v3/system/status. +func (h *AutoscanHandler) HandleTestConnection(w http.ResponseWriter, r *http.Request) { + var in autoscanTestConnectionInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + var ( + result autoscan.ConnectionTestResult + err error + ) + if in.ConnectionID != nil && strings.TrimSpace(*in.ConnectionID) != "" { + result, err = h.svc.TestConnectionByID(r.Context(), strings.TrimSpace(*in.ConnectionID)) + } else { + // Ad-hoc connection: must carry own base_url or a linked integration, + // same invariant as create/update. + conn := autoscan.Connection{ + BaseURL: strings.TrimSpace(in.BaseURL), + APIKeyRef: strings.TrimSpace(in.APIKeyRef), + RequestIntegrationID: in.RequestIntegrationID, + } + hasOwn := conn.BaseURL != "" + hasLink := conn.RequestIntegrationID != nil && strings.TrimSpace(*conn.RequestIntegrationID) != "" + if !hasOwn && !hasLink { + writeError(w, http.StatusBadRequest, "bad_request", "connection requires connection_id, base_url, or request_integration_id") + return + } + result, err = h.svc.TestConnection(r.Context(), conn) + } + if err != nil { + writeAutoscanError(w, err) + return + } + writeJSON(w, http.StatusOK, autoscanTestConnectionResponse{ + OK: result.OK, + Version: result.Version, + Error: result.Err, + }) +} + +// --- Rewrite suggestions (sync from arr) --- + +// HandleRewriteSuggestions proposes path rewrites for a source by matching its +// bound connection's arr root folders against Silo's media folders. The +// operator applies chosen suggestions via the normal source PUT (path_rewrites). +func (h *AutoscanHandler) HandleRewriteSuggestions(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + suggestions, err := h.svc.SuggestRewrites(r.Context(), id) + if err != nil { + if errors.Is(err, autoscan.ErrNoConnection) { + writeError(w, http.StatusBadRequest, "bad_request", "source has no bound connection; bind one before syncing rewrites") + return + } + writeAutoscanError(w, err) + return + } + writeJSON(w, http.StatusOK, suggestions) +} + +// --- Trigger --- + +func (h *AutoscanHandler) HandleTrigger(w http.ResponseWriter, r *http.Request) { + // Run the poll detached: don't block the request on a full cycle, and don't + // tie the poll to the request context (which is cancelled once we respond). + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if err := h.svc.PollOnce(ctx); err != nil { + slog.WarnContext(ctx, "autoscan: manual poll failed", "err", err) + } + }() + writeJSON(w, http.StatusAccepted, struct { + Status string `json:"status"` + }{Status: "ok"}) +} + +// --- Events --- + +type autoscanEventScanRunResponse struct { + ID string `json:"id"` + LibraryID int `json:"library_id"` + Mode string `json:"mode"` + Path string `json:"path,omitempty"` + Trigger string `json:"trigger"` + Status string `json:"status"` + RequestedAt *time.Time `json:"requested_at,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +type autoscanEventResponse struct { + ID int64 `json:"id"` + SourceID *string `json:"source_id"` + InstallationID int `json:"installation_id"` + CapabilityID string `json:"capability_id"` + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at"` + DurationMS int64 `json:"duration_ms"` + Status string `json:"status"` + ChangesReturned int `json:"changes_returned"` + ChangesResolved int `json:"changes_resolved"` + TargetsClaimed int `json:"targets_claimed"` + ScansCreated int `json:"scans_created"` + ScansReused int `json:"scans_reused"` + ScansSuppressed int `json:"scans_suppressed"` + ErrorMessage string `json:"error_message,omitempty"` + ScanRuns []autoscanEventScanRunResponse `json:"scan_runs"` +} + +type autoscanEventsResponse struct { + Events []autoscanEventResponse `json:"events"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +func eventResponse(event autoscan.EventWithRuns) autoscanEventResponse { + runs := make([]autoscanEventScanRunResponse, 0, len(event.Runs)) + for _, run := range event.Runs { + runs = append(runs, autoscanEventScanRunResponse{ + ID: run.ID, + LibraryID: run.MediaFolderID, + Mode: run.Mode, + Path: run.Path, + Trigger: run.Trigger, + Status: run.Status, + RequestedAt: run.RequestedAt, + StartedAt: run.StartedAt, + CompletedAt: run.CompletedAt, + ErrorMessage: run.ErrorMessage, + }) + } + e := event.Event + return autoscanEventResponse{ + ID: e.ID, + SourceID: e.SourceID, + InstallationID: e.InstallationID, + CapabilityID: e.CapabilityID, + StartedAt: e.StartedAt, + CompletedAt: e.CompletedAt, + DurationMS: e.DurationMS, + Status: string(e.Status), + ChangesReturned: e.ChangesReturned, + ChangesResolved: e.ChangesResolved, + TargetsClaimed: e.TargetsClaimed, + ScansCreated: e.ScansCreated, + ScansReused: e.ScansReused, + ScansSuppressed: e.ScansSuppressed, + ErrorMessage: e.ErrorMessage, + ScanRuns: runs, + } +} + +func (h *AutoscanHandler) HandleListEvents(w http.ResponseWriter, r *http.Request) { + filter := autoscan.EventListFilter{ + SourceID: strings.TrimSpace(r.URL.Query().Get("source_id")), + Status: autoscan.EventStatus(strings.TrimSpace(r.URL.Query().Get("status"))), + Search: strings.TrimSpace(r.URL.Query().Get("q")), + Limit: 50, + } + if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" { + limit, err := strconv.Atoi(rawLimit) + if err != nil || limit < 1 { + writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer") + return + } + filter.Limit = limit + } + if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" { + offset, err := strconv.Atoi(rawOffset) + if err != nil || offset < 0 { + writeError(w, http.StatusBadRequest, "bad_request", "offset must be a non-negative integer") + return + } + filter.Offset = offset + } + switch filter.Status { + case "", autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved: + default: + writeError(w, http.StatusBadRequest, "bad_request", "status must be success, error, or unresolved") + return + } + + events, err := h.repo.ListEvents(r.Context(), filter) + if err != nil { + writeAutoscanError(w, err) + return + } + total, err := h.repo.CountEvents(r.Context(), filter) + if err != nil { + writeAutoscanError(w, err) + return + } + out := make([]autoscanEventResponse, 0, len(events)) + for _, event := range events { + out = append(out, eventResponse(event)) + } + writeJSON(w, http.StatusOK, autoscanEventsResponse{ + Events: out, + Total: total, + Limit: filter.Limit, + Offset: filter.Offset, + }) +} + +type autoscanScanResponse struct { + ID string `json:"id"` + LibraryID int `json:"library_id"` + Mode string `json:"mode"` + Path string `json:"path,omitempty"` + Trigger string `json:"trigger"` + Status string `json:"status"` + ErrorMessage string `json:"error_message,omitempty"` + RequestedAt *time.Time `json:"requested_at,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + AutoscanEventID *int64 `json:"autoscan_event_id,omitempty"` + SourceID *string `json:"source_id,omitempty"` + InstallationID *int `json:"installation_id,omitempty"` + CapabilityID string `json:"capability_id,omitempty"` + EventStatus string `json:"event_status,omitempty"` + EventCompletedAt *time.Time `json:"event_completed_at,omitempty"` +} + +type autoscanScansResponse struct { + Scans []autoscanScanResponse `json:"scans"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +func autoscanScanRunResponse(scan autoscan.ScanWithEvent) autoscanScanResponse { + return autoscanScanResponse{ + ID: scan.ID, + LibraryID: scan.MediaFolderID, + Mode: scan.Mode, + Path: scan.Path, + Trigger: scan.Trigger, + Status: scan.Status, + ErrorMessage: scan.ErrorMessage, + RequestedAt: scan.RequestedAt, + StartedAt: scan.StartedAt, + CompletedAt: scan.CompletedAt, + AutoscanEventID: scan.AutoscanEventID, + SourceID: scan.SourceID, + InstallationID: scan.InstallationID, + CapabilityID: scan.CapabilityID, + EventStatus: string(scan.EventStatus), + EventCompletedAt: scan.EventCompletedAt, + } +} + +func (h *AutoscanHandler) HandleListScans(w http.ResponseWriter, r *http.Request) { + filter := autoscan.ScanListFilter{ + Status: strings.TrimSpace(r.URL.Query().Get("status")), + Search: strings.TrimSpace(r.URL.Query().Get("q")), + Limit: 50, + } + if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" { + limit, err := strconv.Atoi(rawLimit) + if err != nil || limit < 1 { + writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer") + return + } + filter.Limit = limit + } + if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" { + offset, err := strconv.Atoi(rawOffset) + if err != nil || offset < 0 { + writeError(w, http.StatusBadRequest, "bad_request", "offset must be a non-negative integer") + return + } + filter.Offset = offset + } + switch filter.Status { + case "", "accepted", "running", "completed", "failed", "cancelled": + default: + writeError(w, http.StatusBadRequest, "bad_request", "status must be accepted, running, completed, failed, or cancelled") + return + } + + scans, err := h.repo.ListAutoscanScans(r.Context(), filter) + if err != nil { + writeAutoscanError(w, err) + return + } + total, err := h.repo.CountAutoscanScans(r.Context(), filter) + if err != nil { + writeAutoscanError(w, err) + return + } + out := make([]autoscanScanResponse, 0, len(scans)) + for _, scan := range scans { + out = append(out, autoscanScanRunResponse(scan)) + } + writeJSON(w, http.StatusOK, autoscanScansResponse{ + Scans: out, + Total: total, + Limit: filter.Limit, + Offset: filter.Offset, + }) +} + +// --- Status --- + +type autoscanStatusSource struct { + ID string `json:"id"` + InstallationID int `json:"installation_id"` + CapabilityID string `json:"capability_id"` + ConnectionID *string `json:"connection_id"` + Enabled bool `json:"enabled"` + Label string `json:"label"` + PathRewrites []autoscan.PathRewrite `json:"path_rewrites"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + LastError *string `json:"last_error,omitempty"` +} + +type autoscanStatusResponse struct { + Enabled bool `json:"enabled"` + Sources []autoscanStatusSource `json:"sources"` + ActiveScans int `json:"active_scans"` + AcceptedScans int `json:"accepted_scans"` + RunningScans int `json:"running_scans"` + LatestEventAt *time.Time `json:"latest_event_at,omitempty"` +} + +func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + s, err := h.repo.GetSettings(ctx) + if err != nil { + writeAutoscanError(w, err) + return + } + sources, err := h.repo.ListSources(ctx) + if err != nil { + writeAutoscanError(w, err) + return + } + queue, err := h.repo.GetQueueSummary(ctx) + if err != nil { + writeAutoscanError(w, err) + return + } + latestEventAt, err := h.repo.LatestEventAt(ctx) + if err != nil { + writeAutoscanError(w, err) + return + } + trimmed := make([]autoscanStatusSource, 0, len(sources)) + for _, src := range sources { + rewrites := src.PathRewrites + if rewrites == nil { + rewrites = []autoscan.PathRewrite{} + } + trimmed = append(trimmed, autoscanStatusSource{ + ID: src.ID, + InstallationID: src.InstallationID, + CapabilityID: src.CapabilityID, + ConnectionID: src.ConnectionID, + Enabled: src.Enabled, + Label: src.Label, + PathRewrites: rewrites, + LastRunAt: src.LastRunAt, + LastError: src.LastError, + }) + } + writeJSON(w, http.StatusOK, autoscanStatusResponse{ + Enabled: s.Enabled, + Sources: trimmed, + ActiveScans: queue.Active, + AcceptedScans: queue.Accepted, + RunningScans: queue.Running, + LatestEventAt: latestEventAt, + }) +} + +// writeAutoscanError maps autoscan repository/service errors to HTTP status +// codes: a missing connection or source maps to 404, everything else to 500. +func writeAutoscanError(w http.ResponseWriter, err error) { + if err == nil { + return + } + if errors.Is(err, autoscan.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Autoscan resource not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Autoscan operation failed") +} diff --git a/internal/api/handlers/autoscan_test.go b/internal/api/handlers/autoscan_test.go new file mode 100644 index 00000000..a3bd0ed6 --- /dev/null +++ b/internal/api/handlers/autoscan_test.go @@ -0,0 +1,1302 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/autoscan" + "github.com/Silo-Server/silo-server/internal/taskmanager" +) + +type fakeAutoscanStore struct { + getSettingsFn func() (autoscan.Settings, error) + updateSettingsFn func(autoscan.Settings) (autoscan.Settings, error) + listConnectionsFn func() ([]autoscan.Connection, error) + createConnectionFn func(autoscan.Connection) (autoscan.Connection, error) + updateConnectionFn func(autoscan.Connection) (autoscan.Connection, error) + deleteConnectionFn func(string) error + listSourcesFn func() ([]autoscan.Source, error) + getSourceFn func(string) (autoscan.Source, error) + createSourceFn func(autoscan.Source) (autoscan.Source, error) + updateSourceFn func(autoscan.Source) (autoscan.Source, error) + deleteSourceFn func(string) error + listScansFn func(autoscan.ScanListFilter) ([]autoscan.ScanWithEvent, error) + countScansFn func(autoscan.ScanListFilter) (int, error) + listEventsFn func(autoscan.EventListFilter) ([]autoscan.EventWithRuns, error) + countEventsFn func(autoscan.EventListFilter) (int, error) + queueSummaryFn func() (autoscan.QueueSummary, error) + latestEventAtFn func() (*time.Time, error) +} + +func (f *fakeAutoscanStore) GetSettings(context.Context) (autoscan.Settings, error) { + if f.getSettingsFn != nil { + return f.getSettingsFn() + } + return autoscan.Settings{}, nil +} + +func (f *fakeAutoscanStore) UpdateSettings(_ context.Context, s autoscan.Settings) (autoscan.Settings, error) { + if f.updateSettingsFn != nil { + return f.updateSettingsFn(s) + } + return s, nil +} + +func (f *fakeAutoscanStore) ListConnections(context.Context) ([]autoscan.Connection, error) { + if f.listConnectionsFn != nil { + return f.listConnectionsFn() + } + return nil, nil +} + +func (f *fakeAutoscanStore) CreateConnection(_ context.Context, c autoscan.Connection) (autoscan.Connection, error) { + if f.createConnectionFn != nil { + return f.createConnectionFn(c) + } + return c, nil +} + +func (f *fakeAutoscanStore) UpdateConnection(_ context.Context, c autoscan.Connection) (autoscan.Connection, error) { + if f.updateConnectionFn != nil { + return f.updateConnectionFn(c) + } + return c, nil +} + +func (f *fakeAutoscanStore) DeleteConnection(_ context.Context, id string) error { + if f.deleteConnectionFn != nil { + return f.deleteConnectionFn(id) + } + return nil +} + +func (f *fakeAutoscanStore) ListSources(context.Context) ([]autoscan.Source, error) { + if f.listSourcesFn != nil { + return f.listSourcesFn() + } + return nil, nil +} + +func (f *fakeAutoscanStore) GetSource(_ context.Context, id string) (autoscan.Source, error) { + if f.getSourceFn != nil { + return f.getSourceFn(id) + } + return autoscan.Source{ID: id}, nil +} + +func (f *fakeAutoscanStore) CreateSource(_ context.Context, s autoscan.Source) (autoscan.Source, error) { + if f.createSourceFn != nil { + return f.createSourceFn(s) + } + return s, nil +} + +func (f *fakeAutoscanStore) UpdateSource(_ context.Context, s autoscan.Source) (autoscan.Source, error) { + if f.updateSourceFn != nil { + return f.updateSourceFn(s) + } + return s, nil +} + +func (f *fakeAutoscanStore) DeleteSource(_ context.Context, id string) error { + if f.deleteSourceFn != nil { + return f.deleteSourceFn(id) + } + return nil +} + +func (f *fakeAutoscanStore) ListAutoscanScans(_ context.Context, filter autoscan.ScanListFilter) ([]autoscan.ScanWithEvent, error) { + if f.listScansFn != nil { + return f.listScansFn(filter) + } + return nil, nil +} + +func (f *fakeAutoscanStore) CountAutoscanScans(_ context.Context, filter autoscan.ScanListFilter) (int, error) { + if f.countScansFn != nil { + return f.countScansFn(filter) + } + return 0, nil +} + +func (f *fakeAutoscanStore) ListEvents(_ context.Context, filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error) { + if f.listEventsFn != nil { + return f.listEventsFn(filter) + } + return nil, nil +} + +func (f *fakeAutoscanStore) CountEvents(_ context.Context, filter autoscan.EventListFilter) (int, error) { + if f.countEventsFn != nil { + return f.countEventsFn(filter) + } + return 0, nil +} + +func (f *fakeAutoscanStore) GetQueueSummary(context.Context) (autoscan.QueueSummary, error) { + if f.queueSummaryFn != nil { + return f.queueSummaryFn() + } + return autoscan.QueueSummary{}, nil +} + +func (f *fakeAutoscanStore) LatestEventAt(context.Context) (*time.Time, error) { + if f.latestEventAtFn != nil { + return f.latestEventAtFn() + } + return nil, nil +} + +// fakeTriggerUpdater records the last UpdateTriggers call so a test can assert +// the handler reschedules the poll task with the new interval. +type fakeTriggerUpdater struct { + called bool + key string + triggers []taskmanager.TriggerConfig + err error +} + +func (f *fakeTriggerUpdater) UpdateTriggers(key string, cfgs []taskmanager.TriggerConfig) error { + f.called = true + f.key = key + f.triggers = cfgs + return f.err +} + +type fakeAutoscanTriggerer struct { + called bool + err error + // available is returned by ListAvailableScanSources (the Add-source picker / + // create-validation set). + available []autoscan.AvailableScanSource + // testResult / testErr drive TestConnection + TestConnectionByID. + testResult autoscan.ConnectionTestResult + testErr error + // suggestions / suggestErr drive SuggestRewrites. + suggestions autoscan.RewriteSuggestions + suggestErr error + // done, when non-nil, receives once PollOnce runs. HandleTrigger dispatches + // PollOnce on a detached goroutine, so tests synchronize on this instead of + // reading `called` straight after the handler returns (which races the + // goroutine and is also an unsynchronized read of `called`). The channel + // send happens-before the test's receive, so reading `called` afterwards is + // race-free. + done chan struct{} +} + +func (f *fakeAutoscanTriggerer) PollOnce(context.Context) error { + f.called = true + if f.done != nil { + f.done <- struct{}{} + } + return f.err +} + +func (f *fakeAutoscanTriggerer) ListAvailableScanSources(context.Context) ([]autoscan.AvailableScanSource, error) { + return f.available, nil +} + +func (f *fakeAutoscanTriggerer) TestConnection(context.Context, autoscan.Connection) (autoscan.ConnectionTestResult, error) { + return f.testResult, f.testErr +} + +func (f *fakeAutoscanTriggerer) TestConnectionByID(context.Context, string) (autoscan.ConnectionTestResult, error) { + return f.testResult, f.testErr +} + +func (f *fakeAutoscanTriggerer) SuggestRewrites(context.Context, string) (autoscan.RewriteSuggestions, error) { + return f.suggestions, f.suggestErr +} + +func newAutoscanRequest(method, target, body, id string) *http.Request { + var r *http.Request + if body != "" { + r = httptest.NewRequest(method, target, strings.NewReader(body)) + } else { + r = httptest.NewRequest(method, target, nil) + } + if id != "" { + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("id", id) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx)) + } + return r +} + +func TestAutoscanHandleGetSettingsReturnsJSON(t *testing.T) { + store := &fakeAutoscanStore{ + getSettingsFn: func() (autoscan.Settings, error) { + return autoscan.Settings{Enabled: true, DefaultPollIntervalSeconds: 300, DebounceSeconds: 30}, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + rec := httptest.NewRecorder() + h.HandleGetSettings(rec, httptest.NewRequest("GET", "/api/v1/admin/autoscan/settings", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var body autoscanSettingsResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if !body.Enabled || body.DefaultPollIntervalSeconds != 300 { + t.Errorf("settings = %+v", body) + } +} + +func TestAutoscanHandleUpdateSettingsRejectsZeroInterval(t *testing.T) { + h := NewAutoscanHandler(&fakeAutoscanStore{}, &fakeAutoscanTriggerer{}) + + req := httptest.NewRequest("PUT", "/api/v1/admin/autoscan/settings", + strings.NewReader(`{"enabled":true,"default_poll_interval_seconds":0,"debounce_seconds":10}`)) + rec := httptest.NewRecorder() + h.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAutoscanHandleListSourcesListsOnly(t *testing.T) { + // Sources are now operator-created (no auto-seed on list). The list endpoint + // must simply return the stored rows. + listed := false + store := &fakeAutoscanStore{ + listSourcesFn: func() ([]autoscan.Source, error) { + listed = true + return []autoscan.Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr"}}, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + rec := httptest.NewRecorder() + h.HandleListSources(rec, newAutoscanRequest("GET", "/api/v1/admin/autoscan/sources", "", "")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !listed { + t.Fatal("HandleListSources did not list stored sources") + } + var body struct { + Sources []autoscanSourceResponse `json:"sources"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Sources) != 1 || body.Sources[0].ID != "src-1" { + t.Fatalf("unexpected sources: %+v", body.Sources) + } +} + +func TestAutoscanHandleListAvailableScanSources(t *testing.T) { + trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{ + {InstallationID: 1, CapabilityID: "arr", PluginID: "sonarr", DisplayName: "Sonarr"}, + }} + h := NewAutoscanHandler(&fakeAutoscanStore{}, trig) + + rec := httptest.NewRecorder() + h.HandleListAvailableScanSources(rec, newAutoscanRequest("GET", "/api/v1/admin/autoscan/scan-source-plugins", "", "")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Plugins []autoscanScanSourcePluginResponse `json:"plugins"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Plugins) != 1 || body.Plugins[0].PluginID != "sonarr" || body.Plugins[0].DisplayName != "Sonarr" { + t.Fatalf("unexpected plugins: %+v", body.Plugins) + } +} + +func TestAutoscanHandleCreateSourceSucceeds(t *testing.T) { + var got autoscan.Source + store := &fakeAutoscanStore{ + createSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + got = s + s.ID = "new-src" + return s, nil + }, + } + trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{ + {InstallationID: 1, CapabilityID: "arr"}, + }} + h := NewAutoscanHandler(store, trig) + + req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources", + `{"installation_id":1,"capability_id":"arr","connection_id":"conn-1","enabled":true}`, "") + rec := httptest.NewRecorder() + h.HandleCreateSource(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) + } + if got.InstallationID != 1 || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" { + t.Fatalf("unexpected created source: %+v", got) + } + var body autoscanSourceResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.ID != "new-src" { + t.Fatalf("expected created id echoed, got %+v", body) + } +} + +func TestAutoscanHandleCreateSourceRejectsUnknownCapability(t *testing.T) { + created := false + store := &fakeAutoscanStore{ + createSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + created = true + return s, nil + }, + } + // Installed set does NOT include (1, "arr"). + trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{ + {InstallationID: 2, CapabilityID: "other"}, + }} + h := NewAutoscanHandler(store, trig) + + req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources", + `{"installation_id":1,"capability_id":"arr","enabled":false}`, "") + rec := httptest.NewRecorder() + h.HandleCreateSource(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if created { + t.Fatal("CreateSource was called for an uninstalled capability") + } +} + +func TestAutoscanHandleCreateSourceEnableWithoutConnectionSucceeds(t *testing.T) { + // A connection is optional: a source can be created enabled without one + // (e.g. a filesystem/CephFS provider). The plugin surfaces any + // missing-credential error at poll time. + var got autoscan.Source + store := &fakeAutoscanStore{ + createSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + got = s + return s, nil + }, + } + trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{ + {InstallationID: 1, CapabilityID: "arr"}, + }} + h := NewAutoscanHandler(store, trig) + + req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources", + `{"installation_id":1,"capability_id":"arr","enabled":true}`, "") + rec := httptest.NewRecorder() + h.HandleCreateSource(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) + } + if !got.Enabled || got.ConnectionID != nil { + t.Fatalf("expected enabled connection-less source created, got %+v", got) + } +} + +func TestAutoscanHandleTestConnectionOK(t *testing.T) { + trig := &fakeAutoscanTriggerer{testResult: autoscan.ConnectionTestResult{OK: true, Version: "4.0.1"}} + h := NewAutoscanHandler(&fakeAutoscanStore{}, trig) + + req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/connections/test", + `{"base_url":"http://radarr:7878","api_key_ref":"k"}`, "") + rec := httptest.NewRecorder() + h.HandleTestConnection(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body autoscanTestConnectionResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if !body.OK || body.Version != "4.0.1" { + t.Fatalf("unexpected test result: %+v", body) + } +} + +func TestAutoscanHandleTestConnectionFailureIs200WithError(t *testing.T) { + trig := &fakeAutoscanTriggerer{testResult: autoscan.ConnectionTestResult{OK: false, Err: "arr: HTTP 401"}} + h := NewAutoscanHandler(&fakeAutoscanStore{}, trig) + + req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/connections/test", + `{"connection_id":"conn-1"}`, "") + rec := httptest.NewRecorder() + h.HandleTestConnection(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body autoscanTestConnectionResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.OK || body.Error == "" { + t.Fatalf("expected ok=false with error, got %+v", body) + } +} + +func TestAutoscanHandleTestConnectionRejectsEmptyInput(t *testing.T) { + h := NewAutoscanHandler(&fakeAutoscanStore{}, &fakeAutoscanTriggerer{}) + + req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/connections/test", `{}`, "") + rec := httptest.NewRecorder() + h.HandleTestConnection(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAutoscanHandleRewriteSuggestions(t *testing.T) { + trig := &fakeAutoscanTriggerer{suggestions: autoscan.RewriteSuggestions{ + Proposed: []autoscan.ProposedRewrite{{From: "/data/tv", To: "/mnt/media/tv", MatchDepth: 1}}, + Unmatched: []string{}, + Ambiguous: []autoscan.AmbiguousRoot{}, + Covered: []string{}, + }} + h := NewAutoscanHandler(&fakeAutoscanStore{}, trig) + + req := newAutoscanRequest("GET", "/api/v1/admin/autoscan/sources/src-1/rewrite-suggestions", "", "src-1") + rec := httptest.NewRecorder() + h.HandleRewriteSuggestions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body autoscan.RewriteSuggestions + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Proposed) != 1 || body.Proposed[0].From != "/data/tv" { + t.Fatalf("unexpected suggestions: %+v", body) + } +} + +func TestAutoscanHandleRewriteSuggestionsNoConnectionReturns400(t *testing.T) { + trig := &fakeAutoscanTriggerer{suggestErr: autoscan.ErrNoConnection} + h := NewAutoscanHandler(&fakeAutoscanStore{}, trig) + + req := newAutoscanRequest("GET", "/api/v1/admin/autoscan/sources/src-1/rewrite-suggestions", "", "src-1") + rec := httptest.NewRecorder() + h.HandleRewriteSuggestions(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAutoscanHandleListConnectionsOmitsSecrets(t *testing.T) { + store := &fakeAutoscanStore{ + listConnectionsFn: func() ([]autoscan.Connection, error) { + return []autoscan.Connection{ + { + ID: "conn-1", + Name: "Radarr", + Kind: "radarr", + BaseURL: "http://radarr.internal:7878", + APIKeyRef: "secret-ref-xyz", + }, + }, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + rec := httptest.NewRecorder() + h.HandleListConnections(rec, httptest.NewRequest("GET", "/api/v1/admin/autoscan/connections", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + raw := rec.Body.String() + if strings.Contains(raw, "api_key_ref") || strings.Contains(raw, "secret-ref-xyz") { + t.Errorf("connections response leaks api_key_ref: %s", raw) + } + // has_api_key should be reported true (so operators know a key is set) without + // disclosing the ref itself. + var body struct { + Connections []autoscanConnectionResponse `json:"connections"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Connections) != 1 || !body.Connections[0].HasAPIKey { + t.Errorf("connections = %+v", body.Connections) + } +} + +func TestAutoscanHandleCreateConnectionRejectsBothEmpty(t *testing.T) { + created := false + store := &fakeAutoscanStore{ + createConnectionFn: func(c autoscan.Connection) (autoscan.Connection, error) { + created = true + return c, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := httptest.NewRequest("POST", "/api/v1/admin/autoscan/connections", + strings.NewReader(`{"name":"x"}`)) + rec := httptest.NewRecorder() + h.HandleCreateConnection(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if created { + t.Fatal("CreateConnection was called for a both-empty connection") + } +} + +func TestAutoscanHandleUpdateConnectionRejectsBothEmpty(t *testing.T) { + updated := false + store := &fakeAutoscanStore{ + updateConnectionFn: func(c autoscan.Connection) (autoscan.Connection, error) { + updated = true + return c, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + // Whitespace-only request_integration_id must count as absent. + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/connections/conn-1", + `{"name":"x","base_url":"","request_integration_id":" "}`, "conn-1") + rec := httptest.NewRecorder() + h.HandleUpdateConnection(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if updated { + t.Fatal("UpdateConnection was called for a both-empty connection") + } +} + +func TestAutoscanHandleUpdateSourceNotFoundReturns404(t *testing.T) { + store := &fakeAutoscanStore{ + updateSourceFn: func(autoscan.Source) (autoscan.Source, error) { + return autoscan.Source{}, autoscan.ErrNotFound + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/missing", + `{"enabled":true,"connection_id":"conn-1"}`, "missing") + rec := httptest.NewRecorder() + h.HandleUpdateSource(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAutoscanHandleUpdateSourceEnableWithoutConnectionSucceeds(t *testing.T) { + // A connection is OPTIONAL — a source may enable without one (e.g. a + // filesystem/CephFS provider that needs no credentials). The handler no + // longer blocks it; if the plugin actually needs a connection it surfaces + // the error at poll time instead. + var got autoscan.Source + store := &fakeAutoscanStore{ + getSourceFn: func(id string) (autoscan.Source, error) { + return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil + }, + updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + got = s + return s, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1", + `{"enabled":true}`, "src-1") + rec := httptest.NewRecorder() + h.HandleUpdateSource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !got.Enabled || got.ConnectionID != nil { + t.Fatalf("expected enabled connection-less source persisted, got %+v", got) + } +} + +func TestAutoscanHandleUpdateSourceEnableWithConnectionSucceeds(t *testing.T) { + var got autoscan.Source + store := &fakeAutoscanStore{ + getSourceFn: func(id string) (autoscan.Source, error) { + return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil + }, + updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + got = s + return s, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + // Full-state update: enable=true and supply the connection_id explicitly. + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1", + `{"enabled":true,"connection_id":"conn-1"}`, "src-1") + rec := httptest.NewRecorder() + h.HandleUpdateSource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got.ConnectionID == nil || *got.ConnectionID != "conn-1" { + t.Fatalf("expected connection bound, got %+v", got.ConnectionID) + } +} + +func TestAutoscanHandleUpdateSourceBindConnectionSucceeds(t *testing.T) { + var got autoscan.Source + store := &fakeAutoscanStore{ + getSourceFn: func(id string) (autoscan.Source, error) { + // Source starts with no connection. + return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil + }, + updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + got = s + return s, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + // Bind a connection while leaving the source disabled. + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1", + `{"enabled":false,"connection_id":"conn-42"}`, "src-1") + rec := httptest.NewRecorder() + h.HandleUpdateSource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got.ConnectionID == nil || *got.ConnectionID != "conn-42" { + t.Fatalf("expected connection bound to conn-42, got %+v", got.ConnectionID) + } +} + +func TestAutoscanHandleUpdateSourceUnbindConnectionSucceeds(t *testing.T) { + var got autoscan.Source + store := &fakeAutoscanStore{ + getSourceFn: func(id string) (autoscan.Source, error) { + // Source starts with a connection already bound. + return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil + }, + updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + got = s + return s, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + // Unbind: send connection_id: null explicitly with enabled: false. + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1", + `{"enabled":false,"connection_id":null}`, "src-1") + rec := httptest.NewRecorder() + h.HandleUpdateSource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got.ConnectionID != nil { + t.Fatalf("expected connection unbound (nil), got %+v", got.ConnectionID) + } +} + +func TestAutoscanHandleUpdateSourceUnbindWhileEnabledSucceeds(t *testing.T) { + // Unbinding the connection while enabled is allowed now that a connection is + // optional — the row persists enabled with connection_id: null. (A provider + // that needs credentials surfaces the error at poll time.) + var got autoscan.Source + store := &fakeAutoscanStore{ + getSourceFn: func(id string) (autoscan.Source, error) { + return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil + }, + updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + got = s + return s, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1", + `{"enabled":true,"connection_id":null}`, "src-1") + rec := httptest.NewRecorder() + h.HandleUpdateSource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !got.Enabled || got.ConnectionID != nil { + t.Fatalf("expected enabled source with nil connection, got %+v", got) + } +} + +func TestAutoscanHandleDeleteConnectionNotFoundReturns404(t *testing.T) { + store := &fakeAutoscanStore{ + deleteConnectionFn: func(string) error { + return autoscan.ErrNotFound + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := newAutoscanRequest("DELETE", "/api/v1/admin/autoscan/connections/missing", "", "missing") + rec := httptest.NewRecorder() + h.HandleDeleteConnection(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAutoscanHandleUpdateConnectionBlankKeyPassedThroughForKeep(t *testing.T) { + // Fix 1: a metadata-only edit omits api_key_ref ("leave blank to keep + // existing"). The handler must pass a blank api_key_ref through to the repo, + // whose UpdateConnection SQL keeps the stored key (CASE WHEN $5 = '' THEN + // api_key_ref ...). Here we assert the handler does NOT fabricate/clear a key: + // it forwards the empty string so the repo's keep-semantics fire. + var got autoscan.Connection + store := &fakeAutoscanStore{ + updateConnectionFn: func(c autoscan.Connection) (autoscan.Connection, error) { + got = c + // Emulate the repo keep-semantics: a blank incoming ref leaves the + // previously-stored key in place. + if strings.TrimSpace(c.APIKeyRef) == "" { + c.APIKeyRef = "existing-stored-ref" + } + return c, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + // Update with base_url present but api_key_ref absent from the JSON. + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/connections/conn-1", + `{"name":"Radarr","kind":"radarr","base_url":"http://radarr:7878"}`, "conn-1") + rec := httptest.NewRecorder() + h.HandleUpdateConnection(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + // Handler must forward a blank api_key_ref (so the repo keeps the existing key). + if strings.TrimSpace(got.APIKeyRef) != "" { + t.Fatalf("expected handler to forward blank api_key_ref, got %q", got.APIKeyRef) + } + // And the response must still report HasAPIKey=true (the stored key survived). + var body autoscanConnectionResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if !body.HasAPIKey { + t.Fatalf("expected preserved key reflected as has_api_key=true, got %+v", body) + } +} + +func TestAutoscanHandleDeleteSourceSucceeds(t *testing.T) { + deleted := "" + store := &fakeAutoscanStore{ + deleteSourceFn: func(id string) error { + deleted = id + return nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := newAutoscanRequest("DELETE", "/api/v1/admin/autoscan/sources/src-1", "", "src-1") + rec := httptest.NewRecorder() + h.HandleDeleteSource(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String()) + } + if deleted != "src-1" { + t.Fatalf("expected DeleteSource called with src-1, got %q", deleted) + } +} + +func TestAutoscanHandleDeleteSourceNotFoundReturns404(t *testing.T) { + store := &fakeAutoscanStore{ + deleteSourceFn: func(string) error { + return autoscan.ErrNotFound + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := newAutoscanRequest("DELETE", "/api/v1/admin/autoscan/sources/missing", "", "missing") + rec := httptest.NewRecorder() + h.HandleDeleteSource(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAutoscanHandleUpdateSettingsReschedulesPollTask(t *testing.T) { + // Fix 5: a successful settings update must reschedule the poll task with the + // new default_poll_interval_seconds. + store := &fakeAutoscanStore{ + updateSettingsFn: func(s autoscan.Settings) (autoscan.Settings, error) { + return s, nil + }, + } + trig := &fakeTriggerUpdater{} + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + h.SetTriggerUpdater(trig) + + req := httptest.NewRequest("PUT", "/api/v1/admin/autoscan/settings", + strings.NewReader(`{"enabled":true,"default_poll_interval_seconds":300,"debounce_seconds":30}`)) + rec := httptest.NewRecorder() + h.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !trig.called { + t.Fatal("expected UpdateTriggers to be called on settings update") + } + if trig.key != "autoscan_poll" { + t.Fatalf("rescheduled wrong task key: %q", trig.key) + } + if len(trig.triggers) != 1 || trig.triggers[0].Type != taskmanager.TriggerTypeInterval { + t.Fatalf("unexpected triggers: %+v", trig.triggers) + } + // 300 seconds -> 300_000 ms. + if trig.triggers[0].IntervalMs != 300*1000 { + t.Fatalf("interval = %d ms, want 300000", trig.triggers[0].IntervalMs) + } +} + +func TestAutoscanHandleUpdateSettingsWithoutTriggerUpdaterSucceeds(t *testing.T) { + // Fix 5: the reschedule dep is optional; a nil updater must not break the + // settings update. + store := &fakeAutoscanStore{} + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := httptest.NewRequest("PUT", "/api/v1/admin/autoscan/settings", + strings.NewReader(`{"enabled":true,"default_poll_interval_seconds":300,"debounce_seconds":30}`)) + rec := httptest.NewRecorder() + h.HandleUpdateSettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAutoscanHandleTriggerInvokesPollOnce(t *testing.T) { + trig := &fakeAutoscanTriggerer{done: make(chan struct{}, 1)} + h := NewAutoscanHandler(&fakeAutoscanStore{}, trig) + + rec := httptest.NewRecorder() + h.HandleTrigger(rec, httptest.NewRequest("POST", "/api/v1/admin/autoscan/trigger", nil)) + + // The handler responds immediately and runs PollOnce on a detached + // goroutine; wait (bounded) for that goroutine to fire. + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want 202", rec.Code) + } + select { + case <-trig.done: + case <-time.After(2 * time.Second): + t.Fatal("PollOnce was not invoked within timeout") + } + if !trig.called { + t.Fatal("PollOnce was not invoked") + } +} + +func TestAutoscanHandleUpdateSourceRoundTripsPathRewrites(t *testing.T) { + var got autoscan.Source + store := &fakeAutoscanStore{ + getSourceFn: func(id string) (autoscan.Source, error) { + return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil + }, + updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + got = s + return s, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1", + `{"enabled":true,"connection_id":"conn-1","path_rewrites":[{"from":"/data/tv","to":"/mnt/media/tv"}]}`, "src-1") + rec := httptest.NewRecorder() + h.HandleUpdateSource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + // The repo received the rewrite. + if len(got.PathRewrites) != 1 || got.PathRewrites[0].From != "/data/tv" || got.PathRewrites[0].To != "/mnt/media/tv" { + t.Fatalf("expected path_rewrites passed to repo, got %+v", got.PathRewrites) + } + // The response echoes it back. + var body autoscanSourceResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.PathRewrites) != 1 || body.PathRewrites[0].From != "/data/tv" || body.PathRewrites[0].To != "/mnt/media/tv" { + t.Fatalf("response missing path_rewrites: %+v", body.PathRewrites) + } +} + +func TestAutoscanHandleUpdateSourceRoundTripsSourceConfig(t *testing.T) { + var got autoscan.Source + store := &fakeAutoscanStore{ + updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + got = s + return s, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1", + `{"enabled":true,"source_config":{" movie_flat_paths ":" /mnt/movies ","exclusions":".downloads\n.recyclebin"}}`, "src-1") + rec := httptest.NewRecorder() + h.HandleUpdateSource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got.SourceConfig["movie_flat_paths"] != "/mnt/movies" || got.SourceConfig["exclusions"] != ".downloads\n.recyclebin" { + t.Fatalf("source_config passed to repo = %#v", got.SourceConfig) + } + var body autoscanSourceResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.SourceConfig["movie_flat_paths"] != "/mnt/movies" { + t.Fatalf("response source_config = %#v", body.SourceConfig) + } +} + +func TestAutoscanHandleUpdateSourceRejectsBlankRewrite(t *testing.T) { + upserted := false + store := &fakeAutoscanStore{ + getSourceFn: func(id string) (autoscan.Source, error) { + return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil + }, + updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + upserted = true + return s, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + // A rewrite with a blank "to" must be rejected with 400. + req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1", + `{"enabled":true,"connection_id":"conn-1","path_rewrites":[{"from":"/data/tv","to":""}]}`, "src-1") + rec := httptest.NewRecorder() + h.HandleUpdateSource(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + if upserted { + t.Fatal("UpsertSource was called despite an invalid path_rewrite") + } +} + +func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) { + requested := time.Date(2026, 6, 4, 15, 0, 0, 0, time.UTC) + started := requested.Add(2 * time.Second) + completed := started.Add(5 * time.Second) + eventCompleted := completed.Add(-1 * time.Second) + eventID := int64(42) + sourceID := "src-1" + installationID := 7 + var gotFilter autoscan.ScanListFilter + var gotCountFilter autoscan.ScanListFilter + store := &fakeAutoscanStore{ + listScansFn: func(filter autoscan.ScanListFilter) ([]autoscan.ScanWithEvent, error) { + gotFilter = filter + return []autoscan.ScanWithEvent{{ + ScanRunSummary: autoscan.ScanRunSummary{ + ID: "run-1", + MediaFolderID: 9, + Mode: "subtree", + Path: "/mnt/media/Show/S01", + Trigger: "autoscan", + Status: "completed", + RequestedAt: &requested, + StartedAt: &started, + CompletedAt: &completed, + }, + AutoscanEventID: &eventID, + SourceID: &sourceID, + InstallationID: &installationID, + CapabilityID: "cephfs", + EventStatus: autoscan.EventStatusSuccess, + EventCompletedAt: &eventCompleted, + }}, nil + }, + countScansFn: func(filter autoscan.ScanListFilter) (int, error) { + gotCountFilter = filter + return 137, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := httptest.NewRequest("GET", "/api/v1/admin/autoscan/scans?status=completed&limit=25&offset=50&q=Show", nil) + rec := httptest.NewRecorder() + h.HandleListScans(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if gotFilter.Status != "completed" || gotFilter.Limit != 25 || gotFilter.Offset != 50 || gotFilter.Search != "Show" { + t.Fatalf("filter = %+v", gotFilter) + } + // Count must filter identically to the list, minus the page window. + if gotCountFilter.Status != "completed" || gotCountFilter.Search != "Show" { + t.Fatalf("count filter = %+v", gotCountFilter) + } + var body autoscanScansResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Total != 137 || body.Limit != 25 || body.Offset != 50 { + t.Fatalf("pagination = total %d limit %d offset %d", body.Total, body.Limit, body.Offset) + } + if len(body.Scans) != 1 { + t.Fatalf("scans = %+v", body.Scans) + } + scan := body.Scans[0] + if scan.ID != "run-1" || scan.Path != "/mnt/media/Show/S01" || scan.Status != "completed" { + t.Fatalf("scan = %+v", scan) + } + if scan.AutoscanEventID == nil || *scan.AutoscanEventID != eventID || scan.EventStatus != "success" { + t.Fatalf("event context = %+v", scan) + } + if scan.SourceID == nil || *scan.SourceID != sourceID || scan.InstallationID == nil || *scan.InstallationID != installationID || scan.CapabilityID != "cephfs" { + t.Fatalf("source context = %+v", scan) + } +} + +func TestAutoscanHandleListScansRejectsBadFilters(t *testing.T) { + listed := false + store := &fakeAutoscanStore{ + listScansFn: func(autoscan.ScanListFilter) ([]autoscan.ScanWithEvent, error) { + listed = true + return nil, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + for _, target := range []string{ + "/api/v1/admin/autoscan/scans?status=unknown", + "/api/v1/admin/autoscan/scans?limit=0", + "/api/v1/admin/autoscan/scans?offset=-1", + } { + rec := httptest.NewRecorder() + h.HandleListScans(rec, httptest.NewRequest("GET", target, nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("%s status = %d, want 400; body=%s", target, rec.Code, rec.Body.String()) + } + } + if listed { + t.Fatal("ListAutoscanScans should not run for invalid filters") + } +} + +func TestAutoscanHandleListEventsSerializesFiltersAndRuns(t *testing.T) { + started := time.Date(2026, 6, 4, 14, 0, 0, 0, time.UTC) + completed := started.Add(1500 * time.Millisecond) + sourceID := "src-1" + var gotFilter autoscan.EventListFilter + var gotCountFilter autoscan.EventListFilter + store := &fakeAutoscanStore{ + listEventsFn: func(filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error) { + gotFilter = filter + return []autoscan.EventWithRuns{{ + Event: autoscan.Event{ + ID: 42, + SourceID: &sourceID, + InstallationID: 7, + CapabilityID: "cephfs", + StartedAt: started, + CompletedAt: completed, + DurationMS: 1500, + Status: autoscan.EventStatusSuccess, + ChangesReturned: 3, + ChangesResolved: 2, + TargetsClaimed: 2, + ScansCreated: 1, + ScansReused: 1, + ScansSuppressed: 1, + }, + Runs: []autoscan.ScanRunSummary{{ + ID: "run-1", + MediaFolderID: 9, + Mode: "subtree", + Path: "/mnt/media/Show/S01", + Trigger: "autoscan", + Status: "accepted", + StartedAt: nil, + CompletedAt: nil, + }}, + }}, nil + }, + countEventsFn: func(filter autoscan.EventListFilter) (int, error) { + gotCountFilter = filter + return 88, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + req := httptest.NewRequest("GET", "/api/v1/admin/autoscan/events?source_id=src-1&status=success&limit=25&offset=25&q=Show", nil) + rec := httptest.NewRecorder() + h.HandleListEvents(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if gotFilter.SourceID != "src-1" || + gotFilter.Status != autoscan.EventStatusSuccess || + gotFilter.Limit != 25 || + gotFilter.Offset != 25 || + gotFilter.Search != "Show" { + t.Fatalf("filter = %+v", gotFilter) + } + // Count must filter identically to the list, minus the page window. + if gotCountFilter.SourceID != "src-1" || gotCountFilter.Status != autoscan.EventStatusSuccess || gotCountFilter.Search != "Show" { + t.Fatalf("count filter = %+v", gotCountFilter) + } + var body autoscanEventsResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Total != 88 || body.Limit != 25 || body.Offset != 25 { + t.Fatalf("pagination = total %d limit %d offset %d", body.Total, body.Limit, body.Offset) + } + if len(body.Events) != 1 { + t.Fatalf("events = %+v", body.Events) + } + event := body.Events[0] + if event.ID != 42 || event.SourceID == nil || *event.SourceID != sourceID || event.Status != "success" { + t.Fatalf("event identity = %+v", event) + } + if event.ChangesReturned != 3 || event.ScansCreated != 1 || event.ScansReused != 1 || event.ScansSuppressed != 1 { + t.Fatalf("event counts = %+v", event) + } + if len(event.ScanRuns) != 1 || event.ScanRuns[0].ID != "run-1" || event.ScanRuns[0].Path != "/mnt/media/Show/S01" { + t.Fatalf("scan_runs = %+v", event.ScanRuns) + } +} + +func TestAutoscanHandleListEventsRejectsBadFilters(t *testing.T) { + listed := false + store := &fakeAutoscanStore{ + listEventsFn: func(autoscan.EventListFilter) ([]autoscan.EventWithRuns, error) { + listed = true + return nil, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + for _, target := range []string{ + "/api/v1/admin/autoscan/events?status=unknown", + "/api/v1/admin/autoscan/events?limit=0", + "/api/v1/admin/autoscan/events?offset=-1", + } { + rec := httptest.NewRecorder() + h.HandleListEvents(rec, httptest.NewRequest("GET", target, nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("%s status = %d, want 400; body=%s", target, rec.Code, rec.Body.String()) + } + } + if listed { + t.Fatal("ListEvents should not run for invalid filters") + } +} + +func TestAutoscanHandleUpdateSourceNormalizesLabel(t *testing.T) { + var got autoscan.Source + store := &fakeAutoscanStore{ + updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) { + got = s + return s, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + body := `{"connection_id":null,"enabled":false,"label":" 4K Movies "}` + req := newAutoscanRequest("PATCH", "/api/v1/admin/autoscan/sources/src-1", body, "src-1") + rec := httptest.NewRecorder() + h.HandleUpdateSource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got.Label != "4K Movies" { + t.Fatalf("stored label = %q, want %q", got.Label, "4K Movies") + } + var resp autoscanSourceResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Label != "4K Movies" { + t.Fatalf("response label = %q, want %q", resp.Label, "4K Movies") + } +} + +func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) { + latestEventAt := time.Date(2026, 6, 4, 14, 30, 0, 0, time.UTC) + store := &fakeAutoscanStore{ + getSettingsFn: func() (autoscan.Settings, error) { + return autoscan.Settings{Enabled: true}, nil + }, + listSourcesFn: func() ([]autoscan.Source, error) { + return []autoscan.Source{ + {ID: "src-1", InstallationID: 7, CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true}, + }, nil + }, + queueSummaryFn: func() (autoscan.QueueSummary, error) { + return autoscan.QueueSummary{Active: 3, Accepted: 2, Running: 1}, nil + }, + latestEventAtFn: func() (*time.Time, error) { + return &latestEventAt, nil + }, + } + h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{}) + + rec := httptest.NewRecorder() + h.HandleStatus(rec, httptest.NewRequest("GET", "/api/v1/admin/autoscan/status", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + raw := rec.Body.String() + if strings.Contains(raw, "base_url") || strings.Contains(raw, "api_key_ref") { + t.Errorf("status leaks secrets: %s", raw) + } + var body autoscanStatusResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if !body.Enabled || len(body.Sources) != 1 || body.Sources[0].ID != "src-1" { + t.Errorf("status = %+v", body) + } + if body.ActiveScans != 3 || body.AcceptedScans != 2 || body.RunningScans != 1 { + t.Errorf("queue summary = %+v", body) + } + if body.LatestEventAt == nil || !body.LatestEventAt.Equal(latestEventAt) { + t.Errorf("latest_event_at = %v, want %v", body.LatestEventAt, latestEventAt) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 6bf72947..1a93ccf0 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -23,6 +23,7 @@ import ( "github.com/Silo-Server/silo-server/internal/api/handlers" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/autoscan" "github.com/Silo-Server/silo-server/internal/cache" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/catalogseed" @@ -340,6 +341,7 @@ func NewRouter(deps Dependencies) chi.Router { var calendarRepo *catalog.CalendarRepository var webhookSyncHandler *handlers.WebhookSyncHandler var requestHandler *handlers.RequestsHandler + var autoscanHandler *handlers.AutoscanHandler if deps.DB != nil { browseRepo := catalog.NewBrowseRepository(deps.DB) itemRepo = catalog.NewItemRepository(deps.DB) @@ -403,8 +405,9 @@ func NewRouter(deps Dependencies) chi.Router { if deps.Config != nil { tmdbAPIKey = deps.Config.TMDBAPIKey } + requestsRepo := mediarequests.NewRepository(deps.DB) requestSvc := mediarequests.NewService( - mediarequests.NewRepository(deps.DB), + requestsRepo, tmdb.NewClient(tmdbAPIKey, 40), mediarequests.NewCatalogPresence(itemRepo, providerIDRepo), ) @@ -415,6 +418,26 @@ func NewRouter(deps Dependencies) chi.Router { } requestHandler = handlers.NewRequestsHandler(requestSvc) + autoscanRepo := autoscan.NewRepository(deps.DB) + if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && deps.PluginService != nil { + autoscanSvc := BuildAutoscanService( + autoscanRepo, + deps.PluginService, + plugins.NewInstallationStore(deps.DB), + requestsRepo, + settingsRepo, + deps.FolderRepo, + deps.LibraryScanQueue, + deps.RedisClient, + ) + autoscanHandler = handlers.NewAutoscanHandler(autoscanRepo, autoscanSvc) + // Wire the optional poll-task rescheduler so a settings change + // re-applies the poll interval without a restart. + if deps.TaskManager != nil { + autoscanHandler.SetTriggerUpdater(deps.TaskManager) + } + } + if deps.PersonRepo != nil { peopleHandler = handlers.NewPeopleHandler(deps.PersonRepo, browseRepo, itemRepo, detailSvc) peopleHandler.SetItemsHandler(itemsHandler) @@ -2049,6 +2072,26 @@ func NewRouter(deps Dependencies) chi.Router { r.Post("/request-integrations/{id}/options", requestHandler.HandleLoadIntegrationOptions) } + if autoscanHandler != nil { + r.Get("/autoscan/settings", autoscanHandler.HandleGetSettings) + r.Put("/autoscan/settings", autoscanHandler.HandleUpdateSettings) + r.Get("/autoscan/connections", autoscanHandler.HandleListConnections) + r.Post("/autoscan/connections", autoscanHandler.HandleCreateConnection) + r.Put("/autoscan/connections/{id}", autoscanHandler.HandleUpdateConnection) + r.Delete("/autoscan/connections/{id}", autoscanHandler.HandleDeleteConnection) + r.Post("/autoscan/connections/test", autoscanHandler.HandleTestConnection) + r.Get("/autoscan/scan-source-plugins", autoscanHandler.HandleListAvailableScanSources) + r.Get("/autoscan/sources", autoscanHandler.HandleListSources) + r.Post("/autoscan/sources", autoscanHandler.HandleCreateSource) + r.Put("/autoscan/sources/{id}", autoscanHandler.HandleUpdateSource) + r.Delete("/autoscan/sources/{id}", autoscanHandler.HandleDeleteSource) + r.Get("/autoscan/sources/{id}/rewrite-suggestions", autoscanHandler.HandleRewriteSuggestions) + r.Get("/autoscan/scans", autoscanHandler.HandleListScans) + r.Get("/autoscan/events", autoscanHandler.HandleListEvents) + r.Post("/autoscan/trigger", autoscanHandler.HandleTrigger) + r.Get("/autoscan/status", autoscanHandler.HandleStatus) + } + if deps.ActivityLogRepo != nil { adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo) r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs) diff --git a/internal/autoscan/connection.go b/internal/autoscan/connection.go new file mode 100644 index 00000000..deac19f8 --- /dev/null +++ b/internal/autoscan/connection.go @@ -0,0 +1,66 @@ +package autoscan + +import ( + "context" + "fmt" + "strings" +) + +// ResolvedConnection is concrete credentials handed to the plugin. +type ResolvedConnection struct { + BaseURL string + APIKey string +} + +// RequestIntegrationLookup resolves a soft-linked Requests integration to its +// base URL and encrypted api-key reference. +type RequestIntegrationLookup interface { + Get(ctx context.Context, integrationID string) (baseURL, apiKeyRef string, err error) +} + +// SecretResolver resolves an encrypted api-key reference to its plaintext value. +type SecretResolver interface { + Get(ctx context.Context, ref string) (string, error) +} + +type ConnectionResolver struct { + requests RequestIntegrationLookup + secrets SecretResolver +} + +func NewConnectionResolver(r RequestIntegrationLookup, s SecretResolver) *ConnectionResolver { + return &ConnectionResolver{requests: r, secrets: s} +} + +// Resolve turns a stored Connection into concrete credentials. When the +// connection is linked to a Requests integration the live base URL + key ref are +// read from there; otherwise the connection's own fields are used. The api-key +// ref is then resolved to plaintext via the secrets resolver. +func (cr *ConnectionResolver) Resolve(ctx context.Context, c Connection) (ResolvedConnection, error) { + baseURL, apiKeyRef := c.BaseURL, c.APIKeyRef + // A pointer-to-empty-string request_integration_id is NOT a live link (it can + // arise from a both-NULL orphan or a stripped link). Treat empty/whitespace as + // "no link" so we don't call requests.Get("") and use the connection's own + // fields instead. + if c.RequestIntegrationID != nil && strings.TrimSpace(*c.RequestIntegrationID) != "" { + if cr.requests == nil { + return ResolvedConnection{}, fmt.Errorf("autoscan: linked requests integration %q: no lookup configured", *c.RequestIntegrationID) + } + u, ref, err := cr.requests.Get(ctx, *c.RequestIntegrationID) + if err != nil { + return ResolvedConnection{}, fmt.Errorf("autoscan: linked requests integration %q: %w", *c.RequestIntegrationID, err) + } + baseURL, apiKeyRef = u, ref + } + apiKey := strings.TrimSpace(apiKeyRef) + if cr.secrets != nil && apiKey != "" { + resolved, err := cr.secrets.Get(ctx, apiKey) + if err != nil { + return ResolvedConnection{}, fmt.Errorf("autoscan: resolve api key: %w", err) + } + if resolved = strings.TrimSpace(resolved); resolved != "" { + apiKey = resolved + } + } + return ResolvedConnection{BaseURL: baseURL, APIKey: apiKey}, nil +} diff --git a/internal/autoscan/connection_test.go b/internal/autoscan/connection_test.go new file mode 100644 index 00000000..4f0327f3 --- /dev/null +++ b/internal/autoscan/connection_test.go @@ -0,0 +1,129 @@ +package autoscan + +import ( + "context" + "errors" + "testing" +) + +// fakeRequestLookup implements RequestIntegrationLookup from a static map. +type fakeRequestLookup struct { + entries map[string]struct{ baseURL, apiKeyRef string } + err error +} + +func (f fakeRequestLookup) Get(_ context.Context, id string) (string, string, error) { + if f.err != nil { + return "", "", f.err + } + e, ok := f.entries[id] + if !ok { + return "", "", errors.New("integration not found: " + id) + } + return e.baseURL, e.apiKeyRef, nil +} + +// fakeSecrets implements SecretResolver from a static map; unknown refs resolve +// to empty (so the ref itself is used). +type fakeSecrets struct { + values map[string]string + err error +} + +func (f fakeSecrets) Get(_ context.Context, ref string) (string, error) { + if f.err != nil { + return "", f.err + } + return f.values[ref], nil +} + +func TestResolveConnectionOwnCredentials(t *testing.T) { + secrets := fakeSecrets{values: map[string]string{"ownref": "OWNKEY"}} + r := NewConnectionResolver(fakeRequestLookup{}, secrets) + + got, err := r.Resolve(context.Background(), Connection{ + BaseURL: "http://own:8989", + APIKeyRef: "ownref", + }) + if err != nil { + t.Fatalf("Resolve own: %v", err) + } + if got.BaseURL != "http://own:8989" || got.APIKey != "OWNKEY" { + t.Fatalf("own resolve = %+v", got) + } +} + +func TestResolveConnectionLinked(t *testing.T) { + lookup := fakeRequestLookup{entries: map[string]struct{ baseURL, apiKeyRef string }{ + "req-1": {baseURL: "http://req:7878", apiKeyRef: "rk"}, + }} + secrets := fakeSecrets{values: map[string]string{"rk": "REQKEY"}} + r := NewConnectionResolver(lookup, secrets) + + id := "req-1" + got, err := r.Resolve(context.Background(), Connection{RequestIntegrationID: &id}) + if err != nil { + t.Fatalf("Resolve linked: %v", err) + } + if got.BaseURL != "http://req:7878" || got.APIKey != "REQKEY" { + t.Fatalf("linked resolve = %+v", got) + } +} + +func TestResolveConnectionLinkedMissingErrors(t *testing.T) { + r := NewConnectionResolver(fakeRequestLookup{}, fakeSecrets{}) + id := "gone" + if _, err := r.Resolve(context.Background(), Connection{RequestIntegrationID: &id}); err == nil { + t.Fatal("expected error when linked Requests integration is missing") + } +} + +func TestResolveConnectionLinkedLookupErrors(t *testing.T) { + r := NewConnectionResolver(fakeRequestLookup{err: errors.New("boom")}, fakeSecrets{}) + id := "req-1" + if _, err := r.Resolve(context.Background(), Connection{RequestIntegrationID: &id}); err == nil { + t.Fatal("expected error when the lookup itself fails") + } +} + +func TestResolveConnectionEmptyIntegrationIDIsNotALink(t *testing.T) { + // A pointer-to-empty/whitespace request_integration_id must NOT be treated as + // a live link: it must fall back to the connection's own fields, never call + // requests.Get(""). The lookup here errors on any call, so reaching it fails. + r := NewConnectionResolver( + fakeRequestLookup{err: errors.New("lookup must not be called")}, + fakeSecrets{values: map[string]string{"ownref": "OWNKEY"}}, + ) + for _, empty := range []string{"", " "} { + id := empty + got, err := r.Resolve(context.Background(), Connection{ + BaseURL: "http://own:8989", + APIKeyRef: "ownref", + RequestIntegrationID: &id, + }) + if err != nil { + t.Fatalf("Resolve with empty integration id %q: %v", empty, err) + } + if got.BaseURL != "http://own:8989" || got.APIKey != "OWNKEY" { + t.Fatalf("empty integration id %q should use own creds, got %+v", empty, got) + } + } +} + +func TestResolveConnectionTrimsAndFallsBackOnEmptySecret(t *testing.T) { + // Whitespace-padded ref; secret resolves to whitespace-only -> fall back to + // the trimmed ref (parity with requests.resolveAPIKey). + secrets := fakeSecrets{values: map[string]string{"ownref": " "}} + r := NewConnectionResolver(fakeRequestLookup{}, secrets) + + got, err := r.Resolve(context.Background(), Connection{ + BaseURL: "http://own:8989", + APIKeyRef: " ownref ", + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got.APIKey != "ownref" { + t.Fatalf("expected trimmed ref fallback, got %q", got.APIKey) + } +} diff --git a/internal/autoscan/dedupe.go b/internal/autoscan/dedupe.go new file mode 100644 index 00000000..b50c55a8 --- /dev/null +++ b/internal/autoscan/dedupe.go @@ -0,0 +1,22 @@ +package autoscan + +import "path/filepath" + +// uniqueParentDirs maps imported file paths to their distinct parent directories, +// dropping empties. A season's episodes in one folder collapse to one entry. +func uniqueParentDirs(paths []string) []string { + seen := make(map[string]struct{}) + var out []string + for _, p := range paths { + if p == "" { + continue + } + dir := filepath.Dir(p) + if _, ok := seen[dir]; ok { + continue + } + seen[dir] = struct{}{} + out = append(out, dir) + } + return out +} diff --git a/internal/autoscan/dedupe_test.go b/internal/autoscan/dedupe_test.go new file mode 100644 index 00000000..23ebbbe7 --- /dev/null +++ b/internal/autoscan/dedupe_test.go @@ -0,0 +1,22 @@ +package autoscan + +import ( + "reflect" + "sort" + "testing" +) + +func TestUniqueParentDirs(t *testing.T) { + in := []string{ + "/mnt/media/Show/Season 01/E01.mkv", + "/mnt/media/Show/Season 01/E02.mkv", + "/mnt/media/Movie/Movie.mkv", + "", + } + got := uniqueParentDirs(in) + sort.Strings(got) + want := []string{"/mnt/media/Movie", "/mnt/media/Show/Season 01"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("uniqueParentDirs = %v, want %v", got, want) + } +} diff --git a/internal/autoscan/discovery.go b/internal/autoscan/discovery.go new file mode 100644 index 00000000..71bf3e20 --- /dev/null +++ b/internal/autoscan/discovery.go @@ -0,0 +1,88 @@ +package autoscan + +import ( + "context" + "fmt" +) + +// DiscoveredSource identifies one installed scan_source.v1 capability instance, +// enriched with the metadata the Add-source picker needs (plugin id + a +// human-friendly display name). +type DiscoveredSource struct { + InstallationID int + CapabilityID string + // PluginID is the installation's plugin id (e.g. "sonarr"); empty when the + // lister cannot supply it. + PluginID string + // DisplayName is a human-friendly label for the capability (from the + // capability's manifest display_name, falling back to plugin/capability ids). + DisplayName string +} + +// ScanSourceLister enumerates every installed scan_source.v1 capability so the +// engine can (a) offer them in the Add-source picker and (b) detect orphaned +// source rows whose plugin has been uninstalled. +type ScanSourceLister interface { + // ListScanSources returns one entry per installed scan_source.v1 capability, + // enriched with plugin id + display name. + ListScanSources(ctx context.Context) ([]DiscoveredSource, error) +} + +// AvailableScanSource is one installed scan_source capability an operator can +// create a source against (the Add-source picker list). +type AvailableScanSource struct { + InstallationID int `json:"installation_id"` + CapabilityID string `json:"capability_id"` + PluginID string `json:"plugin_id"` + DisplayName string `json:"display_name"` +} + +// installedKey identifies an installed scan_source capability for set +// membership tests (orphan detection in PollOnce). +type installedKey struct { + InstallationID int + CapabilityID string +} + +// ListAvailableScanSources enumerates every installed scan_source capability so +// an operator can pick one when creating a source. When no lister is configured +// it returns an empty list. The handler also uses this to validate that a +// create request targets a currently-installed capability. +func (s *Service) ListAvailableScanSources(ctx context.Context) ([]AvailableScanSource, error) { + if s.lister == nil { + return []AvailableScanSource{}, nil + } + discovered, err := s.lister.ListScanSources(ctx) + if err != nil { + return nil, fmt.Errorf("list scan sources: %w", err) + } + out := make([]AvailableScanSource, 0, len(discovered)) + for _, d := range discovered { + out = append(out, AvailableScanSource{ + InstallationID: d.InstallationID, + CapabilityID: d.CapabilityID, + PluginID: d.PluginID, + DisplayName: d.DisplayName, + }) + } + return out, nil +} + +// installedScanSources returns the set of currently-installed scan_source +// capabilities so PollOnce can skip orphaned source rows (their plugin is +// gone). A nil set means the set is unavailable (no lister) — callers must treat +// that as "discovery unavailable" and NOT prune. +func (s *Service) installedScanSources(ctx context.Context) (map[installedKey]struct{}, error) { + if s.lister == nil { + return nil, nil + } + discovered, err := s.lister.ListScanSources(ctx) + if err != nil { + return nil, fmt.Errorf("list scan sources: %w", err) + } + present := make(map[installedKey]struct{}, len(discovered)) + for _, d := range discovered { + present[installedKey{InstallationID: d.InstallationID, CapabilityID: d.CapabilityID}] = struct{}{} + } + return present, nil +} diff --git a/internal/autoscan/discovery_test.go b/internal/autoscan/discovery_test.go new file mode 100644 index 00000000..1314732c --- /dev/null +++ b/internal/autoscan/discovery_test.go @@ -0,0 +1,72 @@ +package autoscan + +import ( + "context" + "testing" +) + +// fakeLister returns a fixed set of discovered scan sources. +type fakeLister struct{ sources []DiscoveredSource } + +func (f fakeLister) ListScanSources(context.Context) ([]DiscoveredSource, error) { + return f.sources, nil +} + +func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) { + lister := fakeLister{sources: []DiscoveredSource{ + {InstallationID: 1, CapabilityID: "arr-a", PluginID: "sonarr", DisplayName: "Sonarr"}, + {InstallationID: 2, CapabilityID: "arr-b", PluginID: "radarr", DisplayName: "Radarr"}, + }} + svc := &Service{lister: lister} + + available, err := svc.ListAvailableScanSources(context.Background()) + if err != nil { + t.Fatalf("ListAvailableScanSources: %v", err) + } + if len(available) != 2 { + t.Fatalf("expected 2 available, got %d: %+v", len(available), available) + } + if available[0] != (AvailableScanSource{InstallationID: 1, CapabilityID: "arr-a", PluginID: "sonarr", DisplayName: "Sonarr"}) { + t.Fatalf("unexpected first available: %+v", available[0]) + } +} + +func TestListAvailableScanSourcesNilListerEmpty(t *testing.T) { + svc := &Service{lister: nil} + available, err := svc.ListAvailableScanSources(context.Background()) + if err != nil { + t.Fatalf("ListAvailableScanSources: %v", err) + } + if len(available) != 0 { + t.Fatalf("nil lister must return empty, got %+v", available) + } +} + +func TestInstalledScanSourcesSetMembership(t *testing.T) { + lister := fakeLister{sources: []DiscoveredSource{ + {InstallationID: 1, CapabilityID: "arr-a"}, + }} + svc := &Service{lister: lister} + + present, err := svc.installedScanSources(context.Background()) + if err != nil { + t.Fatalf("installedScanSources: %v", err) + } + if _, ok := present[installedKey{1, "arr-a"}]; !ok { + t.Fatalf("expected 1/arr-a present: %+v", present) + } + if _, ok := present[installedKey{2, "arr-b"}]; ok { + t.Fatalf("did not expect 2/arr-b present: %+v", present) + } +} + +func TestInstalledScanSourcesNilListerNilSet(t *testing.T) { + svc := &Service{lister: nil} + present, err := svc.installedScanSources(context.Background()) + if err != nil { + t.Fatalf("installedScanSources: %v", err) + } + if present != nil { + t.Fatalf("nil lister must return a nil set, got %+v", present) + } +} diff --git a/internal/autoscan/errors.go b/internal/autoscan/errors.go new file mode 100644 index 00000000..3d720516 --- /dev/null +++ b/internal/autoscan/errors.go @@ -0,0 +1,7 @@ +package autoscan + +import "errors" + +// ErrNotFound is returned when an autoscan operation references a connection or +// source that does not exist. The admin API surfaces it as a 404. +var ErrNotFound = errors.New("autoscan: not found") diff --git a/internal/autoscan/labels.go b/internal/autoscan/labels.go new file mode 100644 index 00000000..15e18a68 --- /dev/null +++ b/internal/autoscan/labels.go @@ -0,0 +1,20 @@ +package autoscan + +import ( + "strings" + "unicode/utf8" +) + +// MaxSourceLabelLen bounds an operator-set source label in runes. +const MaxSourceLabelLen = 120 + +// NormalizeSourceLabel trims surrounding whitespace and caps the label at +// MaxSourceLabelLen runes without splitting a multi-byte rune. An all-whitespace +// label normalizes to "" (unset). +func NormalizeSourceLabel(s string) string { + s = strings.TrimSpace(s) + if utf8.RuneCountInString(s) <= MaxSourceLabelLen { + return s + } + return string([]rune(s)[:MaxSourceLabelLen]) +} diff --git a/internal/autoscan/labels_test.go b/internal/autoscan/labels_test.go new file mode 100644 index 00000000..4ee0539f --- /dev/null +++ b/internal/autoscan/labels_test.go @@ -0,0 +1,39 @@ +package autoscan + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestNormalizeSourceLabel(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"trims surrounding whitespace", " 4K Movies ", "4K Movies"}, + {"empty stays empty", "", ""}, + {"all whitespace becomes empty", " ", ""}, + {"under cap unchanged", strings.Repeat("x", 120), strings.Repeat("x", 120)}, + {"over cap truncated to 120 runes", strings.Repeat("x", 200), strings.Repeat("x", 120)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := NormalizeSourceLabel(c.in); got != c.want { + t.Fatalf("NormalizeSourceLabel(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestNormalizeSourceLabelRuneSafe(t *testing.T) { + // 200 multi-byte runes must cap to 120 runes and stay valid UTF-8. + got := NormalizeSourceLabel(strings.Repeat("é", 200)) + if utf8.RuneCountInString(got) != 120 { + t.Fatalf("rune count = %d, want 120", utf8.RuneCountInString(got)) + } + if !utf8.ValidString(got) { + t.Fatal("result is not valid UTF-8") + } +} diff --git a/internal/autoscan/probe.go b/internal/autoscan/probe.go new file mode 100644 index 00000000..f17fe983 --- /dev/null +++ b/internal/autoscan/probe.go @@ -0,0 +1,54 @@ +package autoscan + +import ( + "context" + "fmt" +) + +// ArrStatusProbe probes an arr instance's /api/v3/system/status to confirm the +// base URL + api key are reachable and valid. It returns the reported version +// on success. +type ArrStatusProbe interface { + SystemStatus(ctx context.Context, baseURL, apiKey string) (version string, err error) +} + +// ConnectionTestResult is the outcome of probing a connection's arr endpoint. +type ConnectionTestResult struct { + OK bool + Version string + Err string +} + +// TestConnection resolves the given connection to concrete credentials and +// probes its arr /api/v3/system/status. A resolve failure or an unreachable / +// unauthorized / non-200 endpoint yields OK=false with a human-readable Err +// (never an HTTP error from this method itself — probe failures are part of the +// successful response payload). A nil probe dependency is a wiring error and is +// returned as a real error. +func (s *Service) TestConnection(ctx context.Context, c Connection) (ConnectionTestResult, error) { + if s.rootFolders == nil { + return ConnectionTestResult{}, fmt.Errorf("autoscan: connection probe not configured") + } + probe, ok := s.rootFolders.(ArrStatusProbe) + if !ok { + return ConnectionTestResult{}, fmt.Errorf("autoscan: connection probe not configured") + } + resolved, err := s.connres.Resolve(ctx, c) + if err != nil { + return ConnectionTestResult{OK: false, Err: err.Error()}, nil + } + version, err := probe.SystemStatus(ctx, resolved.BaseURL, resolved.APIKey) + if err != nil { + return ConnectionTestResult{OK: false, Err: err.Error()}, nil + } + return ConnectionTestResult{OK: true, Version: version}, nil +} + +// TestConnectionByID loads a stored connection by id and tests it. +func (s *Service) TestConnectionByID(ctx context.Context, id string) (ConnectionTestResult, error) { + c, err := s.store.GetConnection(ctx, id) + if err != nil { + return ConnectionTestResult{}, err + } + return s.TestConnection(ctx, c) +} diff --git a/internal/autoscan/probe_test.go b/internal/autoscan/probe_test.go new file mode 100644 index 00000000..3410f984 --- /dev/null +++ b/internal/autoscan/probe_test.go @@ -0,0 +1,97 @@ +package autoscan + +import ( + "context" + "errors" + "testing" +) + +// fakeArrClient implements both RootFolderClient and ArrStatusProbe for the +// connection-test and suggester service tests. +type fakeArrClient struct { + roots []string + rootsErr error + version string + statusErr error + gotBaseURL string + gotAPIKey string +} + +func (f *fakeArrClient) RootFolders(_ context.Context, baseURL, apiKey string) ([]string, error) { + f.gotBaseURL, f.gotAPIKey = baseURL, apiKey + return f.roots, f.rootsErr +} + +func (f *fakeArrClient) SystemStatus(_ context.Context, baseURL, apiKey string) (string, error) { + f.gotBaseURL, f.gotAPIKey = baseURL, apiKey + return f.version, f.statusErr +} + +type fakeFolderLister struct{ paths []string } + +func (f fakeFolderLister) ListFolderPaths(context.Context) ([]string, error) { + return f.paths, nil +} + +func TestTestConnectionOK(t *testing.T) { + arr := &fakeArrClient{version: "4.1.2.3"} + svc := &Service{connres: passthroughConnRes{}, rootFolders: arr} + + res, err := svc.TestConnection(context.Background(), Connection{BaseURL: "http://radarr:7878", APIKeyRef: "k"}) + if err != nil { + t.Fatalf("TestConnection: %v", err) + } + if !res.OK || res.Version != "4.1.2.3" { + t.Fatalf("expected ok with version, got %+v", res) + } +} + +func TestTestConnectionProbeFailureIsPayload(t *testing.T) { + arr := &fakeArrClient{statusErr: errors.New("arr: HTTP 401")} + svc := &Service{connres: passthroughConnRes{}, rootFolders: arr} + + res, err := svc.TestConnection(context.Background(), Connection{BaseURL: "http://radarr:7878"}) + if err != nil { + t.Fatalf("probe failure must not be a method error: %v", err) + } + if res.OK || res.Err == "" { + t.Fatalf("expected ok=false with error, got %+v", res) + } +} + +func TestTestConnectionNoProbeConfigured(t *testing.T) { + svc := &Service{connres: passthroughConnRes{}} + if _, err := svc.TestConnection(context.Background(), Connection{BaseURL: "http://x"}); err == nil { + t.Fatal("expected error when no probe configured") + } +} + +func TestSuggestRewritesMatchesArrRootsToSiloFolders(t *testing.T) { + store := &fakeStore{ + sources: []Source{ + {ID: "src-1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1")}, + }, + } + arr := &fakeArrClient{roots: []string{"/data/tv"}} + folders := fakeFolderLister{paths: []string{"/mnt/media/tv"}} + svc := &Service{store: store, connres: passthroughConnRes{}, rootFolders: arr, folders: folders} + + sugg, err := svc.SuggestRewrites(context.Background(), "src-1") + if err != nil { + t.Fatalf("SuggestRewrites: %v", err) + } + if len(sugg.Proposed) != 1 || sugg.Proposed[0].From != "/data/tv" || sugg.Proposed[0].To != "/mnt/media/tv" { + t.Fatalf("unexpected proposals: %+v", sugg.Proposed) + } +} + +func TestSuggestRewritesNoConnection(t *testing.T) { + store := &fakeStore{ + sources: []Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}}, + } + svc := &Service{store: store, connres: passthroughConnRes{}, rootFolders: &fakeArrClient{}, folders: fakeFolderLister{}} + + if _, err := svc.SuggestRewrites(context.Background(), "src-1"); !errors.Is(err, ErrNoConnection) { + t.Fatalf("expected ErrNoConnection, got %v", err) + } +} diff --git a/internal/autoscan/provider.go b/internal/autoscan/provider.go new file mode 100644 index 00000000..7a47a05a --- /dev/null +++ b/internal/autoscan/provider.go @@ -0,0 +1,83 @@ +package autoscan + +import ( + "context" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" +) + +// ScanSourceProvider yields changed paths for one source. The engine calls +// PollChanges; production wraps the plugins.Service scan_source resolver. +type ScanSourceProvider interface { + PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) (changes []Change, nextMarker string, err error) +} + +// PollChangesClient is the slice of *pluginhost.ScanSourceClient used here. It +// is exported so wiring in the api package can declare an adapter whose +// ScanSourceClient returns it (Go interface method signatures must match +// exactly across packages, and the unexported form could not be named there). +type PollChangesClient interface { + PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) +} + +// ScanSourceResolver yields a per-(installation, capability) scan-source client. +// Exported for the same cross-package adapter reason as PollChangesClient. +type ScanSourceResolver interface { + ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (PollChangesClient, error) +} + +type pluginProvider struct{ resolver ScanSourceResolver } + +// NewPluginProvider builds the production scan-source provider over the plugins +// resolver. +func NewPluginProvider(resolver ScanSourceResolver) ScanSourceProvider { + return &pluginProvider{resolver: resolver} +} + +func (p *pluginProvider) PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) { + client, err := p.resolver.ScanSourceClient(ctx, installationID, capabilityID) + if err != nil { + return nil, "", err + } + resp, err := client.PollChanges(ctx, &pluginv1.PollChangesRequest{ + CapabilityId: capabilityID, + Marker: marker, + Connection: &pluginv1.ResolvedConnection{BaseUrl: conn.BaseURL, ApiKey: conn.APIKey}, + SourceConfig: sourceConfig, + }) + if err != nil { + return nil, "", err + } + if structured := resp.GetChanges(); len(structured) > 0 { + changes := make([]Change, 0, len(structured)) + for _, change := range structured { + if change == nil { + continue + } + changes = append(changes, Change{ + SourcePath: change.GetSourcePath(), + Scope: scanSourceScope(change.GetScope()), + }) + } + return changes, resp.GetNextMarker(), nil + } + + // Legacy plugins return only source_paths. Treat them as auto/file-like + // paths so the existing parent-directory collapse behavior is preserved. + changes := make([]Change, 0, len(resp.GetSourcePaths())) + for _, path := range resp.GetSourcePaths() { + changes = append(changes, Change{SourcePath: path, Scope: ChangeScopeAuto}) + } + return changes, resp.GetNextMarker(), nil +} + +func scanSourceScope(scope pluginv1.ScanSourceChangeScope) ChangeScope { + switch scope { + case pluginv1.ScanSourceChangeScope_SCAN_SOURCE_CHANGE_SCOPE_FILE: + return ChangeScopeFile + case pluginv1.ScanSourceChangeScope_SCAN_SOURCE_CHANGE_SCOPE_SUBTREE: + return ChangeScopeSubtree + default: + return ChangeScopeAuto + } +} diff --git a/internal/autoscan/provider_test.go b/internal/autoscan/provider_test.go new file mode 100644 index 00000000..649e9a7d --- /dev/null +++ b/internal/autoscan/provider_test.go @@ -0,0 +1,99 @@ +package autoscan + +import ( + "context" + "testing" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" +) + +// capturingClient records the last PollChangesRequest it was handed. +type capturingClient struct { + last *pluginv1.PollChangesRequest +} + +func (c *capturingClient) PollChanges(_ context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) { + c.last = req + return &pluginv1.PollChangesResponse{SourcePaths: []string{"/mnt/media/x"}, NextMarker: "m2"}, nil +} + +// capturingResolver yields a fixed PollChangesClient. +type capturingResolver struct{ client PollChangesClient } + +func (r capturingResolver) ScanSourceClient(context.Context, int, string) (PollChangesClient, error) { + return r.client, nil +} + +// TestPluginProviderPopulatesConnection asserts the resolved connection is +// delivered to the plugin on the PollChangesRequest. +func TestPluginProviderPopulatesConnection(t *testing.T) { + client := &capturingClient{} + prov := NewPluginProvider(capturingResolver{client}) + + conn := ResolvedConnection{BaseURL: "https://arr.example", APIKey: "secret-key"} + sourceConfig := map[string]string{"exclusions": ".downloads"} + changes, next, err := prov.PollChanges(context.Background(), 1, "cap", "m1", conn, sourceConfig) + if err != nil { + t.Fatalf("PollChanges: %v", err) + } + if len(changes) != 1 || changes[0].SourcePath != "/mnt/media/x" || changes[0].Scope != ChangeScopeAuto || next != "m2" { + t.Fatalf("unexpected provider result: changes=%v next=%q", changes, next) + } + if client.last == nil { + t.Fatal("expected a PollChangesRequest to be sent") + } + if client.last.GetCapabilityId() != "cap" || client.last.GetMarker() != "m1" { + t.Fatalf("unexpected request fields: cap=%q marker=%q", client.last.GetCapabilityId(), client.last.GetMarker()) + } + rc := client.last.GetConnection() + if rc == nil { + t.Fatal("expected connection to be populated on the request") + } + if rc.GetBaseUrl() != conn.BaseURL || rc.GetApiKey() != conn.APIKey { + t.Fatalf("connection not delivered: base_url=%q api_key=%q", rc.GetBaseUrl(), rc.GetApiKey()) + } + if client.last.GetSourceConfig()["exclusions"] != ".downloads" { + t.Fatalf("source_config not delivered: %#v", client.last.GetSourceConfig()) + } +} + +func TestPluginProviderPrefersStructuredChanges(t *testing.T) { + client := &capturingStructuredClient{} + prov := NewPluginProvider(capturingResolver{client: client}) + + changes, next, err := prov.PollChanges(context.Background(), 1, "cap", "m1", ResolvedConnection{}, nil) + if err != nil { + t.Fatalf("PollChanges: %v", err) + } + if next != "m3" || len(changes) != 2 { + t.Fatalf("unexpected provider result: changes=%v next=%q", changes, next) + } + if changes[0] != (Change{SourcePath: "/ceph/movie/Movie", Scope: ChangeScopeSubtree}) { + t.Fatalf("first change = %#v", changes[0]) + } + if changes[1] != (Change{SourcePath: "/ceph/show/S01/E01.mkv", Scope: ChangeScopeFile}) { + t.Fatalf("second change = %#v", changes[1]) + } +} + +type capturingStructuredClient struct { + last *pluginv1.PollChangesRequest +} + +func (c *capturingStructuredClient) PollChanges(_ context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) { + c.last = req + return &pluginv1.PollChangesResponse{ + SourcePaths: []string{"/legacy/ignored.mkv"}, + NextMarker: "m3", + Changes: []*pluginv1.ScanSourceChange{ + { + SourcePath: "/ceph/movie/Movie", + Scope: pluginv1.ScanSourceChangeScope_SCAN_SOURCE_CHANGE_SCOPE_SUBTREE, + }, + { + SourcePath: "/ceph/show/S01/E01.mkv", + Scope: pluginv1.ScanSourceChangeScope_SCAN_SOURCE_CHANGE_SCOPE_FILE, + }, + }, + }, nil +} diff --git a/internal/autoscan/repository.go b/internal/autoscan/repository.go new file mode 100644 index 00000000..ecd4359d --- /dev/null +++ b/internal/autoscan/repository.go @@ -0,0 +1,861 @@ +package autoscan + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository struct{ pool *pgxpool.Pool } + +func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +// --- Settings --- + +func (r *Repository) GetSettings(ctx context.Context) (Settings, error) { + var s Settings + err := r.pool.QueryRow(ctx, ` + SELECT enabled, default_poll_interval_seconds, debounce_seconds + FROM autoscan_settings WHERE id = true`). + Scan(&s.Enabled, &s.DefaultPollIntervalSeconds, &s.DebounceSeconds) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Settings{ + Enabled: false, + DefaultPollIntervalSeconds: 600, + DebounceSeconds: 60, + }, nil + } + return Settings{}, fmt.Errorf("get autoscan settings: %w", err) + } + return s, nil +} + +func (r *Repository) UpdateSettings(ctx context.Context, s Settings) (Settings, error) { + var out Settings + err := r.pool.QueryRow(ctx, ` + INSERT INTO autoscan_settings ( + id, enabled, default_poll_interval_seconds, debounce_seconds, updated_at + ) + VALUES (true, $1, $2, $3, now()) + ON CONFLICT (id) DO UPDATE SET + enabled = EXCLUDED.enabled, + default_poll_interval_seconds = EXCLUDED.default_poll_interval_seconds, + debounce_seconds = EXCLUDED.debounce_seconds, + updated_at = now() + RETURNING enabled, default_poll_interval_seconds, debounce_seconds`, + s.Enabled, s.DefaultPollIntervalSeconds, s.DebounceSeconds). + Scan(&out.Enabled, &out.DefaultPollIntervalSeconds, &out.DebounceSeconds) + if err != nil { + return Settings{}, fmt.Errorf("update autoscan settings: %w", err) + } + return out, nil +} + +// --- Connections --- + +const connectionColumns = `id, name, kind, base_url, api_key_ref, request_integration_id` + +func scanConnection(row interface{ Scan(...any) error }) (Connection, error) { + var c Connection + var baseURL, apiKeyRef, reqIntegrationID *string + if err := row.Scan(&c.ID, &c.Name, &c.Kind, &baseURL, &apiKeyRef, &reqIntegrationID); err != nil { + return Connection{}, err + } + if baseURL != nil { + c.BaseURL = *baseURL + } + if apiKeyRef != nil { + c.APIKeyRef = *apiKeyRef + } + c.RequestIntegrationID = reqIntegrationID + return c, nil +} + +// nullable returns nil for empty strings so they map to SQL NULL. +func nullable(s string) any { + if s == "" { + return nil + } + return s +} + +func (r *Repository) CreateConnection(ctx context.Context, c Connection) (Connection, error) { + row := r.pool.QueryRow(ctx, ` + INSERT INTO autoscan_connections (name, kind, base_url, api_key_ref, request_integration_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING `+connectionColumns, + c.Name, c.Kind, nullable(c.BaseURL), nullable(c.APIKeyRef), c.RequestIntegrationID) + out, err := scanConnection(row) + if err != nil { + return Connection{}, fmt.Errorf("create autoscan connection: %w", err) + } + return out, nil +} + +func (r *Repository) UpdateConnection(ctx context.Context, c Connection) (Connection, error) { + // A blank incoming api_key_ref KEEPS the existing stored value: the UI + // deliberately omits the key on a metadata-only edit ("leave blank to keep + // existing"), so unconditionally writing it would NULL the key and break the + // next poll. Mirrors requests.UpdateIntegration's CASE-WHEN keep-semantics. + // Pass the raw trimmed string (not nullable()) so the empty-string sentinel + // reaches the CASE. + row := r.pool.QueryRow(ctx, ` + UPDATE autoscan_connections + SET name = $2, kind = $3, base_url = $4, + api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END, + request_integration_id = $6, updated_at = now() + WHERE id = $1 + RETURNING `+connectionColumns, + c.ID, c.Name, c.Kind, nullable(c.BaseURL), c.APIKeyRef, c.RequestIntegrationID) + out, err := scanConnection(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Connection{}, fmt.Errorf("%w: connection %s", ErrNotFound, c.ID) + } + return Connection{}, fmt.Errorf("update autoscan connection: %w", err) + } + return out, nil +} + +func (r *Repository) DeleteConnection(ctx context.Context, id string) error { + tag, err := r.pool.Exec(ctx, `DELETE FROM autoscan_connections WHERE id = $1`, id) + if err != nil { + // A source still references this connection (ON DELETE RESTRICT, 23503). + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" { + return fmt.Errorf("autoscan: connection %s is in use by a source", id) + } + return fmt.Errorf("delete autoscan connection: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: connection %s", ErrNotFound, id) + } + return nil +} + +func (r *Repository) ListConnections(ctx context.Context) ([]Connection, error) { + rows, err := r.pool.Query(ctx, `SELECT `+connectionColumns+` + FROM autoscan_connections ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("list autoscan connections: %w", err) + } + defer rows.Close() + var out []Connection + for rows.Next() { + c, err := scanConnection(rows) + if err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (r *Repository) GetConnection(ctx context.Context, id string) (Connection, error) { + row := r.pool.QueryRow(ctx, `SELECT `+connectionColumns+` + FROM autoscan_connections WHERE id = $1`, id) + c, err := scanConnection(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Connection{}, fmt.Errorf("%w: connection %s", ErrNotFound, id) + } + return Connection{}, fmt.Errorf("get autoscan connection: %w", err) + } + return c, nil +} + +// --- Sources --- + +const sourceColumns = `id, installation_id, capability_id, connection_id, enabled, + poll_interval_seconds, path_rewrites, source_config, label, marker, last_run_at, last_error` + +func scanSource(row interface{ Scan(...any) error }) (Source, error) { + var s Source + var pathRewrites []byte + var sourceConfig []byte + if err := row.Scan(&s.ID, &s.InstallationID, &s.CapabilityID, &s.ConnectionID, + &s.Enabled, &s.PollIntervalSeconds, &pathRewrites, &sourceConfig, &s.Label, &s.Marker, &s.LastRunAt, &s.LastError); err != nil { + return Source{}, err + } + rewrites, err := unmarshalPathRewrites(pathRewrites) + if err != nil { + return Source{}, err + } + s.PathRewrites = rewrites + config, err := unmarshalSourceConfig(sourceConfig) + if err != nil { + return Source{}, err + } + s.SourceConfig = config + return s, nil +} + +// unmarshalPathRewrites decodes the jsonb path_rewrites column into a slice. A +// NULL/empty column maps to an empty (non-nil) slice so callers never see a nil. +func unmarshalPathRewrites(raw []byte) ([]PathRewrite, error) { + if len(raw) == 0 { + return []PathRewrite{}, nil + } + var out []PathRewrite + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("decode autoscan path_rewrites: %w", err) + } + if out == nil { + out = []PathRewrite{} + } + return out, nil +} + +// marshalPathRewrites encodes path rewrites for the jsonb column. A nil slice is +// stored as an empty JSON array (matching the column default '[]'). +func marshalPathRewrites(rewrites []PathRewrite) ([]byte, error) { + if rewrites == nil { + rewrites = []PathRewrite{} + } + b, err := json.Marshal(rewrites) + if err != nil { + return nil, fmt.Errorf("encode autoscan path_rewrites: %w", err) + } + return b, nil +} + +func unmarshalSourceConfig(raw []byte) (map[string]string, error) { + if len(raw) == 0 { + return map[string]string{}, nil + } + var out map[string]string + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("decode autoscan source_config: %w", err) + } + if out == nil { + out = map[string]string{} + } + return out, nil +} + +func marshalSourceConfig(config map[string]string) ([]byte, error) { + normalized := make(map[string]string, len(config)) + for key, value := range config { + key = strings.TrimSpace(key) + if key == "" { + continue + } + normalized[key] = strings.TrimSpace(value) + } + b, err := json.Marshal(normalized) + if err != nil { + return nil, fmt.Errorf("encode autoscan source_config: %w", err) + } + return b, nil +} + +// connectionIDArg maps a nullable connection id to a SQL value: nil pointer and +// whitespace-only ids map to SQL NULL. +func connectionIDArg(id *string) any { + if id == nil { + return nil + } + return nullable(*id) +} + +// CreateSource inserts a new autoscan source row. One installed scan_source +// capability can back many sources (e.g. one Sonarr plugin install fronting 4 +// arr servers, one source per connection), so this is a plain INSERT with a +// fresh uuid rather than an upsert. A non-existent connection trips the FK +// constraint and maps to ErrNotFound. +func (r *Repository) CreateSource(ctx context.Context, s Source) (Source, error) { + rewrites, err := marshalPathRewrites(s.PathRewrites) + if err != nil { + return Source{}, err + } + sourceConfig, err := marshalSourceConfig(s.SourceConfig) + if err != nil { + return Source{}, err + } + row := r.pool.QueryRow(ctx, ` + INSERT INTO autoscan_sources ( + installation_id, capability_id, connection_id, enabled, poll_interval_seconds, path_rewrites, source_config, label + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING `+sourceColumns, + s.InstallationID, s.CapabilityID, connectionIDArg(s.ConnectionID), s.Enabled, s.PollIntervalSeconds, rewrites, sourceConfig, s.Label) + out, err := scanSource(row) + if err != nil { + if connID, ok := connectionFKViolation(err, s.ConnectionID); ok { + return Source{}, fmt.Errorf("%w: connection %s", ErrNotFound, connID) + } + return Source{}, fmt.Errorf("create autoscan source: %w", err) + } + return out, nil +} + +// UpdateSource updates a source's binding/scheduling fields by id. Identity +// (installation_id, capability_id) and bookkeeping fields (marker/last_run_at/ +// last_error) are left untouched. An unknown id maps to ErrNotFound; a +// non-existent connection trips the FK constraint and also maps to ErrNotFound. +func (r *Repository) UpdateSource(ctx context.Context, s Source) (Source, error) { + rewrites, err := marshalPathRewrites(s.PathRewrites) + if err != nil { + return Source{}, err + } + sourceConfig, err := marshalSourceConfig(s.SourceConfig) + if err != nil { + return Source{}, err + } + row := r.pool.QueryRow(ctx, ` + UPDATE autoscan_sources + SET connection_id = $2, + enabled = $3, + poll_interval_seconds = $4, + path_rewrites = $5, + source_config = $6, + label = $7, + updated_at = now() + WHERE id = $1 + RETURNING `+sourceColumns, + s.ID, connectionIDArg(s.ConnectionID), s.Enabled, s.PollIntervalSeconds, rewrites, sourceConfig, s.Label) + out, err := scanSource(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Source{}, fmt.Errorf("%w: source %s", ErrNotFound, s.ID) + } + if connID, ok := connectionFKViolation(err, s.ConnectionID); ok { + return Source{}, fmt.Errorf("%w: connection %s", ErrNotFound, connID) + } + return Source{}, fmt.Errorf("update autoscan source: %w", err) + } + return out, nil +} + +// connectionFKViolation reports whether err is the connection FK constraint +// violation (23503), returning the offending connection id for the error +// message. +func connectionFKViolation(err error, connectionID *string) (string, bool) { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Code != "23503" { + return "", false + } + connID := "" + if connectionID != nil { + connID = *connectionID + } + return connID, true +} + +func (r *Repository) ListSources(ctx context.Context) ([]Source, error) { + rows, err := r.pool.Query(ctx, `SELECT `+sourceColumns+` + FROM autoscan_sources ORDER BY installation_id, capability_id`) + if err != nil { + return nil, fmt.Errorf("list autoscan sources: %w", err) + } + defer rows.Close() + return collectSources(rows) +} + +func (r *Repository) ListEnabledSources(ctx context.Context) ([]Source, error) { + rows, err := r.pool.Query(ctx, `SELECT `+sourceColumns+` + FROM autoscan_sources WHERE enabled = true + ORDER BY installation_id, capability_id`) + if err != nil { + return nil, fmt.Errorf("list enabled autoscan sources: %w", err) + } + defer rows.Close() + return collectSources(rows) +} + +func collectSources(rows pgx.Rows) ([]Source, error) { + var out []Source + for rows.Next() { + s, err := scanSource(rows) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func (r *Repository) GetSource(ctx context.Context, id string) (Source, error) { + row := r.pool.QueryRow(ctx, `SELECT `+sourceColumns+` + FROM autoscan_sources WHERE id = $1`, id) + s, err := scanSource(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Source{}, fmt.Errorf("%w: source %s", ErrNotFound, id) + } + return Source{}, fmt.Errorf("get autoscan source: %w", err) + } + return s, nil +} + +// DeleteSource removes a source row by id. It lets an operator clear an orphaned +// source (one whose scan_source plugin was uninstalled/disabled). An unknown id +// maps to ErrNotFound. +func (r *Repository) DeleteSource(ctx context.Context, id string) error { + tag, err := r.pool.Exec(ctx, `DELETE FROM autoscan_sources WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete autoscan source: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: source %s", ErrNotFound, id) + } + return nil +} + +// AdvanceMarker stores the opaque next marker for a source, stamps last_run_at, +// and clears any prior error. Called only after a successful enqueue. +func (r *Repository) AdvanceMarker(ctx context.Context, sourceID, marker string) error { + tag, err := r.pool.Exec(ctx, ` + UPDATE autoscan_sources + SET marker = $2, last_run_at = now(), last_error = NULL, updated_at = now() + WHERE id = $1`, sourceID, nullable(marker)) + if err != nil { + return fmt.Errorf("advance autoscan marker: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: source %s", ErrNotFound, sourceID) + } + return nil +} + +// maxLastErrorLen bounds the stored last_error (in bytes) so a pathological +// provider error can't bloat the row. +const maxLastErrorLen = 2048 + +// truncateUTF8 caps s to at most maxBytes bytes without splitting a multi-byte +// UTF-8 rune: if the byte cut lands mid-rune it backs off to the last valid +// rune boundary, so the stored value is always valid UTF-8. +func truncateUTF8(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + cut := maxBytes + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] +} + +// RecordError records a poll failure for a source: it stamps last_run_at and +// stores the (length-bounded) error message without advancing the marker. +func (r *Repository) RecordError(ctx context.Context, sourceID, msg string) error { + msg = truncateUTF8(msg, maxLastErrorLen) + tag, err := r.pool.Exec(ctx, ` + UPDATE autoscan_sources + SET last_error = $2, last_run_at = now(), updated_at = now() + WHERE id = $1`, sourceID, msg) + if err != nil { + return fmt.Errorf("record autoscan error: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: source %s", ErrNotFound, sourceID) + } + return nil +} + +const eventColumns = `id, source_id, installation_id, capability_id, started_at, completed_at, + duration_ms, status, changes_returned, changes_resolved, targets_claimed, scans_created, + scans_reused, scans_suppressed, error_message, marker_before, marker_after` + +func scanEvent(row interface{ Scan(...any) error }) (Event, error) { + var e Event + var status string + if err := row.Scan( + &e.ID, + &e.SourceID, + &e.InstallationID, + &e.CapabilityID, + &e.StartedAt, + &e.CompletedAt, + &e.DurationMS, + &status, + &e.ChangesReturned, + &e.ChangesResolved, + &e.TargetsClaimed, + &e.ScansCreated, + &e.ScansReused, + &e.ScansSuppressed, + &e.ErrorMessage, + &e.MarkerBefore, + &e.MarkerAfter, + ); err != nil { + return Event{}, err + } + e.Status = EventStatus(status) + return e, nil +} + +func (r *Repository) CreateEvent(ctx context.Context, in EventCreate) (int64, error) { + started := in.StartedAt + if started.IsZero() { + started = time.Now() + } + row := r.pool.QueryRow(ctx, ` + INSERT INTO autoscan_events ( + source_id, installation_id, capability_id, started_at, completed_at, + duration_ms, status, error_message, marker_before + ) + VALUES ($1, $2, $3, $4, $4, 0, $5, $6, $7) + RETURNING id`, + nullable(in.SourceID), + in.InstallationID, + in.CapabilityID, + started, + string(EventStatusError), + "poll started but did not finish", + nullable(in.MarkerBefore), + ) + var id int64 + if err := row.Scan(&id); err != nil { + return 0, fmt.Errorf("create autoscan event: %w", err) + } + return id, nil +} + +func (r *Repository) FinishEvent(ctx context.Context, in EventFinish) error { + if in.ID == 0 { + return nil + } + completed := in.CompletedAt + if completed.IsZero() { + completed = time.Now() + } + msg := truncateUTF8(in.ErrorMessage, maxLastErrorLen) + tag, err := r.pool.Exec(ctx, ` + UPDATE autoscan_events + SET completed_at = $2, + duration_ms = GREATEST(0, EXTRACT(EPOCH FROM ($2 - started_at)) * 1000)::bigint, + status = $3, + changes_returned = $4, + changes_resolved = $5, + targets_claimed = $6, + scans_created = $7, + scans_reused = $8, + scans_suppressed = $9, + error_message = $10, + marker_after = $11 + WHERE id = $1`, + in.ID, + completed, + string(in.Status), + in.ChangesReturned, + in.ChangesResolved, + in.TargetsClaimed, + in.ScansCreated, + in.ScansReused, + in.ScansSuppressed, + msg, + nullable(in.MarkerAfter), + ) + if err != nil { + return fmt.Errorf("finish autoscan event: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: autoscan event %d", ErrNotFound, in.ID) + } + return nil +} + +// clampAutoscanLimit bounds a requested page size to a sane window: the +// default page when unset, capped so a single query can never fan out. +func clampAutoscanLimit(limit int) int { + if limit <= 0 { + return 50 + } + if limit > 200 { + return 200 + } + return limit +} + +// eventFilterClauses builds the shared WHERE clauses (and positional args) for +// autoscan event queries, so listing and counting filter identically and the +// search SQL lives in exactly one place. +func eventFilterClauses(filter EventListFilter) ([]string, []any) { + clauses := []string{"true"} + args := []any{} + if strings.TrimSpace(filter.SourceID) != "" { + args = append(args, strings.TrimSpace(filter.SourceID)) + clauses = append(clauses, fmt.Sprintf("source_id = $%d", len(args))) + } + if filter.Status != "" { + args = append(args, string(filter.Status)) + clauses = append(clauses, fmt.Sprintf("status = $%d", len(args))) + } + if search := strings.ToLower(strings.TrimSpace(filter.Search)); search != "" { + args = append(args, "%"+search+"%") + param := fmt.Sprintf("$%d", len(args)) + clauses = append(clauses, `( + lower(capability_id) LIKE `+param+` + OR lower(status) LIKE `+param+` + OR lower(error_message) LIKE `+param+` + OR lower(COALESCE(source_id::text, '')) LIKE `+param+` + OR EXISTS ( + SELECT 1 + FROM scan_runs sr + WHERE sr.autoscan_event_id = autoscan_events.id + AND ( + lower(sr.id) LIKE `+param+` + OR lower(sr.mode) LIKE `+param+` + OR lower(sr.path) LIKE `+param+` + OR lower(sr.status) LIKE `+param+` + OR lower(COALESCE(sr.error_message, '')) LIKE `+param+` + ) + ) + )`) + } + return clauses, args +} + +// scanFilterClauses builds the shared WHERE clauses for autoscan scan queries. +// The clauses reference the `sr` (scan_runs) and `e` (autoscan_events) aliases, +// so callers must select FROM scan_runs sr LEFT JOIN autoscan_events e. +func scanFilterClauses(filter ScanListFilter) ([]string, []any) { + clauses := []string{"sr.trigger = 'autoscan'"} + args := []any{} + if strings.TrimSpace(filter.Status) != "" { + args = append(args, strings.TrimSpace(filter.Status)) + clauses = append(clauses, fmt.Sprintf("sr.status = $%d", len(args))) + } + if search := strings.ToLower(strings.TrimSpace(filter.Search)); search != "" { + args = append(args, "%"+search+"%") + param := fmt.Sprintf("$%d", len(args)) + clauses = append(clauses, `( + lower(sr.id) LIKE `+param+` + OR lower(sr.mode) LIKE `+param+` + OR lower(sr.path) LIKE `+param+` + OR lower(sr.status) LIKE `+param+` + OR lower(COALESCE(sr.error_message, '')) LIKE `+param+` + OR lower(COALESCE(e.capability_id, '')) LIKE `+param+` + OR lower(COALESCE(e.status, '')) LIKE `+param+` + OR lower(COALESCE(e.source_id::text, '')) LIKE `+param+` + )`) + } + return clauses, args +} + +// CountEvents returns the total number of autoscan events matching filter, +// ignoring limit/offset. It powers the "of N" total in paginated views. +func (r *Repository) CountEvents(ctx context.Context, filter EventListFilter) (int, error) { + clauses, args := eventFilterClauses(filter) + var total int + if err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM autoscan_events + WHERE `+strings.Join(clauses, " AND "), + args..., + ).Scan(&total); err != nil { + return 0, fmt.Errorf("count autoscan events: %w", err) + } + return total, nil +} + +// CountAutoscanScans returns the total number of autoscan scan runs matching +// filter, ignoring limit/offset. It powers the "of N" total in paginated views. +func (r *Repository) CountAutoscanScans(ctx context.Context, filter ScanListFilter) (int, error) { + clauses, args := scanFilterClauses(filter) + var total int + if err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM scan_runs sr + LEFT JOIN autoscan_events e ON e.id = sr.autoscan_event_id + WHERE `+strings.Join(clauses, " AND "), + args..., + ).Scan(&total); err != nil { + return 0, fmt.Errorf("count autoscan scans: %w", err) + } + return total, nil +} + +func (r *Repository) ListEvents(ctx context.Context, filter EventListFilter) ([]EventWithRuns, error) { + limit := clampAutoscanLimit(filter.Limit) + offset := filter.Offset + if offset < 0 { + offset = 0 + } + + clauses, args := eventFilterClauses(filter) + args = append(args, limit) + limitParam := len(args) + args = append(args, offset) + offsetParam := len(args) + + rows, err := r.pool.Query(ctx, ` + SELECT `+eventColumns+` + FROM autoscan_events + WHERE `+strings.Join(clauses, " AND ")+` + ORDER BY completed_at DESC, id DESC + LIMIT $`+fmt.Sprint(limitParam)+` OFFSET $`+fmt.Sprint(offsetParam), + args..., + ) + if err != nil { + return nil, fmt.Errorf("list autoscan events: %w", err) + } + defer rows.Close() + + events := make([]EventWithRuns, 0) + ids := make([]int64, 0) + indexByID := map[int64]int{} + for rows.Next() { + event, scanErr := scanEvent(rows) + if scanErr != nil { + return nil, scanErr + } + indexByID[event.ID] = len(events) + ids = append(ids, event.ID) + events = append(events, EventWithRuns{Event: event, Runs: []ScanRunSummary{}}) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(ids) == 0 { + return events, nil + } + + runRows, err := r.pool.Query(ctx, ` + SELECT autoscan_event_id, id, media_folder_id, mode, path, trigger, status, + COALESCE(error_message, ''), requested_at, started_at, completed_at + FROM scan_runs + WHERE autoscan_event_id = ANY($1) + ORDER BY requested_at ASC`, + ids, + ) + if err != nil { + return nil, fmt.Errorf("list autoscan event scan runs: %w", err) + } + defer runRows.Close() + for runRows.Next() { + var eventID int64 + var run ScanRunSummary + if err := runRows.Scan( + &eventID, + &run.ID, + &run.MediaFolderID, + &run.Mode, + &run.Path, + &run.Trigger, + &run.Status, + &run.ErrorMessage, + &run.RequestedAt, + &run.StartedAt, + &run.CompletedAt, + ); err != nil { + return nil, err + } + if idx, ok := indexByID[eventID]; ok { + events[idx].Runs = append(events[idx].Runs, run) + } + } + return events, runRows.Err() +} + +func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilter) ([]ScanWithEvent, error) { + limit := clampAutoscanLimit(filter.Limit) + offset := filter.Offset + if offset < 0 { + offset = 0 + } + + clauses, args := scanFilterClauses(filter) + args = append(args, limit) + limitParam := len(args) + args = append(args, offset) + offsetParam := len(args) + + rows, err := r.pool.Query(ctx, ` + SELECT + sr.id, + sr.media_folder_id, + sr.mode, + sr.path, + sr.trigger, + sr.status, + COALESCE(sr.error_message, ''), + sr.requested_at, + sr.started_at, + sr.completed_at, + sr.autoscan_event_id, + e.source_id, + e.installation_id, + COALESCE(e.capability_id, ''), + COALESCE(e.status, ''), + e.completed_at + FROM scan_runs sr + LEFT JOIN autoscan_events e ON e.id = sr.autoscan_event_id + WHERE `+strings.Join(clauses, " AND ")+` + ORDER BY COALESCE(sr.completed_at, sr.started_at, sr.requested_at) DESC, sr.id DESC + LIMIT $`+fmt.Sprint(limitParam)+` OFFSET $`+fmt.Sprint(offsetParam), + args..., + ) + if err != nil { + return nil, fmt.Errorf("list autoscan scans: %w", err) + } + defer rows.Close() + + scans := make([]ScanWithEvent, 0) + for rows.Next() { + var scan ScanWithEvent + var eventStatus string + if err := rows.Scan( + &scan.ID, + &scan.MediaFolderID, + &scan.Mode, + &scan.Path, + &scan.Trigger, + &scan.Status, + &scan.ErrorMessage, + &scan.RequestedAt, + &scan.StartedAt, + &scan.CompletedAt, + &scan.AutoscanEventID, + &scan.SourceID, + &scan.InstallationID, + &scan.CapabilityID, + &eventStatus, + &scan.EventCompletedAt, + ); err != nil { + return nil, err + } + scan.EventStatus = EventStatus(eventStatus) + scans = append(scans, scan) + } + return scans, rows.Err() +} + +func (r *Repository) GetQueueSummary(ctx context.Context) (QueueSummary, error) { + var summary QueueSummary + err := r.pool.QueryRow(ctx, ` + SELECT + COUNT(*) FILTER (WHERE status = ANY($1))::int, + COUNT(*) FILTER (WHERE status = $2)::int, + COUNT(*) FILTER (WHERE status = $3)::int + FROM scan_runs + WHERE trigger = $4`, + []string{"accepted", "running"}, + "accepted", + "running", + "autoscan", + ).Scan(&summary.Active, &summary.Accepted, &summary.Running) + if err != nil { + return QueueSummary{}, fmt.Errorf("get autoscan queue summary: %w", err) + } + return summary, nil +} + +func (r *Repository) LatestEventAt(ctx context.Context) (*time.Time, error) { + var latest *time.Time + err := r.pool.QueryRow(ctx, `SELECT max(completed_at) FROM autoscan_events`).Scan(&latest) + if err != nil { + return nil, fmt.Errorf("get latest autoscan event time: %w", err) + } + return latest, nil +} diff --git a/internal/autoscan/rewrite.go b/internal/autoscan/rewrite.go new file mode 100644 index 00000000..5aca46e2 --- /dev/null +++ b/internal/autoscan/rewrite.go @@ -0,0 +1,48 @@ +package autoscan + +import "strings" + +// normalizeSeparators converts Windows backslash separators to forward slashes +// so paths from a Windows-hosted arr resolve on the Linux host. (filepath.ToSlash +// is a no-op on Linux, so the replacement is explicit.) +func normalizeSeparators(path string) string { + return strings.ReplaceAll(path, "\\", "/") +} + +// applyRewrites returns path with the MOST-SPECIFIC matching prefix rewrite +// applied, or path unchanged when none match. +// +// "Most-specific" means the longest matching From wins, not the first one in the +// slice. A first-match strategy lets a broad rewrite (From="/data") shadow a +// nested, more-specific one (From="/data/media") when the broad rule happens to +// be listed first; the arr plugin review flagged exactly this. Selecting the +// longest matching prefix makes the result independent of rule ordering. +func applyRewrites(path string, rewrites []PathRewrite) string { + bestIdx := -1 + bestLen := -1 + var bestTrimmed string + for i, rw := range rewrites { + // Normalize the stored From the SAME way coveredBy/normalizePath does + // (backslash→slash, collapse '//', strip trailing '/') so a Windows-style + // or dup-slash rewrite that suggest-time reports as "covered" actually + // matches at poll time. Without this, a From like `D:\data\tv` would be + // "covered" in the suggester yet never match the normalized incoming path. + trimmed := normalizePath(rw.From) + if trimmed == "" { + continue + } + // Match only at a path-segment boundary: exact, or prefix followed by '/'. + // This prevents From="/data/media" from rewriting "/data/media2/x". + if path == trimmed || strings.HasPrefix(path, trimmed+"/") { + if len(trimmed) > bestLen { + bestLen = len(trimmed) + bestIdx = i + bestTrimmed = trimmed + } + } + } + if bestIdx < 0 { + return path + } + return strings.TrimSpace(rewrites[bestIdx].To) + strings.TrimPrefix(path, bestTrimmed) +} diff --git a/internal/autoscan/rewrite_test.go b/internal/autoscan/rewrite_test.go new file mode 100644 index 00000000..0791a81b --- /dev/null +++ b/internal/autoscan/rewrite_test.go @@ -0,0 +1,97 @@ +package autoscan + +import "testing" + +func TestApplyRewrites(t *testing.T) { + rw := []PathRewrite{{From: "/data/media", To: "/mnt/media"}} + cases := []struct{ in, want string }{ + {"/data/media/Movies/Dune/Dune.mkv", "/mnt/media/Movies/Dune/Dune.mkv"}, + {"/other/path/file.mkv", "/other/path/file.mkv"}, // no-match pass-through + } + for _, tc := range cases { + if got := applyRewrites(tc.in, rw); got != tc.want { + t.Fatalf("applyRewrites(%q) = %q, want %q", tc.in, got, tc.want) + } + } + + // Exact match (no trailing segment) rewrites the whole path. + if got := applyRewrites("/data/media", rw); got != "/mnt/media" { + t.Fatalf("exact match: got %q want %q", got, "/mnt/media") + } + + // nil/empty rewrites pass through unchanged. + if got := applyRewrites("/data/media/x", nil); got != "/data/media/x" { + t.Fatalf("nil rewrites: got %q", got) + } + + // Segment-boundary matching: a sibling dir sharing the prefix must NOT match. + boundary := []PathRewrite{{From: "/data/media", To: "/mnt"}} + if got := applyRewrites("/data/media2/x", boundary); got != "/data/media2/x" { + t.Fatalf("boundary: /data/media2/x must not rewrite, got %q", got) + } +} + +// TestApplyRewritesNormalizesStoredFrom verifies that a Windows-style / dup-slash +// stored From is normalized the same way coveredBy/normalizePath does, so a +// rewrite the suggester reports as "covered" actually matches at poll time. The +// incoming path is already separator-normalized by PollOnce before applyRewrites. +func TestApplyRewritesNormalizesStoredFrom(t *testing.T) { + // Backslash From: a Windows-hosted arr root stored verbatim. + winFrom := []PathRewrite{{From: `D:\data\tv`, To: "/mnt/media/tv"}} + if got := applyRewrites("D:/data/tv/Show/S01/E01.mkv", winFrom); got != "/mnt/media/tv/Show/S01/E01.mkv" { + t.Fatalf("backslash From should match normalized path, got %q", got) + } + // coveredBy must agree with applyRewrites: the normalized root is covered. + if !coveredBy("D:/data/tv", winFrom) { + t.Fatalf("coveredBy should report the backslash From as covering the root") + } + + // Dup-slash From collapses too. + dupFrom := []PathRewrite{{From: "/data//tv/", To: "/mnt/media/tv"}} + if got := applyRewrites("/data/tv/Show/E.mkv", dupFrom); got != "/mnt/media/tv/Show/E.mkv" { + t.Fatalf("dup-slash From should match collapsed path, got %q", got) + } +} + +// TestApplyRewritesMostSpecificWins verifies the longest matching From wins +// regardless of slice ordering: a broad rule must not shadow a nested one. +func TestApplyRewritesMostSpecificWins(t *testing.T) { + // Broad rule listed FIRST: a first-match strategy would (wrongly) pick /data + // and yield "/A/media/x". Most-specific must pick /data/media -> "/B/x". + broadFirst := []PathRewrite{ + {From: "/data", To: "/A"}, + {From: "/data/media", To: "/B"}, + } + if got := applyRewrites("/data/media/x", broadFirst); got != "/B/x" { + t.Fatalf("broad-first: got %q want %q", got, "/B/x") + } + + // Same rules, specific listed first: result must be identical (order-independent). + specificFirst := []PathRewrite{ + {From: "/data/media", To: "/B"}, + {From: "/data", To: "/A"}, + } + if got := applyRewrites("/data/media/x", specificFirst); got != "/B/x" { + t.Fatalf("specific-first: got %q want %q", got, "/B/x") + } + + // A path under the broad rule but NOT the specific one still uses the broad rule. + if got := applyRewrites("/data/other/x", broadFirst); got != "/A/other/x" { + t.Fatalf("broad fallthrough: got %q want %q", got, "/A/other/x") + } +} + +func TestNormalizeSeparators(t *testing.T) { + if got := normalizeSeparators(`C:\Media\Movies\Dune\Dune.mkv`); got != "C:/Media/Movies/Dune/Dune.mkv" { + t.Fatalf("normalizeSeparators(windows) = %q", got) + } + // POSIX paths are unchanged. + if got := normalizeSeparators("/mnt/media/x.mkv"); got != "/mnt/media/x.mkv" { + t.Fatalf("normalizeSeparators(posix) = %q", got) + } + // A normalized Windows path then rewrites on the Linux host. + rw := []PathRewrite{{From: "C:/Media", To: "/mnt/media"}} + if got := applyRewrites(normalizeSeparators(`C:\Media\ShowA\S01\E01.mkv`), rw); got != "/mnt/media/ShowA/S01/E01.mkv" { + t.Fatalf("windows rewrite = %q", got) + } +} diff --git a/internal/autoscan/service.go b/internal/autoscan/service.go new file mode 100644 index 00000000..7821adfc --- /dev/null +++ b/internal/autoscan/service.go @@ -0,0 +1,464 @@ +package autoscan + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/Silo-Server/silo-server/internal/scantrigger" +) + +const scanTrigger = "autoscan" + +// Store is the persistence surface the engine needs. The repository implements +// the full CRUD surface; this interface is the read/bookkeeping subset the poll +// loop touches. +type Store interface { + GetSettings(ctx context.Context) (Settings, error) + ListEnabledSources(ctx context.Context) ([]Source, error) + GetSource(ctx context.Context, id string) (Source, error) + GetConnection(ctx context.Context, id string) (Connection, error) + AdvanceMarker(ctx context.Context, sourceID, marker string) error + RecordError(ctx context.Context, sourceID, msg string) error + CreateEvent(ctx context.Context, event EventCreate) (int64, error) + FinishEvent(ctx context.Context, event EventFinish) error +} + +// Resolver maps a Silo-native path to a concrete scan target (library folder). +type Resolver interface { + Resolve(ctx context.Context, req scantrigger.Request) (*scantrigger.Target, error) + ResolveMissingSubtree(ctx context.Context, subtreePath, trigger string) (*scantrigger.Target, error) +} + +// Queuer enqueues resolved scan targets. +type Queuer interface { + EnqueueScans(ctx context.Context, targets []scantrigger.Target) error + EnqueueAutoscanScans(ctx context.Context, targets []scantrigger.Target, eventID int64) (created, reused int, err error) +} + +type resolveStats struct { + ChangesResolved int + TargetsClaimed int + Suppressed int +} + +// connectionResolver resolves a stored connection to concrete credentials. +type connectionResolver interface { + Resolve(ctx context.Context, c Connection) (ResolvedConnection, error) +} + +// RootFolderClient lists a Radarr/Sonarr instance's configured root folder +// paths (used by the rewrite suggester). The concrete impl additionally +// satisfies ArrStatusProbe for the connection-test endpoint. +type RootFolderClient interface { + RootFolders(ctx context.Context, baseURL, apiKey string) ([]string, error) +} + +// FolderLister lists every Silo media-folder path (used by the rewrite +// suggester to match arr roots against Silo folders). +type FolderLister interface { + ListFolderPaths(ctx context.Context) ([]string, error) +} + +type Service struct { + store Store + provider ScanSourceProvider + connres connectionResolver + resolver Resolver + queue Queuer + suppress Suppressor + lister ScanSourceLister + + // Optional deps for the connection-test and rewrite-suggester endpoints. + // Wired via setters so the poll-loop constructor stays unchanged and tests + // that only exercise PollOnce need not supply them. + rootFolders RootFolderClient + folders FolderLister +} + +// SetSuggesterDeps wires the dependencies the rewrite-suggester and +// connection-test endpoints need: an arr root-folder/status client and a Silo +// media-folder lister. Optional — when unset, SuggestRewrites/TestConnection +// return an error. +func (s *Service) SetSuggesterDeps(rootFolders RootFolderClient, folders FolderLister) { + s.rootFolders = rootFolders + s.folders = folders +} + +// NewService builds the autoscan engine. The provider supplies changed paths +// from scan_source plugins; connres resolves a source's connection to concrete +// credentials; resolver/queue/suppress drive the resolve→suppress→enqueue loop. +// lister enumerates installed scan_source capabilities so the Add-source picker +// can offer them and PollOnce can skip orphaned source rows; it may be nil +// (the picker then returns an empty list and orphan-pruning is disabled). +func NewService( + store Store, + provider ScanSourceProvider, + connres connectionResolver, + resolver Resolver, + queue Queuer, + suppress Suppressor, + lister ScanSourceLister, +) *Service { + return &Service{ + store: store, + provider: provider, + connres: connres, + resolver: resolver, + queue: queue, + suppress: suppress, + lister: lister, + } +} + +// PollOnce runs one autoscan cycle. Per-source failures are logged and skipped; +// only settings/listing errors propagate. The opaque next marker returned by the +// provider is stored verbatim, but only when the cycle's work is genuinely +// consumed: the provider returned no paths, or it returned paths and at least one +// resolved+enqueued. When paths come back but NONE resolve to a library folder +// (e.g. a freshly-enabled source with unconfigured rewrites) the marker is held +// and an error recorded, so those imports aren't skipped forever. +func (s *Service) PollOnce(ctx context.Context) error { + // Fetch the set of currently-installed scan_source capabilities so we can skip + // orphaned source rows (their plugin was uninstalled). Listing failures are + // non-fatal: a nil set means the installed set is unavailable, in which case + // we do NOT prune orphans — every enabled source is assumed present. + present, derr := s.installedScanSources(ctx) + if derr != nil { + slog.WarnContext(ctx, "autoscan: list installed scan sources failed", "err", derr) + present = nil + } + + settings, err := s.store.GetSettings(ctx) + if err != nil { + return err + } + if !settings.Enabled { + return nil + } + sources, err := s.store.ListEnabledSources(ctx) + if err != nil { + return err + } + ttl := time.Duration(settings.DebounceSeconds) * time.Second + now := time.Now() + + for _, src := range sources { + // Skip orphaned sources: an enabled source whose scan_source plugin has + // been uninstalled/disabled is no longer in the discovered set, so polling + // it would error every cycle. Skip it quietly (no RecordError) so the spam + // stops; the operator can delete it via the source delete endpoint. A nil + // `present` set means discovery is unavailable — don't prune in that case. + if present != nil { + if _, ok := present[installedKey{InstallationID: src.InstallationID, CapabilityID: src.CapabilityID}]; !ok { + continue + } + } + // Honor the per-source poll interval as a "poll at most every N seconds" + // floor: the global task fires at the default cadence, so a source with a + // longer interval is skipped until enough time has elapsed. + interval := time.Duration(settings.DefaultPollIntervalSeconds) * time.Second + if src.PollIntervalSeconds != nil { + interval = time.Duration(*src.PollIntervalSeconds) * time.Second + } + if src.LastRunAt != nil && now.Sub(*src.LastRunAt) < interval { + continue + } + marker := "" + if src.Marker != nil { + marker = *src.Marker + } + eventID := s.createEvent(ctx, src, marker, time.Now()) + // A connection is OPTIONAL. Server-based providers (Sonarr/Radarr) bind a + // connection and the resolved {base_url, api_key} is handed to the plugin. + // Other providers (e.g. a filesystem/CephFS watcher) need none and get an + // empty connection they ignore. If a plugin requires a connection it didn't + // get, it returns an error that is RecordError'd below — so the operator + // still sees "needs attention" without the host assuming every source is + // credential-based. + var conn ResolvedConnection + if src.ConnectionID != nil { + resolved, cerr := s.resolveConnection(ctx, *src.ConnectionID) + if cerr != nil { + slog.WarnContext(ctx, "autoscan: resolve connection failed", "source_id", src.ID, "err", cerr) + if rerr := s.store.RecordError(ctx, src.ID, cerr.Error()); rerr != nil { + slog.WarnContext(ctx, "autoscan: record error failed", "source_id", src.ID, "err", rerr) + } + s.finishEvent(ctx, eventID, EventFinish{ + Status: EventStatusError, + ErrorMessage: cerr.Error(), + MarkerAfter: marker, + }) + continue + } + conn = resolved + } + changes, next, perr := s.provider.PollChanges(ctx, src.InstallationID, src.CapabilityID, marker, conn, src.SourceConfig) + if perr != nil { + slog.WarnContext(ctx, "autoscan: poll changes failed", "source_id", src.ID, "err", perr) + if rerr := s.store.RecordError(ctx, src.ID, perr.Error()); rerr != nil { + slog.WarnContext(ctx, "autoscan: record error failed", "source_id", src.ID, "err", rerr) + } + s.finishEvent(ctx, eventID, EventFinish{ + Status: EventStatusError, + ErrorMessage: perr.Error(), + MarkerAfter: marker, + }) + continue // do NOT advance marker + } + + rewritten := rewriteChanges(changes, src.PathRewrites) + targets, claimed, resolvedAny, stats := s.resolveAndClaim(ctx, rewritten, ttl) + var enqueue EnqueueResult + if len(targets) > 0 { + var eerr error + if eventID != 0 { + enqueue.Created, enqueue.Reused, eerr = s.queue.EnqueueAutoscanScans(ctx, targets, eventID) + } else { + eerr = s.queue.EnqueueScans(ctx, targets) + enqueue.Created = len(targets) + } + if eerr != nil { + s.releaseClaims(ctx, claimed) + slog.WarnContext(ctx, "autoscan: enqueue failed", "source_id", src.ID, "err", eerr) + s.finishEvent(ctx, eventID, EventFinish{ + Status: EventStatusError, + ChangesReturned: len(changes), + ChangesResolved: stats.ChangesResolved, + TargetsClaimed: stats.TargetsClaimed, + ScansSuppressed: stats.Suppressed, + ErrorMessage: eerr.Error(), + MarkerAfter: marker, + }) + continue // do NOT advance marker + } + } + + // Advancing the marker is what tells the provider "I've consumed up to + // here". Advance it ONLY when the work it represents is genuinely done: + // - provider returned ZERO paths → nothing to do, advance normally. + // - returned paths AND ≥1 resolved → enqueued (above), advance. + // - returned paths, resolved but all + // suppressed (recently scanned / + // debounced) → work is effectively done; advance. + // - returned paths AND NOTHING resolved → do NOT advance. This is the + // freshly-enabled-source state (path_rewrites not configured yet); the + // incoming paths don't map to any Silo library folder. Advancing here + // would skip those imports forever. Surface why so the operator can fix + // the rewrites, then a later poll re-reads the same window and resolves. + // (Some-but-not-all resolving counts as resolved — the unresolved paths are + // legitimately outside Silo's libraries, so advancing is correct.) + // + // NOTE: len(targets)==0 alone is NOT misconfiguration — paths can resolve + // yet be fully suppressed. Gate the error on resolvedAny, not targets. + if len(changes) > 0 && !resolvedAny { + msg := fmt.Sprintf("returned %d path(s) but none matched a Silo library folder — check this source's path rewrites", len(changes)) + if rerr := s.store.RecordError(ctx, src.ID, msg); rerr != nil { + slog.WarnContext(ctx, "autoscan: record error failed", "source_id", src.ID, "err", rerr) + } + s.finishEvent(ctx, eventID, EventFinish{ + Status: EventStatusUnresolved, + ChangesReturned: len(changes), + ErrorMessage: msg, + MarkerAfter: marker, + }) + continue // do NOT advance marker + } + if aerr := s.store.AdvanceMarker(ctx, src.ID, next); aerr != nil { + slog.WarnContext(ctx, "autoscan: advance marker failed", "source_id", src.ID, "err", aerr) + s.finishEvent(ctx, eventID, EventFinish{ + Status: EventStatusError, + ChangesReturned: len(changes), + ChangesResolved: stats.ChangesResolved, + TargetsClaimed: stats.TargetsClaimed, + ScansCreated: enqueue.Created, + ScansReused: enqueue.Reused, + ScansSuppressed: stats.Suppressed, + ErrorMessage: aerr.Error(), + MarkerAfter: marker, + }) + continue + } + s.finishEvent(ctx, eventID, EventFinish{ + Status: EventStatusSuccess, + ChangesReturned: len(changes), + ChangesResolved: stats.ChangesResolved, + TargetsClaimed: stats.TargetsClaimed, + ScansCreated: enqueue.Created, + ScansReused: enqueue.Reused, + ScansSuppressed: stats.Suppressed, + MarkerAfter: next, + }) + } + return nil +} + +func (s *Service) createEvent(ctx context.Context, src Source, marker string, startedAt time.Time) int64 { + if s == nil || s.store == nil { + return 0 + } + id, err := s.store.CreateEvent(ctx, EventCreate{ + SourceID: src.ID, + InstallationID: src.InstallationID, + CapabilityID: src.CapabilityID, + StartedAt: startedAt, + MarkerBefore: marker, + }) + if err != nil { + slog.WarnContext(ctx, "autoscan: create event failed", "source_id", src.ID, "err", err) + return 0 + } + return id +} + +func (s *Service) finishEvent(ctx context.Context, eventID int64, finish EventFinish) { + if eventID == 0 || s == nil || s.store == nil { + return + } + finish.ID = eventID + finish.CompletedAt = time.Now() + if finish.Status == "" { + finish.Status = EventStatusSuccess + } + if err := s.store.FinishEvent(ctx, finish); err != nil { + slog.WarnContext(ctx, "autoscan: finish event failed", "event_id", eventID, "err", err) + } +} + +// resolveConnection loads and resolves a source's connection to credentials. +func (s *Service) resolveConnection(ctx context.Context, connectionID string) (ResolvedConnection, error) { + conn, err := s.store.GetConnection(ctx, connectionID) + if err != nil { + return ResolvedConnection{}, err + } + return s.connres.Resolve(ctx, conn) +} + +func rewriteChanges(changes []Change, rewrites []PathRewrite) []Change { + rewritten := make([]Change, 0, len(changes)) + for _, change := range changes { + path := applyRewrites(normalizeSeparators(change.SourcePath), rewrites) + rewritten = append(rewritten, Change{SourcePath: path, Scope: change.Scope}) + } + return rewritten +} + +// resolveAndClaim resolves changes to scan targets and atomically claims them +// via the suppressor. Legacy/auto changes retain the historical parent-dir +// collapse. Structured file changes resolve exact files, and subtree changes +// resolve exact subtree paths even when the path no longer exists. +func (s *Service) resolveAndClaim(ctx context.Context, changes []Change, ttl time.Duration) (targets []scantrigger.Target, claimed []string, resolvedAny bool, stats resolveStats) { + seenTargets := make(map[string]struct{}) + + var legacyPaths []string + for _, change := range changes { + switch change.Scope { + case ChangeScopeFile, ChangeScopeSubtree: + target, ok := s.resolveChange(ctx, change) + if !ok { + continue + } + resolvedAny = true + stats.ChangesResolved++ + if !s.claimTarget(ctx, *target, ttl, seenTargets, &targets, &claimed) { + stats.Suppressed++ + } + default: + legacyPaths = append(legacyPaths, change.SourcePath) + } + } + + for _, dir := range uniqueParentDirs(legacyPaths) { + target, rerr := s.resolver.Resolve(ctx, scantrigger.Request{Path: dir, Trigger: scanTrigger}) + if rerr != nil { + var reqErr *scantrigger.RequestError + if errors.As(rerr, &reqErr) { + // Path outside Silo's media folders (or otherwise unresolvable) + // — an expected skip, not an error worth logging every cycle. + continue + } + slog.WarnContext(ctx, "autoscan: resolve failed", "path", dir, "err", rerr) + continue + } + if target == nil || target.Folder == nil { + continue + } + resolvedAny = true + stats.ChangesResolved++ + if !s.claimTarget(ctx, *target, ttl, seenTargets, &targets, &claimed) { + stats.Suppressed++ + } + } + stats.TargetsClaimed = len(targets) + return targets, claimed, resolvedAny, stats +} + +func (s *Service) resolveChange(ctx context.Context, change Change) (*scantrigger.Target, bool) { + if change.SourcePath == "" { + return nil, false + } + var ( + target *scantrigger.Target + err error + ) + switch change.Scope { + case ChangeScopeSubtree: + target, err = s.resolver.ResolveMissingSubtree(ctx, change.SourcePath, scanTrigger) + case ChangeScopeFile: + target, err = s.resolver.Resolve(ctx, scantrigger.Request{Path: change.SourcePath, Trigger: scanTrigger}) + if err == nil && target != nil && target.Mode != scantrigger.ModeFile { + return nil, false + } + default: + target, err = s.resolver.Resolve(ctx, scantrigger.Request{Path: change.SourcePath, Trigger: scanTrigger}) + } + if err != nil { + var reqErr *scantrigger.RequestError + if errors.As(err, &reqErr) { + return nil, false + } + slog.WarnContext(ctx, "autoscan: resolve failed", "path", change.SourcePath, "scope", change.Scope, "err", err) + return nil, false + } + if target == nil || target.Folder == nil { + return nil, false + } + return target, true +} + +func (s *Service) claimTarget( + ctx context.Context, + target scantrigger.Target, + ttl time.Duration, + seenTargets map[string]struct{}, + targets *[]scantrigger.Target, + claimed *[]string, +) bool { + targetKey := fmt.Sprintf("%d|%s|%s", target.Folder.ID, target.Mode, target.Path) + if _, seen := seenTargets[targetKey]; seen { + return false + } + seenTargets[targetKey] = struct{}{} + + key := fmt.Sprintf("%d|%s", target.Folder.ID, target.Path) + ok, serr := s.suppress.ShouldScan(ctx, key, ttl) + if serr != nil || !ok { + return false + } + target.Trigger = scanTrigger + *targets = append(*targets, target) + *claimed = append(*claimed, key) + return true +} + +// releaseClaims drops suppression claims (used when the scan enqueue fails so a +// later cycle can retry the same targets). +func (s *Service) releaseClaims(ctx context.Context, claimed []string) { + for _, k := range claimed { + if rerr := s.suppress.Release(ctx, k); rerr != nil { + slog.WarnContext(ctx, "autoscan: release claim failed", "key", k, "err", rerr) + } + } +} diff --git a/internal/autoscan/service_test.go b/internal/autoscan/service_test.go new file mode 100644 index 00000000..a797cbe6 --- /dev/null +++ b/internal/autoscan/service_test.go @@ -0,0 +1,746 @@ +package autoscan + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scantrigger" +) + +type fakeStore struct { + settings Settings + sources []Source + connection Connection + advanced map[string]string // source ID -> marker + recorded map[string]string // source ID -> error message + createdEvents []EventCreate + events []EventFinish +} + +func (f *fakeStore) GetSettings(context.Context) (Settings, error) { return f.settings, nil } +func (f *fakeStore) ListEnabledSources(context.Context) ([]Source, error) { + return f.sources, nil +} +func (f *fakeStore) GetSource(_ context.Context, id string) (Source, error) { + for _, s := range f.sources { + if s.ID == id { + return s, nil + } + } + return Source{}, ErrNotFound +} +func (f *fakeStore) GetConnection(context.Context, string) (Connection, error) { + return f.connection, nil +} +func (f *fakeStore) AdvanceMarker(_ context.Context, sourceID, marker string) error { + if f.advanced == nil { + f.advanced = map[string]string{} + } + f.advanced[sourceID] = marker + return nil +} +func (f *fakeStore) RecordError(_ context.Context, sourceID, msg string) error { + if f.recorded == nil { + f.recorded = map[string]string{} + } + f.recorded[sourceID] = msg + return nil +} +func (f *fakeStore) CreateEvent(_ context.Context, event EventCreate) (int64, error) { + f.createdEvents = append(f.createdEvents, event) + return int64(len(f.createdEvents)), nil +} +func (f *fakeStore) FinishEvent(_ context.Context, event EventFinish) error { + f.events = append(f.events, event) + return nil +} + +// fakeProvider implements ScanSourceProvider, keyed by capability ID. +type fakeProvider struct { + paths map[string][]string // key: capabilityID, legacy source_paths behavior + changes map[string][]Change // key: capabilityID, structured changes + nextMarker string + err error + lastConfig map[string]string +} + +func (f *fakeProvider) PollChanges(_ context.Context, _ int, capabilityID, _ string, _ ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) { + f.lastConfig = sourceConfig + if f.err != nil { + return nil, "", f.err + } + if changes, ok := f.changes[capabilityID]; ok { + return changes, f.nextMarker, nil + } + changes := make([]Change, 0, len(f.paths[capabilityID])) + for _, path := range f.paths[capabilityID] { + changes = append(changes, Change{SourcePath: path, Scope: ChangeScopeAuto}) + } + return changes, f.nextMarker, nil +} + +func TestPollOncePassesSourceConfigToProvider(t *testing.T) { + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true, + SourceConfig: map[string]string{"exclusions": ".downloads\n.recyclebin"}, + }}, + } + prov := &fakeProvider{nextMarker: "m1"} + svc := newService(store, prov, &recordingQueuer{}, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if got := prov.lastConfig["exclusions"]; got != ".downloads\n.recyclebin" { + t.Fatalf("source config = %#v", prov.lastConfig) + } +} + +// passthroughConnRes resolves to empty credentials; the engine doesn't inspect +// them in tests (the fake provider ignores conn). +type passthroughConnRes struct{} + +func (passthroughConnRes) Resolve(context.Context, Connection) (ResolvedConnection, error) { + return ResolvedConnection{}, nil +} + +type fakeResolver struct{} + +func (fakeResolver) Resolve(_ context.Context, req scantrigger.Request) (*scantrigger.Target, error) { + if strings.HasPrefix(req.Path, "/mnt/media/") { + mode := scantrigger.ModeSubtree + if filepath.Ext(req.Path) == ".mkv" { + mode = scantrigger.ModeFile + } + return &scantrigger.Target{Folder: &models.MediaFolder{ID: 7}, Mode: mode, Path: req.Path, Trigger: req.Trigger}, nil + } + return nil, nil +} + +func (fakeResolver) ResolveMissingSubtree(_ context.Context, subtreePath, trigger string) (*scantrigger.Target, error) { + if strings.HasPrefix(subtreePath, "/mnt/media/") { + return &scantrigger.Target{Folder: &models.MediaFolder{ID: 7}, Mode: scantrigger.ModeSubtree, Path: subtreePath, Trigger: trigger}, nil + } + return nil, &scantrigger.RequestError{Status: 400, Code: "bad_request", Message: "outside media folders"} +} + +// unresolvableResolver treats every path as outside Silo's media folders, +// returning a RequestError — the "none resolved → misconfiguration" signal. +type unresolvableResolver struct{} + +func (unresolvableResolver) Resolve(_ context.Context, req scantrigger.Request) (*scantrigger.Target, error) { + return nil, &scantrigger.RequestError{Status: 400, Code: "bad_request", Message: "outside media folders"} +} +func (unresolvableResolver) ResolveMissingSubtree(context.Context, string, string) (*scantrigger.Target, error) { + return nil, &scantrigger.RequestError{Status: 400, Code: "bad_request", Message: "outside media folders"} +} + +// denySuppressor resolves paths normally but denies every claim, simulating a +// recently-scanned / debounced target (resolved but suppressed). +type denySuppressor struct{} + +func (denySuppressor) ShouldScan(context.Context, string, time.Duration) (bool, error) { + return false, nil +} +func (denySuppressor) Release(context.Context, string) error { return nil } + +type recordingQueuer struct { + enqueued []scantrigger.Target + autoscanEvents []int64 + createdCount *int + reusedCount int +} + +func (q *recordingQueuer) EnqueueScans(_ context.Context, targets []scantrigger.Target) error { + q.enqueued = append(q.enqueued, targets...) + return nil +} +func (q *recordingQueuer) EnqueueAutoscanScans(_ context.Context, targets []scantrigger.Target, eventID int64) (int, int, error) { + q.enqueued = append(q.enqueued, targets...) + q.autoscanEvents = append(q.autoscanEvents, eventID) + if q.createdCount != nil { + return *q.createdCount, q.reusedCount, nil + } + return len(targets), q.reusedCount, nil +} + +type allowSuppressor struct{} + +func (allowSuppressor) ShouldScan(context.Context, string, time.Duration) (bool, error) { + return true, nil +} +func (allowSuppressor) Release(context.Context, string) error { return nil } + +type recordingSuppressor struct { + claimed []string + released []string +} + +func (s *recordingSuppressor) ShouldScan(_ context.Context, key string, _ time.Duration) (bool, error) { + s.claimed = append(s.claimed, key) + return true, nil +} +func (s *recordingSuppressor) Release(_ context.Context, key string) error { + s.released = append(s.released, key) + return nil +} + +type failingQueuer struct{} + +func (failingQueuer) EnqueueScans(context.Context, []scantrigger.Target) error { + return context.DeadlineExceeded +} +func (failingQueuer) EnqueueAutoscanScans(context.Context, []scantrigger.Target, int64) (int, int, error) { + return 0, 0, context.DeadlineExceeded +} + +func newService(store Store, provider ScanSourceProvider, queue Queuer, suppress Suppressor) *Service { + return NewService(store, provider, passthroughConnRes{}, fakeResolver{}, queue, suppress, nil) +} + +// strptr is a tiny helper for the *string ConnectionID field in tests. +func strptr(s string) *string { return &s } + +func TestPollOnceEnqueuesDedupedFolders(t *testing.T) { + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, + }}, + } + prov := &fakeProvider{paths: map[string][]string{ + "arr": { + "/mnt/media/Show/S01/E01.mkv", + "/mnt/media/Show/S01/E02.mkv", + "/outside/lib/x.mkv", + }, + }, nextMarker: "m1"} + q := &recordingQueuer{} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 1 { + t.Fatalf("expected 1 deduped folder scan, got %d: %+v", len(q.enqueued), q.enqueued) + } + if q.enqueued[0].Trigger != "autoscan" || q.enqueued[0].Folder.ID != 7 { + t.Fatalf("unexpected target: %+v", q.enqueued[0]) + } + if _, ok := store.advanced["s1"]; !ok { + t.Fatalf("expected marker advanced for s1") + } +} + +func TestPollOnceRecordsSuccessfulEvent(t *testing.T) { + marker := "m0" + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, Marker: &marker, + }}, + } + prov := &fakeProvider{paths: map[string][]string{ + "arr": {"/mnt/media/Show/S01/E01.mkv"}, + }, nextMarker: "m1"} + q := &recordingQueuer{} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(store.createdEvents) != 1 { + t.Fatalf("expected one event created, got %d", len(store.createdEvents)) + } + if got := store.createdEvents[0].MarkerBefore; got != marker { + t.Fatalf("marker_before = %q, want %q", got, marker) + } + if len(q.autoscanEvents) != 1 || q.autoscanEvents[0] != 1 { + t.Fatalf("autoscan enqueue event ids = %v, want [1]", q.autoscanEvents) + } + if len(store.events) != 1 { + t.Fatalf("expected one finished event, got %d", len(store.events)) + } + event := store.events[0] + if event.ID != 1 || event.Status != EventStatusSuccess { + t.Fatalf("event identity/status = %+v", event) + } + if event.ChangesReturned != 1 || event.ChangesResolved != 1 || event.TargetsClaimed != 1 { + t.Fatalf("event change counts = %+v", event) + } + if event.ScansCreated != 1 || event.ScansReused != 0 || event.ScansSuppressed != 0 { + t.Fatalf("event scan counts = %+v", event) + } + if event.MarkerAfter != "m1" || event.ErrorMessage != "" { + t.Fatalf("event completion fields = %+v", event) + } +} + +func TestPollOnceRecordsReusedScanCounts(t *testing.T) { + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, + }}, + } + prov := &fakeProvider{paths: map[string][]string{ + "arr": { + "/mnt/media/ShowA/S01/E01.mkv", + "/mnt/media/ShowB/S01/E01.mkv", + }, + }, nextMarker: "m1"} + created := 1 + q := &recordingQueuer{createdCount: &created, reusedCount: 1} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(store.events) != 1 { + t.Fatalf("expected one finished event, got %d", len(store.events)) + } + event := store.events[0] + if event.ScansCreated != 1 || event.ScansReused != 1 { + t.Fatalf("event scan counts = %+v", event) + } + if event.TargetsClaimed != 2 { + t.Fatalf("targets_claimed = %d, want 2", event.TargetsClaimed) + } +} + +func TestPollOnceAppliesSourceRewritesBeforeEnqueue(t *testing.T) { + // The provider returns RAW source-namespace paths (/data/tv/...). The source's + // rewrite /data/tv -> /mnt/media/tv must be applied host-side so the resolved + // target uses the rewritten, Silo-native path. fakeResolver only resolves + // paths under /mnt/media/, so a missing rewrite would resolve to nothing. + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, + PathRewrites: []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}}, + }}, + } + prov := &fakeProvider{paths: map[string][]string{ + "arr": {"/data/tv/Show/S01/E01.mkv"}, + }, nextMarker: "m1"} + q := &recordingQueuer{} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 1 { + t.Fatalf("expected 1 enqueued target, got %d: %+v", len(q.enqueued), q.enqueued) + } + // uniqueParentDirs collapses E01.mkv to its parent dir; the rewritten target + // must be the Silo-native /mnt/media/tv/Show/S01. + if got := q.enqueued[0].Path; got != "/mnt/media/tv/Show/S01" { + t.Fatalf("expected rewritten target path /mnt/media/tv/Show/S01, got %q", got) + } + if _, ok := store.advanced["s1"]; !ok { + t.Fatalf("expected marker advanced for s1") + } +} + +func TestPollOnceStructuredFileChangeEnqueuesExactFile(t *testing.T) { + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true, + PathRewrites: []PathRewrite{{From: "/ceph/tv", To: "/mnt/media/tv"}}, + }}, + } + prov := &fakeProvider{changes: map[string][]Change{ + "cephfs": {{ + SourcePath: "/ceph/tv/Show/S01/E01.mkv", + Scope: ChangeScopeFile, + }}, + }, nextMarker: "m1"} + q := &recordingQueuer{} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 1 { + t.Fatalf("expected 1 file scan, got %d: %+v", len(q.enqueued), q.enqueued) + } + if got := q.enqueued[0].Mode; got != scantrigger.ModeFile { + t.Fatalf("mode = %q, want %q", got, scantrigger.ModeFile) + } + if got := q.enqueued[0].Path; got != "/mnt/media/tv/Show/S01/E01.mkv" { + t.Fatalf("file path = %q", got) + } + if _, ok := store.advanced["s1"]; !ok { + t.Fatalf("expected marker advanced for s1") + } +} + +func TestPollOnceStructuredSubtreeChangeEnqueuesExactSubtree(t *testing.T) { + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true, + PathRewrites: []PathRewrite{{From: "/ceph/movies", To: "/mnt/media/movies"}}, + }}, + } + prov := &fakeProvider{changes: map[string][]Change{ + "cephfs": {{ + SourcePath: "/ceph/movies/Movie (2026)", + Scope: ChangeScopeSubtree, + }}, + }, nextMarker: "m1"} + q := &recordingQueuer{} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 1 { + t.Fatalf("expected 1 subtree scan, got %d: %+v", len(q.enqueued), q.enqueued) + } + if got := q.enqueued[0].Mode; got != scantrigger.ModeSubtree { + t.Fatalf("mode = %q, want %q", got, scantrigger.ModeSubtree) + } + if got := q.enqueued[0].Path; got != "/mnt/media/movies/Movie (2026)" { + t.Fatalf("subtree path = %q", got) + } + if _, ok := store.advanced["s1"]; !ok { + t.Fatalf("expected marker advanced for s1") + } +} + +func TestPollOnceScansDistinctPathsUnderSameFolder(t *testing.T) { + // Two imported subtrees resolve to the SAME folder ID (7) but DIFFERENT + // target paths. Keying suppression on folder ID alone would drop the second; + // keying on (folder, path) must scan both. + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, + }}, + } + prov := &fakeProvider{paths: map[string][]string{ + "arr": { + "/mnt/media/ShowA/S01/E01.mkv", + "/mnt/media/ShowB/S01/E01.mkv", + }, + }, nextMarker: "m1"} + q := &recordingQueuer{} + sup := &recordingSuppressor{} + svc := newService(store, prov, q, sup) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 2 { + t.Fatalf("expected 2 enqueued targets (distinct paths), got %d: %+v", len(q.enqueued), q.enqueued) + } + if len(sup.claimed) != 2 { + t.Fatalf("expected 2 claimed keys, got %d: %v", len(sup.claimed), sup.claimed) + } + if sup.claimed[0] == sup.claimed[1] { + t.Fatalf("expected distinct claimed keys, both were %q", sup.claimed[0]) + } +} + +func TestPollOnceStoresOpaqueMarkerVerbatim(t *testing.T) { + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, + }}, + } + const opaque = "eyJjdXJzb3IiOiJhYmMxMjMifQ==|2026-06-02T14:10:00Z" + prov := &fakeProvider{paths: map[string][]string{ + "arr": {"/mnt/media/Show/S01/E01.mkv"}, + }, nextMarker: opaque} + svc := newService(store, prov, &recordingQueuer{}, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if got := store.advanced["s1"]; got != opaque { + t.Fatalf("marker not stored verbatim: got %q want %q", got, opaque) + } +} + +func TestPollOnceHoldsMarkerWhenPathsReturnedButNoneResolve(t *testing.T) { + // A freshly-enabled source with no path_rewrites: the provider returns paths + // that don't map to any Silo library folder (the resolver RequestErrors on + // every path). The marker must NOT advance (so the imports aren't skipped + // forever) and an explaining error is recorded. + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, + }}, + } + prov := &fakeProvider{paths: map[string][]string{ + "arr": { + "/data/tv/Show/S01/E01.mkv", + "/data/tv/Show/S01/E02.mkv", + }, + }, nextMarker: "m1"} + q := &recordingQueuer{} + // unresolvableResolver rejects every path; allowSuppressor would happily claim + // anything, so the only reason nothing resolves is the resolver. + svc := NewService(store, prov, passthroughConnRes{}, unresolvableResolver{}, q, allowSuppressor{}, nil) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 0 { + t.Fatalf("nothing should enqueue when no path resolves, got %d", len(q.enqueued)) + } + if _, ok := store.advanced["s1"]; ok { + t.Fatalf("marker must NOT advance when paths returned but none resolved") + } + msg, ok := store.recorded["s1"] + if !ok || msg == "" { + t.Fatalf("expected an explaining error recorded for s1, got %q ok=%v", msg, ok) + } + if want := "returned 2 path(s)"; !strings.Contains(msg, want) { + t.Fatalf("recorded error %q should mention %q", msg, want) + } + if len(store.events) != 1 { + t.Fatalf("expected one finished event, got %d", len(store.events)) + } + event := store.events[0] + if event.Status != EventStatusUnresolved { + t.Fatalf("event status = %q, want %q", event.Status, EventStatusUnresolved) + } + if event.ChangesReturned != 2 || event.ChangesResolved != 0 || event.TargetsClaimed != 0 { + t.Fatalf("event counts = %+v", event) + } + if !strings.Contains(event.ErrorMessage, "returned 2 path(s)") { + t.Fatalf("event error = %q", event.ErrorMessage) + } +} + +func TestPollOnceAdvancesMarkerWhenResolvedButAllSuppressed(t *testing.T) { + // Paths DO resolve to a Silo library folder, but every claim is denied by the + // suppressor (recently scanned / debounced). This is NOT a misconfiguration: + // the work is effectively done, so the marker must ADVANCE and no error is + // recorded. Regression guard for treating "resolved-but-suppressed" as + // "nothing resolved." + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, + // rewrite /data/tv -> /mnt/media/tv so fakeResolver resolves the paths. + PathRewrites: []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}}, + }}, + } + prov := &fakeProvider{paths: map[string][]string{ + "arr": { + "/data/tv/Show/S01/E01.mkv", + "/data/tv/Show/S01/E02.mkv", + }, + }, nextMarker: "m1"} + q := &recordingQueuer{} + svc := newService(store, prov, q, denySuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 0 { + t.Fatalf("nothing should enqueue when every target is suppressed, got %d", len(q.enqueued)) + } + if got, ok := store.advanced["s1"]; !ok || got != "m1" { + t.Fatalf("marker must advance to %q when paths resolve but are all suppressed, got %q ok=%v", "m1", got, ok) + } + if _, ok := store.recorded["s1"]; ok { + t.Fatalf("no error should be recorded when targets merely suppressed (work is debounced, not misconfigured)") + } +} + +func TestPollOnceAdvancesMarkerWhenZeroPathsReturned(t *testing.T) { + // The "nothing to do" case: provider returns no paths at all. The marker must + // advance normally and no error is recorded. + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, + }}, + } + prov := &fakeProvider{paths: map[string][]string{"arr": {}}, nextMarker: "m1"} + q := &recordingQueuer{} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if got, ok := store.advanced["s1"]; !ok || got != "m1" { + t.Fatalf("marker must advance to %q when zero paths returned, got %q ok=%v", "m1", got, ok) + } + if _, ok := store.recorded["s1"]; ok { + t.Fatalf("no error should be recorded when there's simply nothing to do") + } +} + +func TestPollOnceDisabledNoop(t *testing.T) { + store := &fakeStore{settings: Settings{Enabled: false}} + q := &recordingQueuer{} + svc := newService(store, &fakeProvider{}, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 0 { + t.Fatalf("disabled autoscan should enqueue nothing, got %d", len(q.enqueued)) + } +} + +func TestPollOnceProviderErrorKeepsMarker(t *testing.T) { + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}}, + } + prov := &fakeProvider{err: context.DeadlineExceeded} + q := &recordingQueuer{} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce should not propagate per-source error: %v", err) + } + if _, ok := store.advanced["s1"]; ok { + t.Fatalf("marker must NOT advance on provider failure") + } + if msg, ok := store.recorded["s1"]; !ok || msg == "" { + t.Fatalf("expected provider error recorded for s1, got %q ok=%v", msg, ok) + } +} + +func TestPollOnceReleasesClaimOnEnqueueFailure(t *testing.T) { + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}}, + } + prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"} + sup := &recordingSuppressor{} + svc := newService(store, prov, failingQueuer{}, sup) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(sup.claimed) != 1 || len(sup.released) != 1 || sup.released[0] != sup.claimed[0] { + t.Fatalf("expected claimed folder to be released, claimed=%v released=%v", sup.claimed, sup.released) + } + if _, ok := store.advanced["s1"]; ok { + t.Fatalf("marker must NOT advance when enqueue fails") + } + if len(store.events) != 1 { + t.Fatalf("expected one finished event, got %d", len(store.events)) + } + event := store.events[0] + if event.Status != EventStatusError || !strings.Contains(event.ErrorMessage, context.DeadlineExceeded.Error()) { + t.Fatalf("event should record enqueue error, got %+v", event) + } + if event.ChangesReturned != 1 || event.ChangesResolved != 1 || event.TargetsClaimed != 1 { + t.Fatalf("event counts = %+v", event) + } +} + +func TestPollOncePollsConnectionlessSource(t *testing.T) { + // A connection is OPTIONAL: a source with no bound connection is still polled + // (the provider gets an empty connection it may ignore — e.g. a filesystem + // watcher). Whether the plugin needs credentials is the plugin's concern. + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: nil, Enabled: true, + }}, + } + prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"} + q := &recordingQueuer{} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 1 { + t.Fatalf("a connection-less source must still poll + enqueue, got %d enqueued", len(q.enqueued)) + } + if _, ok := store.recorded["s1"]; ok { + t.Fatalf("a connection-less source must not record a 'no connection' error") + } + if _, ok := store.advanced["s1"]; !ok { + t.Fatalf("marker must advance after a successful connection-less poll") + } +} + +func TestPollOnceSkipsSourcePolledTooRecently(t *testing.T) { + recent := time.Now().Add(-30 * time.Second) + interval := 600 + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, + PollIntervalSeconds: &interval, LastRunAt: &recent, + }}, + } + prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"} + q := &recordingQueuer{} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 0 { + t.Fatalf("a source within its poll interval must be skipped, got %d enqueued", len(q.enqueued)) + } + if _, ok := store.advanced["s1"]; ok { + t.Fatalf("marker must NOT advance for a skipped (too-recent) source") + } +} + +func TestPollOnceRunsSourcePastItsInterval(t *testing.T) { + old := time.Now().Add(-20 * time.Minute) + interval := 600 // 10 min; last run was 20 min ago => eligible + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{{ + ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, + PollIntervalSeconds: &interval, LastRunAt: &old, + }}, + } + prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"} + q := &recordingQueuer{} + svc := newService(store, prov, q, allowSuppressor{}) + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 1 { + t.Fatalf("a source past its interval must poll, got %d enqueued", len(q.enqueued)) + } + if _, ok := store.advanced["s1"]; !ok { + t.Fatalf("expected marker advanced for s1") + } +} + +func TestPollOnceSkipsOrphanedSourceQuietly(t *testing.T) { + // Two enabled sources: only "arr-live" is still discovered; "arr-gone"'s + // plugin has been uninstalled. The orphan must be skipped quietly — no poll, + // no RecordError spam — while the live source still polls normally. + store := &fakeStore{ + settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60}, + sources: []Source{ + {ID: "live", InstallationID: 1, CapabilityID: "arr-live", ConnectionID: strptr("c1"), Enabled: true}, + {ID: "gone", InstallationID: 2, CapabilityID: "arr-gone", ConnectionID: strptr("c1"), Enabled: true}, + }, + } + prov := &fakeProvider{paths: map[string][]string{ + "arr-live": {"/mnt/media/Show/S01/E01.mkv"}, + "arr-gone": {"/mnt/media/Other/S01/E01.mkv"}, + }, nextMarker: "m1"} + q := &recordingQueuer{} + // Lister reports only the live capability; the orphan is absent. + lister := fakeLister{sources: []DiscoveredSource{{InstallationID: 1, CapabilityID: "arr-live"}}} + svc := NewService(store, prov, passthroughConnRes{}, fakeResolver{}, q, allowSuppressor{}, lister) + + if err := svc.PollOnce(context.Background()); err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(q.enqueued) != 1 { + t.Fatalf("only the live source should enqueue, got %d: %+v", len(q.enqueued), q.enqueued) + } + if _, ok := store.advanced["live"]; !ok { + t.Fatalf("expected live source marker advanced") + } + if _, ok := store.advanced["gone"]; ok { + t.Fatalf("orphaned source must NOT poll/advance") + } + if _, ok := store.recorded["gone"]; ok { + t.Fatalf("orphaned source must be skipped quietly, but an error was recorded: %q", store.recorded["gone"]) + } +} diff --git a/internal/autoscan/suggest.go b/internal/autoscan/suggest.go new file mode 100644 index 00000000..89df0dca --- /dev/null +++ b/internal/autoscan/suggest.go @@ -0,0 +1,186 @@ +package autoscan + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// ErrNoConnection is returned when the rewrite suggester is asked for a source +// that has no bound connection. The admin API surfaces it as a 400. +var ErrNoConnection = errors.New("autoscan: source has no bound connection") + +// SuggestRewrites resolves the source's bound connection, lists the arr root +// folders and the Silo media-folder paths, and suffix-matches arr roots to Silo +// folders to propose path rewrites. The source's existing rewrites are passed so +// already-covered roots are reported separately. Returns ErrNoConnection when +// the source has no bound connection (the handler maps it to 400). +func (s *Service) SuggestRewrites(ctx context.Context, sourceID string) (RewriteSuggestions, error) { + if s.rootFolders == nil || s.folders == nil { + return RewriteSuggestions{}, fmt.Errorf("autoscan: rewrite suggester not configured") + } + src, err := s.store.GetSource(ctx, sourceID) + if err != nil { + return RewriteSuggestions{}, err + } + if src.ConnectionID == nil { + return RewriteSuggestions{}, ErrNoConnection + } + conn, err := s.store.GetConnection(ctx, *src.ConnectionID) + if err != nil { + return RewriteSuggestions{}, err + } + resolved, err := s.connres.Resolve(ctx, conn) + if err != nil { + return RewriteSuggestions{}, fmt.Errorf("autoscan: resolve connection: %w", err) + } + arrRoots, err := s.rootFolders.RootFolders(ctx, resolved.BaseURL, resolved.APIKey) + if err != nil { + return RewriteSuggestions{}, fmt.Errorf("autoscan: list arr root folders: %w", err) + } + siloFolders, err := s.folders.ListFolderPaths(ctx) + if err != nil { + return RewriteSuggestions{}, fmt.Errorf("autoscan: list silo folders: %w", err) + } + return suggestRewrites(arrRoots, siloFolders, src.PathRewrites), nil +} + +// RewriteSuggestions is the result of matching arr root folders to Silo folders. +type RewriteSuggestions struct { + Proposed []ProposedRewrite `json:"proposed"` + Unmatched []string `json:"unmatched"` + Ambiguous []AmbiguousRoot `json:"ambiguous"` + Covered []string `json:"covered"` +} + +// ProposedRewrite is a suggested rewrite plus its confidence (shared trailing segments). +type ProposedRewrite struct { + From string `json:"from"` + To string `json:"to"` + MatchDepth int `json:"match_depth"` +} + +// AmbiguousRoot is an arr root that tied across multiple Silo folders. +type AmbiguousRoot struct { + Root string `json:"root"` + Candidates []string `json:"candidates"` +} + +// normalizePath makes a path comparable: backslashes -> '/', collapse duplicate +// slashes, strip a trailing slash (but keep a bare "/"). +func normalizePath(p string) string { + p = normalizeSeparators(strings.TrimSpace(p)) + for strings.Contains(p, "//") { + p = strings.ReplaceAll(p, "//", "/") + } + if len(p) > 1 { + p = strings.TrimRight(p, "/") + } + return p +} + +func segments(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + return strings.Split(p, "/") +} + +// commonSuffixLen counts equal trailing segments (full-segment equality). +func commonSuffixLen(a, b []string) int { + i, j, n := len(a)-1, len(b)-1, 0 + for i >= 0 && j >= 0 && a[i] == b[j] { + n++ + i-- + j-- + } + return n +} + +// coveredBy reports whether an existing rewrite already matches root (same +// boundary rule as applyRewrites). The existing From is normalized too, so a +// stored Windows/dup-slash rewrite still covers a normalized root. +func coveredBy(root string, existing []PathRewrite) bool { + for _, rw := range existing { + from := normalizePath(rw.From) + if from == "" { + continue + } + if root == from || strings.HasPrefix(root, from+"/") { + return true + } + } + return false +} + +// suggestRewrites matches each arr root to the Silo folder sharing the most +// trailing path segments (unique winner). Pure: no I/O, no deployment constants. +func suggestRewrites(arrRoots, siloFolderPaths []string, existing []PathRewrite) RewriteSuggestions { + siloNorm := make([]string, 0, len(siloFolderPaths)) + siloSegs := make([][]string, 0, len(siloFolderPaths)) + siloSeen := make(map[string]struct{}) + for _, p := range siloFolderPaths { + n := normalizePath(p) + if n == "" { + continue + } + if _, dup := siloSeen[n]; dup { // dedup Silo paths so candidates are distinct + continue + } + siloSeen[n] = struct{}{} + siloNorm = append(siloNorm, n) + siloSegs = append(siloSegs, segments(n)) + } + + // Initialize to non-nil so the JSON response always has arrays ([]) rather + // than null — the frontend maps over these directly. + out := RewriteSuggestions{ + Proposed: []ProposedRewrite{}, + Unmatched: []string{}, + Ambiguous: []AmbiguousRoot{}, + Covered: []string{}, + } + rootSeen := make(map[string]struct{}) + for _, raw := range arrRoots { + root := normalizePath(raw) + if root == "" { + continue + } + if _, dup := rootSeen[root]; dup { // dedup arr roots that normalize alike + continue + } + rootSeen[root] = struct{}{} + if coveredBy(root, existing) { + out.Covered = append(out.Covered, root) + continue + } + rootSegs := segments(root) + best := 0 + var winners []string + for i, segs := range siloSegs { + n := commonSuffixLen(rootSegs, segs) + if n == 0 { + continue + } + if n > best { + best, winners = n, []string{siloNorm[i]} + } else if n == best { + winners = append(winners, siloNorm[i]) + } + } + switch { + case best == 0: + out.Unmatched = append(out.Unmatched, root) + case len(winners) == 1: + if winners[0] == root { + continue // arr path already equals the Silo path — no rewrite needed + } + out.Proposed = append(out.Proposed, ProposedRewrite{From: root, To: winners[0], MatchDepth: best}) + default: + out.Ambiguous = append(out.Ambiguous, AmbiguousRoot{Root: root, Candidates: winners}) + } + } + return out +} diff --git a/internal/autoscan/suggest_deps.go b/internal/autoscan/suggest_deps.go new file mode 100644 index 00000000..c3890201 --- /dev/null +++ b/internal/autoscan/suggest_deps.go @@ -0,0 +1,81 @@ +package autoscan + +import ( + "context" + "net/http" + "time" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/requests/arrclient" +) + +// rootFolderTimeout is generous: Radarr/Sonarr compute unmappedFolders by +// scanning every root folder (often slow network shares), so a large library's +// /api/v3/rootfolder can take 20-30s+ — well past arrclient's 30s default. +const rootFolderTimeout = 2 * time.Minute + +type arrRootFolderClient struct{ httpClient *http.Client } + +// NewArrRootFolderClient returns a RootFolderClient backed by the shared arrclient. +// When no client is given it uses a long timeout, since the root-folder scan is slow. +func NewArrRootFolderClient(httpClient *http.Client) RootFolderClient { + if httpClient == nil { + httpClient = &http.Client{Timeout: rootFolderTimeout} + } + return &arrRootFolderClient{httpClient: httpClient} +} + +func (c *arrRootFolderClient) RootFolders(ctx context.Context, baseURL, apiKey string) ([]string, error) { + client := arrclient.New(baseURL, apiKey, c.httpClient) + folders, err := arrclient.ListRootFolders(ctx, client) + if err != nil { + return nil, err + } + paths := make([]string, 0, len(folders)) + for _, f := range folders { + if f.Path != "" { + paths = append(paths, f.Path) + } + } + return paths, nil +} + +// statusTimeout bounds the connection-test probe: /api/v3/system/status is a +// cheap call, so a short timeout keeps a misconfigured/unreachable target from +// hanging the request. +const statusTimeout = 10 * time.Second + +// SystemStatus probes the arr /api/v3/system/status endpoint and returns the +// reported version. It satisfies ArrStatusProbe. A dedicated short-timeout +// client is used (the suggester's long root-folder timeout is inappropriate for +// a liveness probe). +func (c *arrRootFolderClient) SystemStatus(ctx context.Context, baseURL, apiKey string) (string, error) { + httpClient := &http.Client{Timeout: statusTimeout} + client := arrclient.New(baseURL, apiKey, httpClient) + var status struct { + Version string `json:"version"` + } + if err := client.GetJSON(ctx, "/api/v3/system/status", &status); err != nil { + return "", err + } + return status.Version, nil +} + +type catalogFolderLister struct{ repo *catalog.FolderRepository } + +// NewCatalogFolderLister adapts catalog.FolderRepository to FolderLister. +func NewCatalogFolderLister(repo *catalog.FolderRepository) FolderLister { + return &catalogFolderLister{repo: repo} +} + +func (l *catalogFolderLister) ListFolderPaths(ctx context.Context) ([]string, error) { + folders, err := l.repo.List(ctx) + if err != nil { + return nil, err + } + var paths []string + for _, f := range folders { + paths = append(paths, f.Paths...) + } + return paths, nil +} diff --git a/internal/autoscan/suppress.go b/internal/autoscan/suppress.go new file mode 100644 index 00000000..02794103 --- /dev/null +++ b/internal/autoscan/suppress.go @@ -0,0 +1,43 @@ +package autoscan + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" +) + +// Suppressor prevents re-enqueuing a scan for the same (folder, path) target +// within a window. The key must match the scanqueue dedup granularity +// (folder ID + path) so two distinct subtrees under one library folder are +// not collapsed into a single suppression claim. +type Suppressor interface { + // ShouldScan atomically claims the target for scanning: returns true and sets + // a TTL key if no claim exists, false if a recent claim is still live. + ShouldScan(ctx context.Context, key string, ttl time.Duration) (bool, error) + // Release drops a suppression claim (used when the scan enqueue fails). + Release(ctx context.Context, key string) error +} + +type redisSuppressor struct{ client *redis.Client } + +func NewRedisSuppressor(client *redis.Client) Suppressor { return &redisSuppressor{client: client} } + +func (s *redisSuppressor) ShouldScan(ctx context.Context, key string, ttl time.Duration) (bool, error) { + if s.client == nil || ttl <= 0 { + return true, nil // no suppression configured -> always scan + } + redisKey := "autoscan:scanned:" + key + ok, err := s.client.SetNX(ctx, redisKey, "1", ttl).Result() + if err != nil { + return true, nil // fail open: a Redis hiccup should not block scanning + } + return ok, nil +} + +func (s *redisSuppressor) Release(ctx context.Context, key string) error { + if s.client == nil { + return nil + } + return s.client.Del(ctx, "autoscan:scanned:"+key).Err() +} diff --git a/internal/autoscan/types.go b/internal/autoscan/types.go new file mode 100644 index 00000000..64c8d214 --- /dev/null +++ b/internal/autoscan/types.go @@ -0,0 +1,167 @@ +package autoscan + +import "time" + +// Settings is the global autoscan configuration (singleton row). +type Settings struct { + Enabled bool + DefaultPollIntervalSeconds int + DebounceSeconds int +} + +// Connection is an arr server the host can reach: either own credentials, or a +// live reference to a Requests integration (RequestIntegrationID set). +type Connection struct { + ID string + Name string + Kind string + BaseURL string // own; empty when linked + APIKeyRef string // own; empty when linked + RequestIntegrationID *string +} + +// PathRewrite is a per-source prefix translation from a raw arr/source-namespace +// path to a Silo-native path. The merged scan_source contract moved rewrite +// ownership from the plugin to the host, so these are applied host-side before a +// raw path is resolved/enqueued. +type PathRewrite struct { + From string `json:"from"` + To string `json:"to"` +} + +// ChangeScope tells the host how to interpret a scan_source path. +type ChangeScope string + +const ( + ChangeScopeAuto ChangeScope = "auto" + ChangeScopeFile ChangeScope = "file" + ChangeScopeSubtree ChangeScope = "subtree" +) + +// Change is one raw provider/source-namespace path returned by a scan_source +// plugin. The host applies PathRewrites before resolving the changed path. +type Change struct { + SourcePath string + Scope ChangeScope +} + +// Source ties a scan_source plugin capability instance to a connection plus the +// host-owned scheduling/bookkeeping state. +type Source struct { + ID string + InstallationID int + CapabilityID string + ConnectionID *string // nil until an operator binds a connection + Enabled bool + PollIntervalSeconds *int // nil => use settings default + PathRewrites []PathRewrite // host-owned raw->Silo prefix rewrites + SourceConfig map[string]string + Label string // operator-set display label; "" = unset + Marker *string // opaque; nil on first run + LastRunAt *time.Time + LastError *string +} + +type EventStatus string + +const ( + EventStatusSuccess EventStatus = "success" + EventStatusError EventStatus = "error" + EventStatusUnresolved EventStatus = "unresolved" +) + +type Event struct { + ID int64 + SourceID *string + InstallationID int + CapabilityID string + StartedAt time.Time + CompletedAt time.Time + DurationMS int64 + Status EventStatus + ChangesReturned int + ChangesResolved int + TargetsClaimed int + ScansCreated int + ScansReused int + ScansSuppressed int + ErrorMessage string + MarkerBefore *string + MarkerAfter *string +} + +type EventCreate struct { + SourceID string + InstallationID int + CapabilityID string + StartedAt time.Time + MarkerBefore string +} + +type EventFinish struct { + ID int64 + CompletedAt time.Time + Status EventStatus + ChangesReturned int + ChangesResolved int + TargetsClaimed int + ScansCreated int + ScansReused int + ScansSuppressed int + ErrorMessage string + MarkerAfter string +} + +type EnqueueResult struct { + Created int + Reused int +} + +type EventListFilter struct { + SourceID string + Status EventStatus + Search string + Limit int + Offset int +} + +type ScanListFilter struct { + Status string + Search string + Limit int + Offset int +} + +type EventWithRuns struct { + Event Event + Runs []ScanRunSummary +} + +type ScanRunSummary struct { + ID string + MediaFolderID int + Mode string + Path string + Trigger string + Status string + ErrorMessage string + RequestedAt *time.Time + StartedAt *time.Time + CompletedAt *time.Time +} + +type ScanWithEvent struct { + ScanRunSummary + AutoscanEventID *int64 + SourceID *string + InstallationID *int + CapabilityID string + EventStatus EventStatus + EventCompletedAt *time.Time +} + +type QueueSummary struct { + Active int + Accepted int + Running int +} diff --git a/internal/models/scan_run.go b/internal/models/scan_run.go index e314071d..7ecb065d 100644 --- a/internal/models/scan_run.go +++ b/internal/models/scan_run.go @@ -7,17 +7,18 @@ import ( // ScanRun represents a persisted library scan orchestration row. type ScanRun struct { - ID string - MediaFolderID int - Mode string - Path string - Trigger string - Status string - ResultPayload json.RawMessage - ErrorMessage string - RequestedAt time.Time - StartedAt *time.Time - CompletedAt *time.Time - HeartbeatAt *time.Time - UpdatedAt time.Time + ID string + MediaFolderID int + Mode string + Path string + Trigger string + Status string + ResultPayload json.RawMessage + ErrorMessage string + AutoscanEventID *int64 + RequestedAt time.Time + StartedAt *time.Time + CompletedAt *time.Time + HeartbeatAt *time.Time + UpdatedAt time.Time } diff --git a/internal/pluginhost/client.go b/internal/pluginhost/client.go index a5106bc5..55c8ea37 100644 --- a/internal/pluginhost/client.go +++ b/internal/pluginhost/client.go @@ -44,6 +44,11 @@ type ScheduledTaskClient struct { timeout time.Duration } +type ScanSourceClient struct { + client pluginv1.ScanSourceClient + timeout time.Duration +} + type EventConsumerClient struct { client pluginv1.EventConsumerClient timeout time.Duration @@ -118,6 +123,16 @@ func (c *Client) ScheduledTask(capabilityID string) (*ScheduledTaskClient, error }, nil } +func (c *Client) ScanSource(capabilityID string) (*ScanSourceClient, error) { + if err := c.requireCapability("scan_source.v1", capabilityID); err != nil { + return nil, err + } + return &ScanSourceClient{ + client: c.rpc.ScanSource(), + timeout: DefaultScanSourceTimeout, + }, nil +} + func (c *Client) EventConsumer(capabilityID string) (*EventConsumerClient, error) { if err := c.requireCapability("event_consumer.v1", capabilityID); err != nil { return nil, err @@ -229,6 +244,12 @@ func (c *ScheduledTaskClient) Run(ctx context.Context, req *pluginv1.RunSchedule return c.client.Run(callCtx, req) } +func (c *ScanSourceClient) PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) { + callCtx, cancel := ensureDeadline(ctx, c.timeout) + defer cancel() + return c.client.PollChanges(callCtx, req) +} + func (c *EventConsumerClient) HandleEvent(ctx context.Context, req *pluginv1.HandleEventRequest) (*pluginv1.HandleEventResponse, error) { callCtx, cancel := ensureDeadline(ctx, c.timeout) defer cancel() diff --git a/internal/pluginhost/client_test.go b/internal/pluginhost/client_test.go new file mode 100644 index 00000000..24d10a94 --- /dev/null +++ b/internal/pluginhost/client_test.go @@ -0,0 +1,107 @@ +package pluginhost + +import ( + "errors" + "testing" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + sdkruntime "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/runtime" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// makeTestClient constructs a Client with the given declared capabilities. +// It creates a lazy (non-connecting) gRPC ClientConn so that accessors which +// call c.rpc.() do not panic — no real connection is made. +func makeTestClient(t *testing.T, capabilities []*pluginv1.CapabilityDescriptor) *Client { + t.Helper() + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + rpc := sdkruntime.NewClient(conn) + manifest := &pluginv1.PluginManifest{Capabilities: capabilities} + return newClient(0, rpc, manifest) +} + +func TestClient_ScheduledTask_CapabilityGate(t *testing.T) { + t.Run("absent capability returns error", func(t *testing.T) { + c := makeTestClient(t, nil) + _, err := c.ScheduledTask("missing") + if err == nil { + t.Fatal("expected error for missing scheduled_task.v1 capability, got nil") + } + if !errors.Is(err, ErrCapabilityNotFound) { + t.Errorf("expected ErrCapabilityNotFound, got %v", err) + } + }) + + t.Run("declared capability returns no error", func(t *testing.T) { + c := makeTestClient(t, []*pluginv1.CapabilityDescriptor{ + {Type: "scheduled_task.v1", Id: "nightly"}, + }) + got, err := c.ScheduledTask("nightly") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil ScheduledTaskClient") + } + }) + + t.Run("wrong id returns error", func(t *testing.T) { + c := makeTestClient(t, []*pluginv1.CapabilityDescriptor{ + {Type: "scheduled_task.v1", Id: "nightly"}, + }) + _, err := c.ScheduledTask("weekly") + if err == nil { + t.Fatal("expected error for mismatched scheduled_task.v1 capability id, got nil") + } + if !errors.Is(err, ErrCapabilityNotFound) { + t.Errorf("expected ErrCapabilityNotFound, got %v", err) + } + }) +} + +func TestClient_ScanSource_CapabilityGate(t *testing.T) { + t.Run("absent capability returns error", func(t *testing.T) { + c := makeTestClient(t, nil) + _, err := c.ScanSource("missing") + if err == nil { + t.Fatal("expected error for missing scan_source.v1 capability, got nil") + } + if !errors.Is(err, ErrCapabilityNotFound) { + t.Errorf("expected ErrCapabilityNotFound, got %v", err) + } + }) + + t.Run("declared capability returns no error", func(t *testing.T) { + c := makeTestClient(t, []*pluginv1.CapabilityDescriptor{ + {Type: "scan_source.v1", Id: "arr"}, + }) + got, err := c.ScanSource("arr") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil ScanSourceClient") + } + }) + + t.Run("wrong id returns error", func(t *testing.T) { + c := makeTestClient(t, []*pluginv1.CapabilityDescriptor{ + {Type: "scan_source.v1", Id: "arr"}, + }) + _, err := c.ScanSource("inotify") + if err == nil { + t.Fatal("expected error for mismatched scan_source.v1 capability id, got nil") + } + if !errors.Is(err, ErrCapabilityNotFound) { + t.Errorf("expected ErrCapabilityNotFound, got %v", err) + } + }) +} diff --git a/internal/pluginhost/handshake.go b/internal/pluginhost/handshake.go index da417fd1..9d13d5e2 100644 --- a/internal/pluginhost/handshake.go +++ b/internal/pluginhost/handshake.go @@ -14,9 +14,14 @@ const ( DefaultMetadataTimeout = 30 * time.Second DefaultAnalyzerTimeout = 5 * time.Minute DefaultControlTimeout = 10 * time.Second - DefaultEventTimeout = 10 * time.Second - DefaultAuthTimeout = 10 * time.Second - DefaultRouteTimeout = 10 * time.Second + // DefaultScanSourceTimeout covers a single PollChanges call. Most scan + // sources return quickly, but filesystem-backed sources may need a longer + // bounded window for an initial baseline; cooperative plugins should still + // checkpoint progress and return before this deadline. + DefaultScanSourceTimeout = 5 * time.Minute + DefaultEventTimeout = 10 * time.Second + DefaultAuthTimeout = 10 * time.Second + DefaultRouteTimeout = 10 * time.Second ) func HandshakeConfig() plugin.HandshakeConfig { diff --git a/internal/plugins/service.go b/internal/plugins/service.go index 13709f93..e4980cb3 100644 --- a/internal/plugins/service.go +++ b/internal/plugins/service.go @@ -23,6 +23,7 @@ type pluginClient interface { MetadataProvider(capabilityID string) (*pluginhost.MetadataProviderClient, error) MediaAnalyzer(capabilityID string) (*pluginhost.MediaAnalyzerClient, error) ScheduledTask(capabilityID string) (*pluginhost.ScheduledTaskClient, error) + ScanSource(capabilityID string) (*pluginhost.ScanSourceClient, error) EventConsumer(capabilityID string) (*pluginhost.EventConsumerClient, error) AuthProvider(capabilityID string) (*pluginhost.AuthProviderClient, error) HTTPRoutes(capabilityID string) (*pluginhost.HTTPRoutesClient, error) @@ -422,6 +423,18 @@ func (s *Service) ScheduledTaskClient( return client.ScheduledTask(capabilityID) } +func (s *Service) ScanSourceClient( + ctx context.Context, + installationID int, + capabilityID string, +) (*pluginhost.ScanSourceClient, error) { + client, err := s.ensureClient(ctx, installationID) + if err != nil { + return nil, err + } + return client.ScanSource(capabilityID) +} + func (s *Service) EventConsumerClient( ctx context.Context, installationID int, diff --git a/internal/plugins/service_hot_reload_test.go b/internal/plugins/service_hot_reload_test.go index 433d8764..6fb63f31 100644 --- a/internal/plugins/service_hot_reload_test.go +++ b/internal/plugins/service_hot_reload_test.go @@ -207,6 +207,10 @@ func (f *fakePluginClient) ScheduledTask(string) (*pluginhost.ScheduledTaskClien return nil, nil } +func (f *fakePluginClient) ScanSource(string) (*pluginhost.ScanSourceClient, error) { + return nil, nil +} + func (f *fakePluginClient) EventConsumer(string) (*pluginhost.EventConsumerClient, error) { return nil, nil } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index b08de2be..ce13faa5 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -504,6 +504,7 @@ func (s *Scanner) scanPaths( var wg sync.WaitGroup pathCh := make(chan string, s.workers) var newCount, updatedCount, unchangedCount, errorCount, processedCount atomic.Int64 + subtitleCache := newExternalSubtitleDirCache() for range s.workers { wg.Go(func() { @@ -511,7 +512,7 @@ func (s *Scanner) scanPaths( if ctx.Err() != nil { continue // drain channel } - action, updateReasons, processErr := s.processFile(ctx, path, folder, existingByPath, existingContentStatuses, rootInference.Assignments[path], groupInference.Assignments[path]) + action, updateReasons, processErr := s.processFile(ctx, path, folder, existingByPath, existingContentStatuses, rootInference.Assignments[path], groupInference.Assignments[path], subtitleCache) if processErr != nil { slog.Error("scanner: file processing failed", "path", path, "error", processErr) errorCount.Add(1) @@ -980,6 +981,7 @@ func (s *Scanner) scanScope( var wg sync.WaitGroup pathCh := make(chan string, s.workers) var newCount, updatedCount, unchangedCount, errorCount, processedCount atomic.Int64 + subtitleCache := newExternalSubtitleDirCache() for range s.workers { wg.Go(func() { @@ -987,7 +989,7 @@ func (s *Scanner) scanScope( if ctx.Err() != nil { continue } - action, updateReasons, processErr := s.processFile(ctx, path, folder, existingByPath, existingContentStatuses, rootInference.Assignments[path], groupInference.Assignments[path]) + action, updateReasons, processErr := s.processFile(ctx, path, folder, existingByPath, existingContentStatuses, rootInference.Assignments[path], groupInference.Assignments[path], subtitleCache) if processErr != nil { slog.Error("scanner: file processing failed", "path", path, "error", processErr) errorCount.Add(1) @@ -1399,7 +1401,7 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models. return fmt.Errorf("loading group overrides for file: %w", err) } applyGroupOverrides(&groupInference, groupOverrides) - _, _, err = s.processFile(ctx, filePath, folder, existingByPath, existingContentStatuses, rootInference.Assignments[filepath.Clean(filePath)], groupInference.Assignments[filepath.Clean(filePath)]) + _, _, err = s.processFile(ctx, filePath, folder, existingByPath, existingContentStatuses, rootInference.Assignments[filepath.Clean(filePath)], groupInference.Assignments[filepath.Clean(filePath)], newExternalSubtitleDirCache()) if err != nil { return err } @@ -1541,6 +1543,7 @@ func (s *Scanner) processFile( existingContentStatuses map[string]string, assignment fileRootAssignment, groupAssignment fileGroupAssignment, + subtitleCache *externalSubtitleDirCache, ) (fileAction, []string, error) { // Stat the file. info, err := os.Stat(filePath) @@ -1550,13 +1553,33 @@ func (s *Scanner) processFile( fileSize := info.Size() fileModifiedAt := normalizeFileModifiedAt(info.ModTime()) + var externalSubs []ExternalSubtitleInfo + externalSubsLoaded := false + externalSubsChecked := false + loadExternalSubs := func() []ExternalSubtitleInfo { + if externalSubsLoaded { + return externalSubs + } + externalSubsLoaded = true + var subErr error + externalSubs, subErr = subtitleCache.Detect(filePath) + if subErr != nil { + slog.Warn("scanner: subtitle detection failed", "path", filePath, "error", subErr) + externalSubs = nil + externalSubsChecked = false + return externalSubs + } + externalSubsChecked = true + return externalSubs + } // Check if unchanged (same size) only when playback-critical probe data // is already present or this scanner cannot repair it on this node. This // lets rescans repair legacy rows when local ffprobe metadata is available // without forcing endless rewrites on nodes that cannot probe. if existing, ok := existingByPath[filePath]; ok { - updateReasons := scanStateUpdateReasons(existing, fileSize, fileModifiedAt, assignment, groupAssignment, folder.Type, s.ffprobePath != "") + currentExternalSubtitlePaths := externalSubtitleInfoPaths(loadExternalSubs()) + updateReasons := scanStateUpdateReasons(existing, fileSize, fileModifiedAt, currentExternalSubtitlePaths, externalSubsChecked, assignment, groupAssignment, folder.Type, s.ffprobePath != "") if shouldSkipStableConfirmedScanState(existing, existingContentStatuses[existing.ContentID], fileSize, fileModifiedAt, updateReasons, s.ffprobePath != "") { return actionUnchanged, nil, nil } @@ -1572,10 +1595,7 @@ func (s *Scanner) processFile( probe, probeSource := s.probeFile(ctx, filePath) // Detect external subtitles. - externalSubs, subErr := DetectExternalSubtitles(filePath) - if subErr != nil { - slog.Warn("scanner: subtitle detection failed", "path", filePath, "error", subErr) - } + externalSubs = loadExternalSubs() // Check for intro/credits markers from S3. markerFetcher := s.markerFetcher @@ -1696,10 +1716,7 @@ func (s *Scanner) processFile( probe, probeSource := s.probeFile(ctx, filePath) // Detect external subtitles. - externalSubs, subErr := DetectExternalSubtitles(filePath) - if subErr != nil { - slog.Warn("scanner: subtitle detection failed", "path", filePath, "error", subErr) - } + externalSubs = loadExternalSubs() // Check for intro/credits markers from S3. markerFetcher := s.markerFetcher @@ -1817,6 +1834,8 @@ func scanStateUpdateReasons( existing *scanStateFile, fileSize int64, fileModifiedAt time.Time, + currentExternalSubtitlePaths []string, + externalSubtitlesChecked bool, assignment fileRootAssignment, groupAssignment fileGroupAssignment, libraryType string, @@ -1839,7 +1858,11 @@ func scanStateUpdateReasons( if canRepairProbe && needsCriticalProbeRepairScanState(existing) { reasons = append(reasons, "probe_repair") } - if hasMissingExternalSubtitlePath(existing.ExternalSubtitlePaths) { + if externalSubtitlesChecked { + if !sameStringSet(existing.ExternalSubtitlePaths, currentExternalSubtitlePaths) { + reasons = append(reasons, "external_subtitle_changed") + } + } else if hasMissingExternalSubtitlePath(existing.ExternalSubtitlePaths) { reasons = append(reasons, "external_subtitle_missing") } if scanStateRootAssignmentChanged(existing, assignment, libraryType) { @@ -1851,6 +1874,45 @@ func scanStateUpdateReasons( return reasons } +func externalSubtitleInfoPaths(subtitles []ExternalSubtitleInfo) []string { + paths := make([]string, 0, len(subtitles)) + for _, subtitle := range subtitles { + path := strings.TrimSpace(subtitle.Path) + if path == "" { + continue + } + paths = append(paths, filepath.Clean(path)) + } + return paths +} + +func sameStringSet(left, right []string) bool { + seen := make(map[string]int, len(left)) + for _, value := range left { + value = filepath.Clean(strings.TrimSpace(value)) + if value == "." || value == "" { + continue + } + seen[value]++ + } + for _, value := range right { + value = filepath.Clean(strings.TrimSpace(value)) + if value == "." || value == "" { + continue + } + if seen[value] == 0 { + return false + } + seen[value]-- + } + for _, count := range seen { + if count != 0 { + return false + } + } + return true +} + func hasMissingExternalSubtitlePath(paths []string) bool { for _, path := range paths { if strings.TrimSpace(path) == "" { diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 36c24e19..a7089f96 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -112,7 +112,7 @@ func TestScanStateUpdateReasons_DetectsMissingExternalSubtitle(t *testing.T) { ExternalSubtitlePaths: []string{filepath.Join(t.TempDir(), "Movie.en.srt")}, } - reasons := scanStateUpdateReasons(existing, 1_000, modifiedAt, fileRootAssignment{}, fileGroupAssignment{}, "movies", false) + reasons := scanStateUpdateReasons(existing, 1_000, modifiedAt, nil, false, fileRootAssignment{}, fileGroupAssignment{}, "movies", false) if !testStringSliceContains(reasons, "external_subtitle_missing") { t.Fatalf("expected external_subtitle_missing reason, got %#v", reasons) } @@ -121,6 +121,37 @@ func TestScanStateUpdateReasons_DetectsMissingExternalSubtitle(t *testing.T) { } } +func TestScanStateUpdateReasons_DetectsExternalSubtitleInventoryChange(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + modifiedAt := time.Now().UTC().Truncate(time.Microsecond) + existing := &scanStateFile{ + ContentID: "matched-content", + FileSize: 1_000, + FileModifiedAt: &modifiedAt, + ExternalSubtitlePaths: []string{filepath.Join(dir, "Movie.en.srt")}, + } + + reasons := scanStateUpdateReasons( + existing, + 1_000, + modifiedAt, + []string{filepath.Join(dir, "Movie.en.srt"), filepath.Join(dir, "Movie.fr.srt")}, + true, + fileRootAssignment{}, + fileGroupAssignment{}, + "movies", + false, + ) + if !testStringSliceContains(reasons, "external_subtitle_changed") { + t.Fatalf("expected external_subtitle_changed reason, got %#v", reasons) + } + if shouldSkipStableConfirmedScanState(existing, "matched", 1_000, modifiedAt, reasons, false) { + t.Fatal("expected external subtitle change to bypass stable scan-state skip") + } +} + func testStringSliceContains(values []string, target string) bool { for _, value := range values { if value == target { diff --git a/internal/scanner/subtitles.go b/internal/scanner/subtitles.go index f006cc80..01265583 100644 --- a/internal/scanner/subtitles.go +++ b/internal/scanner/subtitles.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/Silo-Server/silo-server/internal/lang" ) @@ -18,6 +19,49 @@ var subtitleExtensions = map[string]bool{ ".sub": true, } +type externalSubtitleDirCache struct { + mu sync.Mutex + dirs map[string]externalSubtitleDirListing +} + +type externalSubtitleDirListing struct { + entries []os.DirEntry + err error +} + +func newExternalSubtitleDirCache() *externalSubtitleDirCache { + return &externalSubtitleDirCache{ + dirs: make(map[string]externalSubtitleDirListing), + } +} + +func (c *externalSubtitleDirCache) Detect(mediaFilePath string) ([]ExternalSubtitleInfo, error) { + if c == nil { + return DetectExternalSubtitles(mediaFilePath) + } + + dir := filepath.Dir(mediaFilePath) + c.mu.Lock() + listing, ok := c.dirs[dir] + c.mu.Unlock() + if !ok { + entries, err := os.ReadDir(dir) + listing = externalSubtitleDirListing{entries: entries, err: err} + c.mu.Lock() + if existing, found := c.dirs[dir]; found { + listing = existing + } else { + c.dirs[dir] = listing + } + c.mu.Unlock() + } + if listing.err != nil { + return nil, fmt.Errorf("subtitles: read dir %s: %w", dir, listing.err) + } + + return externalSubtitlesFromEntries(mediaFilePath, listing.entries), nil +} + // DetectExternalSubtitles scans the directory containing the media file for // sidecar subtitle files that match the media file's basename. // @@ -30,13 +74,18 @@ var subtitleExtensions = map[string]bool{ // Movie.forced.eng.srt -> language: "eng", forced: true func DetectExternalSubtitles(mediaFilePath string) ([]ExternalSubtitleInfo, error) { dir := filepath.Dir(mediaFilePath) - mediaBase := stripExtension(filepath.Base(mediaFilePath)) entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("subtitles: read dir %s: %w", dir, err) } + return externalSubtitlesFromEntries(mediaFilePath, entries), nil +} + +func externalSubtitlesFromEntries(mediaFilePath string, entries []os.DirEntry) []ExternalSubtitleInfo { + dir := filepath.Dir(mediaFilePath) + mediaBase := stripExtension(filepath.Base(mediaFilePath)) var results []ExternalSubtitleInfo for _, entry := range entries { @@ -75,7 +124,7 @@ func DetectExternalSubtitles(mediaFilePath string) ([]ExternalSubtitleInfo, erro results = append(results, info) } - return results, nil + return results } // parseSuffix extracts the language and forced flag from the suffix portion diff --git a/internal/scanqueue/repository.go b/internal/scanqueue/repository.go index fceecd32..5d4fab75 100644 --- a/internal/scanqueue/repository.go +++ b/internal/scanqueue/repository.go @@ -33,10 +33,11 @@ const ( var ErrScanRunNotFound = errors.New("scan run not found") type CreateInput struct { - LibraryID int - Mode string - Path string - Trigger string + LibraryID int + Mode string + Path string + Trigger string + AutoscanEventID *int64 } type Repository struct { @@ -48,7 +49,7 @@ func NewRepository(pool *pgxpool.Pool) *Repository { } const scanRunColumns = `id, media_folder_id, mode, path, trigger, status, result_payload, - error_message, requested_at, started_at, completed_at, heartbeat_at, updated_at` + error_message, autoscan_event_id, requested_at, started_at, completed_at, heartbeat_at, updated_at` func scanRunRow(row pgx.Row) (*models.ScanRun, error) { var run models.ScanRun @@ -61,6 +62,7 @@ func scanRunRow(row pgx.Row) (*models.ScanRun, error) { &run.Status, &run.ResultPayload, &run.ErrorMessage, + &run.AutoscanEventID, &run.RequestedAt, &run.StartedAt, &run.CompletedAt, @@ -95,8 +97,8 @@ func scanRunRows(rows pgx.Rows) ([]*models.ScanRun, error) { func (r *Repository) Create(ctx context.Context, input CreateInput) (*models.ScanRun, bool, error) { run, err := scanRunRow(r.pool.QueryRow(ctx, ` INSERT INTO scan_runs ( - id, media_folder_id, mode, path, trigger, status - ) VALUES ($1, $2, $3, $4, $5, $6) + id, media_folder_id, mode, path, trigger, status, autoscan_event_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING `+scanRunColumns, ulid.Make().String(), input.LibraryID, @@ -104,6 +106,7 @@ func (r *Repository) Create(ctx context.Context, input CreateInput) (*models.Sca input.Path, input.Trigger, StatusAccepted, + input.AutoscanEventID, )) if err == nil { return run, true, nil @@ -136,8 +139,8 @@ func (r *Repository) CreateBatch(ctx context.Context, inputs []CreateInput) ([]* 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) + id, media_folder_id, mode, path, trigger, status, autoscan_event_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING RETURNING `+scanRunColumns, ulid.Make().String(), @@ -146,6 +149,7 @@ func (r *Repository) CreateBatch(ctx context.Context, inputs []CreateInput) ([]* input.Path, input.Trigger, StatusAccepted, + input.AutoscanEventID, )) if err == nil { runs = append(runs, run) diff --git a/internal/scanqueue/service.go b/internal/scanqueue/service.go index b38c8953..38faa0e5 100644 --- a/internal/scanqueue/service.go +++ b/internal/scanqueue/service.go @@ -133,31 +133,46 @@ func (s *Service) EnqueueScan(ctx context.Context, folderID int, mode, path, tri } func (s *Service) EnqueueScans(ctx context.Context, targets []scantrigger.Target) error { + _, _, err := s.enqueueScans(ctx, targets, nil) + return err +} + +func (s *Service) EnqueueAutoscanScans(ctx context.Context, targets []scantrigger.Target, eventID int64) (int, int, error) { + return s.enqueueScans(ctx, targets, &eventID) +} + +func (s *Service) enqueueScans(ctx context.Context, targets []scantrigger.Target, autoscanEventID *int64) (int, int, error) { if s == nil || s.repo == nil { - return fmt.Errorf("scan queue is not configured") + return 0, 0, fmt.Errorf("scan queue is not configured") } inputs := make([]CreateInput, 0, len(targets)) for _, target := range targets { if target.Folder == nil { - return fmt.Errorf("scan queue: target is missing folder") + return 0, 0, fmt.Errorf("scan queue: target is missing folder") } inputs = append(inputs, CreateInput{ - LibraryID: target.Folder.ID, - Mode: target.Mode, - Path: target.Path, - Trigger: target.Trigger, + LibraryID: target.Folder.ID, + Mode: target.Mode, + Path: target.Path, + Trigger: target.Trigger, + AutoscanEventID: autoscanEventID, }) } runs, created, err := s.repo.CreateBatch(ctx, inputs) if err != nil { - return err + return 0, 0, err } + createdCount := 0 + reusedCount := 0 for i, run := range runs { if i < len(created) && created[i] { + createdCount++ s.publish(ctx, "scan.accepted", run) + } else { + reusedCount++ } } - return nil + return createdCount, reusedCount, nil } func (s *Service) CancelAcceptedByLibrary(ctx context.Context, libraryID int) (int, error) { diff --git a/internal/scantrigger/scantrigger.go b/internal/scantrigger/scantrigger.go index d6c0ffca..b9ca0b48 100644 --- a/internal/scantrigger/scantrigger.go +++ b/internal/scantrigger/scantrigger.go @@ -95,6 +95,37 @@ func (r *Resolver) Resolve(ctx context.Context, req Request) (*Target, error) { return r.resolve(ctx, req, nil, false) } +// ResolveMissingSubtree resolves a subtree path that may no longer exist on +// disk. This is intentionally narrower than Resolve: it never stats the path +// and only returns ModeSubtree for paths below a configured library root. +func (r *Resolver) ResolveMissingSubtree(ctx context.Context, subtreePath, trigger string) (*Target, error) { + if r == nil || r.folders == nil { + return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"} + } + cleanPath := filepath.Clean(subtreePath) + if strings.TrimSpace(subtreePath) == "" || cleanPath == "." { + return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path is required"} + } + folders, err := r.folders.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, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"} + } + if filepath.Clean(cleanPath) == filepath.Clean(matchedRoot) { + return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Subtree path must be below a library root"} + } + if trigger = strings.TrimSpace(trigger); trigger == "" { + trigger = "path" + } + return &Target{Folder: folder, Mode: ModeSubtree, Path: cleanPath, Trigger: trigger}, nil +} + 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"} diff --git a/internal/scantrigger/scantrigger_test.go b/internal/scantrigger/scantrigger_test.go index a5095580..a0271447 100644 --- a/internal/scantrigger/scantrigger_test.go +++ b/internal/scantrigger/scantrigger_test.go @@ -71,6 +71,44 @@ func TestResolverClassifiesSubtree(t *testing.T) { } } +func TestResolverResolvesMissingSubtreeWithoutStat(t *testing.T) { + root := t.TempDir() + missing := filepath.Join(root, "Missing Movie") + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 18, + Name: "Movies", + Enabled: true, + Paths: []string{root}, + }}} + + target, err := NewResolver(repo).ResolveMissingSubtree(context.Background(), missing, "autoscan") + if err != nil { + t.Fatalf("ResolveMissingSubtree returned error: %v", err) + } + if target.Folder == nil || target.Folder.ID != 18 || target.Mode != ModeSubtree || target.Path != filepath.Clean(missing) { + t.Fatalf("unexpected target: %#v", target) + } +} + +func TestResolverRejectsMissingSubtreeAtLibraryRoot(t *testing.T) { + root := t.TempDir() + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 19, + Name: "Movies", + Enabled: true, + Paths: []string{root}, + }}} + + _, err := NewResolver(repo).ResolveMissingSubtree(context.Background(), root, "autoscan") + var reqErr *RequestError + if !errors.As(err, &reqErr) { + t.Fatalf("expected RequestError, got %T: %v", err, err) + } + if reqErr.Status != http.StatusBadRequest || reqErr.Code != "bad_request" { + t.Fatalf("unexpected error: %#v", reqErr) + } +} + func TestResolverClassifiesVideoFile(t *testing.T) { root := t.TempDir() filePath := filepath.Join(root, "Movie (2024).mkv") diff --git a/internal/taskmanager/tasks/autoscan_poll.go b/internal/taskmanager/tasks/autoscan_poll.go new file mode 100644 index 00000000..81d9bb92 --- /dev/null +++ b/internal/taskmanager/tasks/autoscan_poll.go @@ -0,0 +1,62 @@ +package tasks + +import ( + "context" + "fmt" + + "github.com/Silo-Server/silo-server/internal/taskmanager" +) + +type AutoscanPoller interface { + PollOnce(ctx context.Context) error +} + +// defaultAutoscanPollIntervalMs is the fallback poll cadence (10 minutes) used +// when no positive interval is supplied. +const defaultAutoscanPollIntervalMs int64 = 10 * 60 * 1000 + +type AutoscanPollTask struct { + poller AutoscanPoller + intervalMs int64 +} + +// NewAutoscanPollTask builds the poll task with an interval expressed in +// MILLISECONDS. This matches the reschedule path (HandleUpdateSettings seeds +// seconds*1000 ms), so the startup-seeded schedule and a settings-driven +// reschedule agree for sub-minute and non-60-multiple intervals — previously the +// startup path integer-divided seconds by 60 (minutes) and diverged. +func NewAutoscanPollTask(poller AutoscanPoller, intervalMs int64) *AutoscanPollTask { + if intervalMs <= 0 { + intervalMs = defaultAutoscanPollIntervalMs + } + return &AutoscanPollTask{poller: poller, intervalMs: intervalMs} +} + +func (t *AutoscanPollTask) Key() string { return "autoscan_poll" } +func (t *AutoscanPollTask) Name() string { return "Autoscan poll" } +func (t *AutoscanPollTask) Description() string { + return "Poll installed scan-source providers for changes and enqueue rescans" +} +func (t *AutoscanPollTask) Category() taskmanager.TaskCategory { + return taskmanager.TaskCategoryLibrary +} +func (t *AutoscanPollTask) IsHidden() bool { return false } + +func (t *AutoscanPollTask) DefaultTriggers() []taskmanager.TriggerConfig { + return []taskmanager.TriggerConfig{ + {Type: taskmanager.TriggerTypeInterval, IntervalMs: t.intervalMs}, + } +} + +func (t *AutoscanPollTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { + progress.Report(0, "Polling scan-source providers") + if t.poller == nil { + progress.Report(100, "Autoscan unavailable") + return nil + } + if err := t.poller.PollOnce(ctx); err != nil { + return fmt.Errorf("autoscan poll: %w", err) + } + progress.Report(100, "Autoscan poll complete") + return nil +} diff --git a/migrations/171_autoscan.down.sql b/migrations/171_autoscan.down.sql new file mode 100644 index 00000000..7ebdcf54 --- /dev/null +++ b/migrations/171_autoscan.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS public.autoscan_sources; +DROP TABLE IF EXISTS public.autoscan_connections; +DROP TABLE IF EXISTS public.autoscan_settings; diff --git a/migrations/171_autoscan.up.sql b/migrations/171_autoscan.up.sql new file mode 100644 index 00000000..44b6ea8a --- /dev/null +++ b/migrations/171_autoscan.up.sql @@ -0,0 +1,62 @@ +-- Autoscan: connections-based scan-source model. The host pulls changed paths +-- from installed scan_source.v1 plugins (Sonarr/Radarr, etc.) on a timer, +-- applies per-source path rewrites, and enqueues targeted library rescans. +-- +-- Three tables: +-- * autoscan_settings - singleton global config (enable, poll cadence, debounce) +-- * autoscan_connections - reachable upstreams: own credentials, OR a live link +-- to a Requests integration (reused arr server) +-- * autoscan_sources - operator-created rows, each binding one installed +-- scan_source capability to a connection + rewrites + +-- cadence (many sources may share one capability) + +CREATE TABLE public.autoscan_settings ( + id boolean PRIMARY KEY DEFAULT true, + enabled boolean NOT NULL DEFAULT false, + default_poll_interval_seconds integer NOT NULL DEFAULT 600, + debounce_seconds integer NOT NULL DEFAULT 60, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT autoscan_settings_singleton CHECK (id), + CONSTRAINT autoscan_settings_interval_pos CHECK (default_poll_interval_seconds > 0), + CONSTRAINT autoscan_settings_debounce_nonneg CHECK (debounce_seconds >= 0) +); +INSERT INTO public.autoscan_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING; + +CREATE TABLE public.autoscan_connections ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + kind text NOT NULL, + base_url text, + api_key_ref text, + request_integration_id text REFERENCES public.request_integrations(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + -- Creation-time validity (own creds OR a link) is enforced at the application + -- layer. A linked connection may become orphaned (both null) when its Requests + -- integration is deleted (ON DELETE SET NULL), surfacing as "needs attention". +); + +CREATE TABLE public.autoscan_sources ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + installation_id integer NOT NULL, + capability_id text NOT NULL, + -- Nullable: a freshly-discovered source has no connection until the operator + -- binds one. The engine skips an enabled source with no connection. + connection_id uuid REFERENCES public.autoscan_connections(id) ON DELETE RESTRICT, + enabled boolean NOT NULL DEFAULT false, + poll_interval_seconds integer, + -- Host-owned per-source prefix rewrites: [{"from": "...", "to": "..."}]. + -- Applied to the raw source-namespace paths the scan_source plugin returns, + -- before resolving/enqueueing. + path_rewrites jsonb NOT NULL DEFAULT '[]'::jsonb, + marker text, + last_run_at timestamptz, + last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT autoscan_sources_interval_pos CHECK (poll_interval_seconds IS NULL OR poll_interval_seconds > 0) + -- No UNIQUE(installation_id, capability_id): one installed scan_source plugin + -- capability backs MANY sources, each bound to a different connection (e.g. 4 + -- arr servers from one plugin install). Operators create sources explicitly. +); diff --git a/migrations/172_autoscan_source_config.down.sql b/migrations/172_autoscan_source_config.down.sql new file mode 100644 index 00000000..5849d154 --- /dev/null +++ b/migrations/172_autoscan_source_config.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.autoscan_sources + DROP COLUMN IF EXISTS source_config; diff --git a/migrations/172_autoscan_source_config.up.sql b/migrations/172_autoscan_source_config.up.sql new file mode 100644 index 00000000..bca30926 --- /dev/null +++ b/migrations/172_autoscan_source_config.up.sql @@ -0,0 +1,23 @@ +ALTER TABLE public.autoscan_sources + ADD COLUMN source_config jsonb NOT NULL DEFAULT '{}'::jsonb; + +-- Move CephFS source-specific settings out of generic plugin runtime config +-- when upgrading dev/early installs. The plugin may still keep runtime-only +-- state settings there, but watch roots and exclusions belong to autoscan +-- source rows so operators manage them from Admin > Autoscan. +UPDATE public.autoscan_sources s +SET source_config = jsonb_strip_nulls(jsonb_build_object( + 'movie_flat_paths', cfg.config_value->>'movie_flat_paths', + 'tv_flat_paths', cfg.config_value->>'tv_flat_paths', + 'movie_nested_paths', cfg.config_value->>'movie_nested_paths', + 'tv_nested_paths', cfg.config_value->>'tv_nested_paths', + 'exclusions', cfg.config_value->>'exclusions' +)) +FROM public.plugin_installations pi +JOIN public.plugin_runtime_configs cfg + ON cfg.plugin_installation_id = pi.id + AND cfg.config_key = 'cephfs_monitor' +WHERE s.installation_id = pi.id + AND pi.plugin_id = 'silo.autoscan.cephfs' + AND s.capability_id = 'cephfs' + AND s.source_config = '{}'::jsonb; diff --git a/migrations/173_autoscan_events.down.sql b/migrations/173_autoscan_events.down.sql new file mode 100644 index 00000000..dfe71278 --- /dev/null +++ b/migrations/173_autoscan_events.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS public.idx_scan_runs_autoscan_event; + +ALTER TABLE public.scan_runs + DROP COLUMN IF EXISTS autoscan_event_id; + +DROP INDEX IF EXISTS public.idx_autoscan_events_completed; +DROP INDEX IF EXISTS public.idx_autoscan_events_source_completed; +DROP TABLE IF EXISTS public.autoscan_events; diff --git a/migrations/173_autoscan_events.up.sql b/migrations/173_autoscan_events.up.sql new file mode 100644 index 00000000..d8caa720 --- /dev/null +++ b/migrations/173_autoscan_events.up.sql @@ -0,0 +1,33 @@ +CREATE TABLE public.autoscan_events ( + id BIGSERIAL PRIMARY KEY, + source_id uuid REFERENCES public.autoscan_sources(id) ON DELETE SET NULL, + installation_id integer NOT NULL, + capability_id text NOT NULL, + started_at timestamptz NOT NULL, + completed_at timestamptz NOT NULL, + duration_ms bigint NOT NULL DEFAULT 0, + status text NOT NULL, + changes_returned integer NOT NULL DEFAULT 0, + changes_resolved integer NOT NULL DEFAULT 0, + targets_claimed integer NOT NULL DEFAULT 0, + scans_created integer NOT NULL DEFAULT 0, + scans_reused integer NOT NULL DEFAULT 0, + scans_suppressed integer NOT NULL DEFAULT 0, + error_message text NOT NULL DEFAULT '', + marker_before text, + marker_after text, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT autoscan_events_status_check CHECK (status = ANY (ARRAY['success'::text, 'error'::text, 'unresolved'::text])) +); + +CREATE INDEX idx_autoscan_events_source_completed + ON public.autoscan_events (source_id, completed_at DESC); + +CREATE INDEX idx_autoscan_events_completed + ON public.autoscan_events (completed_at DESC); + +ALTER TABLE public.scan_runs + ADD COLUMN autoscan_event_id bigint REFERENCES public.autoscan_events(id) ON DELETE SET NULL; + +CREATE INDEX idx_scan_runs_autoscan_event + ON public.scan_runs (autoscan_event_id); diff --git a/migrations/174_autoscan_source_label.down.sql b/migrations/174_autoscan_source_label.down.sql new file mode 100644 index 00000000..c0f53878 --- /dev/null +++ b/migrations/174_autoscan_source_label.down.sql @@ -0,0 +1 @@ +ALTER TABLE public.autoscan_sources DROP COLUMN label; diff --git a/migrations/174_autoscan_source_label.up.sql b/migrations/174_autoscan_source_label.up.sql new file mode 100644 index 00000000..91ffdfb8 --- /dev/null +++ b/migrations/174_autoscan_source_label.up.sql @@ -0,0 +1,3 @@ +-- Operator-editable display label for an autoscan source. Empty string = unset; +-- the admin UI falls back to connection name / plugin display_name / capability. +ALTER TABLE public.autoscan_sources ADD COLUMN label text NOT NULL DEFAULT ''; diff --git a/migrations/174_movie_release_date_desc_index.down.sql b/migrations/175_movie_release_date_desc_index.down.sql similarity index 100% rename from migrations/174_movie_release_date_desc_index.down.sql rename to migrations/175_movie_release_date_desc_index.down.sql diff --git a/migrations/174_movie_release_date_desc_index.up.sql b/migrations/175_movie_release_date_desc_index.up.sql similarity index 100% rename from migrations/174_movie_release_date_desc_index.up.sql rename to migrations/175_movie_release_date_desc_index.up.sql diff --git a/web/src/App.tsx b/web/src/App.tsx index 6f4e0874..050a6c0d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -40,6 +40,7 @@ import AdminActivity from "@/pages/AdminActivity"; import AdminLogs from "@/pages/AdminLogs"; import AdminUsers from "@/pages/AdminUsers"; import AdminRequests from "@/pages/AdminRequests"; +import AdminAutoscan from "@/pages/AdminAutoscan"; import AdminDevices from "@/pages/AdminDevices"; import AdminLibraries from "@/pages/AdminLibraries"; import AdminSettingsLayout from "@/pages/admin-settings/AdminSettingsLayout"; @@ -372,6 +373,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 8d9ba897..7179d259 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1660,6 +1660,208 @@ export interface RequestIntegrationsResponse { integrations: RequestIntegration[]; } +// --- Autoscan v2 types (matched to autoscan.go handler DTOs) --- + +export interface AutoscanSettings { + enabled: boolean; + default_poll_interval_seconds: number; + debounce_seconds: number; +} + +export interface AutoscanConnection { + id: string; + name: string; + kind: string; + base_url?: string; + request_integration_id?: string | null; + has_api_key: boolean; +} + +export interface AutoscanConnectionInput { + name: string; + kind: string; + base_url?: string; + api_key_ref?: string; + request_integration_id?: string | null; +} + +export interface AutoscanConnectionsResponse { + connections: AutoscanConnection[]; +} + +export interface AutoscanPathRewrite { + from: string; + to: string; +} + +export interface AutoscanSource { + id: string; + installation_id: number; + capability_id: string; + connection_id: string | null; + enabled: boolean; + poll_interval_seconds: number | null; + last_run_at: string | null; + last_error: string | null; + path_rewrites: AutoscanPathRewrite[]; + source_config: Record; + label: string; +} + +export interface AutoscanSourceInput { + connection_id: string | null; + enabled: boolean; + poll_interval_seconds: number | null; + path_rewrites?: AutoscanPathRewrite[]; + source_config?: Record; + label?: string; +} + +export interface AutoscanSourcesResponse { + sources: AutoscanSource[]; +} + +export interface AutoscanAvailableSource { + installation_id: number; + capability_id: string; + plugin_id: string; + display_name: string; +} + +export interface AutoscanAvailableSourcesResponse { + plugins: AutoscanAvailableSource[]; +} + +export interface AutoscanSourceCreateInput { + installation_id: number; + capability_id: string; + connection_id?: string | null; + enabled: boolean; + poll_interval_seconds?: number | null; + path_rewrites: AutoscanPathRewrite[]; + source_config?: Record; +} + +export interface AutoscanConnectionTestInput { + connection_id?: string; + base_url?: string; + api_key_ref?: string; + request_integration_id?: string | null; +} + +export interface AutoscanConnectionTestResult { + ok: boolean; + version?: string; + error?: string; +} + +export interface AutoscanProposedRewrite { + from: string; + to: string; + match_depth: number; +} + +export interface AutoscanAmbiguousRoot { + root: string; + candidates: string[]; +} + +export interface AutoscanRewriteSuggestions { + proposed: AutoscanProposedRewrite[]; + unmatched: string[]; + ambiguous: AutoscanAmbiguousRoot[]; + covered: string[]; +} + +export interface AutoscanStatusSource { + id: string; + installation_id: number; + capability_id: string; + connection_id: string | null; + enabled: boolean; + label: string; + last_run_at: string | null; + last_error: string | null; +} + +export interface AutoscanStatus { + enabled: boolean; + sources: AutoscanStatusSource[]; + active_scans: number; + accepted_scans: number; + running_scans: number; + latest_event_at?: string; +} + +export type AutoscanEventStatus = "success" | "error" | "unresolved"; + +export interface AutoscanEventScanRun { + id: string; + library_id: number; + mode: "library" | "subtree" | "file"; + path?: string; + trigger: string; + status: "accepted" | "running" | "completed" | "failed" | "cancelled"; + requested_at?: string; + started_at?: string; + completed_at?: string; + error_message?: string; +} + +export interface AutoscanEvent { + id: number; + source_id: string | null; + installation_id: number; + capability_id: string; + started_at: string; + completed_at: string; + duration_ms: number; + status: AutoscanEventStatus; + changes_returned: number; + changes_resolved: number; + targets_claimed: number; + scans_created: number; + scans_reused: number; + scans_suppressed: number; + error_message?: string; + scan_runs: AutoscanEventScanRun[]; +} + +export interface AutoscanEventsResponse { + events: AutoscanEvent[]; + total: number; + limit: number; + offset: number; +} + +export type AutoscanScanStatus = "accepted" | "running" | "completed" | "failed" | "cancelled"; + +export interface AutoscanScan { + id: string; + library_id: number; + mode: "library" | "subtree" | "file"; + path?: string; + trigger: string; + status: AutoscanScanStatus; + error_message?: string; + requested_at?: string; + started_at?: string; + completed_at?: string; + autoscan_event_id?: number; + source_id?: string; + installation_id?: number; + capability_id?: string; + event_status?: AutoscanEventStatus; + event_completed_at?: string; +} + +export interface AutoscanScansResponse { + scans: AutoscanScan[]; + total: number; + limit: number; + offset: number; +} + export interface RequestListParams { status?: MediaRequestStatus | "all"; outcome?: MediaRequestOutcome | "all"; diff --git a/web/src/components/AdminSidebar.tsx b/web/src/components/AdminSidebar.tsx index cd077b53..7f869487 100644 --- a/web/src/components/AdminSidebar.tsx +++ b/web/src/components/AdminSidebar.tsx @@ -21,6 +21,7 @@ import { Blocks, Puzzle, Send, + RefreshCw, } from "lucide-react"; import type { ReactNode } from "react"; import { SiloBrand } from "@/components/SiloBrand"; @@ -113,6 +114,11 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) { icon: , href: "/admin/requests", }, + { + label: "Autoscan", + icon: , + href: "/admin/autoscan", + }, { label: "Sections", icon: , diff --git a/web/src/components/ui/pagination.test.ts b/web/src/components/ui/pagination.test.ts new file mode 100644 index 00000000..318ebe49 --- /dev/null +++ b/web/src/components/ui/pagination.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { pageWindow } from "./pagination.utils"; + +describe("pageWindow", () => { + it("lists every page without ellipsis when the count is small", () => { + expect(pageWindow(0, 5)).toEqual([0, 1, 2, 3, 4]); + expect(pageWindow(3, 7)).toEqual([0, 1, 2, 3, 4, 5, 6]); + }); + + it("anchors first/last and the current neighbours in the middle", () => { + expect(pageWindow(10, 20)).toEqual([0, "ellipsis", 9, 10, 11, "ellipsis", 19]); + }); + + it("collapses only the trailing gap near the start", () => { + expect(pageWindow(0, 20)).toEqual([0, 1, "ellipsis", 19]); + expect(pageWindow(1, 20)).toEqual([0, 1, 2, "ellipsis", 19]); + }); + + it("collapses only the leading gap near the end", () => { + expect(pageWindow(19, 20)).toEqual([0, "ellipsis", 18, 19]); + expect(pageWindow(18, 20)).toEqual([0, "ellipsis", 17, 18, 19]); + }); + + it("never emits duplicate or out-of-range indices", () => { + for (let count = 1; count <= 30; count++) { + for (let current = 0; current < count; current++) { + const window = pageWindow(current, count); + const pages = window.filter((entry): entry is number => entry !== "ellipsis"); + // Strictly increasing, in-range, and always includes the current page. + expect(pages).toEqual([...pages].sort((a, b) => a - b)); + expect(new Set(pages).size).toBe(pages.length); + expect(pages.every((p) => p >= 0 && p < count)).toBe(true); + expect(pages).toContain(current); + expect(pages).toContain(0); + expect(pages).toContain(count - 1); + } + } + }); +}); diff --git a/web/src/components/ui/pagination.tsx b/web/src/components/ui/pagination.tsx new file mode 100644 index 00000000..757df702 --- /dev/null +++ b/web/src/components/ui/pagination.tsx @@ -0,0 +1,166 @@ +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; + +import { pageWindow } from "@/components/ui/pagination.utils"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100]; + +export interface TablePaginationProps { + /** Zero-indexed current page. */ + page: number; + pageSize: number; + /** Total number of matching rows across all pages. */ + total: number; + onPageChange: (page: number) => void; + /** When provided, renders a rows-per-page selector. */ + onPageSizeChange?: (size: number) => void; + pageSizeOptions?: number[]; + /** Singular noun for the summary, e.g. "scan" → "Showing 1–25 of 240 scans". */ + itemNoun?: string; + /** Dims the summary while a background refetch is in flight. */ + isFetching?: boolean; + className?: string; +} + +export function TablePagination({ + page, + pageSize, + total, + onPageChange, + onPageSizeChange, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + itemNoun = "item", + isFetching = false, + className, +}: TablePaginationProps) { + if (total <= 0) return null; + + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(Math.max(page, 0), pageCount - 1); + const from = safePage * pageSize + 1; + const to = Math.min((safePage + 1) * pageSize, total); + const window = pageWindow(safePage, pageCount); + const noun = total === 1 ? itemNoun : `${itemNoun}s`; + + const goTo = (next: number) => { + const clamped = Math.min(Math.max(next, 0), pageCount - 1); + if (clamped !== safePage) onPageChange(clamped); + }; + + return ( +
    +
    + + Showing {from}– + {to} of{" "} + {total} {noun} + + {onPageSizeChange ? ( + + ) : null} +
    + + {pageCount > 1 ? ( + + ) : null} +
    + ); +} diff --git a/web/src/components/ui/pagination.utils.ts b/web/src/components/ui/pagination.utils.ts new file mode 100644 index 00000000..947a2e77 --- /dev/null +++ b/web/src/components/ui/pagination.utils.ts @@ -0,0 +1,21 @@ +/** + * Build a compact run of page indices with ellipsis gaps, always anchoring the + * first and last page plus the current page and its immediate neighbours, e.g. + * `[0, "ellipsis", 4, 5, 6, "ellipsis", 19]`. Pages are zero-indexed. + */ +export function pageWindow(current: number, pageCount: number): (number | "ellipsis")[] { + const last = pageCount - 1; + if (pageCount <= 7) { + return Array.from({ length: pageCount }, (_, i) => i); + } + const anchors = [0, last, current, current - 1, current + 1].filter((p) => p >= 0 && p <= last); + const unique = [...new Set(anchors)].sort((a, b) => a - b); + const out: (number | "ellipsis")[] = []; + let prev: number | null = null; + for (const p of unique) { + if (prev !== null && p - prev > 1) out.push("ellipsis"); + out.push(p); + prev = p; + } + return out; +} diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index e17b382b..29d9fcd3 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -393,4 +393,13 @@ export const adminKeys = { itemImages: (id: string) => ["admin", "items", id, "images"] as const, buildInfo: () => ["admin", "system", "buildInfo"] as const, hwAccel: () => ["admin", "system", "hwAccel"] as const, + autoscanSettings: () => ["admin", "autoscan", "settings"] as const, + autoscanConnections: () => ["admin", "autoscan", "connections"] as const, + autoscanSources: () => ["admin", "autoscan", "sources"] as const, + autoscanScanSourcePlugins: () => ["admin", "autoscan", "scan-source-plugins"] as const, + autoscanStatus: () => ["admin", "autoscan", "status"] as const, + autoscanScans: (params?: Record) => + ["admin", "autoscan", "scans", params ?? {}] as const, + autoscanEvents: (params?: Record) => + ["admin", "autoscan", "events", params ?? {}] as const, }; diff --git a/web/src/hooks/queries/useAutoscan.ts b/web/src/hooks/queries/useAutoscan.ts new file mode 100644 index 00000000..b9fdf6be --- /dev/null +++ b/web/src/hooks/queries/useAutoscan.ts @@ -0,0 +1,327 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { api } from "@/api/client"; +import type { + AutoscanAvailableSourcesResponse, + AutoscanConnection, + AutoscanConnectionInput, + AutoscanConnectionsResponse, + AutoscanConnectionTestInput, + AutoscanConnectionTestResult, + AutoscanEvent, + AutoscanEventsResponse, + AutoscanEventStatus, + AutoscanScan, + AutoscanScansResponse, + AutoscanScanStatus, + AutoscanRewriteSuggestions, + AutoscanSettings, + AutoscanSource, + AutoscanSourceCreateInput, + AutoscanSourceInput, + AutoscanSourcesResponse, + AutoscanStatus, +} from "@/api/types"; +import { adminKeys } from "./keys"; + +const AUTOSCAN_STALE_TIME = 30_000; +const AUTOSCAN_ACTIVITY_REFRESH_MS = 15_000; + +// --- Settings --- + +export function useAutoscanSettings() { + return useQuery({ + queryKey: adminKeys.autoscanSettings(), + queryFn: () => api("/admin/autoscan/settings"), + staleTime: AUTOSCAN_STALE_TIME, + }); +} + +export function useUpdateAutoscanSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: AutoscanSettings) => + api("/admin/autoscan/settings", { + method: "PUT", + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success("Autoscan settings saved"); + queryClient.invalidateQueries({ queryKey: adminKeys.autoscanSettings() }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to save autoscan settings"); + }, + }); +} + +// --- Connections --- + +export function useAutoscanConnections() { + return useQuery({ + queryKey: adminKeys.autoscanConnections(), + queryFn: () => + api("/admin/autoscan/connections").then( + (data) => data.connections ?? [], + ), + staleTime: AUTOSCAN_STALE_TIME, + }); +} + +export function useCreateAutoscanConnection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: AutoscanConnectionInput) => + api("/admin/autoscan/connections", { + method: "POST", + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success("Autoscan connection created"); + queryClient.invalidateQueries({ queryKey: adminKeys.autoscanConnections() }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to create autoscan connection"); + }, + }); +} + +export function useUpdateAutoscanConnection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: AutoscanConnectionInput }) => + api(`/admin/autoscan/connections/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success("Autoscan connection updated"); + queryClient.invalidateQueries({ queryKey: adminKeys.autoscanConnections() }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to update autoscan connection"); + }, + }); +} + +export function useDeleteAutoscanConnection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + api(`/admin/autoscan/connections/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + onSuccess: () => { + toast.success("Autoscan connection deleted"); + queryClient.invalidateQueries({ queryKey: adminKeys.autoscanConnections() }); + // Sources may have lost their connection binding; invalidate them too. + queryClient.invalidateQueries({ queryKey: adminKeys.autoscanSources() }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to delete autoscan connection"); + }, + }); +} + +// --- Sources --- + +export function useAutoscanSources() { + return useQuery({ + queryKey: adminKeys.autoscanSources(), + queryFn: () => + api("/admin/autoscan/sources").then((data) => data.sources ?? []), + staleTime: AUTOSCAN_STALE_TIME, + }); +} + +export function useAvailableScanSources() { + return useQuery({ + queryKey: adminKeys.autoscanScanSourcePlugins(), + queryFn: () => + api("/admin/autoscan/scan-source-plugins").then( + (data) => data.plugins ?? [], + ), + staleTime: AUTOSCAN_STALE_TIME, + }); +} + +export function useCreateAutoscanSource() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: AutoscanSourceCreateInput) => + api("/admin/autoscan/sources", { + method: "POST", + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success("Autoscan source created"); + queryClient.invalidateQueries({ queryKey: adminKeys.autoscanSources() }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to create autoscan source"); + }, + }); +} + +export function useUpdateAutoscanSource() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: AutoscanSourceInput }) => + api(`/admin/autoscan/sources/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success("Autoscan source saved"); + queryClient.invalidateQueries({ queryKey: adminKeys.autoscanSources() }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to save autoscan source"); + }, + }); +} + +export function useDeleteAutoscanSource() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + api(`/admin/autoscan/sources/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + onSuccess: () => { + toast.success("Autoscan source deleted"); + queryClient.invalidateQueries({ queryKey: adminKeys.autoscanSources() }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to delete autoscan source"); + }, + }); +} + +/** + * Test an arr connection. Accepts either an existing connection id, or raw + * credentials (base_url + api_key_ref) / a request integration id for an + * unsaved dialog. Returns the result so the caller can render it inline; + * errors are surfaced via the returned result, not a toast (advisory only). + */ +export function useTestAutoscanConnection() { + return useMutation({ + mutationFn: (body: AutoscanConnectionTestInput) => + api("/admin/autoscan/connections/test", { + method: "POST", + body: JSON.stringify(body), + }), + }); +} + +/** + * Lazily fetch rewrite suggestions for a single source. Triggered on demand + * (per source) so it is modelled as a mutation rather than a query. Returns + * the suggestions; the 400 (no bound connection) surfaces as a toast. + */ +export function useAutoscanRewriteSuggestions() { + return useMutation({ + mutationFn: (id: string) => + api( + `/admin/autoscan/sources/${encodeURIComponent(id)}/rewrite-suggestions`, + ), + onError: (err) => + toast.error( + err instanceof Error ? err.message : "Could not sync rewrites from the arr instance", + ), + }); +} + +// --- Status --- + +export function useAutoscanStatus() { + return useQuery({ + queryKey: adminKeys.autoscanStatus(), + queryFn: () => api("/admin/autoscan/status"), + staleTime: AUTOSCAN_STALE_TIME, + refetchInterval: AUTOSCAN_ACTIVITY_REFRESH_MS, + }); +} + +/** A single page of history rows plus the total matching count for pagination. */ +export interface AutoscanPage { + rows: T[]; + total: number; +} + +export function useAutoscanEvents(params?: { + sourceId?: string; + status?: AutoscanEventStatus; + query?: string; + limit?: number; + offset?: number; + enabled?: boolean; +}) { + const queryParams = new URLSearchParams(); + if (params?.sourceId) queryParams.set("source_id", params.sourceId); + if (params?.status) queryParams.set("status", params.status); + if (params?.query) queryParams.set("q", params.query); + if (params?.limit != null) queryParams.set("limit", String(params.limit)); + if (params?.offset != null) queryParams.set("offset", String(params.offset)); + const suffix = queryParams.toString(); + const path = suffix ? `/admin/autoscan/events?${suffix}` : "/admin/autoscan/events"; + return useQuery({ + queryKey: adminKeys.autoscanEvents(params ?? {}), + queryFn: (): Promise> => + api(path).then((data) => ({ + rows: data.events ?? [], + total: data.total ?? data.events?.length ?? 0, + })), + staleTime: AUTOSCAN_ACTIVITY_REFRESH_MS, + refetchInterval: AUTOSCAN_ACTIVITY_REFRESH_MS, + // Hold the prior page on screen while the next one loads so paging through + // history never flashes an empty/loading state. + placeholderData: keepPreviousData, + enabled: params?.enabled ?? true, + }); +} + +export function useAutoscanScans(params?: { + status?: AutoscanScanStatus; + query?: string; + limit?: number; + offset?: number; + enabled?: boolean; +}) { + const queryParams = new URLSearchParams(); + if (params?.status) queryParams.set("status", params.status); + if (params?.query) queryParams.set("q", params.query); + if (params?.limit != null) queryParams.set("limit", String(params.limit)); + if (params?.offset != null) queryParams.set("offset", String(params.offset)); + const suffix = queryParams.toString(); + const path = suffix ? `/admin/autoscan/scans?${suffix}` : "/admin/autoscan/scans"; + return useQuery({ + queryKey: adminKeys.autoscanScans(params ?? {}), + queryFn: (): Promise> => + api(path).then((data) => ({ + rows: data.scans ?? [], + total: data.total ?? data.scans?.length ?? 0, + })), + staleTime: AUTOSCAN_ACTIVITY_REFRESH_MS, + refetchInterval: AUTOSCAN_ACTIVITY_REFRESH_MS, + placeholderData: keepPreviousData, + enabled: params?.enabled ?? true, + }); +} + +// --- Trigger --- + +export function useTriggerAutoscan() { + return useMutation({ + mutationFn: () => + api<{ status: string }>("/admin/autoscan/trigger", { + method: "POST", + }), + onSuccess: () => { + toast.success("Autoscan triggered"); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to trigger autoscan"); + }, + }); +} diff --git a/web/src/lib/autoscanLabels.test.ts b/web/src/lib/autoscanLabels.test.ts new file mode 100644 index 00000000..c1caf3f3 --- /dev/null +++ b/web/src/lib/autoscanLabels.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import type { AutoscanSource } from "@/api/types"; +import { + composeSourceLabel, + resolveEventSourceName, + type SourceLabelLookups, +} from "./autoscanLabels"; + +describe("composeSourceLabel", () => { + const base = { capabilityId: "arr", installationId: 4 }; + + it("uses the operator label first, demoting connection to detail", () => { + expect( + composeSourceLabel({ ...base, operatorLabel: "4K Movies", connectionName: "Radarr4k" }), + ).toEqual({ name: "4K Movies", detail: "Radarr4k · plugin #4" }); + }); + + it("uses the connection name when no operator label", () => { + expect( + composeSourceLabel({ ...base, connectionName: "Radarr4k", displayName: "Arr Watcher" }), + ).toEqual({ name: "Radarr4k", detail: "Arr Watcher · plugin #4" }); + }); + + it("uses the manifest display name when no connection", () => { + expect( + composeSourceLabel({ + capabilityId: "cephfs", + installationId: 5, + displayName: "CephFS Watcher", + }), + ).toEqual({ name: "CephFS Watcher", detail: "plugin #5" }); + }); + + it("falls back to capability id when nothing else is set", () => { + expect(composeSourceLabel(base)).toEqual({ name: "arr", detail: "plugin #4" }); + }); + + it("ignores whitespace-only rungs", () => { + expect(composeSourceLabel({ ...base, operatorLabel: " ", connectionName: " " })).toEqual({ + name: "arr", + detail: "plugin #4", + }); + }); +}); + +describe("resolveEventSourceName", () => { + const source: AutoscanSource = { + id: "src-1", + installation_id: 4, + capability_id: "arr", + connection_id: "conn-1", + enabled: true, + poll_interval_seconds: null, + last_run_at: null, + last_error: null, + path_rewrites: [], + source_config: {}, + label: "", + }; + const lookups: SourceLabelLookups = { + sourceByID: new Map([["src-1", source]]), + connectionByID: new Map([["conn-1", "Radarr4k"]]), + displayNames: new Map([["4:arr", "Arr Watcher"]]), + }; + + it("resolves the connection name via the source reference", () => { + expect( + resolveEventSourceName( + { source_id: "src-1", capability_id: "arr", installation_id: 4 }, + lookups, + ), + ).toBe("Radarr4k"); + }); + + it("prefers the operator label on the source", () => { + const withLabel: SourceLabelLookups = { + ...lookups, + sourceByID: new Map([["src-1", { ...source, label: "4K Movies" }]]), + }; + expect( + resolveEventSourceName( + { source_id: "src-1", capability_id: "arr", installation_id: 4 }, + withLabel, + ), + ).toBe("4K Movies"); + }); + + it("falls back to display name when the source was deleted (null source_id)", () => { + expect( + resolveEventSourceName( + { source_id: null, capability_id: "arr", installation_id: 4 }, + lookups, + ), + ).toBe("Arr Watcher"); + }); + + it("returns empty string when the reference has no capability", () => { + expect(resolveEventSourceName({ source_id: null }, lookups)).toBe(""); + }); + + it("falls back to display name when the bound connection is missing (deleted)", () => { + const orphaned: SourceLabelLookups = { + ...lookups, + sourceByID: new Map([["src-1", { ...source, connection_id: "conn-gone" }]]), + }; + expect( + resolveEventSourceName( + { source_id: "src-1", capability_id: "arr", installation_id: 4 }, + orphaned, + ), + ).toBe("Arr Watcher"); + }); +}); diff --git a/web/src/lib/autoscanLabels.ts b/web/src/lib/autoscanLabels.ts new file mode 100644 index 00000000..a3ffb545 --- /dev/null +++ b/web/src/lib/autoscanLabels.ts @@ -0,0 +1,85 @@ +import type { AutoscanAvailableSource, AutoscanSource } from "@/api/types"; + +export interface SourceLabelParts { + operatorLabel?: string | null; + connectionName?: string | null; + displayName?: string | null; + capabilityId: string; + installationId: number; +} + +export interface SourceLabel { + name: string; + detail: string; +} + +/** + * Resolve a scan source's display label through four rungs, most-specific first: + * operator label -> connection name -> manifest display_name -> capability_id. + * The winning rung is `name`; the remaining plugin identity is `detail`. + */ +export function composeSourceLabel(parts: SourceLabelParts): SourceLabel { + const operator = parts.operatorLabel?.trim() ?? ""; + const connection = parts.connectionName?.trim() ?? ""; + const display = parts.displayName?.trim() ?? ""; + const pluginSuffix = `plugin #${parts.installationId}`; + const pluginIdentity = display || parts.capabilityId; + + if (operator) { + return { name: operator, detail: `${connection || pluginIdentity} · ${pluginSuffix}` }; + } + if (connection) { + return { name: connection, detail: `${pluginIdentity} · ${pluginSuffix}` }; + } + if (display) { + return { name: display, detail: pluginSuffix }; + } + return { name: parts.capabilityId, detail: pluginSuffix }; +} + +/** Stable key for the (installation, capability) -> manifest display_name map. */ +export function pluginDisplayNameKey(installationId: number, capabilityId: string): string { + return `${installationId}:${capabilityId}`; +} + +/** Build the (installation, capability) -> display_name lookup from the picker list. */ +export function buildPluginDisplayNames(available: AutoscanAvailableSource[]): Map { + const map = new Map(); + for (const a of available) { + map.set(pluginDisplayNameKey(a.installation_id, a.capability_id), a.display_name); + } + return map; +} + +/** Maps the Activity panel uses to resolve an event/scan's source label. */ +export interface SourceLabelLookups { + sourceByID: Map; + connectionByID: Map; + displayNames: Map; +} + +/** + * Resolve the `name` rung for an Activity event/scan that references a source by + * id. Returns "" when the reference carries no capability (caller supplies its + * own fallback, e.g. "Autoscan"). + */ +export function resolveEventSourceName( + ref: { source_id?: string | null; capability_id?: string; installation_id?: number | null }, + lookups: SourceLabelLookups, +): string { + if (!ref.capability_id || ref.installation_id == null) return ""; + const source = ref.source_id ? lookups.sourceByID.get(ref.source_id) : undefined; + const connectionName = source?.connection_id + ? lookups.connectionByID.get(source.connection_id) + : undefined; + const displayName = lookups.displayNames.get( + pluginDisplayNameKey(ref.installation_id, ref.capability_id), + ); + return composeSourceLabel({ + operatorLabel: source?.label, + connectionName, + displayName, + capabilityId: ref.capability_id, + installationId: ref.installation_id, + }).name; +} diff --git a/web/src/lib/scanRuns.ts b/web/src/lib/scanRuns.ts new file mode 100644 index 00000000..16299064 --- /dev/null +++ b/web/src/lib/scanRuns.ts @@ -0,0 +1,88 @@ +import type { ScanRun } from "@/api/types"; + +export function compareActiveScans(left: ScanRun, right: ScanRun) { + const leftRank = left.status === "running" ? 0 : 1; + const rightRank = right.status === "running" ? 0 : 1; + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + const modeCompare = formatActiveScanMode(left).localeCompare(formatActiveScanMode(right)); + if (modeCompare !== 0) { + return modeCompare; + } + return (left.path ?? "").localeCompare(right.path ?? ""); +} + +export function formatActiveScanMode(scan: Pick) { + switch (scan.mode) { + case "library": + return "Full library scan"; + case "subtree": + return "Subtree scan"; + case "file": + return "Single file scan"; + default: + return scan.mode; + } +} + +export function formatActiveScanTrigger(trigger: string) { + switch (trigger) { + case "autoscan": + return "Autoscan"; + case "library_created": + return "Created"; + case "library_paths_changed": + case "library_updated": + return "Updated"; + case "library_id": + case "manual": + return "Manual"; + case "task:scan_libraries": + case "task_scan_libraries": + return "Scheduled"; + default: + return trigger.replace(/_/g, " "); + } +} + +export function formatActiveScanTime(iso: string | undefined, prefix: string) { + if (!iso) { + return prefix; + } + + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return prefix; + } + + const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)); + if (seconds < 60) { + return `${prefix} just now`; + } + if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + return `${prefix} ${minutes}m ago`; + } + if (seconds < 86400) { + const hours = Math.floor(seconds / 3600); + return `${prefix} ${hours}h ago`; + } + const days = Math.floor(seconds / 86400); + return `${prefix} ${days}d ago`; +} + +export function formatActiveScanProgress(scan: ScanRun) { + const result = scan.result; + if (!result) { + return ""; + } + if (result.total_files && result.files_processed) { + const percent = Math.max( + 0, + Math.min(100, Math.round((result.files_processed / result.total_files) * 100)), + ); + return `${result.message ?? "Processing files"} · ${result.files_processed.toLocaleString()} / ${result.total_files.toLocaleString()} (${percent}%)`; + } + return result.message ?? ""; +} diff --git a/web/src/pages/AdminAutoscan.tsx b/web/src/pages/AdminAutoscan.tsx new file mode 100644 index 00000000..b27dc576 --- /dev/null +++ b/web/src/pages/AdminAutoscan.tsx @@ -0,0 +1,220 @@ +import { useState } from "react"; +import { useSearchParams } from "react-router"; +import { Play } from "lucide-react"; +import type { AutoscanSettings } from "@/api/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + useAutoscanSettings, + useTriggerAutoscan, + useUpdateAutoscanSettings, +} from "@/hooks/queries/useAutoscan"; +import ConnectionsPanel from "@/pages/admin/autoscan/ConnectionsPanel"; +import ActivityPanel from "@/pages/admin/autoscan/ActivityPanel"; +import SourcesPanel from "@/pages/admin/autoscan/SourcesPanel"; + +// --------------------------------------------------------------------------- +// Tab routing helpers +// --------------------------------------------------------------------------- + +const AUTOSCAN_TABS = ["sources", "activity", "connections", "settings"] as const; +type AutoscanTab = (typeof AUTOSCAN_TABS)[number]; + +function normalizeTab(value: string | null): AutoscanTab { + return AUTOSCAN_TABS.includes(value as AutoscanTab) ? (value as AutoscanTab) : "sources"; +} + +// --------------------------------------------------------------------------- +// Settings tab +// --------------------------------------------------------------------------- + +function SettingsTab() { + const settings = useAutoscanSettings(); + const updateSettings = useUpdateAutoscanSettings(); + + // Local form state — initialised from server data; reflected immediately on + // every mutation so the UI stays responsive without waiting for refetch. + const [form, setForm] = useState(null); + + // Merge server data into local form on first load (and after invalidation). + const serverData = settings.data; + const effective: AutoscanSettings = form ?? + serverData ?? { enabled: false, default_poll_interval_seconds: 300, debounce_seconds: 10 }; + + function patch(delta: Partial) { + setForm((prev) => ({ + ...(prev ?? effective), + ...delta, + })); + } + + function save(override?: Partial) { + const body: AutoscanSettings = { ...effective, ...override }; + updateSettings.mutate(body, { + onSuccess: () => setForm(null), // reset to server truth after save + }); + } + + if (settings.isLoading) { + return

    Loading settings…

    ; + } + + return ( +
    +

    + Autoscan can be enabled or disabled from the toggle in the page header. Tune polling + behavior below. +

    + + {/* Default poll interval */} +
    + +
    + + patch({ default_poll_interval_seconds: Number(e.target.value) || 300 }) + } + onBlur={() => save()} + /> + sec +
    +

    + Used for sources that have no per-source interval set. +

    +
    + + {/* Debounce */} +
    + +
    + patch({ debounce_seconds: Number(e.target.value) || 0 })} + onBlur={() => save()} + /> + sec +
    +

    + Coalesces rapid change events before triggering a scan. +

    +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default function AdminAutoscan() { + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = normalizeTab(searchParams.get("tab")); + const trigger = useTriggerAutoscan(); + const settings = useAutoscanSettings(); + const updateSettings = useUpdateAutoscanSettings(); + + const enabled = settings.data?.enabled ?? false; + + function toggleEnabled(checked: boolean) { + if (!settings.data) return; + updateSettings.mutate({ ...settings.data, enabled: checked }); + } + + function setActiveTab(value: string) { + const next = new URLSearchParams(searchParams); + if (value === "sources") { + next.delete("tab"); + } else { + next.set("tab", value); + } + setSearchParams(next, { replace: true }); + } + + return ( +
    +
    +
    +
    +

    + Autoscan +

    + {settings.data && + (enabled ? ( + Enabled + ) : ( + + Disabled + + ))} +
    +

    + Automatically detect library changes from installed scan-source plugins. Install them + from the Plugins page, configure each source, bind a connection if it needs one, and + tune polling intervals. +

    +
    +
    +
    + + +
    + +
    +
    + + + + Sources + Activity + Connections + Settings + + + + + + + + + + + + + + + + + + +
    + ); +} diff --git a/web/src/pages/AdminLibraries.tsx b/web/src/pages/AdminLibraries.tsx index 810ad044..76bdad1a 100644 --- a/web/src/pages/AdminLibraries.tsx +++ b/web/src/pages/AdminLibraries.tsx @@ -101,6 +101,13 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; import FolderBrowser from "@/components/FolderBrowser"; import PathAutocompleteInput from "@/components/PathAutocompleteInput"; import { cn } from "@/lib/utils"; +import { + compareActiveScans, + formatActiveScanMode, + formatActiveScanProgress, + formatActiveScanTime, + formatActiveScanTrigger, +} from "@/lib/scanRuns"; import { DndContext, DragOverlay, @@ -802,91 +809,6 @@ function getLibraryScanGroupName(library: Library | null, libraryID: number) { return library?.name ?? `Library #${libraryID}`; } -function compareActiveScans(left: ScanRun, right: ScanRun) { - const leftRank = left.status === "running" ? 0 : 1; - const rightRank = right.status === "running" ? 0 : 1; - if (leftRank !== rightRank) { - return leftRank - rightRank; - } - const modeCompare = formatActiveScanMode(left).localeCompare(formatActiveScanMode(right)); - if (modeCompare !== 0) { - return modeCompare; - } - return (left.path ?? "").localeCompare(right.path ?? ""); -} - -function formatActiveScanMode(scan: Pick) { - switch (scan.mode) { - case "library": - return "Full library scan"; - case "subtree": - return "Subtree scan"; - case "file": - return "Single file scan"; - default: - return scan.mode; - } -} - -function formatActiveScanTrigger(trigger: string) { - switch (trigger) { - case "library_created": - return "Created"; - case "library_paths_changed": - case "library_updated": - return "Updated"; - case "library_id": - case "manual": - return "Manual"; - case "task:scan_libraries": - case "task_scan_libraries": - return "Scheduled"; - default: - return trigger.replace(/_/g, " "); - } -} - -function formatActiveScanTime(iso: string | undefined, prefix: string) { - if (!iso) { - return prefix; - } - - const date = new Date(iso); - if (Number.isNaN(date.getTime())) { - return prefix; - } - - const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)); - if (seconds < 60) { - return `${prefix} just now`; - } - if (seconds < 3600) { - const minutes = Math.floor(seconds / 60); - return `${prefix} ${minutes}m ago`; - } - if (seconds < 86400) { - const hours = Math.floor(seconds / 3600); - return `${prefix} ${hours}h ago`; - } - const days = Math.floor(seconds / 86400); - return `${prefix} ${days}d ago`; -} - -function formatActiveScanProgress(scan: ScanRun) { - const result = scan.result; - if (!result) { - return ""; - } - if (result.total_files && result.files_processed) { - const percent = Math.max( - 0, - Math.min(100, Math.round((result.files_processed / result.total_files) * 100)), - ); - return `${result.message ?? "Processing files"} · ${result.files_processed.toLocaleString()} / ${result.total_files.toLocaleString()} (${percent}%)`; - } - return result.message ?? ""; -} - function getLibraryRefreshLibraryID(job: AdminJob): number | null { const value = (job.request_payload as { library_id?: unknown }).library_id; if (typeof value === "number" && Number.isFinite(value)) { diff --git a/web/src/pages/admin/autoscan/ActivityPanel.tsx b/web/src/pages/admin/autoscan/ActivityPanel.tsx new file mode 100644 index 00000000..2aed3428 --- /dev/null +++ b/web/src/pages/admin/autoscan/ActivityPanel.tsx @@ -0,0 +1,924 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { + AlertTriangle, + CheckCircle2, + Clock, + History, + ListChecks, + RefreshCw, + ScanLine, + Search, + Square, + X, +} from "lucide-react"; +import type { + AutoscanEvent, + AutoscanEventScanRun, + AutoscanEventStatus, + AutoscanScan, + AutoscanScanStatus, + Library, + ScanRun, +} from "@/api/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { TablePagination } from "@/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useEventChannel } from "@/components/realtimeEventsContext"; +import { useDebounce } from "@/hooks/useDebounce"; +import { + useAutoscanConnections, + useAutoscanEvents, + useAutoscanScans, + useAutoscanSources, + useAutoscanStatus, + useAvailableScanSources, +} from "@/hooks/queries/useAutoscan"; +import { useActiveScans } from "@/hooks/queries/admin/scans"; +import { useAdminLibraries, useCancelLibraryScans } from "@/hooks/queries/admin/libraries"; +import { + buildPluginDisplayNames, + resolveEventSourceName, + type SourceLabelLookups, +} from "@/lib/autoscanLabels"; +import { + compareActiveScans, + formatActiveScanMode, + formatActiveScanProgress, + formatActiveScanTime, + formatActiveScanTrigger, +} from "@/lib/scanRuns"; +import { cn } from "@/lib/utils"; + +const HISTORY_PAGE_SIZE_OPTIONS = [25, 50, 100]; +const DEFAULT_HISTORY_PAGE_SIZE = 25; +const QUEUE_PAGE_SIZE = 8; + +type HistoryView = "scans" | "polls"; + +function formatTime(value: string | undefined): string { + if (!value) return "-"; + return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function formatTimestamp(value: string | undefined): string { + if (!value) return "-"; + return new Date(value).toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${Math.round(ms / 100) / 10}s`; + const minutes = Math.floor(ms / 60_000); + const seconds = Math.round((ms % 60_000) / 1000); + return `${minutes}m ${seconds}s`; +} + +function eventStatusTone(status: AutoscanEventStatus): { + label: string; + icon: typeof CheckCircle2; + className: string; +} { + switch (status) { + case "success": + return { + label: "Success", + icon: CheckCircle2, + className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500", + }; + case "unresolved": + return { + label: "Unresolved", + icon: AlertTriangle, + className: "border-amber-500/30 bg-amber-500/10 text-amber-500", + }; + case "error": + return { + label: "Error", + icon: AlertTriangle, + className: "border-destructive/30 bg-destructive/10 text-destructive", + }; + } +} + +function scanStatusClass(status: AutoscanEventScanRun["status"] | ScanRun["status"]): string { + switch (status) { + case "completed": + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-500"; + case "failed": + return "border-destructive/30 bg-destructive/10 text-destructive"; + case "running": + return "border-sky-500/30 bg-sky-500/10 text-sky-500"; + case "accepted": + return "border-muted-foreground/25 bg-muted/60 text-muted-foreground"; + case "cancelled": + return "border-amber-500/30 bg-amber-500/10 text-amber-500"; + } +} + +function scanStatusLabel(status: AutoscanScanStatus | ScanRun["status"]) { + return status === "accepted" ? "queued" : status; +} + +// arr-plugin sources fan out one-per-connection under a single generic +// capability, so resolve every Activity row through the shared label chain: +// operator label -> connection name -> manifest display_name -> capability_id. +function pollSourceName(event: AutoscanEvent, lookups: SourceLabelLookups): string { + return ( + resolveEventSourceName(event, lookups) || `${event.capability_id} #${event.installation_id}` + ); +} + +function scanSourceName(scan: AutoscanScan, lookups: SourceLabelLookups): string { + return resolveEventSourceName(scan, lookups) || "Autoscan"; +} + +function libraryName(librariesByID: Map, libraryID: number): string { + return librariesByID.get(libraryID)?.name ?? `Library #${libraryID}`; +} + +function PollStatusBadge({ status }: { status: AutoscanEventStatus }) { + const tone = eventStatusTone(status); + const Icon = tone.icon; + return ( + + + {tone.label} + + ); +} + +function ScanStatusBadge({ status }: { status: AutoscanScanStatus | ScanRun["status"] }) { + return ( + + {scanStatusLabel(status)} + + ); +} + +// Shared desktop table chrome so the queue, scan history, and poll log read as +// one family: clipped rounded border, muted sticky-feeling header band. +function DataTable({ head, children }: { head: ReactNode; children: ReactNode }) { + return ( +
    + + + {head} + + {children} +
    +
    + ); +} + +function RunList({ runs }: { runs: AutoscanEventScanRun[] }) { + if (runs.length === 0) { + return No new scan rows were created.; + } + return ( +
    + {runs.map((run) => ( +
    + +
    +
    {run.mode}
    +
    + {run.path || "Entire library"} +
    +
    +
    + {formatTime(run.completed_at ?? run.started_at ?? run.requested_at)} +
    +
    + ))} +
    + ); +} + +function PollMetricStrip({ event }: { event: AutoscanEvent }) { + return ( +
    + {event.changes_returned} changes + {event.targets_claimed} targets + {event.scans_created} created + {event.scans_reused} reused + {event.scans_suppressed} suppressed +
    + ); +} + +function QueueCard({ + scan, + librariesByID, + cancellingLibraryID, + onCancel, +}: { + scan: ScanRun; + librariesByID: Map; + cancellingLibraryID: number | null; + onCancel: (libraryID: number) => void; +}) { + const progress = formatActiveScanProgress(scan); + return ( +
    +
    +
    +
    + + {libraryName(librariesByID, scan.library_id)} +
    +
    + {formatActiveScanMode(scan)} + {formatActiveScanTrigger(scan.trigger)} + + {scan.status === "running" + ? formatActiveScanTime(scan.started_at, "Started") + : "Waiting for capacity"} + +
    +
    + +
    +
    + {scan.path || "Entire library"} +
    + {progress ? ( +
    + {progress} +
    + ) : null} +
    + ); +} + +function AutoscanQueue({ + scans, + statusActiveCount, + librariesByID, + cancellingLibraryID, + onCancel, +}: { + scans: ScanRun[]; + statusActiveCount: number; + librariesByID: Map; + cancellingLibraryID: number | null; + onCancel: (libraryID: number) => void; +}) { + const [page, setPage] = useState(0); + const pageCount = Math.max(1, Math.ceil(scans.length / QUEUE_PAGE_SIZE)); + // Live scans complete out from under us; the active set length is known + // synchronously, so derive the in-range page during render (no effect needed) + // and the view self-heals when the queue shrinks below the current offset. + const safePage = Math.min(page, pageCount - 1); + const rows = scans.slice(safePage * QUEUE_PAGE_SIZE, (safePage + 1) * QUEUE_PAGE_SIZE); + + if (scans.length === 0) { + return ( +
    +
    + + Autoscan queue +
    +

    + {statusActiveCount > 0 + ? "Queue counts are active, but live scan details have not arrived yet." + : "No autoscan scans are queued or running."} +

    +
    + ); + } + + return ( +
    +
    +
    +
    + + Autoscan queue +
    +

    + Live scans created by autoscan sources. +

    +
    + + {scans.length} active + +
    + +
    + {rows.map((scan) => ( + + ))} +
    + + + Status + Library + Scope + Progress + Action + + } + > + {rows.map((scan) => { + const progress = formatActiveScanProgress(scan); + return ( + + + + + + {libraryName(librariesByID, scan.library_id)} +
    + {scan.status === "running" + ? formatActiveScanTime(scan.started_at, "Started") + : "Waiting for capacity"} +
    +
    + +
    {formatActiveScanMode(scan)}
    +
    + {scan.path || "Entire library"} +
    +
    + + {progress || formatActiveScanTrigger(scan.trigger)} + + + + +
    + ); + })} +
    + + {scans.length > QUEUE_PAGE_SIZE ? ( + + ) : null} +
    + ); +} + +function ScanHistoryCard({ + scan, + librariesByID, + lookups, +}: { + scan: AutoscanScan; + librariesByID: Map; + lookups: SourceLabelLookups; +}) { + return ( +
    +
    +
    +
    + + {libraryName(librariesByID, scan.library_id)} +
    +
    + {formatActiveScanMode(scan)} + {scanSourceName(scan, lookups)} + + {formatTimestamp(scan.completed_at ?? scan.started_at ?? scan.requested_at)} + +
    +
    + {scan.event_status ? : null} +
    +
    + {scan.path || "Entire library"} +
    + {scan.error_message ? ( +
    + {scan.error_message} +
    + ) : null} +
    + {scan.id} +
    +
    + ); +} + +function ScanHistoryTable({ + scans, + librariesByID, + lookups, +}: { + scans: AutoscanScan[]; + librariesByID: Map; + lookups: SourceLabelLookups; +}) { + return ( + <> +
    + {scans.map((scan) => ( + + ))} +
    + + Status + Library + Source + Scope + Time + Poll + + } + > + {scans.map((scan) => ( + + + + + + {libraryName(librariesByID, scan.library_id)} +
    {scan.id}
    +
    + {scanSourceName(scan, lookups)} + +
    {formatActiveScanMode(scan)}
    +
    + {scan.path || "Entire library"} +
    + {scan.error_message ? ( +
    + {scan.error_message} +
    + ) : null} +
    + + {formatTimestamp(scan.completed_at ?? scan.started_at ?? scan.requested_at)} + + + {scan.event_status ? : "-"} + +
    + ))} +
    + + ); +} + +function PollEventCard({ event, lookups }: { event: AutoscanEvent; lookups: SourceLabelLookups }) { + return ( +
    +
    +
    +
    + + {pollSourceName(event, lookups)} +
    + +
    +
    +
    {formatTimestamp(event.completed_at)}
    +
    {formatDuration(event.duration_ms)}
    +
    +
    + {event.error_message ? ( +

    + {event.error_message} +

    + ) : null} +
    + + {event.scan_runs.length} linked scan {event.scan_runs.length === 1 ? "run" : "runs"} + +
    + +
    +
    +
    + ); +} + +function PollEventTable({ + events, + lookups, +}: { + events: AutoscanEvent[]; + lookups: SourceLabelLookups; +}) { + return ( + <> +
    + {events.map((event) => ( + + ))} +
    + + Status + Source + Counts + Scans + Duration + Completed + + } + > + {events.map((event) => ( + + + + + {pollSourceName(event, lookups)} + + + {event.error_message ? ( +
    + {event.error_message} +
    + ) : null} +
    + +
    + + {event.scan_runs.length} linked + +
    + +
    +
    +
    + + {formatDuration(event.duration_ms)} + + + {formatTimestamp(event.completed_at)} + +
    + ))} +
    + + ); +} + +function HistorySkeleton() { + return ( + + ); +} + +function StatTile({ + label, + value, + icon: Icon, +}: { + label: string; + value: ReactNode; + icon?: typeof ScanLine; +}) { + return ( +
    +
    + {Icon ? : null} + {label} +
    +
    {value}
    +
    + ); +} + +export default function ActivityPanel() { + useEventChannel("scans"); + const [historyView, setHistoryView] = useState("scans"); + const [historyQuery, setHistoryQuery] = useState(""); + const [scanStatus, setScanStatus] = useState("all"); + const [pollStatus, setPollStatus] = useState("all"); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(DEFAULT_HISTORY_PAGE_SIZE); + const debouncedHistoryQuery = useDebounce(historyQuery.trim(), 300); + + const scanHistory = useAutoscanScans({ + limit: pageSize, + offset: page * pageSize, + query: debouncedHistoryQuery || undefined, + status: scanStatus === "all" ? undefined : scanStatus, + enabled: historyView === "scans", + }); + const pollEvents = useAutoscanEvents({ + limit: pageSize, + offset: page * pageSize, + query: debouncedHistoryQuery || undefined, + status: pollStatus === "all" ? undefined : pollStatus, + enabled: historyView === "polls", + }); + const status = useAutoscanStatus(); + const { data: activeScans = [] } = useActiveScans(); + const { data: libraries = [] } = useAdminLibraries(); + const { data: autoscanSources = [] } = useAutoscanSources(); + const { data: autoscanConnections = [] } = useAutoscanConnections(); + const available = useAvailableScanSources(); + const cancelScans = useCancelLibraryScans(); + + const queue = status.data; + const librariesByID = useMemo( + () => new Map(libraries.map((library) => [library.id, library])), + [libraries], + ); + const labelLookups: SourceLabelLookups = useMemo( + () => ({ + sourceByID: new Map(autoscanSources.map((s) => [s.id, s])), + connectionByID: new Map(autoscanConnections.map((c) => [c.id, c.name])), + displayNames: buildPluginDisplayNames(available.data ?? []), + }), + [autoscanSources, autoscanConnections, available.data], + ); + const autoscanQueue = useMemo( + () => + activeScans + .filter((scan) => scan.trigger === "autoscan") + .slice() + .sort(compareActiveScans), + [activeScans], + ); + + const activeQuery = historyView === "scans" ? scanHistory : pollEvents; + const activeRows = activeQuery.data?.rows ?? []; + const activeTotal = activeQuery.data?.total ?? 0; + const activeStatus = historyView === "scans" ? scanStatus : pollStatus; + const hasHistoryFilters = debouncedHistoryQuery !== "" || activeStatus !== "all"; + const isRefreshing = status.isFetching || activeQuery.isFetching; + const itemNoun = historyView === "scans" ? "scan" : "poll"; + + // If a background refresh shrinks the result set below the current page, + // pull the viewer back to the last page that still has rows. Adjusting state + // during render (React's escape hatch) converges in one guarded step and + // avoids an extra effect pass; the offset query then refetches the valid page. + const historyPageCount = Math.max(1, Math.ceil(activeTotal / pageSize)); + if (page > 0 && page >= historyPageCount && !activeQuery.isFetching) { + setPage(historyPageCount - 1); + } + + function refresh() { + void status.refetch(); + void activeQuery.refetch(); + } + + function resetHistoryFilters() { + setHistoryQuery(""); + setScanStatus("all"); + setPollStatus("all"); + setPage(0); + } + + function switchHistoryView(nextView: HistoryView) { + setHistoryView(nextView); + setPage(0); + } + + return ( +
    +
    + + + +
    + + +
    +
    + + cancelScans.mutate(libraryID)} + /> + +
    +
    +
    +
    + {historyView === "scans" ? ( + + ) : ( + + )} + {historyView === "scans" ? "Scan history" : "Poll log"} +
    +

    + {historyView === "scans" + ? "Real scan rows created by autoscan, searchable by path, library, source, status, or scan id." + : "Diagnostic poll records from scan-source plugins."} +

    +
    +
    +
    + + +
    +
    + + { + setHistoryQuery(event.target.value); + setPage(0); + }} + placeholder={historyView === "scans" ? "Search scan history" : "Search poll log"} + className="pr-9 pl-9" + /> + {historyQuery ? ( + + ) : null} +
    + {historyView === "scans" ? ( + + ) : ( + + )} + +
    +
    + + {activeQuery.isLoading ? ( + + ) : activeQuery.isError ? ( +
    +

    Failed to load autoscan activity.

    + +
    + ) : activeRows.length === 0 ? ( +
    +

    + {hasHistoryFilters + ? "No autoscan activity matches those filters." + : historyView === "scans" + ? "No autoscan scans have been created yet." + : "No autoscan polls have been recorded yet."} +

    +
    + ) : ( +
    + {historyView === "scans" ? ( + + ) : ( + + )} + + { + setPageSize(size); + setPage(0); + }} + pageSizeOptions={HISTORY_PAGE_SIZE_OPTIONS} + itemNoun={itemNoun} + isFetching={activeQuery.isFetching} + /> +
    + )} +
    +
    + ); +} diff --git a/web/src/pages/admin/autoscan/ConnectionsPanel.tsx b/web/src/pages/admin/autoscan/ConnectionsPanel.tsx new file mode 100644 index 00000000..8ee917de --- /dev/null +++ b/web/src/pages/admin/autoscan/ConnectionsPanel.tsx @@ -0,0 +1,532 @@ +import { useState } from "react"; +import { CheckCircle2, Pencil, Plus, Trash2, XCircle } from "lucide-react"; +import type { + AutoscanConnection, + AutoscanConnectionInput, + AutoscanConnectionTestInput, + AutoscanConnectionTestResult, +} from "@/api/types"; +import type { RequestIntegration } from "@/api/types"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + useAutoscanConnections, + useCreateAutoscanConnection, + useDeleteAutoscanConnection, + useTestAutoscanConnection, + useUpdateAutoscanConnection, +} from "@/hooks/queries/useAutoscan"; +import { useRequestIntegrations } from "@/hooks/queries/useRequests"; + +// --------------------------------------------------------------------------- +// Dialog mode types +// --------------------------------------------------------------------------- + +type DialogMode = "reuse" | "own"; + +interface DialogState { + open: boolean; + editing: AutoscanConnection | null; + mode: DialogMode; + // "reuse" fields + requestIntegrationId: string; + // "own" fields + name: string; + kind: string; + baseUrl: string; + apiKey: string; +} + +const BLANK_DIALOG: DialogState = { + open: false, + editing: null, + mode: "own", + requestIntegrationId: "", + name: "", + kind: "sonarr", + baseUrl: "", + apiKey: "", +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function connectionKindLabel(kind: string): string { + if (kind === "sonarr") return "Sonarr"; + if (kind === "radarr") return "Radarr"; + return kind; +} + +function isArrKind(integration: RequestIntegration): boolean { + return integration.kind === "sonarr" || integration.kind === "radarr"; +} + +// --------------------------------------------------------------------------- +// Main panel +// --------------------------------------------------------------------------- + +export default function ConnectionsPanel() { + const connections = useAutoscanConnections(); + const requestIntegrations = useRequestIntegrations(); + const createConnection = useCreateAutoscanConnection(); + const updateConnection = useUpdateAutoscanConnection(); + const deleteConnection = useDeleteAutoscanConnection(); + const testConnection = useTestAutoscanConnection(); + + const [dialog, setDialog] = useState(BLANK_DIALOG); + const [deleteTarget, setDeleteTarget] = useState(null); + const [testResult, setTestResult] = useState(null); + + const arrIntegrations = (requestIntegrations.data ?? []).filter(isArrKind); + + // ------------------------------------------------------------------------- + // Dialog helpers + // ------------------------------------------------------------------------- + + function openAddDialog() { + setTestResult(null); + setDialog({ ...BLANK_DIALOG, open: true }); + } + + function openEditDialog(conn: AutoscanConnection) { + setTestResult(null); + if (conn.request_integration_id) { + setDialog({ + open: true, + editing: conn, + mode: "reuse", + requestIntegrationId: conn.request_integration_id, + name: conn.name, + kind: conn.kind, + baseUrl: "", + apiKey: "", + }); + } else { + setDialog({ + open: true, + editing: conn, + mode: "own", + requestIntegrationId: "", + name: conn.name, + kind: conn.kind, + baseUrl: conn.base_url ?? "", + apiKey: "", + }); + } + } + + function closeDialog() { + setTestResult(null); + setDialog(BLANK_DIALOG); + } + + // ------------------------------------------------------------------------- + // Test connection (advisory — never blocks save) + // ------------------------------------------------------------------------- + + function handleTest() { + setTestResult(null); + let body: AutoscanConnectionTestInput; + if (dialog.editing && dialog.mode === "own") { + // Editing an existing own-credentials connection: test the saved record + // unless the operator typed new credentials in this dialog session. + if (dialog.baseUrl.trim() && dialog.apiKey.trim()) { + body = { base_url: dialog.baseUrl.trim(), api_key_ref: dialog.apiKey.trim() }; + } else { + body = { connection_id: dialog.editing.id }; + } + } else if (dialog.editing && dialog.mode === "reuse") { + body = { connection_id: dialog.editing.id }; + } else if (dialog.mode === "reuse") { + body = { request_integration_id: dialog.requestIntegrationId }; + } else { + body = { + base_url: dialog.baseUrl.trim(), + ...(dialog.apiKey.trim() ? { api_key_ref: dialog.apiKey.trim() } : {}), + }; + } + testConnection.mutate(body, { + onSuccess: (result) => setTestResult(result), + onError: (err) => + setTestResult({ + ok: false, + error: err instanceof Error ? err.message : "Connection test failed", + }), + }); + } + + const canTest = + dialog.mode === "reuse" + ? Boolean(dialog.requestIntegrationId) || Boolean(dialog.editing) + : Boolean(dialog.baseUrl.trim()) || Boolean(dialog.editing); + + // ------------------------------------------------------------------------- + // Save + // ------------------------------------------------------------------------- + + function handleSave() { + let body: AutoscanConnectionInput; + + if (dialog.mode === "reuse") { + if (!dialog.requestIntegrationId) return; + // Resolve name + kind from the selected integration for display purposes; + // the backend can also derive them from the linked integration. + const linked = arrIntegrations.find((i) => i.id === dialog.requestIntegrationId); + body = { + name: linked?.name ?? dialog.requestIntegrationId, + kind: linked?.kind ?? "sonarr", + request_integration_id: dialog.requestIntegrationId, + }; + } else { + if (!dialog.name.trim() || !dialog.baseUrl.trim()) return; + body = { + name: dialog.name.trim(), + kind: dialog.kind, + base_url: dialog.baseUrl.trim(), + // Only send api_key_ref when the operator has typed something; + // on edit with a blank field the backend keeps the stored key. + ...(dialog.apiKey.trim() ? { api_key_ref: dialog.apiKey.trim() } : {}), + }; + } + + if (dialog.editing) { + updateConnection.mutate({ id: dialog.editing.id, body }, { onSuccess: closeDialog }); + } else { + createConnection.mutate(body, { onSuccess: closeDialog }); + } + } + + const isSaving = createConnection.isPending || updateConnection.isPending; + + const canSave = + dialog.mode === "reuse" + ? Boolean(dialog.requestIntegrationId) + : Boolean(dialog.name.trim()) && + Boolean(dialog.baseUrl.trim()) && + (Boolean(dialog.apiKey.trim()) || Boolean(dialog.editing?.has_api_key)); + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + + const list = connections.data ?? []; + + return ( + <> + + +
    + Connections + +
    +
    + + {connections.isLoading ? ( +

    Loading…

    + ) : list.length === 0 ? ( +

    + No connections yet. A connection holds credentials for a server-based source (e.g. + Sonarr/Radarr). Add one if your source needs to reach a server. +

    + ) : ( + + + + Name + Kind + Source + URL + + + + + {list.map((conn) => ( + + {conn.name} + {connectionKindLabel(conn.kind)} + + {conn.request_integration_id ? ( + Reused from Requests + ) : ( + Own + )} + + + {conn.base_url ?? "—"} + + +
    + + +
    +
    +
    + ))} +
    +
    + )} +
    +
    + + {/* Add / Edit Dialog */} + !open && closeDialog()}> + + + {dialog.editing ? "Edit connection" : "Add connection"} + + Reuse a server you already configured in Requests (Sonarr/Radarr), or enter your own + credentials. + + + +
    + {/* Mode picker (only when creating) */} + {!dialog.editing && ( +
    + + +
    + )} + + {/* Reuse mode */} + {dialog.mode === "reuse" && ( +
    + + {requestIntegrations.isLoading ? ( +

    Loading integrations…

    + ) : arrIntegrations.length === 0 ? ( +

    + No Sonarr/Radarr integrations found. Add one in the Requests page first. +

    + ) : ( + + )} +
    + )} + + {/* Own credentials mode */} + {dialog.mode === "own" && ( +
    +
    + + setDialog((d) => ({ ...d, name: e.target.value }))} + /> +
    +
    + + +
    +
    + + { + setTestResult(null); + setDialog((d) => ({ ...d, baseUrl: e.target.value })); + }} + /> +
    +
    + + { + setTestResult(null); + setDialog((d) => ({ ...d, apiKey: e.target.value })); + }} + autoComplete="new-password" + /> +
    +
    + )} +
    + + {/* Inline test result (advisory) */} + {testResult && ( +
    + {testResult.ok ? ( + + ) : ( + + )} + + {testResult.ok + ? `Connected${testResult.version ? ` (v${testResult.version})` : ""}` + : (testResult.error ?? "Connection failed")} + +
    + )} + + + +
    + + +
    +
    +
    +
    + + {/* Delete confirmation */} + !open && setDeleteTarget(null)} + > + + + Delete connection? + + “{deleteTarget?.name}” will be removed. Any scan sources bound to it will + lose their connection and will need to be reconfigured. + + + + Cancel + { + if (deleteTarget) { + deleteConnection.mutate(deleteTarget.id); + setDeleteTarget(null); + } + }} + > + Delete + + + + + + ); +} diff --git a/web/src/pages/admin/autoscan/SourcesPanel.tsx b/web/src/pages/admin/autoscan/SourcesPanel.tsx new file mode 100644 index 00000000..aa052816 --- /dev/null +++ b/web/src/pages/admin/autoscan/SourcesPanel.tsx @@ -0,0 +1,1426 @@ +import { useId, useMemo, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Library as LibraryIcon, + Plus, + RefreshCw, + Trash2, + X, +} from "lucide-react"; +import { Link } from "react-router"; +import type { + AutoscanPathRewrite, + AutoscanRewriteSuggestions, + AutoscanSource, + AutoscanSourceInput, + Library, +} from "@/api/types"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + useAutoscanConnections, + useAutoscanRewriteSuggestions, + useAutoscanSettings, + useAutoscanSources, + useAvailableScanSources, + useCreateAutoscanSource, + useDeleteAutoscanSource, + useUpdateAutoscanSource, +} from "@/hooks/queries/useAutoscan"; +import { useAdminLibraries } from "@/hooks/queries/admin/libraries"; +import { + buildPluginDisplayNames, + composeSourceLabel, + pluginDisplayNameKey, +} from "@/lib/autoscanLabels"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Resolve a source's display name from its persisted state via the shared label + * chain (operator label -> connection name -> manifest display_name -> capability). + * Used where no live row-edit state is available (e.g. the delete dialog); inside + * a SourceRow, prefer the row's `resolvedLabel` which reflects in-progress edits. + */ +function resolveSourceName( + source: AutoscanSource, + connectionOptions: Array<{ id: string; name: string }>, + pluginDisplayNames: Map, +): string { + return composeSourceLabel({ + operatorLabel: source.label, + connectionName: connectionOptions.find((c) => c.id === (source.connection_id ?? ""))?.name, + displayName: pluginDisplayNames.get( + pluginDisplayNameKey(source.installation_id, source.capability_id), + ), + capabilityId: source.capability_id, + installationId: source.installation_id, + }).name; +} + +function formatRelativeTime(isoString: string | null): string { + if (!isoString) return "Never"; + const date = new Date(isoString); + const diffMs = Date.now() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return "Just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDays = Math.floor(diffHr / 24); + return `${diffDays}d ago`; +} + +// --------------------------------------------------------------------------- +// Row state for per-row edits (connection + interval + rewrites) +// --------------------------------------------------------------------------- + +interface RowEdit { + connectionId: string; // "" means no connection + intervalStr: string; // "" means use default + rewrites: AutoscanPathRewrite[]; + sourceConfig: Record; + label: string; +} + +function sourceToRowEdit(source: AutoscanSource): RowEdit { + return { + connectionId: source.connection_id ?? "", + intervalStr: source.poll_interval_seconds != null ? String(source.poll_interval_seconds) : "", + rewrites: source.path_rewrites.map((r) => ({ ...r })), + sourceConfig: sourceConfigForEdit(source), + label: source.label ?? "", + }; +} + +const CEPHFS_PLUGIN_ID = "silo.autoscan.cephfs"; +const CEPHFS_CAPABILITY_ID = "cephfs"; +const DEFAULT_CEPHFS_EXCLUSIONS = [ + "*.partial", + "*.tmp", + "@eaDir", + "#recycle", + ".downloads", + ".recyclebin", + "volumes", +]; +const CEPHFS_MOVIE_PATHS_KEY = "movie_flat_paths"; +const CEPHFS_TV_PATHS_KEY = "tv_flat_paths"; +const CEPHFS_LEGACY_MOVIE_NESTED_KEY = "movie_nested_paths"; +const CEPHFS_LEGACY_TV_NESTED_KEY = "tv_nested_paths"; + +function isCephFSSource(source: AutoscanSource): boolean { + return source.capability_id === CEPHFS_CAPABILITY_ID; +} + +function isCephFSPlugin(plugin: { plugin_id: string; capability_id: string } | undefined): boolean { + return plugin?.plugin_id === CEPHFS_PLUGIN_ID || plugin?.capability_id === CEPHFS_CAPABILITY_ID; +} + +function defaultCephFSConfig(): Record { + return { exclusions: DEFAULT_CEPHFS_EXCLUSIONS.join("\n") }; +} + +function mergeLineValues(...values: Array): string { + const lines = new Set(); + for (const value of values) { + (value ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .forEach((line) => lines.add(line)); + } + return Array.from(lines).join("\n"); +} + +function mergeLineDefaults(value: string | undefined, defaults: string[]): string { + const lines = new Set( + (value ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean), + ); + for (const entry of defaults) { + lines.add(entry); + } + return Array.from(lines).join("\n"); +} + +function sourceConfigForEdit(source: AutoscanSource): Record { + if (!isCephFSSource(source)) { + return { ...(source.source_config ?? {}) }; + } + const config = source.source_config ?? {}; + return { + [CEPHFS_MOVIE_PATHS_KEY]: mergeLineValues( + config[CEPHFS_MOVIE_PATHS_KEY], + config[CEPHFS_LEGACY_MOVIE_NESTED_KEY], + ), + [CEPHFS_TV_PATHS_KEY]: mergeLineValues( + config[CEPHFS_TV_PATHS_KEY], + config[CEPHFS_LEGACY_TV_NESTED_KEY], + ), + exclusions: mergeLineDefaults(config.exclusions, DEFAULT_CEPHFS_EXCLUSIONS), + }; +} + +function normalizeSourceConfig(config: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(config)) { + const trimmedKey = key.trim(); + if (!trimmedKey) continue; + out[trimmedKey] = value.trim(); + } + return out; +} + +function libraryKind(type: string): "movie" | "tv" | "mixed" | null { + switch (type.trim().toLowerCase()) { + case "movie": + case "movies": + return "movie"; + case "series": + case "show": + case "shows": + case "tv": + case "tvshows": + return "tv"; + case "mixed": + return "mixed"; + default: + return null; + } +} + +function configFromLibraries( + libraries: Library[], + current: Record, +): Record { + const moviePaths = new Set(); + const tvPaths = new Set(); + + for (const library of libraries) { + if (!library.enabled) { + continue; + } + const kind = libraryKind(library.type); + if (!kind) { + continue; + } + for (const path of library.paths ?? []) { + const trimmed = path.trim(); + if (!trimmed) { + continue; + } + if (kind === "movie" || kind === "mixed") { + moviePaths.add(trimmed); + } + if (kind === "tv" || kind === "mixed") { + tvPaths.add(trimmed); + } + } + } + + return { + [CEPHFS_MOVIE_PATHS_KEY]: Array.from(moviePaths).join("\n"), + [CEPHFS_TV_PATHS_KEY]: Array.from(tvPaths).join("\n"), + exclusions: mergeLineDefaults(current.exclusions, DEFAULT_CEPHFS_EXCLUSIONS), + }; +} + +// --------------------------------------------------------------------------- +// RewriteEditor — expandable section inside a SourceRow +// --------------------------------------------------------------------------- + +function RewriteEditor({ + sourceId, + hasConnection, + rewrites, + onChange, + onSave, + isSaving, +}: { + sourceId: string; + hasConnection: boolean; + rewrites: AutoscanPathRewrite[]; + onChange: (next: AutoscanPathRewrite[]) => void; + onSave: (rewrites?: AutoscanPathRewrite[]) => void; + isSaving: boolean; +}) { + const [open, setOpen] = useState(false); + const panelId = useId(); + const [rewriteError, setRewriteError] = useState(null); + + const suggest = useAutoscanRewriteSuggestions(); + const [preview, setPreview] = useState(null); + const [selected, setSelected] = useState>(new Set()); + + function updateRewrite(index: number, patch: Partial) { + setRewriteError(null); + onChange(rewrites.map((r, i) => (i === index ? { ...r, ...patch } : r))); + } + + function addRewrite() { + onChange([...rewrites, { from: "", to: "" }]); + setOpen(true); + } + + function removeRewrite(index: number) { + setRewriteError(null); + onChange(rewrites.filter((_, i) => i !== index)); + } + + async function handleSync() { + const s = await suggest.mutateAsync(sourceId); + setPreview(s); + setSelected(new Set((s.proposed ?? []).map((p) => p.from))); + setOpen(true); + } + + function toggleSelected(from: string) { + setSelected((current) => { + const next = new Set(current); + if (next.has(from)) { + next.delete(from); + } else { + next.add(from); + } + return next; + }); + } + + /** Merge the checked proposed rewrites into the list (dedupe by `from`) and save. */ + function applySelected() { + if (!preview) return; + const existingFroms = new Set(rewrites.map((r) => r.from)); + const additions = preview.proposed + .filter((p) => selected.has(p.from) && !existingFroms.has(p.from)) + .map((p) => ({ from: p.from, to: p.to })); + const merged = [...rewrites, ...additions]; + onChange(merged); + setPreview(null); + setOpen(true); + // Persist immediately via the normal full-state source PUT. + onSave(merged); + } + + function handleSave() { + // Validate: any non-empty row must have both from and to filled in. + const hasIncomplete = rewrites.some( + (r) => + (r.from.trim().length > 0 && r.to.trim().length === 0) || + (r.from.trim().length === 0 && r.to.trim().length > 0), + ); + if (hasIncomplete) { + setRewriteError("Each rewrite must have both a 'from' and a 'to' path."); + return; + } + setRewriteError(null); + onSave(); + } + + const syncDisabled = !hasConnection || suggest.isPending; + + return ( +
    + + + {open && ( +
    + {rewrites.length === 0 ? ( +

    + No path rewrites. Map remote paths (from the scan source) to local library paths. +

    + ) : ( +
    + {rewrites.map((rewrite, index) => ( +
    +
    + + updateRewrite(index, { from: e.target.value })} + placeholder="/remote/media" + className="h-8 text-sm" + aria-label={`Rewrite ${index + 1} from path`} + /> +
    +
    + + updateRewrite(index, { to: e.target.value })} + placeholder="/media" + className="h-8 text-sm" + aria-label={`Rewrite ${index + 1} to path`} + /> +
    + +
    + ))} +
    + )} + + {rewriteError &&

    {rewriteError}

    } + +
    + + + +
    + + {/* Sync-from-arr preview */} + {preview && ( +
    +
    +

    Proposed

    + {preview.proposed.length === 0 ? ( +

    No proposed rewrites.

    + ) : ( + preview.proposed.map((proposal) => ( + + )) + )} +
    + + {preview.unmatched.length > 0 && ( + + )} + + {preview.ambiguous.length > 0 && ( + `${a.root} → ${a.candidates.join(", ")}`)} + /> + )} + + {preview.covered.length > 0 && ( + + )} + +
    + + +
    +
    + )} +
    + )} +
    + ); +} + +/** Collapsed list section used inside the sync-from-arr preview. */ +function CollapsibleList({ title, items }: { title: string; items: string[] }) { + const [open, setOpen] = useState(false); + const panelId = useId(); + return ( +
    + + {open && ( +
      + {items.map((item) => ( +
    • + {item} +
    • + ))} +
    + )} +
    + ); +} + +function CephFSConfigEditor({ + config, + onChange, + onSave, + isSaving, + showSave = true, +}: { + config: Record; + onChange: (next: Record) => void; + onSave: (next?: Record) => void; + isSaving: boolean; + showSave?: boolean; +}) { + const [open, setOpen] = useState(false); + const panelId = useId(); + const libraries = useAdminLibraries(); + + function patch(key: string, value: string) { + onChange({ ...config, [key]: value }); + } + + function useConfiguredLibraries() { + const next = configFromLibraries(libraries.data ?? [], config); + onChange(next); + if (showSave) { + onSave(next); + } + } + + const libraryPathCount = + libraries.data + ?.filter((library) => library.enabled) + .reduce((count, library) => count + (library.paths?.length ?? 0), 0) ?? 0; + const canUseLibraries = !libraries.isLoading && libraryPathCount > 0 && !isSaving; + + const fields = [ + { + key: CEPHFS_MOVIE_PATHS_KEY, + label: "Movie library roots", + description: "One configured movie library path per line.", + placeholder: "/mnt/media/movies", + }, + { + key: CEPHFS_TV_PATHS_KEY, + label: "TV library roots", + description: "One configured TV library path per line.", + placeholder: "/mnt/media/television", + }, + { + key: "exclusions", + label: "Ignored paths", + description: "One glob, directory name, or path prefix per line.", + placeholder: DEFAULT_CEPHFS_EXCLUSIONS.join("\n"), + }, + ]; + + return ( +
    +
    + + +
    + + {open && ( +
    +
    + {fields.map((field) => ( +
    + +