diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-17 15:25:52 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-17 15:25:52 +0300 |
| commit | 914bd7cd6aa14e839332a98d91c30b19865b0cf2 (patch) | |
| tree | 02b11537237a204048589e14ed24938b805e42f7 /internal/scanner/scanner_test.go | |
| parent | 3b24f0e1be832584d6550e8cc3e5d24329f66f90 (diff) | |
Restructure repo: move Go server into player-server/
Diffstat (limited to 'internal/scanner/scanner_test.go')
| -rw-r--r-- | internal/scanner/scanner_test.go | 610 |
1 files changed, 0 insertions, 610 deletions
diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go deleted file mode 100644 index 0738709..0000000 --- a/internal/scanner/scanner_test.go +++ /dev/null @@ -1,610 +0,0 @@ -package scanner - -import ( - "context" - "errors" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "codeberg.org/snonux/player/internal/clock" - "codeberg.org/snonux/player/internal/model" - "codeberg.org/snonux/player/internal/probe" - "codeberg.org/snonux/player/internal/repository" - "codeberg.org/snonux/player/internal/thumb" -) - -// mockDirEntry implements os.DirEntry for tests. -type mockDirEntry struct { - name string - isDir bool -} - -func (m mockDirEntry) Name() string { return m.name } -func (m mockDirEntry) IsDir() bool { return m.isDir } -func (m mockDirEntry) Type() os.FileMode { return 0 } -func (m mockDirEntry) Info() (os.FileInfo, error) { - return mockFileInfo{name: m.name, isDir: m.isDir}, nil -} - -// mockFileInfo implements os.FileInfo for tests. -type mockFileInfo struct { - name string - size int64 - isDir bool - modTime time.Time - mode os.FileMode -} - -func (m mockFileInfo) Name() string { return m.name } -func (m mockFileInfo) Size() int64 { return m.size } -func (m mockFileInfo) Mode() os.FileMode { return m.mode } -func (m mockFileInfo) ModTime() time.Time { return m.modTime } -func (m mockFileInfo) IsDir() bool { return m.isDir } -func (m mockFileInfo) Sys() interface{} { return nil } - -// walkEntry describes a single path yielded by mockFS.WalkDir. -type walkEntry struct { - path string - isDir bool -} - -// mockFS implements FS for tests. -type mockFS struct { - entries map[string][]os.DirEntry - fileInfos map[string]os.FileInfo - walkList []walkEntry - walkErr error - mkdirErr error -} - -func (m *mockFS) ReadDir(name string) ([]os.DirEntry, error) { - if ents, ok := m.entries[name]; ok { - return ents, nil - } - return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrNotExist} -} - -func (m *mockFS) Stat(name string) (os.FileInfo, error) { - if info, ok := m.fileInfos[name]; ok { - return info, nil - } - return nil, &os.PathError{Op: "stat", Path: name, Err: os.ErrNotExist} -} - -func (m *mockFS) MkdirAll(path string, perm os.FileMode) error { return m.mkdirErr } - -func (m *mockFS) WalkDir(root string, walkFn fs.WalkDirFunc) error { - if m.walkErr != nil { - return m.walkErr - } - var skipDirs []string - for _, e := range m.walkList { - cleanRoot := filepath.Clean(root) - cleanPath := filepath.Clean(e.path) - if !strings.HasPrefix(cleanPath, cleanRoot) { - continue - } - skipped := false - for _, sd := range skipDirs { - if strings.HasPrefix(cleanPath, sd) { - skipped = true - break - } - } - if skipped { - continue - } - de := mockDirEntry{name: filepath.Base(e.path), isDir: e.isDir} - err := walkFn(e.path, de, nil) - if err == filepath.SkipDir { - skipDirs = append(skipDirs, cleanPath) - continue - } - if err != nil { - return err - } - } - return nil -} - -func newTestScanner(store repository.ScannerStore, prober probe.Prober, gen thumb.Generator, clk clock.Clock, filesystem FS) *FSScanner { - return &FSScanner{ - store: store, - prober: prober, - thumbGen: gen, - clock: clk, - fs: filesystem, - } -} - -func TestFSScanner_Scan(t *testing.T) { - now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - clk := &clock.MockClock{T: now} - ctx := context.Background() - - t.Run("empty root", func(t *testing.T) { - mfs := &mockFS{ - entries: map[string][]os.DirEntry{ - "/media": {}, - }, - } - store := repository.NewMockStore() - store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { return nil, nil } - s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs) - if err := s.Scan(ctx, "/media", nil); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - t.Run("creates new set with video and audio", func(t *testing.T) { - mfs := &mockFS{ - entries: map[string][]os.DirEntry{ - "/media": {mockDirEntry{name: "Movies", isDir: true}}, - }, - fileInfos: map[string]os.FileInfo{ - "/media/Movies/video.mp4": mockFileInfo{name: "video.mp4", size: 1000}, - "/media/Movies/song.mp3": mockFileInfo{name: "song.mp3", size: 500}, - }, - walkList: []walkEntry{ - {path: "/media/Movies", isDir: true}, - {path: "/media/Movies/video.mp4", isDir: false}, - {path: "/media/Movies/song.mp3", isDir: false}, - }, - } - store := repository.NewMockStore() - store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { return nil, nil } - var createdSetID int64 = 7 - store.SetRepo.CreateSetFunc = func(_ context.Context, set *model.Set) (int64, error) { - if set.Name != "Movies" || set.RootPath != "Movies" { - t.Errorf("unexpected set: %+v", set) - } - return createdSetID, nil - } - store.MediaRepo.ListMediaFunc = func(_ context.Context, filter repository.MediaFilter) ([]model.Media, error) { - if filter.SetID == nil || *filter.SetID != createdSetID { - t.Errorf("unexpected filter: %+v", filter) - } - return nil, nil - } - - var created []model.Media - store.MediaRepo.CreateMediaFunc = func(_ context.Context, m *model.Media) (int64, error) { - created = append(created, *m) - return int64(len(created)), nil - } - - prober := &probe.MockProber{ - ProbeFunc: func(_ context.Context, path string) (*model.Metadata, error) { - if strings.HasSuffix(path, ".mp4") { - return &model.Metadata{Duration: 120, Codec: "h264", Resolution: "1920x1080", Bitrate: 1000}, nil - } - return &model.Metadata{Duration: 180, Codec: "mp3", Bitrate: 256}, nil - }, - } - - genCalled := false - gen := &thumb.MockGenerator{ - GenerateFunc: func(_ context.Context, inputPath, outputPath string, duration float64) error { - genCalled = true - if !strings.HasSuffix(inputPath, ".mp4") { - t.Errorf("unexpected thumbnail input: %s", inputPath) - } - return nil - }, - } - - s := newTestScanner(store, prober, gen, clk, mfs) - if err := s.Scan(ctx, "/media", nil); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(created) != 2 { - t.Fatalf("expected 2 media created, got %d", len(created)) - } - - vid := created[0] - if vid.Type != model.MediaTypeVideo || vid.FileName != "video.mp4" || vid.RelPath != "video.mp4" { - t.Errorf("unexpected video media: %+v", vid) - } - if vid.ThumbnailPath == "" { - t.Error("expected thumbnail path for video") - } - if vid.FileSizeBytes != 1000 { - t.Errorf("expected file size 1000, got %d", vid.FileSizeBytes) - } - - aud := created[1] - if aud.Type != model.MediaTypeAudio || aud.FileName != "song.mp3" || aud.RelPath != "song.mp3" { - t.Errorf("unexpected audio media: %+v", aud) - } - if aud.ThumbnailPath != "" { - t.Error("expected no thumbnail path for audio") - } - if aud.FileSizeBytes != 500 { - t.Errorf("expected file size 500, got %d", aud.FileSizeBytes) - } - - if !genCalled { - t.Error("expected thumbnail generation to be called") - } - }) - - t.Run("skips existing media", func(t *testing.T) { - mfs := &mockFS{ - entries: map[string][]os.DirEntry{ - "/media": {mockDirEntry{name: "Music", isDir: true}}, - }, - fileInfos: map[string]os.FileInfo{ - "/media/Music/track.mp3": mockFileInfo{name: "track.mp3", size: 300}, - }, - walkList: []walkEntry{ - {path: "/media/Music", isDir: true}, - {path: "/media/Music/track.mp3", isDir: false}, - }, - } - store := repository.NewMockStore() - store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { - return []model.Set{{ID: 1, Name: "Music", RootPath: "Music"}}, nil - } - store.MediaRepo.ListMediaFunc = func(_ context.Context, filter repository.MediaFilter) ([]model.Media, error) { - return []model.Media{{ID: 10, SetID: 1, RelPath: "track.mp3"}}, nil - } - var created int - store.MediaRepo.CreateMediaFunc = func(_ context.Context, m *model.Media) (int64, error) { - created++ - return 0, nil - } - - s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs) - if err := s.Scan(ctx, "/media", nil); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if created != 0 { - t.Fatalf("expected 0 new media, got %d", created) - } - }) - - t.Run("progress accumulates file totals across sets", func(t *testing.T) { - mfs := &mockFS{ - entries: map[string][]os.DirEntry{ - "/media": { - mockDirEntry{name: "Music", isDir: true}, - mockDirEntry{name: "Movies", isDir: true}, - }, - }, - fileInfos: map[string]os.FileInfo{ - "/media/Music/a.mp3": mockFileInfo{name: "a.mp3", size: 100}, - "/media/Music/b.mp3": mockFileInfo{name: "b.mp3", size: 200}, - "/media/Movies/c.mp3": mockFileInfo{name: "c.mp3", size: 300}, - }, - walkList: []walkEntry{ - {path: "/media/Music", isDir: true}, - {path: "/media/Music/a.mp3", isDir: false}, - {path: "/media/Music/b.mp3", isDir: false}, - {path: "/media/Movies", isDir: true}, - {path: "/media/Movies/c.mp3", isDir: false}, - {path: "/media/Movies/readme", isDir: false}, - }, - } - store := repository.NewMockStore() - store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { - return []model.Set{ - {ID: 1, Name: "Music", RootPath: "Music"}, - {ID: 2, Name: "Movies", RootPath: "Movies"}, - }, nil - } - store.MediaRepo.ListMediaFunc = func(_ context.Context, filter repository.MediaFilter) ([]model.Media, error) { - return nil, nil - } - store.MediaRepo.CreateMediaFunc = func(_ context.Context, m *model.Media) (int64, error) { - return 1, nil - } - prober := &probe.MockProber{ - ProbeFunc: func(_ context.Context, path string) (*model.Metadata, error) { - return &model.Metadata{Duration: 1, Codec: "mp3"}, nil - }, - } - - var progress model.ScanProgress - s := newTestScanner(store, prober, &thumb.MockGenerator{}, clk, mfs) - if err := s.Scan(ctx, "/media", &progress); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - cp := progress.Copy() - if cp.SetsTotal != 2 || cp.SetsDone != 2 { - t.Fatalf("expected 2/2 sets, got %d/%d", cp.SetsDone, cp.SetsTotal) - } - if cp.FilesTotal != 3 || cp.FilesDone != 3 { - t.Fatalf("expected 3/3 files, got %d/%d", cp.FilesDone, cp.FilesTotal) - } - }) - - t.Run("scans all top-level dirs and marks canonical podcast root", func(t *testing.T) { - mfs := &mockFS{ - entries: map[string][]os.DirEntry{ - "/media": { - mockDirEntry{name: "qa-stable-podcast-1", isDir: true}, - mockDirEntry{name: "podcast", isDir: true}, - }, - }, - fileInfos: map[string]os.FileInfo{ - "/media/podcast/feed/episode.mp3": mockFileInfo{name: "episode.mp3", size: 500}, - }, - walkList: []walkEntry{ - {path: "/media/podcast", isDir: true}, - {path: "/media/podcast/feed", isDir: true}, - {path: "/media/podcast/feed/episode.mp3", isDir: false}, - }, - } - store := repository.NewMockStore() - store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { return nil, nil } - var createdSets []model.Set - store.SetRepo.CreateSetFunc = func(_ context.Context, set *model.Set) (int64, error) { - createdSets = append(createdSets, *set) - return int64(len(createdSets)), nil - } - store.MediaRepo.ListMediaFunc = func(_ context.Context, filter repository.MediaFilter) ([]model.Media, error) { - return nil, nil - } - store.MediaRepo.CreateMediaFunc = func(_ context.Context, m *model.Media) (int64, error) { - return 1, nil - } - prober := &probe.MockProber{ - ProbeFunc: func(_ context.Context, path string) (*model.Metadata, error) { - return &model.Metadata{Duration: 180, Codec: "mp3", Bitrate: 256}, nil - }, - } - - s := newTestScanner(store, prober, &thumb.MockGenerator{}, clk, mfs) - if err := s.Scan(ctx, "/media", nil); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(createdSets) != 2 { - t.Fatalf("expected both top-level dirs to be created, got %+v", createdSets) - } - if createdSets[0].RootPath != "qa-stable-podcast-1" { - t.Fatalf("expected qa-stable-podcast-1 root path, got %+v", createdSets[0]) - } - if createdSets[1].RootPath != "podcast" || !createdSets[1].IsPodcast { - t.Fatalf("expected podcast root path marked as podcast, got %+v", createdSets[1]) - } - }) - - t.Run("nested directories", func(t *testing.T) { - mfs := &mockFS{ - entries: map[string][]os.DirEntry{ - "/media": {mockDirEntry{name: "Series", isDir: true}}, - }, - fileInfos: map[string]os.FileInfo{ - "/media/Series/season1/ep1.mp4": mockFileInfo{name: "ep1.mp4", size: 2000}, - }, - walkList: []walkEntry{ - {path: "/media/Series", isDir: true}, - {path: "/media/Series/season1", isDir: true}, - {path: "/media/Series/season1/ep1.mp4", isDir: false}, - }, - } - store := repository.NewMockStore() - store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { return nil, nil } - store.SetRepo.CreateSetFunc = func(_ context.Context, set *model.Set) (int64, error) { return 3, nil } - store.MediaRepo.ListMediaFunc = func(_ context.Context, filter repository.MediaFilter) ([]model.Media, error) { return nil, nil } - - var created model.Media - store.MediaRepo.CreateMediaFunc = func(_ context.Context, m *model.Media) (int64, error) { - created = *m - return 1, nil - } - - prober := &probe.MockProber{ - ProbeFunc: func(_ context.Context, path string) (*model.Metadata, error) { - return &model.Metadata{Duration: 45}, nil - }, - } - - s := newTestScanner(store, prober, &thumb.MockGenerator{}, clk, mfs) - if err := s.Scan(ctx, "/media", nil); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if created.RelPath != filepath.Join("season1", "ep1.mp4") { - t.Errorf("unexpected nested rel path: %s", created.RelPath) - } - }) - - t.Run("read dir error", func(t *testing.T) { - mfs := &mockFS{entries: map[string][]os.DirEntry{}} - s := newTestScanner(repository.NewMockStore(), &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs) - err := s.Scan(ctx, "/media", nil) - if err == nil { - t.Fatal("expected error for missing root") - } - }) - - t.Run("stat error", func(t *testing.T) { - mfs := &mockFS{ - entries: map[string][]os.DirEntry{ - "/media": {mockDirEntry{name: "Set", isDir: true}}, - }, - walkList: []walkEntry{ - {path: "/media/Set", isDir: true}, - {path: "/media/Set/file.mp4", isDir: false}, - }, - } - store := repository.NewMockStore() - store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { return nil, nil } - store.SetRepo.CreateSetFunc = func(_ context.Context, set *model.Set) (int64, error) { return 1, nil } - store.MediaRepo.ListMediaFunc = func(_ context.Context, filter repository.MediaFilter) ([]model.Media, error) { return nil, nil } - - s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs) - err := s.Scan(ctx, "/media", nil) - if err == nil { - t.Fatal("expected error for stat failure") - } - }) - - t.Run("probe error", func(t *testing.T) { - mfs := &mockFS{ - entries: map[string][]os.DirEntry{ - "/media": {mockDirEntry{name: "Set", isDir: true}}, - }, - fileInfos: map[string]os.FileInfo{ - "/media/Set/bad.mp4": mockFileInfo{name: "bad.mp4", size: 100}, - }, - walkList: []walkEntry{ - {path: "/media/Set", isDir: true}, - {path: "/media/Set/bad.mp4", isDir: false}, - }, - } - store := repository.NewMockStore() - store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { return nil, nil } - store.SetRepo.CreateSetFunc = func(_ context.Context, _ *model.Set) (int64, error) { return 1, nil } - store.MediaRepo.ListMediaFunc = func(_ context.Context, _ repository.MediaFilter) ([]model.Media, error) { return nil, nil } - prober := &probe.MockProber{ - ProbeFunc: func(_ context.Context, _ string) (*model.Metadata, error) { - return nil, errors.New("probe failed") - }, - } - - s := newTestScanner(store, prober, &thumb.MockGenerator{}, clk, mfs) - // Unprobeable files are skipped with a log instead of failing the whole scan. - err := s.Scan(ctx, "/media", nil) - if err != nil { - t.Fatalf("unexpected error for probe failure; expected skip, got: %v", err) - } - if store.MediaRepo.CreateMediaFunc != nil { - // no media should have been created for the bad file - } - }) - - t.Run("thumbnail generation error", func(t *testing.T) { - mfs := &mockFS{ - entries: map[string][]os.DirEntry{ - "/media": {mockDirEntry{name: "Set", isDir: true}}, - }, - fileInfos: map[string]os.FileInfo{ - "/media/Set/video.mp4": mockFileInfo{name: "video.mp4", size: 100}, - }, - walkList: []walkEntry{ - {path: "/media/Set", isDir: true}, - {path: "/media/Set/video.mp4", isDir: false}, - }, - mkdirErr: nil, - } - store := repository.NewMockStore() - store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { return nil, nil } - store.SetRepo.CreateSetFunc = func(_ context.Context, _ *model.Set) (int64, error) { return 1, nil } - store.MediaRepo.ListMediaFunc = func(_ context.Context, _ repository.MediaFilter) ([]model.Media, error) { return nil, nil } - prober := &probe.MockProber{ - ProbeFunc: func(_ context.Context, _ string) (*model.Metadata, error) { - return &model.Metadata{Duration: 60}, nil - }, - } - gen := &thumb.MockGenerator{ - GenerateFunc: func(_ context.Context, _, _ string, _ float64) error { - return errors.New("thumb failed") - }, - } - - s := newTestScanner(store, prober, gen, clk, mfs) - // Thumbnail generation errors are skipped so the scan continues. - err := s.Scan(ctx, "/media", nil) - if err != nil { - t.Fatalf("unexpected error for thumbnail failure; expected skip, got: %v", err) - } - }) - - t.Run("walk error", func(t *testing.T) { - mfs := &mockFS{ - entries: map[string][]os.DirEntry{ - "/media": {mockDirEntry{name: "Set", isDir: true}}, - }, - walkErr: errors.New("walk failed"), - } - store := repository.NewMockStore() - store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { - return []model.Set{{ID: 1, Name: "Set", RootPath: "Set"}}, nil - } - store.MediaRepo.ListMediaFunc = func(_ context.Context, _ repository.MediaFilter) ([]model.Media, error) { return nil, nil } - - s := newTestScanner(store, &probe.MockProber{}, &thumb.MockGenerator{}, clk, mfs) - err := s.Scan(ctx, "/media", nil) - if err == nil { - t.Fatal("expected error for walk failure") - } - }) -} - -func TestFSScanner_collectFiles(t *testing.T) { - t.Run("collects supported media files excluding hidden directories", func(t *testing.T) { - mfs := &mockFS{ - walkList: []walkEntry{ - {path: "/music", isDir: true}, - {path: "/music/track.mp3", isDir: false}, - {path: "/music/cover.jpg", isDir: false}, - {path: "/music/.hidden", isDir: true}, - {path: "/music/.hidden/secret.mp3", isDir: false}, - }, - } - s := newTestScanner(nil, nil, nil, nil, mfs) - files, err := s.collectFiles("/music") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(files) != 2 { - t.Fatalf("expected 2 supported files, got %v", files) - } - if files[0] != "/music/track.mp3" || files[1] != "/music/cover.jpg" { - t.Fatalf("unexpected files: %v", files) - } - }) - - t.Run("skips dot directories", func(t *testing.T) { - mfs := &mockFS{ - walkList: []walkEntry{ - {path: "/music", isDir: true}, - {path: "/music/.hidden", isDir: true}, - {path: "/music/.hidden/secret.mp3", isDir: false}, - }, - } - s := newTestScanner(nil, nil, nil, nil, mfs) - files, err := s.collectFiles("/music") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(files) != 0 { - t.Fatalf("expected 0 files from dot dir, got %v", files) - } - }) - - t.Run("walk error", func(t *testing.T) { - mfs := &mockFS{walkErr: errors.New("walk failed")} - s := newTestScanner(nil, nil, nil, nil, mfs) - _, err := s.collectFiles("/bad") - if err == nil { - t.Fatal("expected error for walk failure") - } - }) - - t.Run("ignores unsupported extensions", func(t *testing.T) { - mfs := &mockFS{ - walkList: []walkEntry{ - {path: "/stuff", isDir: true}, - {path: "/stuff/file.txt", isDir: false}, - {path: "/stuff/notes.md", isDir: false}, - {path: "/stuff/track.mp3", isDir: false}, - }, - } - s := newTestScanner(nil, nil, nil, nil, mfs) - files, err := s.collectFiles("/stuff") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(files) != 1 || files[0] != "/stuff/track.mp3" { - t.Fatalf("expected only mp3, got %v", files) - } - }) -} |
