fix(scan): handle typed media file edge cases
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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{{
|
||||
|
||||
Reference in New Issue
Block a user