summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-01-30 19:05:44 +0200
committerPaul Buetow <paul@buetow.org>2026-03-07 14:49:40 +0200
commit02b5a54c6d4bbef198c8ae22816392d1fc26f073 (patch)
tree96ed1fb07474f0328c1de6272bbe4740058c74ef /internal
parentef81e63c0578f3fbe25134731e437d0d8cf51737 (diff)
Phase 1-2: Foundation, architecture, and thumbnail system
- Add fyne.io/fyne/v2 dependency - Create internal/gui and internal/thumbnail packages - Extend video struct with Thumbnail and ThumbnailGenerated fields - Implement thumbnail generator with ffmpeg integration - Implement thumbnail cache with JSON persistence - Add comprehensive unit tests for thumbnail system - Create loader_gui.go for GUI video loading - Update progress tracking in plan.md All tests pass.
Diffstat (limited to 'internal')
-rw-r--r--internal/app/loader_gui.go132
-rw-r--r--internal/app/video.go16
-rw-r--r--internal/thumbnail/cache.go121
-rw-r--r--internal/thumbnail/cache_test.go197
-rw-r--r--internal/thumbnail/config.go10
-rw-r--r--internal/thumbnail/generator.go111
6 files changed, 580 insertions, 7 deletions
diff --git a/internal/app/loader_gui.go b/internal/app/loader_gui.go
new file mode 100644
index 0000000..d964287
--- /dev/null
+++ b/internal/app/loader_gui.go
@@ -0,0 +1,132 @@
+package app
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "codeberg.org/snonux/yoga/internal/tags"
+ "codeberg.org/snonux/yoga/internal/thumbnail"
+)
+
+type Loader struct {
+ root string
+ durationCache *durationCache
+ thumbnailCache *thumbnail.Cache
+ generator *thumbnail.Generator
+}
+
+func NewLoader(root string, durationCachePath string) *Loader {
+ durationCache, _ := loadDurationCache(durationCachePath)
+ return &Loader{
+ root: root,
+ durationCache: durationCache,
+ thumbnailCache: thumbnail.NewCache(root),
+ generator: thumbnail.NewGenerator(),
+ }
+}
+
+func (l *Loader) LoadVideos(ctx context.Context) ([]video, []string, []string, error) {
+ paths, err := collectVideoPathsForLoader(ctx, l.root)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ videos := make([]video, 0, len(paths))
+ durationPending := make([]string, 0)
+ thumbnailPending := make([]string, 0)
+ var tagErrors []string
+
+ for _, path := range paths {
+ info, statErr := os.Stat(path)
+ if statErr != nil {
+ videos = append(videos, video{
+ Name: filepath.Base(path),
+ Path: path,
+ Err: statErr,
+ Tags: []string{},
+ })
+ continue
+ }
+
+ dur := cachedDuration(l.durationCache, path, info)
+ if dur == 0 {
+ durationPending = append(durationPending, path)
+ }
+
+ thumbPath, hasThumb := l.checkThumbnail(path, info)
+ if !hasThumb {
+ thumbnailPending = append(thumbnailPending, path)
+ }
+
+ tagList, tagErr := tags.Load(path)
+ if tagErr != nil {
+ tagErrors = append(tagErrors, fmt.Sprintf("%s: %v", filepath.Base(path), tagErr))
+ }
+
+ videos = append(videos, video{
+ Name: filepath.Base(path),
+ Path: path,
+ Duration: dur,
+ ModTime: info.ModTime(),
+ Size: info.Size(),
+ Tags: tagList,
+ Thumbnail: thumbPath,
+ ThumbnailGenerated: hasThumb,
+ })
+ }
+
+ sort.Strings(durationPending)
+ sort.Strings(thumbnailPending)
+ sort.Strings(tagErrors)
+
+ return videos, durationPending, thumbnailPending, joinErrors(tagErrors)
+}
+
+func (l *Loader) checkThumbnail(videoPath string, info os.FileInfo) (string, bool) {
+ if l.thumbnailCache == nil {
+ return "", false
+ }
+
+ return l.thumbnailCache.Lookup(videoPath, info.ModTime())
+}
+
+func (l *Loader) GenerateThumbnail(ctx context.Context, videoPath string, modTime os.FileInfo) (string, error) {
+ if l.thumbnailCache == nil {
+ return "", fmt.Errorf("thumbnail cache not initialized")
+ }
+
+ thumbPath, err := l.generator.Generate(ctx, videoPath)
+ if err != nil {
+ return "", err
+ }
+
+ if err := l.thumbnailCache.Store(videoPath, modTime.ModTime(), thumbPath); err != nil {
+ return "", fmt.Errorf("store thumbnail cache: %w", err)
+ }
+
+ return thumbPath, nil
+}
+
+func collectVideoPathsForLoader(ctx context.Context, root string) ([]string, error) {
+ info, err := os.Stat(root)
+ if err != nil {
+ return nil, err
+ }
+ if !info.IsDir() {
+ if isVideo(root) {
+ return []string{root}, nil
+ }
+ return nil, nil
+ }
+
+ paths, err := collectVideoPaths(root)
+ if err != nil {
+ return nil, err
+ }
+
+ sort.Strings(paths)
+ return paths, nil
+}
diff --git a/internal/app/video.go b/internal/app/video.go
index 9c85772..e0896cc 100644
--- a/internal/app/video.go
+++ b/internal/app/video.go
@@ -3,11 +3,13 @@ package app
import "time"
type video struct {
- Name string
- Path string
- Duration time.Duration
- ModTime time.Time
- Size int64
- Err error
- Tags []string
+ Name string
+ Path string
+ Duration time.Duration
+ ModTime time.Time
+ Size int64
+ Err error
+ Tags []string
+ Thumbnail string
+ ThumbnailGenerated bool
}
diff --git a/internal/thumbnail/cache.go b/internal/thumbnail/cache.go
new file mode 100644
index 0000000..2a1af40
--- /dev/null
+++ b/internal/thumbnail/cache.go
@@ -0,0 +1,121 @@
+package thumbnail
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+type entry struct {
+ VideoPath string `json:"video_path"`
+ Thumbnail string `json:"thumbnail"`
+ ModTime time.Time `json:"mod_time"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+type Cache struct {
+ entries map[string]entry
+ mu sync.RWMutex
+ path string
+}
+
+func NewCache(root string) *Cache {
+ return newCache(root)
+}
+
+func newCache(root string) *Cache {
+ path := filepath.Join(root, cacheFilename)
+ return &Cache{
+ entries: make(map[string]entry),
+ path: path,
+ }
+}
+
+func (c *Cache) Load() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ data, err := os.ReadFile(c.path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read cache file: %w", err)
+ }
+
+ var loaded []entry
+ if err := json.Unmarshal(data, &loaded); err != nil {
+ return fmt.Errorf("unmarshal cache: %w", err)
+ }
+
+ c.entries = make(map[string]entry, len(loaded))
+ for _, e := range loaded {
+ c.entries[e.VideoPath] = e
+ }
+
+ return nil
+}
+
+func (c *Cache) Lookup(videoPath string, modTime time.Time) (string, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ e, ok := c.entries[videoPath]
+ if !ok {
+ return "", false
+ }
+
+ if !e.ModTime.Equal(modTime) {
+ return "", false
+ }
+
+ if _, err := os.Stat(e.Thumbnail); err != nil {
+ return "", false
+ }
+
+ return e.Thumbnail, true
+}
+
+func (c *Cache) Store(videoPath string, modTime time.Time, thumbnailPath string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.entries[videoPath] = entry{
+ VideoPath: videoPath,
+ Thumbnail: thumbnailPath,
+ ModTime: modTime,
+ Timestamp: time.Now(),
+ }
+
+ return c.flush()
+}
+
+func (c *Cache) Remove(videoPath string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ delete(c.entries, videoPath)
+ return c.flush()
+}
+
+func (c *Cache) flush() error {
+ loaded := make([]entry, 0, len(c.entries))
+ for _, e := range c.entries {
+ loaded = append(loaded, e)
+ }
+
+ data, err := json.MarshalIndent(loaded, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal cache: %w", err)
+ }
+
+ if err := os.WriteFile(c.path, data, 0o644); err != nil {
+ return fmt.Errorf("write cache file: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/thumbnail/cache_test.go b/internal/thumbnail/cache_test.go
new file mode 100644
index 0000000..877695c
--- /dev/null
+++ b/internal/thumbnail/cache_test.go
@@ -0,0 +1,197 @@
+package thumbnail
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestNewGenerator(t *testing.T) {
+ g := NewGenerator()
+ if g == nil {
+ t.Fatal("NewGenerator returned nil")
+ }
+ if g.ffmpegPath != "ffmpeg" {
+ t.Errorf("expected ffmpegPath to be 'ffmpeg', got '%s'", g.ffmpegPath)
+ }
+}
+
+func TestGeneratorGetThumbnailPath(t *testing.T) {
+ g := NewGenerator()
+
+ tests := []struct {
+ videoPath string
+ expectedSuffix string
+ }{
+ {
+ videoPath: "/home/user/video.mp4",
+ expectedSuffix: filepath.Join(".thumbnails", "video.jpg"),
+ },
+ {
+ videoPath: "/home/user/yoga/morning.mp4",
+ expectedSuffix: filepath.Join(".thumbnails", "morning.jpg"),
+ },
+ {
+ videoPath: "/home/user/yoga/evening.mkv",
+ expectedSuffix: filepath.Join(".thumbnails", "evening.jpg"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.videoPath, func(t *testing.T) {
+ result := g.getThumbnailPath(tt.videoPath)
+ if !strings.HasSuffix(result, tt.expectedSuffix) {
+ t.Errorf("expected path to end with '%s', got '%s'", tt.expectedSuffix, result)
+ }
+ })
+ }
+}
+
+func TestCacheNewCache(t *testing.T) {
+ c := newCache("/tmp/test")
+ if c == nil {
+ t.Fatal("newCache returned nil")
+ }
+ if c.path != filepath.Join("/tmp/test", cacheFilename) {
+ t.Errorf("expected path to be '%s', got '%s'", filepath.Join("/tmp/test", cacheFilename), c.path)
+ }
+ if c.entries == nil {
+ t.Fatal("expected entries to be initialized")
+ }
+}
+
+func TestCacheLoadNotExist(t *testing.T) {
+ tmpDir := t.TempDir()
+ c := newCache(tmpDir)
+
+ err := c.Load()
+ if err != nil {
+ t.Errorf("expected no error for non-existent cache file, got %v", err)
+ }
+}
+
+func TestCacheStoreAndLookup(t *testing.T) {
+ tmpDir := t.TempDir()
+ c := newCache(tmpDir)
+
+ videoPath := "/test/video.mp4"
+ modTime := time.Now()
+ thumbnailPath := filepath.Join(tmpDir, ".thumbnails", "video.jpg")
+
+ err := c.Store(videoPath, modTime, thumbnailPath)
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(thumbnailPath), 0o755); err != nil {
+ t.Fatalf("failed to create thumbnail directory: %v", err)
+ }
+ if err := os.WriteFile(thumbnailPath, []byte("fake thumbnail"), 0o644); err != nil {
+ t.Fatalf("failed to create thumbnail file: %v", err)
+ }
+
+ retrieved, ok := c.Lookup(videoPath, modTime)
+ if !ok {
+ t.Fatal("Lookup returned false for stored entry")
+ }
+ if retrieved != thumbnailPath {
+ t.Errorf("expected thumbnailPath '%s', got '%s'", thumbnailPath, retrieved)
+ }
+}
+
+func TestCacheLookupDifferentModTime(t *testing.T) {
+ tmpDir := t.TempDir()
+ c := newCache(tmpDir)
+
+ videoPath := "/test/video.mp4"
+ modTime1 := time.Now()
+ thumbnailPath := "/test/.thumbnails/video.jpg"
+
+ err := c.Store(videoPath, modTime1, thumbnailPath)
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ modTime2 := modTime1.Add(1 * time.Hour)
+ _, ok := c.Lookup(videoPath, modTime2)
+ if ok {
+ t.Error("expected Lookup to return false for different mod time")
+ }
+}
+
+func TestCacheRemove(t *testing.T) {
+ tmpDir := t.TempDir()
+ c := newCache(tmpDir)
+
+ videoPath := "/test/video.mp4"
+ modTime := time.Now()
+ thumbnailPath := "/test/.thumbnails/video.jpg"
+
+ err := c.Store(videoPath, modTime, thumbnailPath)
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ err = c.Remove(videoPath)
+ if err != nil {
+ t.Fatalf("Remove failed: %v", err)
+ }
+
+ _, ok := c.Lookup(videoPath, modTime)
+ if ok {
+ t.Error("expected Lookup to return false after Remove")
+ }
+}
+
+func TestCachePersistence(t *testing.T) {
+ tmpDir := t.TempDir()
+ cachePath := filepath.Join(tmpDir, cacheFilename)
+
+ videoPath := "/test/video.mp4"
+ modTime := time.Now()
+ thumbnailPath := filepath.Join(tmpDir, ".thumbnails", "video.jpg")
+
+ if err := os.MkdirAll(filepath.Dir(thumbnailPath), 0o755); err != nil {
+ t.Fatalf("failed to create thumbnail directory: %v", err)
+ }
+ if err := os.WriteFile(thumbnailPath, []byte("fake thumbnail"), 0o644); err != nil {
+ t.Fatalf("failed to create thumbnail file: %v", err)
+ }
+
+ c1 := newCache(tmpDir)
+ err := c1.Store(videoPath, modTime, thumbnailPath)
+ if err != nil {
+ t.Fatalf("first Store failed: %v", err)
+ }
+
+ if _, err := os.Stat(cachePath); err != nil {
+ t.Fatalf("cache file not created: %v", err)
+ }
+
+ c2 := newCache(tmpDir)
+ err = c2.Load()
+ if err != nil {
+ t.Fatalf("Load failed: %v", err)
+ }
+
+ retrieved, ok := c2.Lookup(videoPath, modTime)
+ if !ok {
+ t.Fatal("Lookup returned false for loaded entry")
+ }
+ if retrieved != thumbnailPath {
+ t.Errorf("expected thumbnailPath '%s', got '%s'", thumbnailPath, retrieved)
+ }
+}
+
+func TestGenerateWithMissingFFmpeg(t *testing.T) {
+ g := &Generator{ffmpegPath: "nonexistent-ffmpeg-binary"}
+
+ ctx := context.Background()
+ _, err := g.Generate(ctx, "/test/video.mp4")
+ if err == nil {
+ t.Error("expected error for missing ffmpeg")
+ }
+}
diff --git a/internal/thumbnail/config.go b/internal/thumbnail/config.go
new file mode 100644
index 0000000..5f7b975
--- /dev/null
+++ b/internal/thumbnail/config.go
@@ -0,0 +1,10 @@
+package thumbnail
+
+const (
+ thumbnailWidth = 320
+ thumbnailHeight = 180
+ thumbnailFormat = "jpg"
+ cacheFilename = ".video_thumbnails.json"
+ thumbnailDir = ".thumbnails"
+ thumbnailPercent = 10
+)
diff --git a/internal/thumbnail/generator.go b/internal/thumbnail/generator.go
new file mode 100644
index 0000000..20aa391
--- /dev/null
+++ b/internal/thumbnail/generator.go
@@ -0,0 +1,111 @@
+package thumbnail
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type Generator struct {
+ ffmpegPath string
+}
+
+func NewGenerator() *Generator {
+ return &Generator{
+ ffmpegPath: "ffmpeg",
+ }
+}
+
+func (g *Generator) Generate(ctx context.Context, videoPath string) (string, error) {
+ duration, err := g.probeVideoDuration(ctx, videoPath)
+ if err != nil {
+ return "", fmt.Errorf("probe video duration: %w", err)
+ }
+
+ timestamp := duration * time.Duration(thumbnailPercent) / 100
+ thumbnailPath := g.getThumbnailPath(videoPath)
+
+ if err := g.extractFrame(ctx, videoPath, thumbnailPath, timestamp); err != nil {
+ return "", fmt.Errorf("extract frame: %w", err)
+ }
+
+ return thumbnailPath, nil
+}
+
+func (g *Generator) probeVideoDuration(ctx context.Context, videoPath string) (time.Duration, error) {
+ args := []string{
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1",
+ videoPath,
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "ffprobe", args...)
+ output, err := cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+
+ seconds, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64)
+ if err != nil {
+ return 0, err
+ }
+
+ return time.Duration(seconds * float64(time.Second)), nil
+}
+
+func (g *Generator) extractFrame(ctx context.Context, videoPath, thumbnailPath string, timestamp time.Duration) error {
+ thumbnailDir := filepath.Dir(thumbnailPath)
+ if err := ensureDir(thumbnailDir); err != nil {
+ return err
+ }
+
+ timestampSec := timestamp.Seconds()
+ timestampStr := fmt.Sprintf("%.3f", timestampSec)
+
+ args := []string{
+ "-ss", timestampStr,
+ "-i", videoPath,
+ "-vframes", "1",
+ "-vf", fmt.Sprintf("scale=%d:%d", thumbnailWidth, thumbnailHeight),
+ "-y",
+ thumbnailPath,
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, g.ffmpegPath, args...)
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (g *Generator) getThumbnailPath(videoPath string) string {
+ dir := filepath.Dir(videoPath)
+ thumbnailDir := filepath.Join(dir, thumbnailDir)
+
+ filename := filepath.Base(videoPath)
+ ext := filepath.Ext(filename)
+ name := strings.TrimSuffix(filename, ext)
+
+ return filepath.Join(thumbnailDir, name+"."+thumbnailFormat)
+}
+
+func ensureDir(path string) error {
+ if _, err := exec.LookPath("mkdir"); err == nil {
+ cmd := exec.Command("mkdir", "-p", path)
+ return cmd.Run()
+ }
+
+ return fmt.Errorf("mkdir not found")
+}