fix(scan): handle typed media file edge cases

This commit is contained in:
rxwatcher
2026-07-22 13:43:57 +02:00
parent f094d70996
commit b3e963f54e
5 changed files with 201 additions and 42 deletions
+15
View File
@@ -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")
+68 -4
View File
@@ -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 {
+21
View File
@@ -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)
}
}
+31 -10
View File
@@ -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) ||
+66 -28
View File
@@ -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{{