summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-01-28 19:04:38 +0200
committerPaul Buetow <paul@buetow.org>2026-01-28 19:04:38 +0200
commit1720b2ebb12d8fdbdd372665e9d5fd394d8bc8dc (patch)
treeefd40ab63a3e305dcf712e4f753d3f7a57435b48
parentd52cb9a8c3dedc2a5395f181981805f582d5a772 (diff)
Add random video selection hotkey (x)
- Add selectRandomVideo() method to randomly select from filtered videos - Bind to 'x' hotkey in table key handler - Respects current filters when selecting - Add comprehensive tests covering edge cases and integration scenarios - Update README with new keyboard shortcut documentation Amp-Thread-ID: https://ampcode.com/threads/T-019c058b-410d-7229-9a95-afa8eb13efce Co-authored-by: Amp <amp@ampcode.com>
-rw-r--r--README.md3
-rw-r--r--coverage.html2009
-rw-r--r--coverage.out582
-rw-r--r--internal/app/model_keys.go15
-rw-r--r--internal/app/model_test.go489
-rwxr-xr-xyogabin0 -> 5758165 bytes
6 files changed, 3097 insertions, 1 deletions
diff --git a/README.md b/README.md
index b6a5f4d..84baf3f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Yoga
-Yoga is a TUI for browsing local yoga videos with quick filtering, duration probing, and one-key playback via VLC. It has ben vibe-coded.
+Yoga is a TUI for browsing local yoga videos with quick filtering, duration probing, and one-key playback via VLC. It has been vibe coded.
![Yoga](yoga.png)
@@ -26,6 +26,7 @@ Yoga recognises common video extensions (`.mp4`, `.mkv`, `.mov`, `.avi`, `.wmv`,
- `n`, `l`, `a` – Sort by name, length, or age
- `c` – Toggle VLC crop
- `t` – Edit tags for the selected video
+- `x` – Select a random video from filtered results
- `H` / `h` – Hide or re-show the help footer
- `q` – Quit
diff --git a/coverage.html b/coverage.html
new file mode 100644
index 0000000..a129207
--- /dev/null
+++ b/coverage.html
@@ -0,0 +1,2009 @@
+
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <title>yoga: Go Coverage Report</title>
+ <style>
+ body {
+ background: black;
+ color: rgb(80, 80, 80);
+ }
+ body, pre, #legend span {
+ font-family: Menlo, monospace;
+ font-weight: bold;
+ }
+ #topbar {
+ background: black;
+ position: fixed;
+ top: 0; left: 0; right: 0;
+ height: 42px;
+ border-bottom: 1px solid rgb(80, 80, 80);
+ }
+ #content {
+ margin-top: 50px;
+ }
+ #nav, #legend {
+ float: left;
+ margin-left: 10px;
+ }
+ #legend {
+ margin-top: 12px;
+ }
+ #nav {
+ margin-top: 10px;
+ }
+ #legend span {
+ margin: 0 5px;
+ }
+ .cov0 { color: rgb(192, 0, 0) }
+.cov1 { color: rgb(128, 128, 128) }
+.cov2 { color: rgb(116, 140, 131) }
+.cov3 { color: rgb(104, 152, 134) }
+.cov4 { color: rgb(92, 164, 137) }
+.cov5 { color: rgb(80, 176, 140) }
+.cov6 { color: rgb(68, 188, 143) }
+.cov7 { color: rgb(56, 200, 146) }
+.cov8 { color: rgb(44, 212, 149) }
+.cov9 { color: rgb(32, 224, 152) }
+.cov10 { color: rgb(20, 236, 155) }
+
+ </style>
+ </head>
+ <body>
+ <div id="topbar">
+ <div id="nav">
+ <select id="files">
+
+ <option value="file0">codeberg.org/snonux/yoga/cmd/yoga/main.go (85.0%)</option>
+
+ <option value="file1">codeberg.org/snonux/yoga/internal/app/app.go (75.0%)</option>
+
+ <option value="file2">codeberg.org/snonux/yoga/internal/app/duration_cache.go (85.7%)</option>
+
+ <option value="file3">codeberg.org/snonux/yoga/internal/app/filters.go (92.0%)</option>
+
+ <option value="file4">codeberg.org/snonux/yoga/internal/app/load_progress.go (81.5%)</option>
+
+ <option value="file5">codeberg.org/snonux/yoga/internal/app/loader.go (81.2%)</option>
+
+ <option value="file6">codeberg.org/snonux/yoga/internal/app/model.go (83.3%)</option>
+
+ <option value="file7">codeberg.org/snonux/yoga/internal/app/model_durations.go (87.5%)</option>
+
+ <option value="file8">codeberg.org/snonux/yoga/internal/app/model_keys.go (78.2%)</option>
+
+ <option value="file9">codeberg.org/snonux/yoga/internal/app/model_sort.go (92.6%)</option>
+
+ <option value="file10">codeberg.org/snonux/yoga/internal/app/model_tags.go (64.4%)</option>
+
+ <option value="file11">codeberg.org/snonux/yoga/internal/app/tag_commands.go (75.0%)</option>
+
+ <option value="file12">codeberg.org/snonux/yoga/internal/app/view_helpers.go (84.4%)</option>
+
+ <option value="file13">codeberg.org/snonux/yoga/internal/fsutil/path.go (83.0%)</option>
+
+ <option value="file14">codeberg.org/snonux/yoga/internal/tags/tags.go (82.9%)</option>
+
+ </select>
+ </div>
+ <div id="legend">
+ <span>not tracked</span>
+
+ <span class="cov0">not covered</span>
+ <span class="cov8">covered</span>
+
+ </div>
+ </div>
+ <div id="content">
+
+ <pre class="file" id="file0" style="display: none">package main
+
+import (
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "codeberg.org/snonux/yoga/internal/app"
+ "codeberg.org/snonux/yoga/internal/fsutil"
+ "codeberg.org/snonux/yoga/internal/meta"
+)
+
+const defaultRoot = "~/Yoga"
+
+var (
+ runApp = app.Run
+ exit = os.Exit
+)
+
+func main() <span class="cov8" title="1">{
+ exit(run(os.Args[1:], os.Stdout, os.Stderr))
+}</span>
+
+func run(args []string, stdout, stderr io.Writer) int <span class="cov8" title="1">{
+ 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 </span><span class="cov0" title="0">{
+ return 2
+ }</span>
+ <span class="cov8" title="1">if *versionFlag </span><span class="cov8" title="1">{
+ fmt.Fprintf(stdout, "Yoga version %s\n", meta.Version)
+ return 0
+ }</span>
+ <span class="cov8" title="1">root, err := fsutil.ResolveRootPath(*rootFlag, defaultRoot)
+ if err != nil </span><span class="cov0" title="0">{
+ fmt.Fprintf(stderr, "%v\n", err)
+ return 1
+ }</span>
+ <span class="cov8" title="1">opts := app.Options{Root: root, Crop: strings.TrimSpace(*cropFlag)}
+ if err := runApp(opts); err != nil </span><span class="cov8" title="1">{
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return 1
+ }</span>
+ <span class="cov8" title="1">return 0</span>
+}
+</pre>
+
+ <pre class="file" id="file1" style="display: none">package app
+
+import (
+ "fmt"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type teaProgram interface {
+ Run() (tea.Model, error)
+}
+
+var programFactory = func(m tea.Model) teaProgram <span class="cov0" title="0">{
+ return tea.NewProgram(m, tea.WithAltScreen())
+}</span>
+
+// Run bootstraps the Bubble Tea program with the provided options.
+func Run(opts Options) error <span class="cov8" title="1">{
+ model, err := newModel(opts)
+ if err != nil </span><span class="cov0" title="0">{
+ return fmt.Errorf("create model: %w", err)
+ }</span>
+ <span class="cov8" title="1">program := programFactory(model)
+ if _, err := program.Run(); err != nil </span><span class="cov8" title="1">{
+ return fmt.Errorf("run program: %w", err)
+ }</span>
+ <span class="cov8" title="1">return nil</span>
+}
+</pre>
+
+ <pre class="file" id="file2" style="display: none">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 <span class="cov8" title="1">{
+ return &amp;durationCache{path: path, entries: make(map[string]cacheEntry)}
+}</span>
+
+func loadDurationCache(path string) (*durationCache, error) <span class="cov8" title="1">{
+ cache := newDurationCache(path)
+ data, err := os.ReadFile(path)
+ if err != nil </span><span class="cov8" title="1">{
+ if errors.Is(err, fs.ErrNotExist) </span><span class="cov8" title="1">{
+ return cache, nil
+ }</span>
+ <span class="cov0" title="0">return cache, err</span>
+ }
+ <span class="cov8" title="1">if len(data) == 0 </span><span class="cov0" title="0">{
+ return cache, nil
+ }</span>
+ <span class="cov8" title="1">if err := json.Unmarshal(data, &amp;cache.entries); err != nil </span><span class="cov8" title="1">{
+ cache.entries = make(map[string]cacheEntry)
+ return cache, err
+ }</span>
+ <span class="cov8" title="1">return cache, nil</span>
+}
+
+func (c *durationCache) Lookup(path string, info os.FileInfo) (time.Duration, bool) <span class="cov8" title="1">{
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ entry, ok := c.entries[path]
+ if !ok </span><span class="cov8" title="1">{
+ return 0, false
+ }</span>
+ <span class="cov8" title="1">if entry.ModTimeUnix != info.ModTime().Unix() || entry.Size != info.Size() </span><span class="cov8" title="1">{
+ delete(c.entries, path)
+ c.dirty = true
+ return 0, false
+ }</span>
+ <span class="cov8" title="1">if entry.DurationSeconds &lt;= 0 </span><span class="cov0" title="0">{
+ return 0, false
+ }</span>
+ <span class="cov8" title="1">return time.Duration(entry.DurationSeconds * float64(time.Second)), true</span>
+}
+
+func (c *durationCache) Record(path string, info os.FileInfo, dur time.Duration) error <span class="cov8" title="1">{
+ if c == nil || dur &lt;= 0 </span><span class="cov0" title="0">{
+ return nil
+ }</span>
+ <span class="cov8" title="1">c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.entries == nil </span><span class="cov0" title="0">{
+ c.entries = make(map[string]cacheEntry)
+ }</span>
+ <span class="cov8" title="1">c.entries[path] = cacheEntry{
+ DurationSeconds: dur.Seconds(),
+ ModTimeUnix: info.ModTime().Unix(),
+ Size: info.Size(),
+ }
+ c.dirty = true
+ return nil</span>
+}
+
+func (c *durationCache) Flush() error <span class="cov8" title="1">{
+ if c == nil </span><span class="cov0" title="0">{
+ return nil
+ }</span>
+ <span class="cov8" title="1">c.mu.Lock()
+ if !c.dirty </span><span class="cov8" title="1">{
+ c.mu.Unlock()
+ return nil
+ }</span>
+ <span class="cov8" title="1">snapshot := make(map[string]cacheEntry, len(c.entries))
+ for k, v := range c.entries </span><span class="cov8" title="1">{
+ snapshot[k] = v
+ }</span>
+ <span class="cov8" title="1">c.dirty = false
+ c.mu.Unlock()
+ data, err := json.MarshalIndent(snapshot, "", " ")
+ if err != nil </span><span class="cov0" title="0">{
+ return err
+ }</span>
+ <span class="cov8" title="1">return os.WriteFile(c.path, data, 0o644)</span>
+}
+</pre>
+
+ <pre class="file" id="file3" style="display: none">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
+ tags string
+}
+
+type filterInputs struct {
+ fields []textinput.Model
+ focus int
+}
+
+func (m *model) applyFilterInputs() error <span class="cov8" title="1">{
+ name := strings.TrimSpace(m.inputs.fields[0].Value())
+ minText := strings.TrimSpace(m.inputs.fields[1].Value())
+ maxText := strings.TrimSpace(m.inputs.fields[2].Value())
+ tags := strings.TrimSpace(m.inputs.fields[3].Value())
+
+ filters := filterState{name: name, tags: tags}
+ if err := populateMinFilter(&amp;filters, minText); err != nil </span><span class="cov0" title="0">{
+ return err
+ }</span>
+ <span class="cov8" title="1">if err := populateMaxFilter(&amp;filters, maxText); err != nil </span><span class="cov0" title="0">{
+ return err
+ }</span>
+ <span class="cov8" title="1">if filters.minEnabled &amp;&amp; filters.maxEnabled &amp;&amp; filters.minMinutes &gt; filters.maxMinutes </span><span class="cov0" title="0">{
+ return errors.New("min minutes cannot exceed max minutes")
+ }</span>
+ <span class="cov8" title="1">m.filters = filters
+ return nil</span>
+}
+
+func populateMinFilter(dst *filterState, value string) error <span class="cov8" title="1">{
+ if value == "" </span><span class="cov8" title="1">{
+ return nil
+ }</span>
+ <span class="cov8" title="1">minutes, err := strconv.Atoi(value)
+ if err != nil </span><span class="cov8" title="1">{
+ return fmt.Errorf("invalid min minutes: %q", value)
+ }</span>
+ <span class="cov8" title="1">if minutes &lt; 0 </span><span class="cov8" title="1">{
+ return errors.New("min minutes must be positive")
+ }</span>
+ <span class="cov8" title="1">dst.minEnabled = true
+ dst.minMinutes = minutes
+ return nil</span>
+}
+
+func populateMaxFilter(dst *filterState, value string) error <span class="cov8" title="1">{
+ if value == "" </span><span class="cov8" title="1">{
+ return nil
+ }</span>
+ <span class="cov8" title="1">minutes, err := strconv.Atoi(value)
+ if err != nil </span><span class="cov8" title="1">{
+ return fmt.Errorf("invalid max minutes: %q", value)
+ }</span>
+ <span class="cov8" title="1">if minutes &lt; 0 </span><span class="cov8" title="1">{
+ return errors.New("max minutes must be positive")
+ }</span>
+ <span class="cov8" title="1">dst.maxEnabled = true
+ dst.maxMinutes = minutes
+ return nil</span>
+}
+
+func (m *model) resetFilters() <span class="cov8" title="1">{
+ m.filters = filterState{}
+ for i := range m.inputs.fields </span><span class="cov8" title="1">{
+ m.inputs.fields[i].SetValue("")
+ }</span>
+}
+
+func (m *model) updateFilterInputs(msg tea.Msg) (filterInputs, tea.Cmd) <span class="cov8" title="1">{
+ inputs := m.inputs
+ var cmds []tea.Cmd
+ for i := range inputs.fields </span><span class="cov8" title="1">{
+ var cmd tea.Cmd
+ inputs.fields[i], cmd = inputs.fields[i].Update(msg)
+ cmds = append(cmds, cmd)
+ }</span>
+ <span class="cov8" title="1">return inputs, tea.Batch(cmds...)</span>
+}
+
+func (m model) describeFilters() string <span class="cov8" title="1">{
+ parts := []string{}
+ if m.filters.name != "" </span><span class="cov8" title="1">{
+ parts = append(parts, fmt.Sprintf("name contains %q", m.filters.name))
+ }</span>
+ <span class="cov8" title="1">if m.filters.tags != "" </span><span class="cov8" title="1">{
+ parts = append(parts, fmt.Sprintf("tags contain %q", m.filters.tags))
+ }</span>
+ <span class="cov8" title="1">if m.filters.minEnabled </span><span class="cov8" title="1">{
+ parts = append(parts, fmt.Sprintf("&gt;=%d min", m.filters.minMinutes))
+ }</span>
+ <span class="cov8" title="1">if m.filters.maxEnabled </span><span class="cov8" title="1">{
+ parts = append(parts, fmt.Sprintf("&lt;=%d min", m.filters.maxMinutes))
+ }</span>
+ <span class="cov8" title="1">if len(parts) == 0 </span><span class="cov0" title="0">{
+ return "(none)"
+ }</span>
+ <span class="cov8" title="1">return strings.Join(parts, ", ")</span>
+}
+
+func (m *model) passesFilters(v video) bool <span class="cov8" title="1">{
+ if m.filters.name != "" &amp;&amp; !strings.Contains(strings.ToLower(v.Name), strings.ToLower(m.filters.name)) </span><span class="cov8" title="1">{
+ return false
+ }</span>
+ <span class="cov8" title="1">durMinutes := int(v.Duration.Round(time.Minute) / time.Minute)
+ if m.filters.minEnabled &amp;&amp; (v.Duration == 0 || durMinutes &lt; m.filters.minMinutes) </span><span class="cov8" title="1">{
+ return false
+ }</span>
+ <span class="cov8" title="1">if m.filters.maxEnabled &amp;&amp; (v.Duration == 0 || durMinutes &gt; m.filters.maxMinutes) </span><span class="cov8" title="1">{
+ return false
+ }</span>
+ <span class="cov8" title="1">if m.filters.tags != "" </span><span class="cov8" title="1">{
+ query := strings.ToLower(m.filters.tags)
+ matched := false
+ for _, tag := range v.Tags </span><span class="cov8" title="1">{
+ if strings.Contains(strings.ToLower(tag), query) </span><span class="cov8" title="1">{
+ matched = true
+ break</span>
+ }
+ }
+ <span class="cov8" title="1">if !matched </span><span class="cov8" title="1">{
+ return false
+ }</span>
+ }
+ <span class="cov8" title="1">return true</span>
+}
+
+func (m *model) renderFilterModal() string <span class="cov8" title="1">{
+ 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):", "Tags contain:"}
+ for i, field := range m.inputs.fields </span><span class="cov8" title="1">{
+ line := fmt.Sprintf("%s %s", labels[i], field.View())
+ if i == m.inputs.focus </span><span class="cov8" title="1">{
+ line = highlightStyle.Render(line)
+ }</span>
+ <span class="cov8" title="1">b.WriteString(line)
+ b.WriteString("\n")</span>
+ }
+ <span class="cov8" title="1">if m.filters.minEnabled || m.filters.maxEnabled || m.filters.name != "" </span><span class="cov0" title="0">{
+ b.WriteString("\nCurrent filter: ")
+ b.WriteString(m.describeFilters())
+ b.WriteString("\n")
+ }</span>
+ <span class="cov8" title="1">return filterStyle.Render(b.String())</span>
+}
+</pre>
+
+ <pre class="file" id="file4" style="display: none">package app
+
+import "sync"
+
+type loadProgress struct {
+ mu sync.Mutex
+ total int
+ processed int
+ done bool
+}
+
+func (p *loadProgress) Reset() <span class="cov8" title="1">{
+ if p == nil </span><span class="cov0" title="0">{
+ return
+ }</span>
+ <span class="cov8" title="1">p.mu.Lock()
+ p.total = 0
+ p.processed = 0
+ p.done = false
+ p.mu.Unlock()</span>
+}
+
+func (p *loadProgress) SetTotal(total int) <span class="cov8" title="1">{
+ if p == nil </span><span class="cov0" title="0">{
+ return
+ }</span>
+ <span class="cov8" title="1">p.mu.Lock()
+ p.total = total
+ p.mu.Unlock()</span>
+}
+
+func (p *loadProgress) Increment() <span class="cov8" title="1">{
+ if p == nil </span><span class="cov0" title="0">{
+ return
+ }</span>
+ <span class="cov8" title="1">p.mu.Lock()
+ p.processed++
+ p.mu.Unlock()</span>
+}
+
+func (p *loadProgress) MarkDone() <span class="cov8" title="1">{
+ if p == nil </span><span class="cov0" title="0">{
+ return
+ }</span>
+ <span class="cov8" title="1">p.mu.Lock()
+ p.done = true
+ p.mu.Unlock()</span>
+}
+
+func (p *loadProgress) Snapshot() (processed, total int, done bool) <span class="cov8" title="1">{
+ if p == nil </span><span class="cov0" title="0">{
+ return 0, 0, true
+ }</span>
+ <span class="cov8" title="1">p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.processed, p.total, p.done</span>
+}
+</pre>
+
+ <pre class="file" id="file5" style="display: none">package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "codeberg.org/snonux/yoga/internal/tags"
+)
+
+func loadVideosCmd(root, cachePath string, progress *loadProgress) tea.Cmd <span class="cov8" title="1">{
+ return func() tea.Msg </span><span class="cov8" title="1">{
+ cache, cacheErr := loadDurationCache(cachePath)
+ videos, pending, tagErr, err := loadVideos(root, cache, progress)
+ if progress != nil </span><span class="cov8" title="1">{
+ progress.MarkDone()
+ }</span>
+ <span class="cov8" title="1">return videosLoadedMsg{videos: videos, err: err, cacheErr: cacheErr, pending: pending, cache: cache, tagErr: tagErr}</span>
+ }
+}
+
+func progressTickerCmd(progress *loadProgress) tea.Cmd <span class="cov8" title="1">{
+ if progress == nil </span><span class="cov8" title="1">{
+ return nil
+ }</span>
+ <span class="cov8" title="1">return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg </span><span class="cov8" title="1">{
+ processed, total, done := progress.Snapshot()
+ return progressUpdateMsg{processed: processed, total: total, done: done}
+ }</span>)
+}
+
+func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error, error) <span class="cov8" title="1">{
+ paths, err := collectVideoPaths(root)
+ if err != nil </span><span class="cov0" title="0">{
+ return nil, nil, nil, err
+ }</span>
+ <span class="cov8" title="1">if progress != nil </span><span class="cov8" title="1">{
+ progress.SetTotal(len(paths))
+ }</span>
+ <span class="cov8" title="1">videos := make([]video, 0, len(paths))
+ pending := make([]string, 0)
+ var tagErrors []string
+ for _, path := range paths </span><span class="cov8" title="1">{
+ info, statErr := os.Stat(path)
+ if statErr != nil </span><span class="cov8" title="1">{
+ videos = append(videos, video{Name: filepath.Base(path), Path: path, Err: statErr})
+ increment(progress)
+ continue</span>
+ }
+ <span class="cov8" title="1">dur := cachedDuration(cache, path, info)
+ if dur == 0 </span><span class="cov8" title="1">{
+ pending = append(pending, path)
+ }</span>
+ <span class="cov8" title="1">tagList, tagErr := tags.Load(path)
+ if tagErr != nil </span><span class="cov0" title="0">{
+ tagErrors = append(tagErrors, fmt.Sprintf("%s: %v", filepath.Base(path), tagErr))
+ }</span>
+ <span class="cov8" title="1">videos = append(videos, video{
+ Name: filepath.Base(path),
+ Path: path,
+ Duration: dur,
+ ModTime: info.ModTime(),
+ Size: info.Size(),
+ Tags: tagList,
+ })
+ increment(progress)</span>
+ }
+ <span class="cov8" title="1">return videos, pending, joinErrors(tagErrors), nil</span>
+}
+
+func joinErrors(messages []string) error <span class="cov8" title="1">{
+ if len(messages) == 0 </span><span class="cov8" title="1">{
+ return nil
+ }</span>
+ <span class="cov0" title="0">return errors.New(strings.Join(messages, "; "))</span>
+}
+
+func increment(progress *loadProgress) <span class="cov8" title="1">{
+ if progress != nil </span><span class="cov8" title="1">{
+ progress.Increment()
+ }</span>
+}
+
+func cachedDuration(cache *durationCache, path string, info os.FileInfo) time.Duration <span class="cov8" title="1">{
+ if cache == nil </span><span class="cov8" title="1">{
+ return 0
+ }</span>
+ <span class="cov8" title="1">dur, ok := cache.Lookup(path, info)
+ if !ok </span><span class="cov8" title="1">{
+ return 0
+ }</span>
+ <span class="cov8" title="1">return dur</span>
+}
+
+func collectVideoPaths(root string) ([]string, error) <span class="cov8" title="1">{
+ info, err := os.Stat(root)
+ if err != nil </span><span class="cov0" title="0">{
+ return nil, err
+ }</span>
+ <span class="cov8" title="1">if !info.IsDir() </span><span class="cov0" title="0">{
+ if isVideo(root) </span><span class="cov0" title="0">{
+ return []string{root}, nil
+ }</span>
+ <span class="cov0" title="0">return nil, nil</span>
+ }
+ <span class="cov8" title="1">visited := make(map[string]struct{})
+ var paths []string
+ if err := traverseVideoPaths(root, root, visited, &amp;paths); err != nil </span><span class="cov0" title="0">{
+ return nil, err
+ }</span>
+ <span class="cov8" title="1">sort.Strings(paths)
+ return paths, nil</span>
+}
+
+func traverseVideoPaths(displayPath, realPath string, visited map[string]struct{}, acc *[]string) error <span class="cov8" title="1">{
+ resolved, err := filepath.EvalSymlinks(realPath)
+ if err != nil </span><span class="cov0" title="0">{
+ resolved = realPath
+ }</span>
+ <span class="cov8" title="1">resolved = filepath.Clean(resolved)
+ if _, seen := visited[resolved]; seen </span><span class="cov0" title="0">{
+ return nil
+ }</span>
+ <span class="cov8" title="1">visited[resolved] = struct{}{}
+
+ entries, err := os.ReadDir(resolved)
+ if err != nil </span><span class="cov0" title="0">{
+ return err
+ }</span>
+ <span class="cov8" title="1">for _, entry := range entries </span><span class="cov8" title="1">{
+ displayChild := filepath.Join(displayPath, entry.Name())
+ realChild := filepath.Join(resolved, entry.Name())
+ mode := entry.Type()
+ var info os.FileInfo
+ if mode == fs.FileMode(0) </span><span class="cov8" title="1">{
+ info, err = entry.Info()
+ if err != nil </span><span class="cov0" title="0">{
+ return err
+ }</span>
+ <span class="cov8" title="1">mode = info.Mode()</span>
+ }
+ <span class="cov8" title="1">if mode&amp;os.ModeSymlink != 0 </span><span class="cov8" title="1">{
+ if err := handleSymlink(displayChild, realChild, visited, acc); err != nil </span><span class="cov0" title="0">{
+ return err
+ }</span>
+ <span class="cov8" title="1">continue</span>
+ }
+ <span class="cov8" title="1">if mode.IsDir() </span><span class="cov0" title="0">{
+ if err := traverseVideoPaths(displayChild, realChild, visited, acc); err != nil </span><span class="cov0" title="0">{
+ return err
+ }</span>
+ <span class="cov0" title="0">continue</span>
+ }
+ <span class="cov8" title="1">if isVideo(displayChild) </span><span class="cov8" title="1">{
+ *acc = append(*acc, displayChild)
+ }</span>
+ }
+ <span class="cov8" title="1">return nil</span>
+}
+
+func handleSymlink(displayChild, realChild string, visited map[string]struct{}, acc *[]string) error <span class="cov8" title="1">{
+ targetPath, err := filepath.EvalSymlinks(realChild)
+ if err != nil </span><span class="cov8" title="1">{
+ return recordIfVideo(displayChild, acc)
+ }</span>
+ <span class="cov8" title="1">targetInfo, err := os.Stat(targetPath)
+ if err != nil </span><span class="cov0" title="0">{
+ return recordIfVideo(displayChild, acc)
+ }</span>
+ <span class="cov8" title="1">if targetInfo.IsDir() </span><span class="cov8" title="1">{
+ return traverseVideoPaths(displayChild, targetPath, visited, acc)
+ }</span>
+ <span class="cov0" title="0">if isVideo(displayChild) || isVideo(targetPath) </span><span class="cov0" title="0">{
+ *acc = append(*acc, displayChild)
+ }</span>
+ <span class="cov0" title="0">return nil</span>
+}
+
+func recordIfVideo(path string, acc *[]string) error <span class="cov8" title="1">{
+ if isVideo(path) </span><span class="cov8" title="1">{
+ *acc = append(*acc, path)
+ }</span>
+ <span class="cov8" title="1">return nil</span>
+}
+
+func probeDurationsCmd(path string, cache *durationCache) tea.Cmd <span class="cov8" title="1">{
+ return func() tea.Msg </span><span class="cov8" title="1">{
+ dur, err := probeDuration(path)
+ if err == nil &amp;&amp; cache != nil </span><span class="cov0" title="0">{
+ if info, statErr := os.Stat(path); statErr == nil </span><span class="cov0" title="0">{
+ _ = cache.Record(path, info, dur)
+ }</span>
+ }
+ <span class="cov8" title="1">return durationUpdateMsg{path: path, duration: dur, err: err}</span>
+ }
+}
+
+func probeDuration(path string) (time.Duration, error) <span class="cov8" title="1">{
+ 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 </span><span class="cov8" title="1">{
+ return 0, err
+ }</span>
+ <span class="cov8" title="1">raw := strings.TrimSpace(string(out))
+ if raw == "" </span><span class="cov0" title="0">{
+ return 0, errors.New("empty duration")
+ }</span>
+ <span class="cov8" title="1">seconds, err := strconv.ParseFloat(raw, 64)
+ if err != nil </span><span class="cov0" title="0">{
+ return 0, err
+ }</span>
+ <span class="cov8" title="1">return time.Duration(seconds * float64(time.Second)), nil</span>
+}
+
+func playVideoCmd(path, crop string) tea.Cmd <span class="cov8" title="1">{
+ return func() tea.Msg </span><span class="cov8" title="1">{
+ args := buildVLCArgs(path, crop)
+ cmd := exec.Command("vlc", args...)
+ if err := cmd.Start(); err != nil </span><span class="cov0" title="0">{
+ return playVideoMsg{path: path, err: err}
+ }</span>
+ <span class="cov8" title="1">go func() </span><span class="cov8" title="1">{ _ = cmd.Wait() }</span>()
+ <span class="cov8" title="1">return playVideoMsg{path: path}</span>
+ }
+}
+
+func buildVLCArgs(path, crop string) []string <span class="cov8" title="1">{
+ args := []string{}
+ if crop != "" </span><span class="cov0" title="0">{
+ args = append(args, "--crop", crop)
+ }</span>
+ <span class="cov8" title="1">return append(args, path)</span>
+}
+
+func isVideo(path string) bool <span class="cov8" title="1">{
+ ext := strings.ToLower(filepath.Ext(path))
+ _, ok := videoExtensions[ext]
+ return ok
+}</span>
+
+// CollectVideoPathsForTest exposes collectVideoPaths for unit testing.
+func CollectVideoPathsForTest(root string) ([]string, error) <span class="cov8" title="1">{
+ return collectVideoPaths(root)
+}</span>
+</pre>
+
+ <pre class="file" id="file6" style="display: none">package app
+
+import (
+ "fmt"
+ "path/filepath"
+ "runtime"
+ "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
+)
+
+const (
+ preferredNameColumnWidth = 40
+ preferredDurationColumnWidth = 12
+ preferredAgeColumnWidth = 14
+ preferredTagsColumnWidth = 28
+ nameColumnFloorWidth = 16
+ durationColumnFloorWidth = 8
+ ageColumnFloorWidth = 10
+ tagsColumnFloorWidth = 12
+)
+
+type model struct {
+ table table.Model
+ videos []video
+ filtered []video
+ filters filterState
+ inputs filterInputs
+ showFilters bool
+ editingTags 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
+ tagInput textinput.Model
+ tagEditPath string
+ baseStatus string
+ showHelp bool
+ viewportWidth int
+}
+
+func newModel(opts Options) (model, error) <span class="cov8" title="1">{
+ tbl := buildTable()
+ inputs := buildFilterInputs()
+ inputs.fields[0].Focus()
+ tagInput := buildTagInput()
+
+ progress := &amp;loadProgress{}
+ cachePath := filepath.Join(opts.Root, ".video_duration_cache.json")
+
+ return model{
+ table: tbl,
+ inputs: inputs,
+ tagInput: tagInput,
+ sortField: sortByName,
+ sortAscending: true,
+ statusMessage: "Scanning for videos...",
+ loading: true,
+ root: opts.Root,
+ progress: progress,
+ cachePath: cachePath,