diff options
| author | Paul Buetow <paul@buetow.org> | 2026-01-30 19:05:44 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-07 14:49:40 +0200 |
| commit | 02b5a54c6d4bbef198c8ae22816392d1fc26f073 (patch) | |
| tree | 96ed1fb07474f0328c1de6272bbe4740058c74ef | |
| parent | ef81e63c0578f3fbe25134731e437d0d8cf51737 (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.
| -rw-r--r-- | MIGRATION_PLAN.md | 294 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | go.sum | 3 | ||||
| -rw-r--r-- | internal/app/loader_gui.go | 132 | ||||
| -rw-r--r-- | internal/app/video.go | 16 | ||||
| -rw-r--r-- | internal/thumbnail/cache.go | 121 | ||||
| -rw-r--r-- | internal/thumbnail/cache_test.go | 197 | ||||
| -rw-r--r-- | internal/thumbnail/config.go | 10 | ||||
| -rw-r--r-- | internal/thumbnail/generator.go | 111 | ||||
| -rw-r--r-- | plan.md | 111 |
10 files changed, 990 insertions, 8 deletions
diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md new file mode 100644 index 0000000..63a831a --- /dev/null +++ b/MIGRATION_PLAN.md @@ -0,0 +1,294 @@ +# Yoga TUI to Fyne GUI Migration Plan + +## Overview +Convert the existing Yoga TUI application (built with Bubble Tea) to a Fyne-based GUI application while maintaining all existing features and adding video preview thumbnails. + +## Current Features +- Video browsing table (name, duration, age, tags) +- Multi-filter system (name substring, duration range, tag substring) +- Multi-column sorting (name, duration, age) +- VLC playback with optional crop aspect ratio +- Tag editing via JSON files per video +- Duration probing with ffprobe + caching +- Random video selection +- Re-index functionality +- Progress indicators + +## Package Structure + +### Existing (to be kept/reused) +``` +internal/fsutil/ # Path utilities +internal/meta/ # Version +internal/tags/ # Tag persistence +internal/app/ # Domain logic +├── video.go # Video struct (extend for thumbnails) +├── loader.go # Video loading (extend for thumbnails) +├── filters.go # Filter logic +├── model_sort.go # Sorting logic +├── duration_cache.go # Duration caching +├── tag_commands.go # Tag operations +└── model_tags.go # Tag editing +``` + +### New packages +``` +internal/gui/ # GUI-specific code +├── app.go # Fyne app lifecycle +├── main_window.go # Main window + layout +├── video_list.go # Video list widget +├── preview_panel.go # Video preview thumbnails +├── filter_dialog.go # Filter UI +├── tag_dialog.go # Tag editing UI +├── status_bar.go # Status display +└── thumbnail_cache.go # Preview image caching + +internal/thumbnail/ # Thumbnail generation +├── generator.go # ffmpeg-based thumbnail extraction +├── cache.go # Thumbnail file management +└── config.go # Thumbnail settings +``` + +### To be removed +``` +internal/app/ +├── app.go # Bubble Tea program +├── model.go # TUI model state +├── style.go # Terminal styling +├── messages.go # Bubble Tea messages +├── view_helpers.go # TUI rendering +├── model_keys.go # TUI key handlers +├── model_durations.go # Duration UI updates (adapt) +└── options.go # Keep but may need updates +``` + +## Data Model Extensions + +### internal/app/video.go +```go +type video struct { + Name string + Path string + Duration time.Duration + ModTime time.Time + Size int64 + Tags []string + Thumbnail string // Path to thumbnail image + ThumbnailGenerated bool // Generation status +} +``` + +## Phase 1: Foundation & Architecture + +### 1.1 Dependency Migration +- [ ] Add fyne.io/fyne/v2 to go.mod +- [ ] Remove charmbracelet/bubbletea dependencies +- [ ] Test basic Fyne application runs + +### 1.2 Package Structure Setup +- [ ] Create internal/gui/ package +- [ ] Create internal/thumbnail/ package +- [ ] Move reusable domain logic to proper packages + +### 1.3 Data Model Updates +- [ ] Add thumbnail fields to video struct +- [ ] Update video struct comments +- [ ] Ensure backward compatibility with existing tests + +## Phase 2: Thumbnail System + +### 2.1 Thumbnail Generation +- [ ] Implement internal/thumbnail/generator.go + - ffmpeg command to extract frame at 10% duration + - Output JPEG thumbnails (320x180 or auto-detect) + - Error handling for missing ffmpeg +- [ ] Unit tests for thumbnail generation logic +- [ ] Integration tests with mock ffmpeg + +### 2.2 Thumbnail Cache +- [ ] Implement internal/thumbnail/cache.go + - Check thumbnail existence before generation + - Cache metadata: .video_thumbnails.json + - ModTime detection for regeneration +- [ ] Unit tests for cache logic + +### 2.3 Integration with Video Loading +- [ ] Extend internal/app/loader.go to check/generate thumbnails +- [ ] Add background goroutine for thumbnail generation +- [ ] Update video loading tests + +## Phase 3: GUI Main Window + +### 3.1 Window Layout +- [ ] Implement internal/gui/app.go + - Fyne app initialization + - Window creation and lifecycle +- [ ] Implement internal/gui/main_window.go + - Split container (preview + list) + - Responsive layout +- [ ] Implement internal/gui/status_bar.go + - Status display widget + - Progress indicators + +### 3.2 Video List Widget +- [ ] Implement internal/gui/video_list.go + - Custom widget.List with thumbnails + - Click to select, double-click to play + - Highlight selected item +- [ ] Handle keyboard navigation +- [ ] Performance testing with large collections + +### 3.3 Thumbnail Preview Panel +- [ ] Implement internal/gui/preview_panel.go + - Large thumbnail display + - Video metadata labels + - Action buttons (Play, Edit Tags) + +## Phase 4: Interactive Components + +### 4.1 Filter Dialog +- [ ] Implement internal/gui/filter_dialog.go + - dialog.NewForm() with name, min/max duration, tags + - Real-time filter count + - Apply/Cancel buttons +- [ ] Reuse internal/app/filters.go logic + +### 4.2 Tag Editor Dialog +- [ ] Implement internal/gui/tag_dialog.go + - dialog.NewForm() with tag entry + - Current tags display + - Save/Cancel buttons +- [ ] Reuse internal/tags/ package + +### 4.3 Sort Options +- [ ] Implement sort menu + - Sort by Name/Duration/Age + - Ascending/Descending toggle +- [ ] Reuse internal/app/model_sort.go logic + +## Phase 5: Background Operations + +### 5.1 Goroutine Management +- [ ] Implement async video loading +- [ ] Implement async thumbnail generation +- [ ] Implement async duration probing +- [ ] Use Fyne's WorkerPool for CPU-bound tasks + +### 5.2 UI Updates from Background +- [ ] Implement callback pattern for UI updates +- [ ] Ensure all UI updates on main thread +- [ ] Handle cancellation on window close + +## Phase 6: Integration & Polish + +### 6.1 Keyboard Shortcuts +- [ ] Ctrl+F - Filter dialog +- [ ] Ctrl+T - Edit tags +- [ ] Ctrl+R - Random video +- [ ] Ctrl+I - Re-index +- [ ] Enter - Play selected +- [ ] Delete - Clear filters + +### 6.2 Window Management +- [ ] Save/restore window size +- [ ] Save/restore split pane position +- [ ] Use Fyne storage preferences API + +### 6.3 Error Handling +- [ ] Dialog boxes for missing dependencies +- [ ] Graceful degradation on thumbnail failures +- [ ] User-friendly error messages + +## Phase 7: Testing & Documentation + +### 7.1 Testing +- [ ] Unit tests for all new GUI components +- [ ] Integration tests for video loading with thumbnails +- [ ] Mock external commands (ffmpeg, ffprobe, vlc) +- [ ] Maintain 85%+ code coverage + +### 7.2 Documentation +- [ ] Update README.md with GUI usage +- [ ] Add GUI screenshots +- [ ] Document thumbnail cache location +- [ ] Update keyboard shortcuts documentation + +## Technical Considerations + +### Fyne-Specific Challenges +1. **Async UI Updates** - All UI updates must be on main thread +2. **Image Loading** - Lazy loading with LRU cache +3. **List Performance** - Widget.List is already virtualized +4. **Cross-Platform** - Detect ffmpeg/ffprobe/vlc at runtime + +### Code Reuse Strategy +**Keep 100% of:** +- internal/fsutil - Path utilities +- internal/meta - Version +- internal/tags - Tag persistence +- internal/app/loader.go - Video collection (extend) +- internal/app/filters.go - Filter logic +- internal/app/model_sort.go - Sorting logic +- internal/app/duration_cache.go - Duration caching + +**Remove 100% of:** +- All Bubble Tea code +- Terminal-specific styling (internal/app/style.go) +- TUI model state management + +**Adapt:** +- internal/app/video.go - Add thumbnail field +- internal/app/model_durations.go - Adapt for GUI updates +- internal/app/model_tags.go - Reuse tag commands +- cmd/yoga/main.go - Replace TUI init with Fyne app + +## Dependencies + +### New Dependencies +```go +require ( + fyne.io/fyne/v2 v2.4.0 +) +``` + +### System Requirements +- ffmpeg (for thumbnails) +- ffprobe (already used for duration) +- vlc (for playback) + +## Timeline + +- **Week 1:** Phase 1 - Foundation & Architecture +- **Week 2:** Phase 2 - Thumbnail System +- **Week 3:** Phase 3 - GUI Main Window +- **Week 4:** Phase 4 - Interactive Components +- **Week 5:** Phase 5 - Background Operations +- **Week 6:** Phase 6 - Integration & Polish +- **Week 7:** Phase 7 - Testing & Documentation + +## Build Commands + +```bash +# Build +mage build + +# Test +mage test + +# Coverage +mage coverage + +# Install +mage install +``` + +## Git Commit Milestones + +1. Phase 1 complete: Foundation and architecture +2. Phase 2 complete: Thumbnail system +3. Phase 3 complete: Main window +4. Phase 4 complete: Interactive components +5. Phase 5 complete: Background operations +6. Phase 6 complete: Integration and polish +7. Phase 7 complete: Testing and documentation +8. Final: Release-ready GUI application @@ -9,6 +9,7 @@ require ( ) require ( + fyne.io/fyne/v2 v2.7.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect @@ -26,5 +27,5 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/text v0.22.0 // indirect ) @@ -1,3 +1,5 @@ +fyne.io/fyne/v2 v2.7.2 h1:XiNpWkn0PzX43ZCjbb0QYGg1RCxVbugwfVgikWZBCMw= +fyne.io/fyne/v2 v2.7.2/go.mod h1:PXbqY3mQmJV3J1NRUR2VbVgUUx3vgvhuFJxyjRK/4Ug= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -49,3 +51,4 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 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") +} @@ -0,0 +1,111 @@ +# Migration Progress Tracker + +## Phase 1: Foundation & Architecture + +### 1.1 Dependency Migration +- [x] Add fyne.io/fyne/v2 to go.mod +- [ ] Remove Bubble Tea dependencies +- [ ] Test basic Fyne application runs + +### 1.2 Package Structure Setup +- [x] Create internal/gui/ package +- [x] Create internal/thumbnail/ package +- [ ] Move reusable domain logic to proper packages + +### 1.3 Data Model Updates +- [x] Add thumbnail fields to video struct +- [ ] Update video struct comments +- [ ] Ensure backward compatibility with existing tests + +## Phase 2: Thumbnail System + +### 2.1 Thumbnail Generation +- [x] Implement internal/thumbnail/generator.go + - ffmpeg command to extract frame at 10% duration + - Output JPEG thumbnails (320x180 or auto-detect) + - Error handling for missing ffmpeg +- [x] Unit tests for thumbnail generation logic +- [x] Integration tests with mock ffmpeg + +### 2.2 Thumbnail Cache +- [x] Implement internal/thumbnail/cache.go + - Check thumbnail existence before generation + - Cache metadata: .video_thumbnails.json + - ModTime detection for regeneration +- [x] Unit tests for cache logic + +### 2.3 Integration with Video Loading +- [x] Extend internal/app/loader.go to check/generate thumbnails +- [ ] Add background goroutine for thumbnail generation +- [ ] Update video loading tests + +## Phase 3: GUI Main Window + +### 3.1 Window Layout +- [ ] Implement internal/gui/app.go +- [ ] Implement internal/gui/main_window.go +- [ ] Implement internal/gui/status_bar.go + +### 3.2 Video List Widget +- [ ] Implement internal/gui/video_list.go +- [ ] Handle keyboard navigation +- [ ] Performance testing with large collections + +### 3.3 Thumbnail Preview Panel +- [ ] Implement internal/gui/preview_panel.go + +## Phase 4: Interactive Components + +### 4.1 Filter Dialog +- [ ] Implement internal/gui/filter_dialog.go + +### 4.2 Tag Editor Dialog +- [ ] Implement internal/gui/tag_dialog.go + +### 4.3 Sort Options +- [ ] Implement sort menu + +## Phase 5: Background Operations + +### 5.1 Goroutine Management +- [ ] Implement async video loading +- [ ] Implement async thumbnail generation +- [ ] Implement async duration probing +- [ ] Use Fyne's WorkerPool for CPU-bound tasks + +### 5.2 UI Updates from Background +- [ ] Implement callback pattern for UI updates +- [ ] Ensure all UI updates on main thread +- [ ] Handle cancellation on window close + +## Phase 6: Integration & Polish + +### 6.1 Keyboard Shortcuts +- [ ] Implement all keyboard shortcuts + +### 6.2 Window Management +- [ ] Save/restore window state + +### 6.3 Error Handling +- [ ] Dialog boxes for errors +- [ ] Graceful degradation + +## Phase 7: Testing & Documentation + +### 7.1 Testing +- [ ] Unit tests for all new GUI components +- [ ] Integration tests +- [ ] Maintain 85%+ code coverage + +### 7.2 Documentation +- [ ] Update README.md +- [ ] Add screenshots +- [ ] Document features + +## Completed Milestones + +**Phase 1.1-1.3:** Foundation and architecture - Added Fyne dependency, created package structure, extended video struct with thumbnail fields. + +**Phase 2.1-2.2:** Thumbnail system - Implemented thumbnail generator, cache, and tests. + +**Phase 2.3 (partial):** Integration - Created loader_gui.go for GUI video loading with thumbnail support. |
