diff options
30 files changed, 2614 insertions, 1327 deletions
diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f92a896 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Repository Guidelines + +## Code structur + +- Minimal entrace point, in ./cmd/yoga/main.go, all other code goes to the ./internal directory. + +## Coding Style & Naming Conventions + +- Avoid duplication of code when the functions are larger than 5 lines. +- If possible, construct individual methods so that they can be unit tested. But only if it doesn't add too much boilerplate to the code base. +- Aim for at least 85% unit test coverage of all source code. The command to check the coverage is "mage coverage" +- Ensure that all unit tests pass before commiting any changes. +- Always run the gofumpt code reformatter on all go files modified. +- There should be no source code file larger than 1000 lines. If so, split it up into multiple. +- There should be no function larger then 50 lines. If so, refactor or split up into multiple smaller functions. +- Code (when added): follow language idioms +- Any type with more than 3 methods should be in it's own source code file, whereas the filename contains the name of the type. + +## Incrementing version + +- Never draft a changelog entry +- Whenever incrementing the version, update the version number in the project, commit to git, tag the version and push to git. +- When a major feature was introduced, increment ?.X.? +- When only minor changes were done or only bugs were fixed, increment the version as ?.?.X + +## Documentation + +- Document in the README all options and basic behaviour and also how to use the Magefile. @@ -1,5 +1,48 @@ # Yoga -A yoga video selector. Fully vibe-coded. +Yoga is a TUI for browsing local yoga videos with quick filtering, duration probing, and one-key playback via VLC.  + +## Usage + +```bash +yoga [--root PATH] [--crop WxH] [--version] +``` + +- `--root` sets the directory to scan for videos. When omitted, Yoga uses `~/Yoga` and creates it on first launch. +- `--crop` supplies an optional VLC crop string (for example `5:4`). Toggle the crop at runtime with the `c` key. +- `--version` prints the current version and exits. + +Yoga recognises common video extensions (`.mp4`, `.mkv`, `.mov`, `.avi`, `.wmv`, `.m4v`) and follows symlinks when scanning. Duration metadata is cached per directory in `.video_duration_cache.json`. + +### Keyboard Shortcuts + +- `↑/↓` – Navigate the table +- `enter` – Play the selected video in VLC +- `/` or `f` – Open the filter dialog +- `r` – Reset filters +- `n`, `l`, `a` – Sort by name, length, or age +- `c` – Toggle VLC crop +- `q` – Quit + +## Development + +The project uses [Mage](https://magefile.org/) for common tasks. Targets live in `magefile.go`. + +```bash +mage build # go build ./cmd/yoga +mage test # go test ./... +mage install # go install ./cmd/yoga +mage coverage # go test with coverage (fails if <85%) +``` + +Before sending changes: + +1. Format Go code with `gofumpt`. +2. Run `mage test` and `mage coverage` to ensure the suite passes and coverage stays above 85%. +3. Update documentation when flags or behaviour change. + +## Licensing + +This repository is released under the terms specified in the accompanying license file (if present). diff --git a/cmd/yoga/main.go b/cmd/yoga/main.go new file mode 100644 index 0000000..fbf11bd --- /dev/null +++ b/cmd/yoga/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "strings" + + "yoga/internal/app" + "yoga/internal/fsutil" + "yoga/internal/meta" +) + +const defaultRoot = "~/Yoga" + +var ( + runApp = app.Run + exit = os.Exit +) + +func main() { + exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("yoga", flag.ContinueOnError) + fs.SetOutput(stderr) + rootFlag := fs.String("root", "", "Directory containing yoga videos (default ~/Yoga)") + cropFlag := fs.String("crop", "", "Optional crop aspect for VLC (e.g. 5:4)") + versionFlag := fs.Bool("version", false, "Print version and exit") + if err := fs.Parse(args); err != nil { + return 2 + } + if *versionFlag { + fmt.Fprintf(stdout, "Yoga version %s\n", meta.Version) + return 0 + } + root, err := fsutil.ResolveRootPath(*rootFlag, defaultRoot) + if err != nil { + fmt.Fprintf(stderr, "%v\n", err) + return 1 + } + opts := app.Options{Root: root, Crop: strings.TrimSpace(*cropFlag)} + if err := runApp(opts); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 +} diff --git a/cmd/yoga/main_test.go b/cmd/yoga/main_test.go new file mode 100644 index 0000000..d06ff33 --- /dev/null +++ b/cmd/yoga/main_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "yoga/internal/app" +) + +func TestRunPrintsVersion(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"--version"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d", code) + } + if !bytes.Contains(stdout.Bytes(), []byte("Yoga version")) { + t.Fatalf("expected version output, got %s", stdout.String()) + } +} + +func TestRunSuccess(t *testing.T) { + var stdout, stderr bytes.Buffer + root := t.TempDir() + orig := runApp + runApp = func(opts app.Options) error { return nil } + defer func() { runApp = orig }() + code := run([]string{"--root", root}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d", code) + } +} + +func TestRunAppError(t *testing.T) { + var stdout, stderr bytes.Buffer + root := t.TempDir() + orig := runApp + runApp = func(app.Options) error { return errors.New("boom") } + defer func() { runApp = orig }() + code := run([]string{"--root", root}, &stdout, &stderr) + if code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } + if !bytes.Contains(stderr.Bytes(), []byte("error:")) { + t.Fatalf("expected error output, got %s", stderr.String()) + } +} + +func TestRunDefaultRootCreated(t *testing.T) { + var stdout, stderr bytes.Buffer + home := t.TempDir() + t.Setenv("HOME", home) + orig := runApp + runApp = func(opts app.Options) error { + if _, err := os.Stat(filepath.Join(home, "Yoga")); err != nil { + t.Fatalf("expected default directory: %v", err) + } + return nil + } + defer func() { runApp = orig }() + code := run(nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d", code) + } +} + +func TestMainUsesExit(t *testing.T) { + root := t.TempDir() + origRun := runApp + origExit := exit + runApp = func(opts app.Options) error { return nil } + var code int + exit = func(c int) { code = c } + defer func() { + runApp = origRun + exit = origExit + }() + os.Args = []string{"yoga", "--root", root} + main() + if code != 0 { + t.Fatalf("expected exit code 0, got %d", code) + } +} 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 |
