From 02b5a54c6d4bbef198c8ae22816392d1fc26f073 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 30 Jan 2026 19:05:44 +0200 Subject: 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. --- internal/app/loader_gui.go | 132 ++++++++++++++++++++++++++ internal/app/video.go | 16 ++-- internal/thumbnail/cache.go | 121 ++++++++++++++++++++++++ internal/thumbnail/cache_test.go | 197 +++++++++++++++++++++++++++++++++++++++ internal/thumbnail/config.go | 10 ++ internal/thumbnail/generator.go | 111 ++++++++++++++++++++++ 6 files changed, 580 insertions(+), 7 deletions(-) create mode 100644 internal/app/loader_gui.go create mode 100644 internal/thumbnail/cache.go create mode 100644 internal/thumbnail/cache_test.go create mode 100644 internal/thumbnail/config.go create mode 100644 internal/thumbnail/generator.go (limited to 'internal') 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") +} -- cgit v1.2.3