diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-29 19:43:05 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-29 19:43:05 +0300 |
| commit | cf30414c2a0696cc75c615f4da66ea85d0522c42 (patch) | |
| tree | 1d0838fdbafbf6fff7e2dd4173f323b6c2968bb9 /internal/thumb | |
| parent | 916d92b94a1ef0f7482914e210d421cf5e3f02cd (diff) | |
feat: implement filesystem scanner
Diffstat (limited to 'internal/thumb')
| -rw-r--r-- | internal/thumb/thumb.go | 63 | ||||
| -rw-r--r-- | internal/thumb/thumb_test.go | 80 |
2 files changed, 143 insertions, 0 deletions
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") + } +} |
