summaryrefslogtreecommitdiff
path: root/internal/thumb
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 /internal/thumb
parent916d92b94a1ef0f7482914e210d421cf5e3f02cd (diff)
feat: implement filesystem scanner
Diffstat (limited to 'internal/thumb')
-rw-r--r--internal/thumb/thumb.go63
-rw-r--r--internal/thumb/thumb_test.go80
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")
+ }
+}