From cf30414c2a0696cc75c615f4da66ea85d0522c42 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 29 Apr 2026 19:43:05 +0300 Subject: feat: implement filesystem scanner --- internal/probe/probe.go | 106 +++++++++ internal/probe/probe_test.go | 126 ++++++++++ internal/probe/testdata/golden.json | 14 ++ internal/scanner/fs.go | 28 +++ internal/scanner/scanner.go | 196 ++++++++++++++++ internal/scanner/scanner_test.go | 442 ++++++++++++++++++++++++++++++++++++ internal/thumb/thumb.go | 63 +++++ internal/thumb/thumb_test.go | 80 +++++++ 8 files changed, 1055 insertions(+) create mode 100644 internal/probe/probe_test.go create mode 100644 internal/probe/testdata/golden.json create mode 100644 internal/scanner/fs.go create mode 100644 internal/scanner/scanner_test.go create mode 100644 internal/thumb/thumb_test.go 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) + } + + m.GenerateFunc = func(context.Context, string, string, float64) error { + return errors.New("fail") + } + if err := m.Generate(ctx, "in", "out", 0); err == nil { + t.Fatal("expected error from mock generator") + } +} -- cgit v1.2.3