summaryrefslogtreecommitdiff
path: root/internal/scanner
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-17 15:25:52 +0300
committerPaul Buetow <paul@buetow.org>2026-05-17 15:25:52 +0300
commit914bd7cd6aa14e839332a98d91c30b19865b0cf2 (patch)
tree02b11537237a204048589e14ed24938b805e42f7 /internal/scanner
parent3b24f0e1be832584d6550e8cc3e5d24329f66f90 (diff)
Restructure repo: move Go server into player-server/
Diffstat (limited to 'internal/scanner')
-rw-r--r--internal/scanner/fs.go28
-rw-r--r--internal/scanner/scanner.go516
-rw-r--r--internal/scanner/scanner_test.go610
3 files changed, 0 insertions, 1154 deletions
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