summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-29 19:43:05 +0300
committerPaul Buetow <paul@buetow.org>2026-04-29 19:43:05 +0300
commitcf30414c2a0696cc75c615f4da66ea85d0522c42 (patch)
tree1d0838fdbafbf6fff7e2dd4173f323b6c2968bb9
parent916d92b94a1ef0f7482914e210d421cf5e3f02cd (diff)
feat: implement filesystem scanner
-rw-r--r--internal/probe/probe.go106
-rw-r--r--internal/probe/probe_test.go126
-rw-r--r--internal/probe/testdata/golden.json14
-rw-r--r--internal/scanner/fs.go28
-rw-r--r--internal/scanner/scanner.go196
-rw-r--r--internal/scanner/scanner_test.go442
-rw-r--r--internal/thumb/thumb.go63
-rw-r--r--internal/thumb/thumb_test.go80
8 files changed, 1055 insertions, 0 deletions
diff --git a/internal/probe/probe.go b/internal/probe/probe.go
index 2cc7dc8..7f5b887 100644
--- a/internal/probe/probe.go
+++ b/internal/probe/probe.go
@@ -1,2 +1,108 @@
// Package probe implements media metadata probing.
package probe
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+ "strconv"
+
+ "github.com/paul/kiss-media-player/internal/model"
+)
+
+// Prober extracts metadata from a media file.
+type Prober interface {
+ Probe(ctx context.Context, path string) (*model.Metadata, error)
+}
+
+// FFProber wraps the ffprobe command-line tool.
+type FFProber struct{}
+
+// NewFFProber creates a new FFProber.
+func NewFFProber() *FFProber {
+ return &FFProber{}
+}
+
+// Probe runs ffprobe against the given path and parses the resulting JSON.
+func (f *FFProber) Probe(ctx context.Context, path string) (*model.Metadata, error) {
+ cmd := exec.CommandContext(ctx, "ffprobe",
+ "-v", "error",
+ "-show_format",
+ "-show_streams",
+ "-of", "json",
+ path,
+ )
+ out, err := cmd.Output()
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
+ return nil, fmt.Errorf("ffprobe %s: %w: %s", path, err, string(exitErr.Stderr))
+ }
+ return nil, fmt.Errorf("ffprobe %s: %w", path, err)
+ }
+ return parseFFprobeOutput(out)
+}
+
+type ffprobeOutput struct {
+ Format struct {
+ Duration string `json:"duration"`
+ BitRate string `json:"bit_rate"`
+ } `json:"format"`
+ Streams []struct {
+ CodecName string `json:"codec_name"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ CodecType string `json:"codec_type"`
+ } `json:"streams"`
+}
+
+func parseFFprobeOutput(data []byte) (*model.Metadata, error) {
+ var out ffprobeOutput
+ if err := json.Unmarshal(data, &out); err != nil {
+ return nil, fmt.Errorf("unmarshal ffprobe output: %w", err)
+ }
+
+ meta := &model.Metadata{}
+ if out.Format.Duration != "" {
+ if d, err := strconv.ParseFloat(out.Format.Duration, 64); err == nil {
+ meta.Duration = d
+ }
+ }
+ if out.Format.BitRate != "" {
+ if b, err := strconv.Atoi(out.Format.BitRate); err == nil {
+ meta.Bitrate = b
+ }
+ }
+
+ for _, s := range out.Streams {
+ if s.CodecType == "video" {
+ if meta.Codec == "" {
+ meta.Codec = s.CodecName
+ }
+ if s.Width > 0 && s.Height > 0 {
+ meta.Resolution = fmt.Sprintf("%dx%d", s.Width, s.Height)
+ }
+ break
+ }
+ }
+
+ // Fallback to first stream codec if no video stream found.
+ if meta.Codec == "" && len(out.Streams) > 0 {
+ meta.Codec = out.Streams[0].CodecName
+ }
+
+ return meta, nil
+}
+
+// MockProber is a test fake for Prober.
+type MockProber struct {
+ ProbeFunc func(ctx context.Context, path string) (*model.Metadata, error)
+}
+
+// Probe delegates to ProbeFunc or returns zero-value metadata.
+func (m *MockProber) Probe(ctx context.Context, path string) (*model.Metadata, error) {
+ if m.ProbeFunc != nil {
+ return m.ProbeFunc(ctx, path)
+ }
+ return &model.Metadata{}, nil
+}
diff --git a/internal/probe/probe_test.go b/internal/probe/probe_test.go
new file mode 100644
index 0000000..9c05846
--- /dev/null
+++ b/internal/probe/probe_test.go
@@ -0,0 +1,126 @@
+package probe
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/paul/kiss-media-player/internal/model"
+)
+
+func TestParseFFprobeOutput(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ want *model.Metadata
+ wantErr bool
+ }{
+ {
+ name: "video with all fields",
+ input: `{
+ "format": {"duration": "123.45", "bit_rate": "500000"},
+ "streams": [
+ {"codec_name": "h264", "width": 1920, "height": 1080, "codec_type": "video"},
+ {"codec_name": "aac", "codec_type": "audio"}
+ ]
+ }`,
+ want: &model.Metadata{
+ Duration: 123.45,
+ Codec: "h264",
+ Resolution: "1920x1080",
+ Bitrate: 500000,
+ },
+ },
+ {
+ name: "audio only no video stream",
+ input: `{
+ "format": {"duration": "200.1", "bit_rate": "128000"},
+ "streams": [
+ {"codec_name": "mp3", "codec_type": "audio"}
+ ]
+ }`,
+ want: &model.Metadata{
+ Duration: 200.1,
+ Codec: "mp3",
+ Bitrate: 128000,
+ },
+ },
+ {
+ name: "invalid json",
+ input: `{bad json`,
+ wantErr: true,
+ },
+ {
+ name: "empty streams uses first stream fallback",
+ input: `{
+ "format": {},
+ "streams": [
+ {"codec_name": "vp9", "codec_type": "video", "width": 0, "height": 0}
+ ]
+ }`,
+ want: &model.Metadata{Codec: "vp9"},
+ },
+ {
+ name: "no format or streams",
+ input: `{"format":{},"streams":[]}`,
+ want: &model.Metadata{},
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, err := parseFFprobeOutput([]byte(c.input))
+ if (err != nil) != c.wantErr {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if c.wantErr {
+ return
+ }
+ if got.Duration != c.want.Duration {
+ t.Errorf("Duration = %v, want %v", got.Duration, c.want.Duration)
+ }
+ if got.Codec != c.want.Codec {
+ t.Errorf("Codec = %v, want %v", got.Codec, c.want.Codec)
+ }
+ if got.Resolution != c.want.Resolution {
+ t.Errorf("Resolution = %v, want %v", got.Resolution, c.want.Resolution)
+ }
+ if got.Bitrate != c.want.Bitrate {
+ t.Errorf("Bitrate = %v, want %v", got.Bitrate, c.want.Bitrate)
+ }
+ })
+ }
+}
+
+func TestMockProber(t *testing.T) {
+ ctx := context.Background()
+ m := &MockProber{}
+ meta, err := m.Probe(ctx, "any")
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if meta == nil {
+ t.Fatal("expected non-nil metadata")
+ }
+
+ m.ProbeFunc = func(context.Context, string) (*model.Metadata, error) {
+ return &model.Metadata{Duration: 42}, nil
+ }
+ meta, err = m.Probe(ctx, "any")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if meta.Duration != 42 {
+ t.Errorf("duration = %v, want 42", meta.Duration)
+ }
+}
+
+func TestFFProber_ContextCancellation(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
+ defer cancel()
+ p := NewFFProber()
+ _, err := p.Probe(ctx, "nonexistent_path_should_fail")
+ if err == nil {
+ t.Fatal("expected error when context is cancelled or ffprobe fails")
+ }
+}
diff --git a/internal/probe/testdata/golden.json b/internal/probe/testdata/golden.json
new file mode 100644
index 0000000..0c6a9c0
--- /dev/null
+++ b/internal/probe/testdata/golden.json
@@ -0,0 +1,14 @@
+{
+ "format": {
+ "duration": "123.456",
+ "bit_rate": "1000000"
+ },
+ "streams": [
+ {
+ "codec_name": "h264",
+ "codec_type": "video",
+ "width": 1920,
+ "height": 1080
+ }
+ ]
+}
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)
+ }
+ }
+}
diff --git a/internal/thumb/thumb.go b/internal/thumb/thumb.go
index 5311f56..96c496b 100644
--- a/internal/thumb/thumb.go
+++ b/internal/thumb/thumb.go
@@ -1,2 +1,65 @@
// Package thumb generates thumbnail images.
package thumb
+
+import (
+ "context"
+ "fmt"
+ "math/rand"
+ "os/exec"
+)
+
+// Generator creates a thumbnail for a given media file.
+type Generator interface {
+ Generate(ctx context.Context, inputPath, outputPath string, duration float64) error
+}
+
+// FFmpegGenerator uses ffmpeg to extract a random frame.
+type FFmpegGenerator struct {
+ execer func(ctx context.Context, name string, arg ...string) *exec.Cmd
+}
+
+// NewFFmpegGenerator creates a new FFmpegGenerator.
+func NewFFmpegGenerator() *FFmpegGenerator {
+ return &FFmpegGenerator{
+ execer: exec.CommandContext,
+ }
+}
+
+// Generate picks a random offset (at least 1 second if duration > 0) and
+// runs ffmpeg to produce a JPEG thumbnail.
+func (g *FFmpegGenerator) Generate(ctx context.Context, inputPath, outputPath string, duration float64) error {
+ offset := 0.0
+ if duration > 0 {
+ offset = rand.Float64() * duration
+ if offset < 1.0 {
+ offset = 1.0
+ }
+ }
+
+ cmd := g.execer(ctx, "ffmpeg",
+ "-ss", fmt.Sprintf("%.3f", offset),
+ "-i", inputPath,
+ "-vf", "scale=320:-1",
+ "-frames:v", "1",
+ "-q:v", "2",
+ "-y",
+ outputPath,
+ )
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("ffmpeg generate thumbnail for %s: %w", inputPath, err)
+ }
+ return nil
+}
+
+// MockGenerator is a test fake for Generator.
+type MockGenerator struct {
+ GenerateFunc func(ctx context.Context, inputPath, outputPath string, duration float64) error
+}
+
+// Generate delegates to GenerateFunc or succeeds silently.
+func (m *MockGenerator) Generate(ctx context.Context, inputPath, outputPath string, duration float64) error {
+ if m.GenerateFunc != nil {
+ return m.GenerateFunc(ctx, inputPath, outputPath, duration)
+ }
+ return nil
+}
diff --git a/internal/thumb/thumb_test.go b/internal/thumb/thumb_test.go
new file mode 100644
index 0000000..2cd6124
--- /dev/null
+++ b/internal/thumb/thumb_test.go
@@ -0,0 +1,80 @@
+package thumb
+
+import (
+ "context"
+ "errors"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+func TestFFmpegGenerator_Generate(t *testing.T) {
+ ctx := context.Background()
+ called := false
+
+ fakeExecer := func(_ context.Context, name string, arg ...string) *exec.Cmd {
+ called = true
+ if name != "ffmpeg" {
+ t.Errorf("expected ffmpeg, got %s", name)
+ }
+ // Verify some expected flags exist.
+ args := strings.Join(arg, " ")
+ if !strings.Contains(args, "-ss") {
+ t.Error("missing -ss flag")
+ }
+ if !strings.Contains(args, "-i") {
+ t.Error("missing -i flag")
+ }
+ if !strings.Contains(args, "-frames:v 1") {
+ t.Error("missing -frames:v 1 flag")
+ }
+ if !strings.Contains(args, "-y") {
+ t.Error("missing -y flag")
+ }
+ // Return a command that does nothing successfully.
+ return exec.Command("true")
+ }
+
+ g := &FFmpegGenerator{execer: fakeExecer}
+ if err := g.Generate(ctx, "input.mp4", "output.jpg", 120.0); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !called {
+ t.Fatal("expected fake execer to be called")
+ }
+
+ // Duration zero or negative should still call execer with valid offset.
+ called = false
+ if err := g.Generate(ctx, "input.mp4", "output.jpg", 0); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !called {
+ t.Fatal("expected fake execer to be called for zero duration")
+ }
+}
+
+func TestFFmpegGenerator_Generate_Error(t *testing.T) {
+ ctx := context.Background()
+ fakeExecer := func(_ context.Context, name string, arg ...string) *exec.Cmd {
+ return exec.Command("false")
+ }
+ g := &FFmpegGenerator{execer: fakeExecer}
+ if err := g.Generate(ctx, "input.mp4", "output.jpg", 10.0); err == nil {
+ t.Fatal("expected error from failing ffmpeg command")
+ }
+}
+
+func TestMockGenerator(t *testing.T) {
+ ctx := context.Background()
+ m := &MockGenerator{}
+ if err := m.Generate(ctx, "in", "out", 0); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }