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/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: 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. diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index 2bf90584..7d5c7acb 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,10 @@ 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, + Mode: scantrigger.ModeLibrary, + Trigger: "library_created", }) h.runFolderScanAsync(initialScanID, folder, "library_created") } @@ -660,10 +661,10 @@ 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, + Mode: scantrigger.ModeLibrary, + Trigger: "library_paths_changed", }) h.runFolderScanAsync(updateScanID, folder, "library_paths_changed") } @@ -791,29 +792,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 +801,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 +817,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.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 } } 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 +840,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.Folder.ID, }) } @@ -908,225 +889,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 +1049,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.Folder.ID, + Mode: target.Mode, + Path: target.Path, + Trigger: target.Trigger, Status: "accepted", }) } diff --git a/internal/jellycompat/auth_api_key.go b/internal/jellycompat/auth_api_key.go new file mode 100644 index 00000000..8841b00d --- /dev/null +++ b/internal/jellycompat/auth_api_key.go @@ -0,0 +1,122 @@ +package jellycompat + +import ( + "context" + "log/slog" + "net/http" + "strings" + "time" + + "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 + code string + message string + 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, result.code, result.message) + 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, result.code, result.message) + return + } + next.ServeHTTP(w, r.WithContext(result.ctx)) + return + } + sessionAuth.RequireSession(next).ServeHTTP(w, r) + }) + } +} + +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 unauthorized + } + token, ok := ExtractToken(r) + if !ok || !strings.HasPrefix(token, "sa_") { + return unauthorized + } + apiKey, err := a.keys.GetByKey(r.Context(), token) + if err != nil || apiKey == nil { + return unauthorized + } + user, err := a.users.GetByID(r.Context(), apiKey.UserID) + if err != nil || user == nil || !user.Enabled { + return unauthorized + } + if user.Role != "admin" { + 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) + 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) + return adminAPIKeyAuthResult{ + ctx: context.WithValue(r.Context(), adminAPIKeyKey, true), + status: http.StatusOK, + ok: true, + } +} diff --git a/internal/jellycompat/auth_test.go b/internal/jellycompat/auth_test.go index 4827ce48..a5e7cb95 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,128 @@ 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()) + } +} + +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 + 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(ctx context.Context, id int64) error { + if f.update != nil { + return f.update(ctx, id) + } + 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/handlers_autoscan.go b/internal/jellycompat/handlers_autoscan.go new file mode 100644 index 00000000..554b5ce4 --- /dev/null +++ b/internal/jellycompat/handlers_autoscan.go @@ -0,0 +1,316 @@ +package jellycompat + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "path/filepath" + "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 + } + resolver := scantrigger.NewResolver(h.folders) + targets := make([]scantrigger.Target, 0, len(req.Updates)) + seenTargets := make(map[autoscanTargetKey]struct{}, 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 + } + + target, err := resolver.Resolve(r.Context(), scantrigger.Request{ + Path: path, + Trigger: autoscanTrigger, + }) + if err != nil { + if parentTarget, handled, fallbackErr := resolveAutoscanParentTarget(r.Context(), resolver, path, err); handled { + if fallbackErr != nil { + slog.Warn("jellycompat autoscan: media update parent path rejected", + "path", path, + "parent_path", filepath.Dir(filepath.Clean(path)), + "update_type", update.UpdateType, + "error", fallbackErr, + ) + writeScanTriggerError(w, fallbackErr) + return + } + if parentTarget != nil { + slog.Debug("jellycompat autoscan: media update falling back to parent scan", + "path", path, + "parent_path", parentTarget.Path, + "parent_mode", parentTarget.Mode, + "update_type", update.UpdateType, + ) + targets = appendAutoscanTarget(targets, seenTargets, parentTarget) + } + continue + } + if softAutoscanUpdateError(err) { + slog.Debug("jellycompat autoscan: media update ignored", + "path", path, + "update_type", update.UpdateType, + "error", err, + ) + continue + } + slog.Warn("jellycompat autoscan: media update path rejected", + "path", path, + "update_type", update.UpdateType, + "error", err, + ) + writeScanTriggerError(w, err) + return + } + targets = appendAutoscanTarget(targets, seenTargets, target) + } + targets = compactAutoscanTargets(targets) + if len(targets) == 0 { + w.WriteHeader(http.StatusNoContent) + return + } + if err := scantrigger.EnqueueAll(r.Context(), h.queue, targets); err != nil { + writeScanTriggerError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type autoscanTargetKey struct { + folderID int + mode string + path string + trigger string +} + +func appendAutoscanTarget( + targets []scantrigger.Target, + seen map[autoscanTargetKey]struct{}, + target *scantrigger.Target, +) []scantrigger.Target { + if target == nil { + return targets + } + folderID := 0 + if target.Folder != nil { + folderID = target.Folder.ID + } + key := autoscanTargetKey{ + folderID: folderID, + mode: target.Mode, + path: target.Path, + trigger: target.Trigger, + } + if _, ok := seen[key]; ok { + return targets + } + seen[key] = struct{}{} + return append(targets, *target) +} + +func compactAutoscanTargets(targets []scantrigger.Target) []scantrigger.Target { + if len(targets) < 2 { + return targets + } + compacted := make([]scantrigger.Target, 0, len(targets)) + for i, target := range targets { + if autoscanTargetCoveredByOther(target, targets, i) { + continue + } + compacted = append(compacted, target) + } + return compacted +} + +func autoscanTargetCoveredByOther(target scantrigger.Target, targets []scantrigger.Target, index int) bool { + if target.Folder == nil || target.Mode == scantrigger.ModeLibrary { + return false + } + for i, other := range targets { + if i == index || other.Folder == nil || other.Folder.ID != target.Folder.ID || other.Trigger != target.Trigger { + continue + } + switch other.Mode { + case scantrigger.ModeLibrary: + return true + case scantrigger.ModeSubtree: + if target.Path != "" && scantrigger.PathWithinRoot(target.Path, other.Path) { + return true + } + } + } + return false +} + +func resolveAutoscanParentTarget( + ctx context.Context, + resolver *scantrigger.Resolver, + path string, + err error, +) (*scantrigger.Target, bool, error) { + if !parentFallbackAutoscanUpdateError(err) { + return nil, false, nil + } + cleanPath := filepath.Clean(path) + parentPath := filepath.Dir(cleanPath) + if parentPath == "." || parentPath == cleanPath { + return nil, true, nil + } + target, parentErr := resolver.Resolve(ctx, scantrigger.Request{ + Path: parentPath, + Trigger: autoscanTrigger, + }) + if parentErr == nil { + if target.Mode == scantrigger.ModeLibrary { + return nil, true, nil + } + return target, true, nil + } + if softAutoscanUpdateError(parentErr) { + return nil, true, nil + } + return nil, true, parentErr +} + +func parentFallbackAutoscanUpdateError(err error) bool { + var reqErr *scantrigger.RequestError + if !errors.As(err, &reqErr) || reqErr.Status != http.StatusBadRequest { + return false + } + switch reqErr.Message { + case "Path does not exist", + "Path must be a file or directory", + "Unsupported media file extension": + return true + default: + return false + } +} + +func softAutoscanUpdateError(err error) bool { + var reqErr *scantrigger.RequestError + if !errors.As(err, &reqErr) || reqErr.Status != http.StatusBadRequest { + return false + } + switch reqErr.Message { + case "No library matches the given path", + "Path does not exist", + "Path must be a file or directory", + "Unsupported media file extension": + return true + default: + return false + } +} + +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", "Failed to process scan update") +} diff --git a/internal/jellycompat/handlers_autoscan_test.go b/internal/jellycompat/handlers_autoscan_test.go new file mode 100644 index 00000000..cd8c875a --- /dev/null +++ b/internal/jellycompat/handlers_autoscan_test.go @@ -0,0 +1,442 @@ +package jellycompat + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "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" + "github.com/Silo-Server/silo-server/internal/scantrigger" +) + +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 + batches [][]scantrigger.Target + batchErr error +} + +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 (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 { + folderID := 0 + if target.Folder != nil { + folderID = target.Folder.ID + } + q.calls = append(q.calls, queuedScan{ + libraryID: folderID, + mode: target.Mode, + path: target.Path, + trigger: target.Trigger, + }) + } + return 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 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]) + } +} + +func TestAutoscanMediaUpdatedRejectsAmbiguousLibraryWithoutPartialEnqueue(t *testing.T) { + root := t.TempDir() + ambiguousRoot := t.TempDir() + filePath := filepath.Join(root, "Movie.mkv") + ambiguousPath := filepath.Join(ambiguousRoot, "Other.mkv") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ambiguousPath, []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}, + }, + { + ID: 5, + Name: "Movies A", + Type: "movie", + Enabled: true, + Paths: []string{ambiguousRoot}, + }, + { + ID: 6, + Name: "Movies B", + Type: "movie", + Enabled: true, + Paths: []string{ambiguousRoot}, + }, + }}, queue, NewResourceIDCodec(), nil) + + payload := map[string]any{"Updates": []map[string]string{ + {"path": filePath, "updateType": "Modified"}, + {"path": ambiguousPath, "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) + } +} + +func TestAutoscanMediaUpdatedIgnoresUnsupportedSidecars(t *testing.T) { + root := t.TempDir() + movieDir := filepath.Join(root, "Movie (2024)") + if err := os.Mkdir(movieDir, 0o755); err != nil { + t.Fatal(err) + } + filePath := filepath.Join(movieDir, "Movie.mkv") + nfoPath := filepath.Join(movieDir, "Movie.nfo") + posterPath := filepath.Join(movieDir, "poster.jpg") + for _, path := range []string{filePath, nfoPath, posterPath} { + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + } + queue := &fakeAutoscanQueue{} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 5, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + payload := map[string]any{"Updates": []map[string]string{ + {"path": nfoPath, "updateType": "Modified"}, + {"path": filePath, "updateType": "Modified"}, + {"path": posterPath, "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.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + if len(queue.calls) != 1 { + t.Fatalf("expected one parent scan, got %#v", queue.calls) + } + if queue.calls[0].libraryID != 5 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" { + t.Fatalf("unexpected parent scan: %#v", queue.calls[0]) + } +} + +func TestAutoscanMediaUpdatedSidecarsScanParent(t *testing.T) { + root := t.TempDir() + movieDir := filepath.Join(root, "Movie (2024)") + if err := os.Mkdir(movieDir, 0o755); err != nil { + t.Fatal(err) + } + nfoPath := filepath.Join(movieDir, "Movie.nfo") + posterPath := filepath.Join(movieDir, "poster.jpg") + for _, path := range []string{nfoPath, posterPath} { + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + } + queue := &fakeAutoscanQueue{} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 6, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + payload := map[string]any{"Updates": []map[string]string{ + {"path": nfoPath, "updateType": "Modified"}, + {"path": posterPath, "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.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + if len(queue.calls) != 1 { + t.Fatalf("expected one parent scan, got %#v", queue.calls) + } + if queue.calls[0].libraryID != 6 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" { + t.Fatalf("unexpected parent scan: %#v", queue.calls[0]) + } +} + +func TestAutoscanMediaUpdatedRootSidecarDoesNotScanLibrary(t *testing.T) { + root := t.TempDir() + anchorPath := filepath.Join(root, ".plexignore") + if err := os.WriteFile(anchorPath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + queue := &fakeAutoscanQueue{} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 7, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + payload := map[string]any{"Updates": []map[string]string{ + {"path": anchorPath, "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.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + if len(queue.calls) != 0 { + t.Fatalf("expected no queued scans, got %#v", queue.calls) + } + if len(queue.batches) != 0 { + t.Fatalf("expected no batch enqueue, got %#v", queue.batches) + } +} + +func TestAutoscanMediaUpdatedFallsBackToParentForMissingFiles(t *testing.T) { + root := t.TempDir() + movieDir := filepath.Join(root, "Movie (2024)") + if err := os.Mkdir(movieDir, 0o755); err != nil { + t.Fatal(err) + } + filePath := filepath.Join(movieDir, "Movie.mkv") + missingPath := filepath.Join(movieDir, "pollermovie.mkv") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + queue := &fakeAutoscanQueue{} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 8, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + payload := map[string]any{"Updates": []map[string]string{ + {"path": missingPath, "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.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 != 8 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" { + t.Fatalf("unexpected queued scan: %#v", queue.calls[0]) + } +} + +func TestAutoscanMediaUpdatedIgnoresUnmatchedLibraryPaths(t *testing.T) { + root := t.TempDir() + unmatchedRoot := t.TempDir() + filePath := filepath.Join(root, "Movie.mkv") + unmatchedPath := filepath.Join(unmatchedRoot, "Other.mkv") + for _, path := range []string{filePath, unmatchedPath} { + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + } + queue := &fakeAutoscanQueue{} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 9, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + payload := map[string]any{"Updates": []map[string]string{ + {"path": unmatchedPath, "updateType": "Modified"}, + {"path": filePath, "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.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 != 9 || 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 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: 10, + 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/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) 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 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..b38c8953 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,34 @@ 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 { + if target.Folder == nil { + return 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, + }) + } + 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 new file mode 100644 index 00000000..d6c0ffca --- /dev/null +++ b/internal/scantrigger/scantrigger.go @@ -0,0 +1,280 @@ +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) + EnqueueScans(ctx context.Context, targets []Target) error +} + +type Request struct { + LibraryID *int + Path string + 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 + 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)) + var pathFolders []*models.MediaFolder + pathFoldersLoaded := false + for _, req := range requests { + 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 + } + targets = append(targets, *target) + } + return targets, nil +} + +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"} + } + 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, 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 := 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 { + 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, 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"} + } + if err := queue.EnqueueScans(ctx, targets); err != nil { + return fmt.Errorf("queueing library scans: %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..a5095580 --- /dev/null +++ b/internal/scantrigger/scantrigger_test.go @@ -0,0 +1,210 @@ +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 + listCalls int +} + +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) { + r.listCalls++ + 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.Folder == nil || target.Folder.ID != 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.Folder == nil || target.Folder.ID != 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.Folder == nil || target.Folder.ID != 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) + } +} + +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{Folder: &models.MediaFolder{ID: 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{} + folder := &models.MediaFolder{ID: 1} + targets := []Target{ + {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 { + 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)) + } +}