diff --git a/internal/jellycompat/handlers_autoscan.go b/internal/jellycompat/handlers_autoscan.go index 8fcf9627..554b5ce4 100644 --- a/internal/jellycompat/handlers_autoscan.go +++ b/internal/jellycompat/handlers_autoscan.go @@ -6,6 +6,7 @@ import ( "errors" "log/slog" "net/http" + "path/filepath" "strings" "github.com/Silo-Server/silo-server/internal/models" @@ -110,21 +111,64 @@ func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Requ writeError(w, http.StatusBadRequest, "BadRequest", "Updates is required") return } - scanRequests := make([]scantrigger.Request, 0, len(req.Updates)) + 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 } - scanRequests = append(scanRequests, scantrigger.Request{ + + 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, err := scantrigger.NewResolver(h.folders).ResolveAll(r.Context(), scanRequests) - if err != nil { - writeScanTriggerError(w, err) + targets = compactAutoscanTargets(targets) + if len(targets) == 0 { + w.WriteHeader(http.StatusNoContent) return } if err := scantrigger.EnqueueAll(r.Context(), h.queue, targets); err != nil { @@ -134,6 +178,133 @@ func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Requ 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) { diff --git a/internal/jellycompat/handlers_autoscan_test.go b/internal/jellycompat/handlers_autoscan_test.go index e5a8b707..cd8c875a 100644 --- a/internal/jellycompat/handlers_autoscan_test.go +++ b/internal/jellycompat/handlers_autoscan_test.go @@ -137,24 +137,45 @@ func TestAutoscanMediaUpdatedEnqueuesResolvedPath(t *testing.T) { } } -func TestAutoscanMediaUpdatedAllOrFail(t *testing.T) { +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}, - }}}, queue, NewResourceIDCodec(), nil) + 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": filepath.Join(root, "missing.mkv"), "updateType": "Modified"}, + {"path": ambiguousPath, "updateType": "Modified"}, }} data, err := json.Marshal(payload) if err != nil { @@ -173,6 +194,224 @@ func TestAutoscanMediaUpdatedAllOrFail(t *testing.T) { } } +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") @@ -181,7 +420,7 @@ func TestAutoscanMediaUpdatedHidesInternalQueueError(t *testing.T) { } queue := &fakeAutoscanQueue{batchErr: errors.New("database password leaked")} handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ - ID: 5, + ID: 10, Name: "Movies", Type: "movie", Enabled: true,