From 914bd7cd6aa14e839332a98d91c30b19865b0cf2 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 17 May 2026 15:25:52 +0300 Subject: Restructure repo: move Go server into player-server/ --- internal/scanner/fs.go | 28 -- internal/scanner/scanner.go | 516 --------------------------------- internal/scanner/scanner_test.go | 610 --------------------------------------- 3 files changed, 1154 deletions(-) delete mode 100644 internal/scanner/fs.go delete mode 100644 internal/scanner/scanner.go delete mode 100644 internal/scanner/scanner_test.go (limited to 'internal/scanner') diff --git a/internal/scanner/fs.go b/internal/scanner/fs.go deleted file mode 100644 index c4299c5..0000000 --- a/internal/scanner/fs.go +++ /dev/null @@ -1,28 +0,0 @@ -// 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 deleted file mode 100644 index 15a4949..0000000 --- a/internal/scanner/scanner.go +++ /dev/null @@ -1,516 +0,0 @@ -// Package scanner implements media library scanning logic. -package scanner - -import ( - "context" - "fmt" - "io/fs" - "log/slog" - "path/filepath" - "runtime" - "strings" - "sync" - "sync/atomic" - - "codeberg.org/snonux/player/internal/clock" - "codeberg.org/snonux/player/internal/mediatype" - "codeberg.org/snonux/player/internal/model" - "codeberg.org/snonux/player/internal/probe" - "codeberg.org/snonux/player/internal/repository" - "codeberg.org/snonux/player/internal/thumb" -) - -// Scanner defines the filesystem scanning contract. -type Scanner interface { - Scan(ctx context.Context, root string, progress *model.ScanProgress) error -} - -// FSScanner recursively scans media root for sets and media files. -type FSScanner struct { - store repository.ScannerStore - prober probe.Prober - thumbGen thumb.Generator - clock clock.Clock - mediaRoot string - fs FS - logger *slog.Logger - workers int -} - -// NewFSScanner creates a filesystem scanner with injected dependencies. -func NewFSScanner(store repository.ScannerStore, prober probe.Prober, thumbGen thumb.Generator, clk clock.Clock, mediaRoot string) *FSScanner { - return NewFSScannerWithLogger(store, prober, thumbGen, clk, mediaRoot, slog.Default()) -} - -// NewFSScannerWithLogger creates a filesystem scanner with an injected logger. -func NewFSScannerWithLogger(store repository.ScannerStore, prober probe.Prober, thumbGen thumb.Generator, clk clock.Clock, mediaRoot string, logger *slog.Logger) *FSScanner { - if logger == nil { - logger = slog.Default() - } - return &FSScanner{ - store: store, - prober: prober, - thumbGen: thumbGen, - clock: clk, - mediaRoot: mediaRoot, - fs: osFS{}, - logger: logger, - workers: runtime.NumCPU(), - } -} - -func (s *FSScanner) log() *slog.Logger { - if s.logger != nil { - return s.logger - } - return slog.Default() -} - -// Scan walks immediate subdirectories of root, treating each as a set. -func (s *FSScanner) Scan(ctx context.Context, root string, progress *model.ScanProgress) error { - entries, err := s.fs.ReadDir(root) - if err != nil { - return fmt.Errorf("read media root %q: %w", root, err) - } - - // Count total sets for progress. - var setCount int - for _, entry := range entries { - if entry.IsDir() { - setCount++ - } - } - if progress != nil { - progress.Start(setCount) - } - s.log().Info("scanner scan started", "root", root, "sets", setCount) - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - setPath := filepath.Join(root, entry.Name()) - if err := s.scanSet(ctx, root, setPath, progress); err != nil { - return err - } - if progress != nil { - progress.IncrementSet() - } - } - s.log().Info("scanner scan finished", "root", root, "sets", setCount) - return nil -} - -// ensureSet returns the set ID for the given root/relative paths, creating the set if necessary. -func (s *FSScanner) ensureSet(ctx context.Context, root, setPath string) (int64, 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 0, "", fmt.Errorf("list sets for %q: %w", setName, err) - } - - for i := range sets { - if sets[i].RootPath == relRoot { - if isPodcastRoot(relRoot) && !sets[i].IsPodcast { - sets[i].IsPodcast = true - if err := s.store.UpdateSet(ctx, &sets[i]); err != nil { - return 0, "", fmt.Errorf("update podcast set %q: %w", setName, err) - } - } - return sets[i].ID, setName, nil - } - } - - newSet := &model.Set{ - Name: setName, - RootPath: relRoot, - IsPodcast: isPodcastRoot(relRoot), - CreatedAt: s.clock.Now(), - } - id, err := s.store.CreateSet(ctx, newSet) - if err != nil { - return 0, "", fmt.Errorf("create set %q: %w", setName, err) - } - return id, setName, nil -} - -func isPodcastRoot(rootPath string) bool { - return strings.EqualFold(filepath.ToSlash(rootPath), "podcast") -} - -// loadExistingMedia builds a lookup map of existing media keyed by relPath. -func (s *FSScanner) loadExistingMedia(ctx context.Context, setID int64, setName string) (map[string]model.Media, error) { - existing := make(map[string]model.Media) - mediaList, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID}) - if err != nil { - return nil, fmt.Errorf("list media for set %q: %w", setName, err) - } - for _, m := range mediaList { - existing[m.RelPath] = m - } - return existing, nil -} - -// gatherCoverImages walks the set and records the first cover image per directory. -func (s *FSScanner) gatherCoverImages(setPath string) map[string]string { - coverImages := make(map[string]string) - _ = s.fs.WalkDir(setPath, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() || !mediatype.IsCoverImageExt(path) { - if d != nil && d.IsDir() && strings.HasPrefix(d.Name(), ".") && path != setPath { - return filepath.SkipDir - } - return nil - } - relPath, _ := filepath.Rel(setPath, path) - dir := filepath.Dir(path) - if _, ok := coverImages[dir]; !ok { - coverImages[dir] = relPath - } - return nil - }) - return coverImages -} - -// thumbnailForVideo generates a thumbnail for a video file inside the set's .thumbnails directory. -func (s *FSScanner) thumbnailForVideo(ctx context.Context, path, setPath string, duration float64) (string, error) { - 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, duration); err != nil { - s.log().Warn("scanner skipping thumbnail", "path", path, "err", err) - return "", nil - } - return thumbnailPath, nil -} - -// thumbnailForImage generates a thumbnail for an image file inside the set's .thumbnails directory. -func (s *FSScanner) thumbnailForImage(ctx context.Context, path, setPath string) (string, error) { - 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, 0); err != nil { - s.log().Warn("scanner skipping thumbnail", "path", path, "err", err) - return "", nil - } - return thumbnailPath, nil -} - -// buildThumbnailPath resolves the thumbnail path for a new media file. -func (s *FSScanner) buildThumbnailPath(ctx context.Context, path, setPath string, mediaType model.MediaType, coverImages map[string]string, meta *model.Metadata) (string, error) { - switch mediaType { - case model.MediaTypeVideo: - return s.thumbnailForVideo(ctx, path, setPath, meta.Duration) - case model.MediaTypeAudio: - return findCoverImage(path, coverImages, setPath), nil - case model.MediaTypeImage: - ext := strings.ToLower(filepath.Ext(path)) - if ext == ".svg" { - return path, nil - } - thumbPath, err := s.thumbnailForImage(ctx, path, setPath) - if err != nil { - return "", err - } - if thumbPath != "" { - if _, statErr := s.fs.Stat(thumbPath); statErr == nil { - return thumbPath, nil - } - } - return path, nil - } - return "", nil -} - -// fileResult carries a successfully probed media record back to the writer. -type fileResult struct { - media *model.Media - path string // absolute path for logging -} - -// probeFile probes a single file and builds a media record. -// It returns nil when the file already exists or is unprobeable. -func (s *FSScanner) probeFile(ctx context.Context, path, setPath string, setID int64, setName string, existing map[string]model.Media, coverImages map[string]string, progress *model.ScanProgress) (*fileResult, error) { - relPath, err := filepath.Rel(setPath, path) - if err != nil { - return nil, fmt.Errorf("rel path for %q: %w", path, err) - } - relPath = filepath.ToSlash(relPath) - - if progress != nil { - progress.IncrementFile() - } - - _, alreadyExists := existing[relPath] - s.log().Debug("scanner file checked", "set", setName, "path", relPath, "existing", alreadyExists) - if alreadyExists { - return nil, nil - } - - info, err := s.fs.Stat(path) - if err != nil { - return nil, fmt.Errorf("stat %q: %w", path, err) - } - - meta, err := s.prober.Probe(ctx, path) - if err != nil { - s.log().Warn("scanner skipping unprobeable file", "path", path, "err", err) - return nil, nil - } - meta.FileSizeBytes = info.Size() - - mediaType := mediatype.TypeForExt(path) - thumbnailPath, err := s.buildThumbnailPath(ctx, path, setPath, mediaType, coverImages, meta) - if err != nil { - return nil, 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, - Width: meta.Width, - Height: meta.Height, - EXIFCamera: meta.EXIFCamera, - EXIFLens: meta.EXIFLens, - EXIFDate: meta.EXIFDate, - EXIFISO: meta.EXIFISO, - EXIFFNumber: meta.EXIFFNumber, - EXIFExposure: meta.EXIFExposure, - EXIFFocalLength: meta.EXIFFocalLength, - ThumbnailPath: thumbnailPath, - CreatedAt: s.clock.Now(), - } - - return &fileResult{media: media, path: path}, nil -} - -// updateAudioThumbnails patches existing audio tracks when a new cover image appears. -func (s *FSScanner) updateAudioThumbnails(ctx context.Context, mediaList []model.Media, coverImages map[string]string, setPath string) { - for _, m := range mediaList { - if m.Type != model.MediaTypeAudio || m.ThumbnailPath != "" { - continue - } - candidate := findCoverImage(m.AbsPath, coverImages, setPath) - if candidate != "" && candidate != m.ThumbnailPath { - if err := s.store.UpdateMediaThumbnail(ctx, m.ID, candidate); err != nil { - s.log().Warn("scanner failed to update thumbnail", "file", m.FileName, "err", err) - } - } - } -} - -// scanSet scans a single set using a pool of workers for probing and a single -// writer goroutine for SQLite inserts. -func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress *model.ScanProgress) error { - workers := s.workers - if workers <= 0 { - workers = 1 - } - setID, setName, err := s.ensureSet(ctx, root, setPath) - if err != nil { - return err - } - - s.log().Info("scanner set started", "name", setName, "path", setPath) - if progress != nil { - progress.SetCurrentSet(setName) - } - - existing, err := s.loadExistingMedia(ctx, setID, setName) - if err != nil { - return err - } - - coverImages := s.gatherCoverImages(setPath) - - files, err := s.collectFiles(setPath) - if err != nil { - return fmt.Errorf("scan set %q: %w", setName, err) - } - - if progress != nil { - progress.AddFilesTotal(len(files)) - } - - pathChan := make(chan string, len(files)) - resultChan := make(chan fileResult, s.workers) - - scanCtx, cancel := context.WithCancel(ctx) - defer cancel() - - errChan := make(chan error, 1) - var errOnce sync.Once - sendErr := func(err error) { - errOnce.Do(func() { errChan <- err; cancel() }) - } - - var workerWg sync.WaitGroup - for i := 0; i < workers; i++ { - workerWg.Add(1) - go func() { - defer workerWg.Done() - s.probeWorkerLoop(ctx, scanCtx, pathChan, resultChan, setPath, setID, setName, existing, coverImages, progress, sendErr) - }() - } - - var newFiles int32 - var writerWg sync.WaitGroup - writerWg.Add(1) - go func() { - defer writerWg.Done() - s.writerLoop(ctx, scanCtx, resultChan, setName, setPath, &newFiles, sendErr) - }() - - // Feed the worker pool. - for _, path := range files { - if scanCtx.Err() != nil { - break - } - select { - case pathChan <- path: - case <-scanCtx.Done(): - break - } - } - close(pathChan) - - // Wait for workers to finish, then close the result channel so the writer exits. - workerWg.Wait() - close(resultChan) - - // Wait for the writer to drain all results. - writerWg.Wait() - - var firstErr error - select { - case firstErr = <-errChan: - default: - } - - if firstErr != nil { - return fmt.Errorf("scan set %q: %w", setName, firstErr) - } - - mediaList, _ := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID}) - s.updateAudioThumbnails(ctx, mediaList, coverImages, setPath) - - s.log().Info("scanner set completed", "name", setName, "existing_media", len(existing), "new_media", newFiles) - return nil -} - -// collectFiles walks the set and returns the absolute paths of all supported media files. -func (s *FSScanner) collectFiles(setPath string) ([]string, error) { - var files []string - 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() { - if strings.HasPrefix(d.Name(), ".") && path != setPath { - return filepath.SkipDir - } - return nil - } - if !mediatype.IsSupportedExt(path) { - return nil - } - files = append(files, path) - return nil - }) - if walkErr != nil { - return nil, walkErr - } - return files, nil -} - -// probeWorkerLoop consumes file paths, probes each one, and sends the result to resultChan. -// It stops early if scanCtx is cancelled or if sendErr reports a fatal error. -func (s *FSScanner) probeWorkerLoop( - ctx context.Context, - scanCtx context.Context, - pathChan <-chan string, - resultChan chan<- fileResult, - setPath string, - setID int64, - setName string, - existing map[string]model.Media, - coverImages map[string]string, - progress *model.ScanProgress, - sendErr func(error), -) { - for path := range pathChan { - if scanCtx.Err() != nil { - continue - } - result, err := s.probeFile(ctx, path, setPath, setID, setName, existing, coverImages, progress) - if err != nil { - sendErr(err) - return - } - if result == nil { - continue - } - select { - case resultChan <- *result: - case <-scanCtx.Done(): - return - } - } -} - -// writerLoop reads probed results and inserts them into the store. -// It logs progress every 25 files and tracks the total newFiles count. -func (s *FSScanner) writerLoop( - ctx context.Context, - scanCtx context.Context, - resultChan <-chan fileResult, - setName string, - setPath string, - newFiles *int32, - sendErr func(error), -) { - for result := range resultChan { - if scanCtx.Err() != nil { - continue - } - if _, err := s.store.CreateMedia(ctx, result.media); err != nil { - sendErr(fmt.Errorf("create media %q: %w", result.path, err)) - continue - } - nf := atomic.AddInt32(newFiles, 1) - if nf == 1 || nf%25 == 0 { - relPath, _ := filepath.Rel(setPath, result.path) - s.log().Info("scanner set progress", "name", setName, "new_media", nf, "latest", filepath.ToSlash(relPath)) - } - } -} - -func findCoverImage(filePath string, coverImages map[string]string, setPath string) string { - for dir := filepath.Dir(filePath); len(dir) >= len(setPath); dir = filepath.Dir(dir) { - if coverRel, ok := coverImages[dir]; ok { - return filepath.Join(setPath, coverRel) - } - if dir == setPath { - break - } - } - return "" -} 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) - } - }) -} -- cgit v1.2.3