340 lines
11 KiB
Go
340 lines
11 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
type recordingQueueSyncer struct {
|
|
folderID int
|
|
scope string
|
|
}
|
|
|
|
func (s *recordingQueueSyncer) SyncForFolder(context.Context, int) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *recordingQueueSyncer) SyncInScope(_ context.Context, folderID int, scopePath string) error {
|
|
s.folderID = folderID
|
|
s.scope = scopePath
|
|
return nil
|
|
}
|
|
|
|
func TestCollectLogicalFilePaths_PreservesLogicalSymlinkRootPaths(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := t.TempDir()
|
|
physicalRoot := filepath.Join(base, "real")
|
|
logicalRoot := filepath.Join(base, "library")
|
|
seasonDir := filepath.Join(physicalRoot, "Season 1")
|
|
|
|
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir season dir: %v", err)
|
|
}
|
|
filePath := filepath.Join(seasonDir, "Episode 01.mkv")
|
|
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
|
|
t.Fatalf("write media file: %v", err)
|
|
}
|
|
if err := os.Symlink(physicalRoot, logicalRoot); err != nil {
|
|
t.Skipf("symlinks not supported on this platform: %v", err)
|
|
}
|
|
|
|
files, err := collectLogicalFilePaths(context.Background(), []string{logicalRoot}, "series")
|
|
if err != nil {
|
|
t.Fatalf("collect logical paths: %v", err)
|
|
}
|
|
|
|
want := filepath.Join(logicalRoot, "Season 1", "Episode 01.mkv")
|
|
if len(files) != 1 {
|
|
t.Fatalf("files len = %d, want 1 (%v)", len(files), files)
|
|
}
|
|
if files[0] != want {
|
|
t.Fatalf("files[0] = %q, want %q", files[0], want)
|
|
}
|
|
}
|
|
|
|
func TestCollectLogicalFilePaths_DedupesSharedPhysicalDirsAndCycles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := t.TempDir()
|
|
physicalRoot := filepath.Join(base, "real")
|
|
aliasRoot := filepath.Join(base, "alias")
|
|
loopPath := filepath.Join(physicalRoot, "loop")
|
|
|
|
if err := os.MkdirAll(physicalRoot, 0o755); err != nil {
|
|
t.Fatalf("mkdir root: %v", err)
|
|
}
|
|
filePath := filepath.Join(physicalRoot, "Movie.mkv")
|
|
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
|
|
t.Fatalf("write media file: %v", err)
|
|
}
|
|
if err := os.Symlink(physicalRoot, aliasRoot); err != nil {
|
|
t.Skipf("symlinks not supported on this platform: %v", err)
|
|
}
|
|
if err := os.Symlink(physicalRoot, loopPath); err != nil {
|
|
t.Skipf("symlinks not supported on this platform: %v", err)
|
|
}
|
|
|
|
files, err := collectLogicalFilePaths(context.Background(), []string{physicalRoot, aliasRoot}, "movie")
|
|
if err != nil {
|
|
t.Fatalf("collect logical paths: %v", err)
|
|
}
|
|
|
|
sort.Strings(files)
|
|
want := []string{filepath.Join(physicalRoot, "Movie.mkv")}
|
|
if len(files) != len(want) {
|
|
t.Fatalf("files len = %d, want %d (%v)", len(files), len(want), files)
|
|
}
|
|
for i := range want {
|
|
if files[i] != want[i] {
|
|
t.Fatalf("files[%d] = %q, want %q", i, files[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShouldSkipStableConfirmedFile_DoesNotSkipAssignmentChanges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
modifiedAt := time.Now().UTC().Truncate(time.Microsecond)
|
|
existing := &models.MediaFile{
|
|
ContentID: "matched-content",
|
|
FileSize: 1_000,
|
|
FileModifiedAt: &modifiedAt,
|
|
}
|
|
|
|
if shouldSkipStableConfirmedFile(existing, "matched", 1_000, modifiedAt, []string{"root_assignment_changed"}, false) {
|
|
t.Fatal("expected assignment changes to bypass stable-file skip")
|
|
}
|
|
if !shouldSkipStableConfirmedFile(existing, "matched", 1_000, modifiedAt, nil, false) {
|
|
t.Fatal("expected unchanged matched file to use stable-file skip")
|
|
}
|
|
}
|
|
|
|
func TestScanStateUpdateReasons_DetectsMissingExternalSubtitle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
modifiedAt := time.Now().UTC().Truncate(time.Microsecond)
|
|
existing := &scanStateFile{
|
|
ContentID: "matched-content",
|
|
FileSize: 1_000,
|
|
FileModifiedAt: &modifiedAt,
|
|
ExternalSubtitlePaths: []string{filepath.Join(t.TempDir(), "Movie.en.srt")},
|
|
}
|
|
|
|
reasons := scanStateUpdateReasons(existing, 1_000, modifiedAt, nil, false, fileRootAssignment{}, fileGroupAssignment{}, "movies", false)
|
|
if !testStringSliceContains(reasons, "external_subtitle_missing") {
|
|
t.Fatalf("expected external_subtitle_missing reason, got %#v", reasons)
|
|
}
|
|
if shouldSkipStableConfirmedScanState(existing, "matched", 1_000, modifiedAt, reasons, false) {
|
|
t.Fatal("expected missing external subtitle to bypass stable scan-state skip")
|
|
}
|
|
}
|
|
|
|
func TestScanStateUpdateReasons_DetectsExternalSubtitleInventoryChange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
modifiedAt := time.Now().UTC().Truncate(time.Microsecond)
|
|
existing := &scanStateFile{
|
|
ContentID: "matched-content",
|
|
FileSize: 1_000,
|
|
FileModifiedAt: &modifiedAt,
|
|
ExternalSubtitlePaths: []string{filepath.Join(dir, "Movie.en.srt")},
|
|
}
|
|
|
|
reasons := scanStateUpdateReasons(
|
|
existing,
|
|
1_000,
|
|
modifiedAt,
|
|
[]string{filepath.Join(dir, "Movie.en.srt"), filepath.Join(dir, "Movie.fr.srt")},
|
|
true,
|
|
fileRootAssignment{},
|
|
fileGroupAssignment{},
|
|
"movies",
|
|
false,
|
|
)
|
|
if !testStringSliceContains(reasons, "external_subtitle_changed") {
|
|
t.Fatalf("expected external_subtitle_changed reason, got %#v", reasons)
|
|
}
|
|
if shouldSkipStableConfirmedScanState(existing, "matched", 1_000, modifiedAt, reasons, false) {
|
|
t.Fatal("expected external subtitle change to bypass stable scan-state skip")
|
|
}
|
|
}
|
|
|
|
func TestIdentityOnlyUpdateReasons(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
reasons []string
|
|
want bool
|
|
}{
|
|
{"empty", nil, false},
|
|
{"group only", []string{"group_assignment_changed"}, true},
|
|
{"root only", []string{"root_assignment_changed"}, true},
|
|
{"group and root", []string{"group_assignment_changed", "root_assignment_changed"}, true},
|
|
{"group plus mtime needs reprobe", []string{"group_assignment_changed", "mtime_changed"}, false},
|
|
{"probe repair needs reprobe", []string{"probe_repair"}, false},
|
|
{"size change needs reprobe", []string{"size_changed"}, false},
|
|
{"was missing needs reprobe", []string{"was_missing"}, false},
|
|
{"subtitle change is not identity-only", []string{"external_subtitle_changed"}, false},
|
|
{"group plus subtitle needs full path", []string{"group_assignment_changed", "external_subtitle_changed"}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := identityOnlyUpdateReasons(tc.reasons); got != tc.want {
|
|
t.Errorf("identityOnlyUpdateReasons(%#v) = %v, want %v", tc.reasons, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIdentityOnlyFastPathEligible(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
identityReasons := []string{"group_assignment_changed"}
|
|
cases := []struct {
|
|
name string
|
|
existing scanStateFile
|
|
reasons []string
|
|
want bool
|
|
}{
|
|
{"probed primary row", scanStateFile{FileHash: "abc"}, identityReasons, true},
|
|
{"non-identity reasons need full path", scanStateFile{FileHash: "abc"}, []string{"size_changed"}, false},
|
|
// A row still linked as an extra is being reclassified as primary;
|
|
// only the full upsert clears extra_id so matching can pick it up.
|
|
{"former extra needs full path", scanStateFile{ExtraID: "extra-1", FileHash: "abc"}, identityReasons, false},
|
|
// A hash-less row needs the full path once to backfill OSHash and the
|
|
// hash-keyed S3 markers.
|
|
{"missing hash needs full path", scanStateFile{}, identityReasons, false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := identityOnlyFastPathEligible(&tc.existing, tc.reasons); got != tc.want {
|
|
t.Errorf("%s: identityOnlyFastPathEligible = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testStringSliceContains(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestWalkModeForEbookLibraryTypes(t *testing.T) {
|
|
for _, libraryType := range []string{"ebook", "ebooks", " EBOOKS "} {
|
|
if got := walkModeFor(libraryType); got != walkModeEbook {
|
|
t.Fatalf("walkModeFor(%q) = %v, want %v", libraryType, got, walkModeEbook)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWalkModeEbookAcceptsEbookExtensionsOnly(t *testing.T) {
|
|
for _, ext := range []string{".epub", ".pdf", ".mobi", ".azw", ".azw3", ".fb2", ".fbz", ".cbz", ".cbr"} {
|
|
if !walkModeEbook.acceptsExt(ext) {
|
|
t.Fatalf("walkModeEbook should accept %s", ext)
|
|
}
|
|
}
|
|
for _, ext := range []string{".txt", ".md", ".mp4", ".mp3", ".mkv"} {
|
|
if walkModeEbook.acceptsExt(ext) {
|
|
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) {
|
|
scanner := &Scanner{}
|
|
result, err := scanner.ScanFolder(context.Background(), &models.MediaFolder{
|
|
ID: 0,
|
|
Type: " ebooks ",
|
|
Paths: []string{t.TempDir()},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ScanFolder ebook error = %v, want nil", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("ScanFolder ebook result = nil, want empty result")
|
|
}
|
|
}
|
|
|
|
func TestScanSubtreeEbookLibraryRoutesToEbookScanner(t *testing.T) {
|
|
subtree := t.TempDir()
|
|
|
|
scanner := &Scanner{}
|
|
result, err := scanner.ScanSubtree(context.Background(), &models.MediaFolder{
|
|
ID: 0,
|
|
Type: "ebook",
|
|
Paths: []string{subtree},
|
|
}, subtree)
|
|
if err != nil {
|
|
t.Fatalf("ScanSubtree ebook error = %v, want nil", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("ScanSubtree ebook result = nil, want empty result")
|
|
}
|
|
}
|
|
|
|
func TestScanFileEbookLibraryUsesEbookPipeline(t *testing.T) {
|
|
filePath := filepath.Join(t.TempDir(), "book.epub")
|
|
if err := os.WriteFile(filePath, []byte("not a real epub"), 0o644); err != nil {
|
|
t.Fatalf("write fake ebook: %v", err)
|
|
}
|
|
|
|
err := (&Scanner{}).ScanFile(context.Background(), filePath, &models.MediaFolder{ID: 44, Type: "ebooks"})
|
|
if err == nil {
|
|
t.Fatal("ScanFile returned nil, want ebook 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=44") {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestSyncVanishedVideoQueuesUsesParentAndExactFileScopes(t *testing.T) {
|
|
series := &recordingQueueSyncer{}
|
|
movie := &recordingQueueSyncer{}
|
|
filePath := filepath.Join("/media", "Movie", "Movie.mkv")
|
|
scanner := &Scanner{seriesQueueSyncer: series, movieQueueSyncer: movie}
|
|
|
|
if err := scanner.syncVanishedVideoQueues(context.Background(), 17, filePath); err != nil {
|
|
t.Fatalf("syncVanishedVideoQueues: %v", err)
|
|
}
|
|
if series.folderID != 17 || series.scope != filepath.Dir(filePath) {
|
|
t.Fatalf("series sync = folder %d scope %q", series.folderID, series.scope)
|
|
}
|
|
if movie.folderID != 17 || movie.scope != filepath.Clean(filePath) {
|
|
t.Fatalf("movie sync = folder %d scope %q", movie.folderID, movie.scope)
|
|
}
|
|
}
|