diff --git a/internal/scanner/ebook_test.go b/internal/scanner/ebook_test.go index 2bc3c845..5052edee 100644 --- a/internal/scanner/ebook_test.go +++ b/internal/scanner/ebook_test.go @@ -1478,6 +1478,21 @@ func TestCollectEbookRootScansTreatsSingleFileRootAsNonReconciling(t *testing.T) } } +func TestCollectEbookRootScansIncludesCompoundFB2ZipFile(t *testing.T) { + fileRoot := filepath.Join(t.TempDir(), "book.fb2.zip") + if err := os.WriteFile(fileRoot, []byte("x"), 0o644); err != nil { + t.Fatalf("write ebook: %v", err) + } + + scans, err := collectEbookRootScans(context.Background(), 44, []string{fileRoot}) + if err != nil { + t.Fatalf("collectEbookRootScans: %v", err) + } + if len(scans) != 1 || len(scans[0].files) != 1 || scans[0].files[0] != fileRoot { + t.Fatalf("scans = %+v, want compound-extension file indexed", scans) + } +} + func TestCollectEbookRootScansMidWalkSubtreeErrorExcludesRoot(t *testing.T) { if os.Getuid() == 0 { t.Skip("permission-based subtree failure cannot be simulated as root") diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 38395bc2..3b6700be 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -451,6 +451,13 @@ func (m walkMode) acceptsExt(ext string) bool { } } +func (m walkMode) acceptsPath(path string) bool { + if m == walkModeEbook { + return SupportsEbookFile(path) + } + return m.acceptsExt(strings.ToLower(filepath.Ext(path))) +} + func canonicalWalkPath(path string) (string, error) { resolved, err := filepath.EvalSymlinks(path) if err != nil { @@ -518,7 +525,7 @@ func walkLogicalTree( if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalPath) { return nil } - if mode.acceptsExt(strings.ToLower(filepath.Ext(logicalPath))) { + if mode.acceptsPath(logicalPath) { *filePaths = append(*filePaths, logicalPath) } return nil @@ -528,7 +535,7 @@ func walkLogicalTree( if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalPath) { return nil } - if mode.acceptsExt(strings.ToLower(filepath.Ext(logicalPath))) { + if mode.acceptsPath(logicalPath) { *filePaths = append(*filePaths, logicalPath) } return nil @@ -590,7 +597,7 @@ func walkLogicalTree( if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalChild) { continue } - if mode.acceptsExt(strings.ToLower(filepath.Ext(entry.Name()))) { + if mode.acceptsPath(entry.Name()) { *filePaths = append(*filePaths, logicalChild) } continue @@ -606,7 +613,7 @@ func walkLogicalTree( if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalChild) { continue } - if mode.acceptsExt(strings.ToLower(filepath.Ext(entry.Name()))) { + if mode.acceptsPath(entry.Name()) { *filePaths = append(*filePaths, logicalChild) } } @@ -1976,15 +1983,30 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models. if err != nil { return err } + if handled, err := s.reconcileVanishedFileIfNeeded(ctx, folder, cleanFile); handled { + return err + } if err := s.ScanAudiobookFolder(ctx, scopedFolderPaths(folder, []string{scanRoot}), false); err != nil { return err } return s.syncFolderScopedAudioLibraryState(ctx, folder.ID) } + if librarykind.IsManga(folder.Type) { + if !SupportsEbookFile(cleanFile) { + return fmt.Errorf("unrecognized manga extension: %s", strings.ToLower(filepath.Ext(cleanFile))) + } + if handled, err := s.reconcileVanishedFileIfNeeded(ctx, folder, cleanFile); handled { + return err + } + return s.scanMangaPaths(ctx, folder, []string{cleanFile}, false) + } if librarykind.IsEbook(folder.Type) { if !SupportsEbookFile(cleanFile) { return fmt.Errorf("unrecognized ebook extension: %s", strings.ToLower(filepath.Ext(cleanFile))) } + if handled, err := s.reconcileVanishedFileIfNeeded(ctx, folder, cleanFile); handled { + return err + } return s.scanEbookPaths(ctx, folder, []string{cleanFile}, false) } @@ -1993,6 +2015,9 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models. if !videoExtensions[ext] { return fmt.Errorf("unrecognized video extension: %s", ext) } + if handled, err := s.reconcileVanishedFileIfNeeded(ctx, folder, cleanFile); handled { + return err + } // Look up only this specific file instead of loading the entire folder. existingByPath := make(map[string]*scanStateFile, 1) @@ -2101,6 +2126,45 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models. return nil } +func (s *Scanner) reconcileVanishedFileIfNeeded(ctx context.Context, folder *models.MediaFolder, filePath string) (bool, error) { + if _, err := os.Stat(filePath); err == nil { + return false, nil + } else if !errors.Is(err, os.ErrNotExist) { + return true, fmt.Errorf("stat media file %s: %w", filePath, err) + } + if s == nil || s.fileRepo == nil || folder == nil { + return true, fmt.Errorf("reconcile vanished file: scanner repositories not configured") + } + + file, err := s.fileRepo.GetByPath(ctx, filePath) + if errors.Is(err, ErrFileNotFound) { + return true, nil + } + if err != nil { + return true, fmt.Errorf("loading vanished media file %s: %w", filePath, err) + } + if file.MediaFolderID != folder.ID { + return true, fmt.Errorf("vanished media file %s belongs to library %d, not %d", filePath, file.MediaFolderID, folder.ID) + } + if file.MissingSince == nil { + if err := s.fileRepo.MarkMissing(ctx, file.ID, time.Now().UTC()); err != nil { + return true, fmt.Errorf("marking vanished media file %s missing: %w", filePath, err) + } + } + if _, _, _, err := s.sweepMissingAndReconcile(ctx, folder, false); err != nil { + return true, err + } + if librarykind.IsManga(folder.Type) { + if err := s.deleteOrphanedMangaSeries(ctx, folder.ID); err != nil { + return true, err + } + } + if librarykind.IsEbook(folder.Type) || librarykind.IsManga(folder.Type) { + s.reconcileMissingEbookEnrichment(ctx, folder.ID) + } + return true, nil +} + func (s *Scanner) watchFolderContext(ctx context.Context, folderID int) (context.Context, context.CancelFunc) { cancel := func() {} if ctx == nil || folderID <= 0 || s == nil || s.folderRepo == nil { diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index baabc04d..650cd7f1 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -233,6 +233,9 @@ func TestWalkModeEbookAcceptsEbookExtensionsOnly(t *testing.T) { t.Fatalf("walkModeEbook should reject %s", ext) } } + if !walkModeEbook.acceptsPath("Book.fb2.zip") { + t.Fatal("walkModeEbook should accept .fb2.zip compound extension") + } } func TestScanFolderEbookLibraryRoutesToEbookScanner(t *testing.T) { @@ -284,3 +287,21 @@ func TestScanFileEbookLibraryUsesEbookPipeline(t *testing.T) { t.Fatalf("error = %q, want ebook scanner aggregate failure", err) } } + +func TestScanFileMangaLibraryUsesMangaPipeline(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "chapter.cbz") + if err := os.WriteFile(filePath, []byte("not a real cbz"), 0o644); err != nil { + t.Fatalf("write fake manga: %v", err) + } + + err := (&Scanner{}).ScanFile(context.Background(), filePath, &models.MediaFolder{ID: 45, Type: "manga"}) + if err == nil { + t.Fatal("ScanFile returned nil, want manga parse failure") + } + if strings.Contains(err.Error(), "unrecognized video extension") { + t.Fatalf("ScanFile used video extension gate: %v", err) + } + if !strings.Contains(err.Error(), "folder_id=45") { + t.Fatalf("error = %q, want manga scanner aggregate failure", err) + } +} diff --git a/internal/scantrigger/scantrigger.go b/internal/scantrigger/scantrigger.go index d4a4e697..4ae68b74 100644 --- a/internal/scantrigger/scantrigger.go +++ b/internal/scantrigger/scantrigger.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/librarykind" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/scanner" ) @@ -143,10 +144,10 @@ func normalizeTrigger(trigger string) string { // ResolveVanishedPath resolves a change for a path that no longer exists on // disk (a file deleted by an upgrade/replacement, or a removed directory) to a -// reconciling scan target. Paths with a supported media extension map to a -// subtree scan of their parent directory; other paths map to a subtree scan of -// the path itself. The scoped scan marks the vanished files missing so stale -// versions stop being offered for playback. +// reconciling scan target. Paths with a supported extension for their library +// map to an exact file scan; other paths map to a subtree scan of the path +// itself. The scoped scan marks vanished files missing so stale versions stop +// being offered for playback. // // Two guards keep this from turning transient storage loss into cleanup: // the path must actually be gone (a still-existing path is rejected — use @@ -176,10 +177,13 @@ func (r *Resolver) ResolveVanishedPath(ctx context.Context, path, trigger string } trigger = normalizeTrigger(trigger) - scope := cleanPath - if supportsMediaFile(cleanPath) { - scope = filepath.Dir(cleanPath) + if supportsLibraryMediaFile(cleanPath, folder.Type) { + return &Target{Folder: folder, Mode: ModeFile, Path: cleanPath, Trigger: trigger}, nil } + if supportsMediaFile(cleanPath) { + return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Unsupported media file extension for library type"} + } + scope := cleanPath if filepath.Clean(scope) == filepath.Clean(matchedRoot) { // A vanished entry directly under the root reconciles via a full // library scan, which keeps the empty-root guard in play. @@ -247,7 +251,7 @@ func (r *Resolver) resolve(ctx context.Context, req Request, pathFolders []*mode return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"} } - mode, err := ClassifyPath(cleanPath, matchedRoot) + mode, err := ClassifyLibraryPath(cleanPath, matchedRoot, folder.Type) if err != nil { return nil, err } @@ -332,6 +336,10 @@ func MatchFolderForPath(targetPath string, folders []*models.MediaFolder) (*mode } func ClassifyPath(targetPath, matchedRoot string) (string, error) { + return ClassifyLibraryPath(targetPath, matchedRoot, "") +} + +func ClassifyLibraryPath(targetPath, matchedRoot, folderType string) (string, error) { if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) { return ModeLibrary, nil } @@ -353,12 +361,25 @@ func ClassifyPath(targetPath, matchedRoot string) (string, error) { if !info.Mode().IsRegular() { return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path must be a file or directory"} } - if !supportsMediaFile(targetPath) { - return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Unsupported media file extension"} + if !supportsLibraryMediaFile(targetPath, folderType) { + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Unsupported media file extension for library type"} } return ModeFile, nil } +func supportsLibraryMediaFile(path, folderType string) bool { + switch { + case librarykind.IsAudiobook(folderType): + return scanner.SupportsAudioFile(path) + case librarykind.IsEbook(folderType), librarykind.IsManga(folderType): + return scanner.SupportsEbookFile(path) + case librarykind.IsPodcast(folderType): + return false + default: + return scanner.SupportsVideoFile(path) + } +} + func supportsMediaFile(path string) bool { return scanner.SupportsVideoFile(path) || scanner.SupportsAudioFile(path) || diff --git a/internal/scantrigger/scantrigger_test.go b/internal/scantrigger/scantrigger_test.go index cb08ae35..562d8576 100644 --- a/internal/scantrigger/scantrigger_test.go +++ b/internal/scantrigger/scantrigger_test.go @@ -6,7 +6,6 @@ import ( "net/http" "os" "path/filepath" - "strings" "testing" "github.com/Silo-Server/silo-server/internal/catalog" @@ -110,28 +109,35 @@ func TestResolverRejectsMissingSubtreeAtLibraryRoot(t *testing.T) { } } -func TestResolverResolvesVanishedMediaFileToParentSubtree(t *testing.T) { - root := t.TempDir() - repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ - ID: 20, - Name: "Media", - Enabled: true, - Paths: []string{root}, - }}} - - for _, name := range []string{"Movie.mkv", "Book.epub", "Audiobook.m4b"} { - t.Run(name, func(t *testing.T) { - mediaDir := filepath.Join(root, strings.TrimSuffix(name, filepath.Ext(name))) +func TestResolverResolvesVanishedMediaFileAsExactFile(t *testing.T) { + for _, tc := range []struct { + name string + libraryType string + }{ + {name: "Movie.mkv", libraryType: "movies"}, + {name: "Book.epub", libraryType: "ebooks"}, + {name: "Audiobook.m4b", libraryType: "audiobooks"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + mediaDir := filepath.Join(root, "Title") if err := os.Mkdir(mediaDir, 0o755); err != nil { t.Fatal(err) } - vanished := filepath.Join(mediaDir, name) + vanished := filepath.Join(mediaDir, tc.name) + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 20, + Name: "Media", + Type: tc.libraryType, + Enabled: true, + Paths: []string{root}, + }}} target, err := NewResolver(repo).ResolveVanishedPath(context.Background(), vanished, "autoscan") if err != nil { t.Fatalf("ResolveVanishedPath returned error: %v", err) } - if target.Folder == nil || target.Folder.ID != 20 || target.Mode != ModeSubtree || target.Path != mediaDir { + if target.Folder == nil || target.Folder.ID != 20 || target.Mode != ModeFile || target.Path != vanished { t.Fatalf("unexpected target: %#v", target) } }) @@ -157,12 +163,13 @@ func TestResolverResolvesVanishedDirToItself(t *testing.T) { } } -func TestResolverResolvesVanishedFileDirectlyUnderRootToLibraryScan(t *testing.T) { +func TestResolverResolvesVanishedFileDirectlyUnderRootAsExactFile(t *testing.T) { root := t.TempDir() vanished := filepath.Join(root, "Movie (2026).mkv") repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ ID: 22, Name: "Movies", + Type: "movies", Enabled: true, Paths: []string{root}, }}} @@ -171,7 +178,7 @@ func TestResolverResolvesVanishedFileDirectlyUnderRootToLibraryScan(t *testing.T if err != nil { t.Fatalf("ResolveVanishedPath returned error: %v", err) } - if target.Folder == nil || target.Folder.ID != 22 || target.Mode != ModeLibrary || target.Path != "" { + if target.Folder == nil || target.Folder.ID != 22 || target.Mode != ModeFile || target.Path != vanished { t.Fatalf("unexpected target: %#v", target) } } @@ -290,20 +297,27 @@ func TestResolverClassifiesVideoFile(t *testing.T) { } func TestResolverClassifiesAudioAndEbookFiles(t *testing.T) { - root := t.TempDir() - repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ - ID: 25, - Name: "Media", - Enabled: true, - Paths: []string{root}, - }}} - - for _, name := range []string{"Book.epub", "Book.fb2.zip", "Audiobook.m4b"} { - t.Run(name, func(t *testing.T) { - filePath := filepath.Join(root, name) + for _, tc := range []struct { + name string + libraryType string + }{ + {name: "Book.epub", libraryType: "ebooks"}, + {name: "Book.fb2.zip", libraryType: "ebooks"}, + {name: "Audiobook.m4b", libraryType: "audiobooks"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, tc.name) if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { t.Fatal(err) } + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 25, + Name: "Media", + Type: tc.libraryType, + Enabled: true, + Paths: []string{root}, + }}} target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: filePath}) if err != nil { @@ -316,6 +330,30 @@ func TestResolverClassifiesAudioAndEbookFiles(t *testing.T) { } } +func TestResolverRejectsPodcastFileTargetUntilPodcastPipelineSupportsIt(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "Episode.mp3") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 26, + Name: "Podcasts", + Type: "podcasts", + Enabled: true, + Paths: []string{root}, + }}} + + _, err := NewResolver(repo).Resolve(context.Background(), Request{Path: filePath}) + var reqErr *RequestError + if !errors.As(err, &reqErr) { + t.Fatalf("expected RequestError, got %T: %v", err, err) + } + if reqErr.Status != http.StatusBadRequest || reqErr.Message != "Unsupported media file extension for library type" { + t.Fatalf("unexpected error: %#v", reqErr) + } +} + func TestResolverRejectsDisabledLibrary(t *testing.T) { root := t.TempDir() repo := &fakeFolderRepo{folders: []*models.MediaFolder{{