summaryrefslogtreecommitdiff
path: root/internal/scanner
diff options
context:
space:
mode:
Diffstat (limited to 'internal/scanner')
-rw-r--r--internal/scanner/fs.go28
-rw-r--r--internal/scanner/scanner.go196
-rw-r--r--internal/scanner/scanner_test.go442
3 files changed, 666 insertions, 0 deletions
diff --git a/internal/scanner/fs.go b/internal/scanner/fs.go
new file mode 100644
index 0000000..8616fde
--- /dev/null
+++ b/internal/scanner/fs.go
@@ -0,0 +1,28 @@
+// Package scanner implements media library scanning logic.
+package scanner
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+// FS abstracts filesystem operations for testability.
+type FS interface {
+ ReadDir(name string) ([]os.DirEntry, error)
+ Stat(name string) (os.FileInfo, error)
+ MkdirAll(path string, perm os.FileMode) error
+ WalkDir(root string, walkFn fs.WalkDirFunc) error
+}
+
+// osFS delegates to the standard library.
+type osFS struct{}
+
+func (osFS) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
+func (osFS) Stat(name string) (os.FileInfo, error) { return os.Stat(name) }
+func (osFS) MkdirAll(path string, perm os.FileMode) error {
+ return os.MkdirAll(path, perm)
+}
+func (osFS) WalkDir(root string, walkFn fs.WalkDirFunc) error {
+ return filepath.WalkDir(root, walkFn)
+}
diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
index 86129a7..b44955e 100644
--- a/internal/scanner/scanner.go
+++ b/internal/scanner/scanner.go
@@ -1,2 +1,198 @@
// Package scanner implements media library scanning logic.
package scanner
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "path/filepath"
+ "strings"
+
+ "github.com/paul/kiss-media-player/internal/clock"
+ "github.com/paul/kiss-media-player/internal/model"
+ "github.com/paul/kiss-media-player/internal/probe"
+ "github.com/paul/kiss-media-player/internal/repository"
+ "github.com/paul/kiss-media-player/internal/thumb"
+)
+
+// Scanner defines the filesystem scanning contract.
+type Scanner interface {
+ Scan(ctx context.Context, root string) error
+}
+
+// FSScanner recursively scans media root for sets and media files.
+type FSScanner struct {
+ store repository.Store
+ prober probe.Prober
+ thumbGen thumb.Generator
+ clock clock.Clock
+ mediaRoot string
+ fs FS
+}
+
+// NewFSScanner creates a filesystem scanner with injected dependencies.
+func NewFSScanner(store repository.Store, prober probe.Prober, thumbGen thumb.Generator, clk clock.Clock, mediaRoot string) Scanner {
+ return &FSScanner{
+ store: store,
+ prober: prober,
+ thumbGen: thumbGen,
+ clock: clk,
+ mediaRoot: mediaRoot,
+ fs: osFS{},
+ }
+}
+
+// Scan walks immediate subdirectories of root, treating each as a set.
+func (s *FSScanner) Scan(ctx context.Context, root string) error {
+ entries, err := s.fs.ReadDir(root)
+ if err != nil {
+ return fmt.Errorf("read media root %q: %w", root, err)
+ }
+
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ setPath := filepath.Join(root, entry.Name())
+ if err := s.scanSet(ctx, root, setPath); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *FSScanner) scanSet(ctx context.Context, root, setPath string) error {
+ setName := filepath.Base(setPath)
+ relRoot, err := filepath.Rel(root, setPath)
+ if err != nil {
+ relRoot = setName
+ }
+
+ sets, err := s.store.ListSets(ctx)
+ if err != nil {
+ return fmt.Errorf("list sets for %q: %w", setName, err)
+ }
+
+ var setID int64
+ var set *model.Set
+ for i := range sets {
+ if sets[i].RootPath == relRoot {
+ set = &sets[i]
+ break
+ }
+ }
+ if set == nil {
+ newSet := &model.Set{
+ Name: setName,
+ RootPath: relRoot,
+ CreatedAt: s.clock.Now(),
+ }
+ id, err := s.store.CreateSet(ctx, newSet)
+ if err != nil {
+ return fmt.Errorf("create set %q: %w", setName, err)
+ }
+ setID = id
+ } else {
+ setID = set.ID
+ }
+
+ // Build map of existing media for quick lookup.
+ existing := make(map[string]bool)
+ mediaList, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID})
+ if err != nil {
+ return fmt.Errorf("list media for set %q: %w", setName, err)
+ }
+ for _, m := range mediaList {
+ existing[m.RelPath] = true
+ }
+
+ // Walk set directory recursively.
+ walkErr := s.fs.WalkDir(setPath, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return fmt.Errorf("walk %q: %w", path, err)
+ }
+ if d.IsDir() {
+ return nil
+ }
+ if !isMediaFile(path) {
+ return nil
+ }
+ relPath, err := filepath.Rel(setPath, path)
+ if err != nil {
+ return fmt.Errorf("rel path for %q: %w", path, err)
+ }
+ if existing[relPath] {
+ return nil
+ }
+
+ info, err := s.fs.Stat(path)
+ if err != nil {
+ return fmt.Errorf("stat %q: %w", path, err)
+ }
+
+ meta, err := s.prober.Probe(ctx, path)
+ if err != nil {
+ return fmt.Errorf("probe %q: %w", path, err)
+ }
+ meta.FileSizeBytes = info.Size()
+
+ mediaType := mediaTypeFromExt(path)
+ var thumbnailPath string
+ if mediaType == model.MediaTypeVideo {
+ thumbDir := filepath.Join(setPath, ".thumbnails")
+ if err := s.fs.MkdirAll(thumbDir, 0o755); err != nil {
+ return fmt.Errorf("mkdir thumbnails %q: %w", thumbDir, err)
+ }
+ thumbName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".jpg"
+ thumbnailPath = filepath.Join(thumbDir, thumbName)
+ if err := s.thumbGen.Generate(ctx, path, thumbnailPath, meta.Duration); err != nil {
+ return fmt.Errorf("thumbnail %q: %w", path, err)
+ }
+ }
+
+ media := &model.Media{
+ SetID: setID,
+ RelPath: relPath,
+ FileName: filepath.Base(path),
+ AbsPath: path,
+ Type: mediaType,
+ Duration: meta.Duration,
+ Codec: meta.Codec,
+ Resolution: meta.Resolution,
+ Bitrate: meta.Bitrate,
+ FileSizeBytes: meta.FileSizeBytes,
+ ThumbnailPath: thumbnailPath,
+ CreatedAt: s.clock.Now(),
+ }
+
+ if _, err := s.store.CreateMedia(ctx, media); err != nil {
+ return fmt.Errorf("create media %q: %w", path, err)
+ }
+ return nil
+ })
+ if walkErr != nil {
+ return fmt.Errorf("scan set %q: %w", setName, walkErr)
+ }
+ return nil
+}
+
+var mediaExtensions = map[string]struct{}{
+ ".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".webm": {},
+ ".mp3": {}, ".flac": {}, ".wav": {}, ".aac": {}, ".ogg": {}, ".m4a": {}, ".opus": {},
+}
+
+func isMediaFile(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ _, ok := mediaExtensions[ext]
+ return ok
+}
+
+func mediaTypeFromExt(path string) model.MediaType {
+ ext := strings.ToLower(filepath.Ext(path))
+ switch ext {
+ case ".mp4", ".mkv", ".avi", ".mov", ".webm":
+ return model.MediaTypeVideo
+ default:
+ return model.MediaTypeAudio
+ }
+}
diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go
new file mode 100644
index 0000000..4accfb7
--- /dev/null
+++ b/internal/scanner/scanner_test.go
@@ -0,0 +1,442 @@
+package scanner
+
+import (
+ "context"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/paul/kiss-media-player/internal/clock"
+ "github.com/paul/kiss-media-player/internal/model"
+ "github.com/paul/kiss-media-player/internal/probe"
+ "github.com/paul/kiss-media-player/internal/repository"
+ "github.com/paul/kiss-media-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
+ }
+ for _, e := range m.walkList {
+ if !strings.HasPrefix(filepath.Clean(e.path), filepath.Clean(root)) {
+ continue
+ }
+ de := mockDirEntry{name: filepath.Base(e.path), isDir: e.isDir}
+ if err := walkFn(e.path, de, nil); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func newTestScanner(store repository.Store, 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"); 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"); 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"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if created != 0 {
+ t.Fatalf("expected 0 new media, got %d", created)
+ }
+ })
+
+ 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"); 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")
+ 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")
+ 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)
+ err := s.Scan(ctx, "/media")
+ if err == nil {
+ t.Fatal("expected error for probe failure")
+ }
+ })
+
+ 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)
+ err := s.Scan(ctx, "/media")
+ if err == nil {
+ t.Fatal("expected error for thumbnail failure")
+ }
+ })
+
+ 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")
+ if err == nil {
+ t.Fatal("expected error for walk failure")
+ }
+ })
+}
+
+func Test_isMediaFile(t *testing.T) {
+ cases := []struct {
+ path string
+ want bool
+ }{
+ {"movie.mp4", true},
+ {"song.MP3", true},
+ {"archive.zip", false},
+ {"photo.jpg", false},
+ {"", false},
+ }
+ for _, c := range cases {
+ if got := isMediaFile(c.path); got != c.want {
+ t.Errorf("isMediaFile(%q) = %v, want %v", c.path, got, c.want)
+ }
+ }
+}
+
+func Test_mediaTypeFromExt(t *testing.T) {
+ cases := []struct {
+ path string
+ want model.MediaType
+ }{
+ {"a.mp4", model.MediaTypeVideo},
+ {"a.mkv", model.MediaTypeVideo},
+ {"a.mp3", model.MediaTypeAudio},
+ {"a.FLAC", model.MediaTypeAudio},
+ }
+ for _, c := range cases {
+ if got := mediaTypeFromExt(c.path); got != c.want {
+ t.Errorf("mediaTypeFromExt(%q) = %v, want %v", c.path, got, c.want)
+ }
+ }
+}