diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/app/app.go | 28 | ||||
| -rw-r--r-- | internal/app/app_test.go | 38 | ||||
| -rw-r--r-- | internal/app/duration_cache.go | 104 | ||||
| -rw-r--r-- | internal/app/duration_cache_test.go | 76 | ||||
| -rw-r--r-- | internal/app/filters.go | 146 | ||||
| -rw-r--r-- | internal/app/filters_test.go | 35 | ||||
| -rw-r--r-- | internal/app/load_progress.go | 57 | ||||
| -rw-r--r-- | internal/app/load_progress_test.go | 25 | ||||
| -rw-r--r-- | internal/app/loader.go | 241 | ||||
| -rw-r--r-- | internal/app/loader_test.go | 162 | ||||
| -rw-r--r-- | internal/app/messages.go | 28 | ||||
| -rw-r--r-- | internal/app/model.go | 198 | ||||
| -rw-r--r-- | internal/app/model_durations.go | 181 | ||||
| -rw-r--r-- | internal/app/model_keys.go | 138 | ||||
| -rw-r--r-- | internal/app/model_sort.go | 58 | ||||
| -rw-r--r-- | internal/app/model_test.go | 482 | ||||
| -rw-r--r-- | internal/app/options.go | 7 | ||||
| -rw-r--r-- | internal/app/style.go | 19 | ||||
| -rw-r--r-- | internal/app/video.go | 12 | ||||
| -rw-r--r-- | internal/app/view_helpers.go | 80 | ||||
| -rw-r--r-- | internal/fsutil/path.go | 99 | ||||
| -rw-r--r-- | internal/fsutil/path_test.go | 112 | ||||
| -rw-r--r-- | internal/meta/version.go | 3 |
23 files changed, 2329 insertions, 0 deletions
diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..ca70f3c --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,28 @@ +package app + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +type teaProgram interface { + Run() (tea.Model, error) +} + +var programFactory = func(m tea.Model) teaProgram { + return tea.NewProgram(m, tea.WithAltScreen()) +} + +// Run bootstraps the Bubble Tea program with the provided options. +func Run(opts Options) error { + model, err := newModel(opts) + if err != nil { + return fmt.Errorf("create model: %w", err) + } + program := programFactory(model) + if _, err := program.Run(); err != nil { + return fmt.Errorf("run program: %w", err) + } + return nil +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..ec96dbd --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,38 @@ +package app + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +type stubProgram struct { + err error +} + +func (s stubProgram) Run() (tea.Model, error) { + return nil, s.err +} + +func TestRunInvokesProgram(t *testing.T) { + t.Helper() + original := programFactory + defer func() { programFactory = original }() + programFactory = func(tea.Model) teaProgram { return stubProgram{} } + if err := Run(Options{Root: t.TempDir()}); err != nil { + t.Fatalf("Run returned error: %v", err) + } +} + +func TestRunPropagatesError(t *testing.T) { + t.Helper() + original := programFactory + defer func() { programFactory = original }() + errRun := errors.New("boom") + programFactory = func(tea.Model) teaProgram { return stubProgram{err: errRun} } + err := Run(Options{Root: t.TempDir()}) + if !errors.Is(err, errRun) { + t.Fatalf("expected error propagation, got %v", err) + } +} diff --git a/internal/app/duration_cache.go b/internal/app/duration_cache.go new file mode 100644 index 0000000..43172b5 --- /dev/null +++ b/internal/app/duration_cache.go @@ -0,0 +1,104 @@ +package app + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "sync" + "time" +) + +type cacheEntry struct { + DurationSeconds float64 `json:"duration_seconds"` + ModTimeUnix int64 `json:"mod_time_unix"` + Size int64 `json:"size"` +} + +type durationCache struct { + path string + entries map[string]cacheEntry + mu sync.Mutex + dirty bool +} + +func newDurationCache(path string) *durationCache { + return &durationCache{path: path, entries: make(map[string]cacheEntry)} +} + +func loadDurationCache(path string) (*durationCache, error) { + cache := newDurationCache(path) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return cache, nil + } + return cache, err + } + if len(data) == 0 { + return cache, nil + } + if err := json.Unmarshal(data, &cache.entries); err != nil { + cache.entries = make(map[string]cacheEntry) + return cache, err + } + return cache, nil +} + +func (c *durationCache) Lookup(path string, info os.FileInfo) (time.Duration, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.entries[path] + if !ok { + return 0, false + } + if entry.ModTimeUnix != info.ModTime().Unix() || entry.Size != info.Size() { + delete(c.entries, path) + c.dirty = true + return 0, false + } + if entry.DurationSeconds <= 0 { + return 0, false + } + return time.Duration(entry.DurationSeconds * float64(time.Second)), true +} + +func (c *durationCache) Record(path string, info os.FileInfo, dur time.Duration) error { + if c == nil || dur <= 0 { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil { + c.entries = make(map[string]cacheEntry) + } + c.entries[path] = cacheEntry{ + DurationSeconds: dur.Seconds(), + ModTimeUnix: info.ModTime().Unix(), + Size: info.Size(), + } + c.dirty = true + return nil +} + +func (c *durationCache) Flush() error { + if c == nil { + return nil + } + c.mu.Lock() + if !c.dirty { + c.mu.Unlock() + return nil + } + snapshot := make(map[string]cacheEntry, len(c.entries)) + for k, v := range c.entries { + snapshot[k] = v + } + c.dirty = false + c.mu.Unlock() + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return err + } + return os.WriteFile(c.path, data, 0o644) +} diff --git a/internal/app/duration_cache_test.go b/internal/app/duration_cache_test.go new file mode 100644 index 0000000..3830277 --- /dev/null +++ b/internal/app/duration_cache_test.go @@ -0,0 +1,76 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestDurationCacheRecordLifecycle(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "cache.json") + cache, err := loadDurationCache(cachePath) + if err != nil { + t.Fatalf("load cache: %v", err) + } + video := filepath.Join(dir, "video.mp4") + if err := os.WriteFile(video, []byte("x"), 0o644); err != nil { + t.Fatalf("write video: %v", err) + } + info, err := os.Stat(video) + if err != nil { + t.Fatalf("stat video: %v", err) + } + duration := 90 * time.Second + if err := cache.Record(video, info, duration); err != nil { + t.Fatalf("record: %v", err) + } + if err := cache.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + cache2, err := loadDurationCache(cachePath) + if err != nil { + t.Fatalf("reload: %v", err) + } + dur, ok := cache2.Lookup(video, info) + if !ok { + t.Fatalf("expected cached entry") + } + if dur != duration { + t.Fatalf("expected %v, got %v", duration, dur) + } +} + +func TestDurationCacheInvalidatesOnChange(t *testing.T) { + dir := t.TempDir() + cache := newDurationCache(filepath.Join(dir, "cache.json")) + video := filepath.Join(dir, "video.mp4") + if err := os.WriteFile(video, []byte("x"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + info, _ := os.Stat(video) + _ = cache.Record(video, info, 30*time.Second) + if err := os.WriteFile(video, []byte("xx"), 0o644); err != nil { + t.Fatalf("rewrite: %v", err) + } + info, _ = os.Stat(video) + if dur, ok := cache.Lookup(video, info); ok || dur != 0 { + t.Fatalf("expected cache miss after change") + } +} + +func TestLoadDurationCacheInvalidJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.json") + if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil { + t.Fatalf("write cache: %v", err) + } + cache, err := loadDurationCache(path) + if err == nil { + t.Fatalf("expected error for invalid json") + } + if len(cache.entries) != 0 { + t.Fatalf("expected cache to reset entries") + } +} diff --git a/internal/app/filters.go b/internal/app/filters.go new file mode 100644 index 0000000..691be41 --- /dev/null +++ b/internal/app/filters.go @@ -0,0 +1,146 @@ +package app + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type filterState struct { + name string + minEnabled bool + minMinutes int + maxEnabled bool + maxMinutes int +} + +type filterInputs struct { + fields []textinput.Model + focus int +} + +func (m *model) applyFilterInputs() error { + name := strings.TrimSpace(m.inputs.fields[0].Value()) + minText := strings.TrimSpace(m.inputs.fields[1].Value()) + maxText := strings.TrimSpace(m.inputs.fields[2].Value()) + + filters := filterState{name: name} + if err := populateMinFilter(&filters, minText); err != nil { + return err + } + if err := populateMaxFilter(&filters, maxText); err != nil { + return err + } + if filters.minEnabled && filters.maxEnabled && filters.minMinutes > filters.maxMinutes { + return errors.New("min minutes cannot exceed max minutes") + } + m.filters = filters + return nil +} + +func populateMinFilter(dst *filterState, value string) error { + if value == "" { + return nil + } + minutes, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid min minutes: %q", value) + } + if minutes < 0 { + return errors.New("min minutes must be positive") + } + dst.minEnabled = true + dst.minMinutes = minutes + return nil +} + +func populateMaxFilter(dst *filterState, value string) error { + if value == "" { + return nil + } + minutes, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid max minutes: %q", value) + } + if minutes < 0 { + return errors.New("max minutes must be positive") + } + dst.maxEnabled = true + dst.maxMinutes = minutes + return nil +} + +func (m *model) resetFilters() { + m.filters = filterState{} + for i := range m.inputs.fields { + m.inputs.fields[i].SetValue("") + } +} + +func (m *model) updateFilterInputs(msg tea.Msg) (filterInputs, tea.Cmd) { + inputs := m.inputs + var cmds []tea.Cmd + for i := range inputs.fields { + var cmd tea.Cmd + inputs.fields[i], cmd = inputs.fields[i].Update(msg) + cmds = append(cmds, cmd) + } + return inputs, tea.Batch(cmds...) +} + +func (m model) describeFilters() string { + parts := []string{} + if m.filters.name != "" { + parts = append(parts, fmt.Sprintf("name contains %q", m.filters.name)) + } + if m.filters.minEnabled { + parts = append(parts, fmt.Sprintf(">=%d min", m.filters.minMinutes)) + } + if m.filters.maxEnabled { + parts = append(parts, fmt.Sprintf("<=%d min", m.filters.maxMinutes)) + } + if len(parts) == 0 { + return "(none)" + } + return strings.Join(parts, ", ") +} + +func (m *model) passesFilters(v video) bool { + if m.filters.name != "" && !strings.Contains(strings.ToLower(v.Name), strings.ToLower(m.filters.name)) { + return false + } + durMinutes := int(v.Duration.Round(time.Minute) / time.Minute) + if m.filters.minEnabled && (v.Duration == 0 || durMinutes < m.filters.minMinutes) { + return false + } + if m.filters.maxEnabled && (v.Duration == 0 || durMinutes > m.filters.maxMinutes) { + return false + } + return true +} + +func (m *model) renderFilterModal() string { + var b strings.Builder + b.WriteString("Filter videos\n") + b.WriteString("(Enter to apply, Esc to cancel)\n\n") + labels := []string{"Name contains:", "Min length (minutes):", "Max length (minutes):"} + for i, field := range m.inputs.fields { + line := fmt.Sprintf("%s %s", labels[i], field.View()) + if i == m.inputs.focus { + line = highlightStyle.Render(line) + } + b.WriteString(line) + b.WriteString("\n") + } + if m.filters.minEnabled || m.filters.maxEnabled || m.filters.name != "" { + b.WriteString("\nCurrent filter: ") + b.WriteString(m.describeFilters()) + b.WriteString("\n") + } + return filterStyle.Render(b.String()) +} diff --git a/internal/app/filters_test.go b/internal/app/filters_test.go new file mode 100644 index 0000000..10eed13 --- /dev/null +++ b/internal/app/filters_test.go @@ -0,0 +1,35 @@ +package app + +import "testing" + +func TestPopulateMinFilterErrors(t *testing.T) { + var state filterState + if err := populateMinFilter(&state, "-1"); err == nil { + t.Fatal("expected error for negative minutes") + } + if err := populateMinFilter(&state, "abc"); err == nil { + t.Fatal("expected error for invalid integer") + } + if err := populateMinFilter(&state, "10"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !state.minEnabled || state.minMinutes != 10 { + t.Fatalf("expected state updated, got %+v", state) + } +} + +func TestPopulateMaxFilterErrors(t *testing.T) { + var state filterState + if err := populateMaxFilter(&state, "-1"); err == nil { + t.Fatal("expected error for negative minutes") + } + if err := populateMaxFilter(&state, "abc"); err == nil { + t.Fatal("expected error for invalid integer") + } + if err := populateMaxFilter(&state, "20"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !state.maxEnabled || state.maxMinutes != 20 { + t.Fatalf("expected state updated, got %+v", state) + } +} diff --git a/internal/app/load_progress.go b/internal/app/load_progress.go new file mode 100644 index 0000000..38679fa --- /dev/null +++ b/internal/app/load_progress.go @@ -0,0 +1,57 @@ +package app + +import "sync" + +type loadProgress struct { + mu sync.Mutex + total int + processed int + done bool +} + +func (p *loadProgress) Reset() { + if p == nil { + return + } + p.mu.Lock() + p.total = 0 + p.processed = 0 + p.done = false + p.mu.Unlock() +} + +func (p *loadProgress) SetTotal(total int) { + if p == nil { + return + } + p.mu.Lock() + p.total = total + p.mu.Unlock() +} + +func (p *loadProgress) Increment() { + if p == nil { + return + } + p.mu.Lock() + p.processed++ + p.mu.Unlock() +} + +func (p *loadProgress) MarkDone() { + if p == nil { + return + } + p.mu.Lock() + p.done = true + p.mu.Unlock() +} + +func (p *loadProgress) Snapshot() (processed, total int, done bool) { + if p == nil { + return 0, 0, true + } + p.mu.Lock() + defer p.mu.Unlock() + return p.processed, p.total, p.done +} diff --git a/internal/app/load_progress_test.go b/internal/app/load_progress_test.go new file mode 100644 index 0000000..c46636d --- /dev/null +++ b/internal/app/load_progress_test.go @@ -0,0 +1,25 @@ +package app + +import "testing" + +func TestLoadProgressLifecycle(t *testing.T) { + var progress loadProgress + progress.SetTotal(5) + for i := 0; i < 3; i++ { + progress.Increment() + } + processed, total, done := progress.Snapshot() + if processed != 3 || total != 5 || done { + t.Fatalf("unexpected snapshot %d/%d done=%v", processed, total, done) + } + progress.MarkDone() + _, _, done = progress.Snapshot() + if !done { + t.Fatal("expected done") + } + progress.Reset() + processed, total, done = progress.Snapshot() + if processed != 0 || total != 0 || done { + t.Fatalf("expected reset to zero, got %d/%d done=%v", processed, total, done) + } +} diff --git a/internal/app/loader.go b/internal/app/loader.go new file mode 100644 index 0000000..37c8c94 --- /dev/null +++ b/internal/app/loader.go @@ -0,0 +1,241 @@ +package app + +import ( + "context" + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func loadVideosCmd(root, cachePath string, progress *loadProgress) tea.Cmd { + return func() tea.Msg { + cache, cacheErr := loadDurationCache(cachePath) + videos, pending, err := loadVideos(root, cache, progress) + if progress != nil { + progress.MarkDone() + } + return videosLoadedMsg{videos: videos, err: err, cacheErr: cacheErr, pending: pending, cache: cache} + } +} + +func progressTickerCmd(progress *loadProgress) tea.Cmd { + if progress == nil { + return nil + } + return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg { + processed, total, done := progress.Snapshot() + return progressUpdateMsg{processed: processed, total: total, done: done} + }) +} + +func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error) { + paths, err := collectVideoPaths(root) + if err != nil { + return nil, nil, err + } + if progress != nil { + progress.SetTotal(len(paths)) + } + videos := make([]video, 0, len(paths)) + pending := make([]string, 0) + for _, path := range paths { + info, statErr := os.Stat(path) + if statErr != nil { + videos = append(videos, video{Name: filepath.Base(path), Path: path, Err: statErr}) + increment(progress) + continue + } + dur := cachedDuration(cache, path, info) + if dur == 0 { + pending = append(pending, path) + } + videos = append(videos, video{ + Name: filepath.Base(path), + Path: path, + Duration: dur, + ModTime: info.ModTime(), + Size: info.Size(), + }) + increment(progress) + } + return videos, pending, nil +} + +func increment(progress *loadProgress) { + if progress != nil { + progress.Increment() + } +} + +func cachedDuration(cache *durationCache, path string, info os.FileInfo) time.Duration { + if cache == nil { + return 0 + } + dur, ok := cache.Lookup(path, info) + if !ok { + return 0 + } + return dur +} + +func collectVideoPaths(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 + } + visited := make(map[string]struct{}) + var paths []string + if err := traverseVideoPaths(root, root, visited, &paths); err != nil { + return nil, err + } + sort.Strings(paths) + return paths, nil +} + +func traverseVideoPaths(displayPath, realPath string, visited map[string]struct{}, acc *[]string) error { + resolved, err := filepath.EvalSymlinks(realPath) + if err != nil { + resolved = realPath + } + resolved = filepath.Clean(resolved) + if _, seen := visited[resolved]; seen { + return nil + } + visited[resolved] = struct{}{} + + entries, err := os.ReadDir(resolved) + if err != nil { + return err + } + for _, entry := range entries { + displayChild := filepath.Join(displayPath, entry.Name()) + realChild := filepath.Join(resolved, entry.Name()) + mode := entry.Type() + var info os.FileInfo + if mode == fs.FileMode(0) { + info, err = entry.Info() + if err != nil { + return err + } + mode = info.Mode() + } + if mode&os.ModeSymlink != 0 { + if err := handleSymlink(displayChild, realChild, visited, acc); err != nil { + return err + } + continue + } + if mode.IsDir() { + if err := traverseVideoPaths(displayChild, realChild, visited, acc); err != nil { + return err + } + continue + } + if isVideo(displayChild) { + *acc = append(*acc, displayChild) + } + } + return nil +} + +func handleSymlink(displayChild, realChild string, visited map[string]struct{}, acc *[]string) error { + targetPath, err := filepath.EvalSymlinks(realChild) + if err != nil { + return recordIfVideo(displayChild, acc) + } + targetInfo, err := os.Stat(targetPath) + if err != nil { + return recordIfVideo(displayChild, acc) + } + if targetInfo.IsDir() { + return traverseVideoPaths(displayChild, targetPath, visited, acc) + } + if isVideo(displayChild) || isVideo(targetPath) { + *acc = append(*acc, displayChild) + } + return nil +} + +func recordIfVideo(path string, acc *[]string) error { + if isVideo(path) { + *acc = append(*acc, path) + } + return nil +} + +func probeDurationsCmd(path string, cache *durationCache) tea.Cmd { + return func() tea.Msg { + dur, err := probeDuration(path) + if err == nil && cache != nil { + if info, statErr := os.Stat(path); statErr == nil { + _ = cache.Record(path, info, dur) + } + } + return durationUpdateMsg{path: path, duration: dur, err: err} + } +} + +func probeDuration(path string) (time.Duration, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path) + out, err := cmd.Output() + if err != nil { + return 0, err + } + raw := strings.TrimSpace(string(out)) + if raw == "" { + return 0, errors.New("empty duration") + } + seconds, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, err + } + return time.Duration(seconds * float64(time.Second)), nil +} + +func playVideoCmd(path, crop string) tea.Cmd { + return func() tea.Msg { + args := buildVLCArgs(path, crop) + cmd := exec.Command("vlc", args...) + if err := cmd.Start(); err != nil { + return playVideoMsg{path: path, err: err} + } + go func() { _ = cmd.Wait() }() + return playVideoMsg{path: path} + } +} + +func buildVLCArgs(path, crop string) []string { + args := []string{} + if crop != "" { + args = append(args, "--crop", crop) + } + return append(args, path) +} + +func isVideo(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + _, ok := videoExtensions[ext] + return ok +} + +// CollectVideoPathsForTest exposes collectVideoPaths for unit testing. +func CollectVideoPathsForTest(root string) ([]string, error) { + return collectVideoPaths(root) +} diff --git a/internal/app/loader_test.go b/internal/app/loader_test.go new file mode 100644 index 0000000..538bca0 --- /dev/null +++ b/internal/app/loader_test.go @@ -0,0 +1,162 @@ +package app + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestCollectVideoPathsDetectsMP4(t *testing.T) { + dir := t.TempDir() + lower := filepath.Join(dir, "video.mp4") + upper := filepath.Join(dir, "UPPER.MP4") + for _, path := range []string{lower, upper} { + if err := os.WriteFile(path, []byte("dummy"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + paths, err := CollectVideoPathsForTest(dir) + if err != nil { + t.Fatalf("collect paths: %v", err) + } + if len(paths) != 2 { + t.Fatalf("expected 2 paths, got %d", len(paths)) + } + want := map[string]struct{}{lower: {}, upper: {}} + for _, got := range paths { + if _, ok := want[got]; !ok { + t.Fatalf("unexpected path %s", got) + } + } +} + +func TestCollectVideoPathsFollowsSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink permissions vary on Windows") + } + root := t.TempDir() + storage := t.TempDir() + video := filepath.Join(storage, "movie.mp4") + if err := os.WriteFile(video, []byte("dummy"), 0o644); err != nil { + t.Fatalf("write video: %v", err) + } + link := filepath.Join(root, "videos") + if err := os.Symlink(storage, link); err != nil { + t.Skipf("symlink not supported: %v", err) + } + paths, err := CollectVideoPathsForTest(root) + if err != nil { + t.Fatalf("collect paths: %v", err) + } + expected := filepath.Join(link, "movie.mp4") + if len(paths) != 1 || paths[0] != expected { + t.Fatalf("expected %s, got %v", expected, paths) + } +} + +func TestLoadVideosWithCache(t *testing.T) { + dir := t.TempDir() + video := filepath.Join(dir, "video.mp4") + if err := os.WriteFile(video, []byte("dummy"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + cache := newDurationCache(filepath.Join(dir, "cache.json")) + info, err := os.Stat(video) + if err != nil { + t.Fatalf("stat: %v", err) + } + _ = cache.Record(video, info, time.Minute) + progress := &loadProgress{} + progress.Reset() + videos, pending, err := loadVideos(dir, cache, progress) + if err != nil { + t.Fatalf("loadVideos: %v", err) + } + if len(videos) != 1 || len(pending) != 0 { + t.Fatalf("expected cached video without pending: videos=%d pending=%d", len(videos), len(pending)) + } + if videos[0].Duration != time.Minute { + t.Fatalf("expected cached duration") + } +} + +func TestProbeDurationsCmdHandlesMissingBinary(t *testing.T) { + cmd := probeDurationsCmd("/no/such/file.mp4", nil) + msg := cmd() + update := msg.(durationUpdateMsg) + if update.err == nil { + t.Fatalf("expected error from ffprobe") + } +} + +func TestProbeDurationSuccess(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "ffprobe") + if err := os.WriteFile(script, []byte("#!/bin/sh\necho 5\n"), 0o755); err != nil { + t.Fatalf("write script: %v", err) + } + oldPath := os.Getenv("PATH") + t.Setenv("PATH", dir+":"+oldPath) + dur, err := probeDuration("dummy.mp4") + if err != nil { + t.Fatalf("probeDuration: %v", err) + } + if dur != 5*time.Second { + t.Fatalf("expected 5s duration, got %v", dur) + } +} + +func TestPlayVideoCmdMissingBinary(t *testing.T) { + cmd := playVideoCmd("/no/such/file.mp4", "") + msg := cmd() + result := msg.(playVideoMsg) + if result.path != "/no/such/file.mp4" { + t.Fatalf("unexpected path %s", result.path) + } +} + +func TestRecordIfVideo(t *testing.T) { + var acc []string + if err := recordIfVideo("test.mp4", &acc); err != nil { + t.Fatalf("recordIfVideo: %v", err) + } + if len(acc) != 1 { + t.Fatalf("expected video recorded") + } +} + +func TestHandleSymlinkBrokenVideo(t *testing.T) { + dir := t.TempDir() + symlink := filepath.Join(dir, "clip.mp4") + target := filepath.Join(dir, "missing.mp4") + if err := os.Symlink(target, symlink); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + var acc []string + if err := handleSymlink(symlink, symlink, map[string]struct{}{}, &acc); err != nil { + t.Fatalf("handleSymlink: %v", err) + } + if len(acc) != 1 { + t.Fatalf("expected symlink video recorded") + } +} + +func TestLoadVideosHandlesStatError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink permissions vary on Windows") + } + dir := t.TempDir() + broken := filepath.Join(dir, "broken.mp4") + if err := os.Symlink(filepath.Join(dir, "missing.mp4"), broken); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + videos, _, err := loadVideos(dir, nil, nil) + if err != nil { + t.Fatalf("loadVideos: %v", err) + } + if len(videos) != 1 || videos[0].Err == nil { + t.Fatalf("expected stat error recorded, got %+v", videos) + } +} diff --git a/internal/app/messages.go b/internal/app/messages.go new file mode 100644 index 0000000..a905263 --- /dev/null +++ b/internal/app/messages.go @@ -0,0 +1,28 @@ +package app + +import "time" + +type videosLoadedMsg struct { + videos []video + err error + cacheErr error + pending []string + cache *durationCache +} + +type playVideoMsg struct { + path string + err error +} + +type progressUpdateMsg struct { + processed int + total int + done bool +} + +type durationUpdateMsg struct { + path string + duration time.Duration + err error +} diff --git a/internal/app/model.go b/internal/app/model.go new file mode 100644 index 0000000..8cdcddc --- /dev/null +++ b/internal/app/model.go @@ -0,0 +1,198 @@ +package app + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type sortField int + +const ( + sortByName sortField = iota + sortByDuration + sortByAge +) + +type model struct { + table table.Model + videos []video + filtered []video + filters filterState + inputs filterInputs + showFilters bool + sortField sortField + sortAscending bool + statusMessage string + loading bool + err error + root string + progress *loadProgress + cachePath string + cache *durationCache + pendingDurations []string + durationTotal int + durationDone int + durationInFlight int + cropValue string + cropEnabled bool +} + +func newModel(opts Options) (model, error) { + tbl := buildTable() + inputs := buildFilterInputs() + inputs.fields[0].Focus() + + progress := &loadProgress{} + cachePath := filepath.Join(opts.Root, ".video_duration_cache.json") + + return model{ + table: tbl, + inputs: inputs, + sortField: sortByName, + sortAscending: true, + statusMessage: "Scanning for videos...", + loading: true, + root: opts.Root, + progress: progress, + cachePath: cachePath, + cropValue: opts.Crop, + cropEnabled: opts.Crop != "", + }, nil +} + +func buildTable() table.Model { + columns := []table.Column{ + {Title: headerStyle.Render("Name"), Width: 50}, + {Title: headerStyle.Render("Duration"), Width: 12}, + {Title: headerStyle.Render("Age"), Width: 14}, + {Title: headerStyle.Render("Path"), Width: 40}, + } + tbl := table.New( + table.WithColumns(columns), |
