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 {