summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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,
+ cropValue: opts.Crop,
+ cropEnabled: opts.Crop != "",
+ showHelp: true,
+ }, nil
+}</span>
+
+func buildTable() table.Model <span class="cov8" title="1">{
+ columns := makeColumns(
+ preferredNameColumnWidth,
+ preferredDurationColumnWidth,
+ preferredAgeColumnWidth,
+ preferredTagsColumnWidth,
+ )
+ tbl := table.New(
+ table.WithColumns(columns),
+ table.WithFocused(true),
+ table.WithHeight(15),
+ )
+ tbl.SetStyles(table.DefaultStyles())
+ return tbl
+}</span>
+
+func buildFilterInputs() filterInputs <span class="cov8" title="1">{
+ nameInput := textinput.New()
+ nameInput.Placeholder = "substring"
+ nameInput.Prompt = "Name: "
+ nameInput.CharLimit = 256
+
+ minInput := textinput.New()
+ minInput.Placeholder = "min minutes"
+ minInput.Prompt = "Min minutes: "
+ minInput.CharLimit = 4
+
+ maxInput := textinput.New()
+ maxInput.Placeholder = "max minutes"
+ maxInput.Prompt = "Max minutes: "
+ maxInput.CharLimit = 4
+
+ tagInput := textinput.New()
+ tagInput.Placeholder = "tag substring"
+ tagInput.Prompt = "Tags: "
+ tagInput.CharLimit = 256
+
+ return filterInputs{
+ fields: []textinput.Model{nameInput, minInput, maxInput, tagInput},
+ focus: 0,
+ }
+}</span>
+
+func buildTagInput() textinput.Model <span class="cov8" title="1">{
+ input := textinput.New()
+ input.Placeholder = "comma-separated tags"
+ input.Prompt = "Tags: "
+ input.CharLimit = 512
+ return input
+}</span>
+
+func makeColumns(nameWidth, durationWidth, ageWidth, tagsWidth int) []table.Column <span class="cov8" title="1">{
+ return []table.Column{
+ {Title: headerStyle.Render("Name"), Width: nameWidth},
+ {Title: headerStyle.Render("Duration"), Width: durationWidth},
+ {Title: headerStyle.Render("Age"), Width: ageWidth},
+ {Title: headerStyle.Render("Tags"), Width: tagsWidth},
+ }
+}</span>
+
+func (m model) Init() tea.Cmd <span class="cov8" title="1">{
+ if m.progress != nil </span><span class="cov8" title="1">{
+ m.progress.Reset()
+ }</span>
+ <span class="cov8" title="1">loadCmd := loadVideosCmd(m.root, m.cachePath, m.progress)
+ if m.progress != nil </span><span class="cov8" title="1">{
+ return tea.Batch(loadCmd, progressTickerCmd(m.progress))
+ }</span>
+ <span class="cov0" title="0">return loadCmd</span>
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ switch typed := msg.(type) </span>{
+ case tea.KeyMsg:<span class="cov8" title="1">
+ return m.handleKeyMsg(typed)</span>
+ case progressUpdateMsg:<span class="cov8" title="1">
+ return m.handleProgressUpdate(typed)</span>
+ case durationUpdateMsg:<span class="cov8" title="1">
+ return m.handleDurationUpdate(typed)</span>
+ case videosLoadedMsg:<span class="cov8" title="1">
+ return m.handleVideosLoaded(typed)</span>
+ case playVideoMsg:<span class="cov8" title="1">
+ return m.handlePlayVideo(typed), nil</span>
+ case reindexVideosMsg:<span class="cov0" title="0">
+ return m.handleReindexVideos(typed)</span>
+ case tagsSavedMsg:<span class="cov0" title="0">
+ return m.handleTagsSaved(typed)</span>
+ case tea.WindowSizeMsg:<span class="cov8" title="1">
+ return m.handleWindowSize(typed)</span>
+ default:<span class="cov0" title="0">
+ return m.updateTable(msg)</span>
+ }
+}
+
+func (m model) View() string <span class="cov8" title="1">{
+ if m.loading </span><span class="cov0" title="0">{
+ return statusStyle.Render("Loading videos, please wait...")
+ }</span>
+ <span class="cov8" title="1">body := m.renderBody()
+ if m.editingTags </span><span class="cov0" title="0">{
+ return body + "\n\n" + m.renderTagModal()
+ }</span>
+ <span class="cov8" title="1">if m.showFilters </span><span class="cov0" title="0">{
+ return body + "\n\n" + m.renderFilterModal()
+ }</span>
+ <span class="cov8" title="1">return body</span>
+}
+
+func (m model) renderBody() string <span class="cov8" title="1">{
+ helpLines := []string{
+ "↑/↓ navigate • enter play • s sort • / filter • c crop • t edit tags • i re-index • q quit",
+ }
+ info := statusStyle.Render(m.statusText())
+ progressLine := m.renderProgressLine()
+ content := tableStyle.Render(m.table.View())
+ parts := []string{content}
+ if progressLine != "" </span><span class="cov0" title="0">{
+ parts = append(parts, progressLine)
+ }</span>
+ <span class="cov8" title="1">parts = append(parts, info)
+ if m.showHelp </span><span class="cov8" title="1">{
+ help := strings.Join(helpLines, "\n")
+ parts = append(parts, help)
+ }</span>
+ <span class="cov8" title="1">return strings.Join(parts, "\n")</span>
+}
+
+func (m model) statusText() string <span class="cov8" title="1">{
+ status := strings.TrimSpace(m.statusMessage)
+ base := strings.TrimSpace(m.baseStatus)
+ if base == "" </span><span class="cov8" title="1">{
+ return status
+ }</span>
+ <span class="cov8" title="1">if status == "" || status == base </span><span class="cov8" title="1">{
+ return base
+ }</span>
+ <span class="cov8" title="1">return fmt.Sprintf("%s • %s", base, status)</span>
+}
+
+func (m model) showHelpBar() (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if m.showHelp </span><span class="cov0" title="0">{
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">m.showHelp = true
+ if strings.Contains(m.statusMessage, "Help hidden") </span><span class="cov8" title="1">{
+ m.statusMessage = ""
+ }</span>
+ <span class="cov8" title="1">return m, nil</span>
+}
+
+func (m model) hideHelpBar() (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if !m.showHelp </span><span class="cov0" title="0">{
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">m.showHelp = false
+ m.statusMessage = "Help hidden (press h to show)"
+ return m, nil</span>
+}
+
+func (m model) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ m.viewportWidth = msg.Width
+ m.resizeColumns(msg.Width)
+ tbl, cmd := m.table.Update(msg)
+ m.table = tbl
+ if cmd == nil </span><span class="cov8" title="1">{
+ return m, nil
+ }</span>
+ <span class="cov0" title="0">return m, cmd</span>
+}
+
+func (m *model) resizeColumns(totalWidth int) <span class="cov8" title="1">{
+ if totalWidth &lt;= 0 </span><span class="cov0" title="0">{
+ return
+ }</span>
+ <span class="cov8" title="1">frame := tableStyle.GetHorizontalFrameSize()
+ contentWidth := totalWidth - frame
+ minWidth := nameColumnFloorWidth + durationColumnFloorWidth + ageColumnFloorWidth + tagsColumnFloorWidth
+ if contentWidth &lt; minWidth </span><span class="cov0" title="0">{
+ contentWidth = minWidth
+ }</span>
+ <span class="cov8" title="1">preferred := preferredNameColumnWidth + preferredDurationColumnWidth + preferredAgeColumnWidth + preferredTagsColumnWidth
+ nameWidth := preferredNameColumnWidth
+ durationWidth := preferredDurationColumnWidth
+ ageWidth := preferredAgeColumnWidth
+ tagsWidth := preferredTagsColumnWidth
+ if contentWidth &gt;= preferred </span><span class="cov8" title="1">{
+ extra := contentWidth - preferred
+ nameWidth += extra
+ }</span> else<span class="cov8" title="1"> {
+ deficit := preferred - contentWidth
+ if deficit &gt; 0 </span><span class="cov8" title="1">{
+ reduce := min(deficit, nameWidth-nameColumnFloorWidth)
+ nameWidth -= reduce
+ deficit -= reduce
+ }</span>
+ <span class="cov8" title="1">if deficit &gt; 0 </span><span class="cov8" title="1">{
+ reduce := min(deficit, tagsWidth-tagsColumnFloorWidth)
+ tagsWidth -= reduce
+ deficit -= reduce
+ }</span>
+ <span class="cov8" title="1">if deficit &gt; 0 </span><span class="cov0" title="0">{
+ reduce := min(deficit, ageWidth-ageColumnFloorWidth)
+ ageWidth -= reduce
+ deficit -= reduce
+ }</span>
+ <span class="cov8" title="1">if deficit &gt; 0 </span><span class="cov0" title="0">{
+ reduce := min(deficit, durationWidth-durationColumnFloorWidth)
+ durationWidth -= reduce
+ }</span>
+ }
+ <span class="cov8" title="1">m.table.SetColumns(makeColumns(nameWidth, durationWidth, ageWidth, tagsWidth))
+ m.table.SetWidth(contentWidth)</span>
+}
+
+func min(a, b int) int <span class="cov8" title="1">{
+ if a &lt; b </span><span class="cov8" title="1">{
+ return a
+ }</span>
+ <span class="cov8" title="1">return b</span>
+}
+
+func (m model) renderProgressLine() string <span class="cov8" title="1">{
+ if m.durationTotal == 0 </span><span class="cov8" title="1">{
+ return ""
+ }</span>
+ <span class="cov8" title="1">bar := renderProgressBar(m.durationDone, m.durationTotal, 24)
+ return statusStyle.Render(fmt.Sprintf("Duration scan %s %d/%d", bar, m.durationDone, m.durationTotal))</span>
+}
+
+func (m model) updateTable(msg tea.Msg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ tbl, cmd := m.table.Update(msg)
+ m.table = tbl
+ return m, cmd
+}</span>
+
+func (m model) handlePlayVideo(msg playVideoMsg) model <span class="cov8" title="1">{
+ if msg.err != nil </span><span class="cov8" title="1">{
+ m.statusMessage = fmt.Sprintf("Failed to launch VLC: %v", msg.err)
+ return m
+ }</span>
+ <span class="cov8" title="1">m.statusMessage = fmt.Sprintf("Playing via VLC: %s", trimPath(msg.path))
+ return m</span>
+}
+
+func (m model) handleReindexVideos(msg reindexVideosMsg) (tea.Model, tea.Cmd) <span class="cov0" title="0">{
+ m.statusMessage = "Re-indexing videos..."
+ return m, loadVideosCmd(m.root, m.cachePath, m.progress)
+}</span>
+
+func (m model) handleVideosLoaded(msg videosLoadedMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ m.loading = false
+ if msg.err != nil </span><span class="cov0" title="0">{
+ m.err = msg.err
+ m.statusMessage = fmt.Sprintf("error: %v", msg.err)
+ }</span>
+
+ <span class="cov8" title="1">if len(m.videos) == 0 </span><span class="cov8" title="1">{
+ m.videos = msg.videos
+ }</span> else<span class="cov0" title="0"> {
+ existingVideos := make(map[string]int)
+ for i, v := range m.videos </span><span class="cov0" title="0">{
+ existingVideos[v.Path] = i
+ }</span>
+
+ <span class="cov0" title="0">for _, newVideo := range msg.videos </span><span class="cov0" title="0">{
+ if i, ok := existingVideos[newVideo.Path]; ok </span><span class="cov0" title="0">{
+ m.videos[i] = newVideo
+ }</span> else<span class="cov0" title="0"> {
+ m.videos = append(m.videos, newVideo)
+ }</span>
+ }
+ }
+
+ <span class="cov8" title="1">m.cache = msg.cache
+ m.pendingDurations = msg.pending
+ m.durationTotal = len(msg.pending)
+ m.durationDone = 0
+ m.applyFiltersAndSort()
+ m.updateStatusAfterLoad(msg)
+ m.durationInFlight = 0
+ if len(msg.pending) == 0 </span><span class="cov8" title="1">{
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">cmd := m.startDurationWorkers()
+ return m, cmd</span>
+}
+
+func (m *model) updateStatusAfterLoad(msg videosLoadedMsg) <span class="cov8" title="1">{
+ if len(m.filtered) == 0 </span><span class="cov0" title="0">{
+ m.baseStatus = "No videos found"
+ m.statusMessage = m.baseStatus
+ return
+ }</span>
+ <span class="cov8" title="1">status := ""
+ if len(msg.pending) &gt; 0 </span><span class="cov8" title="1">{
+ status = fmt.Sprintf("Loaded %d videos, probing durations...", len(m.filtered))
+ if msg.cacheErr != nil </span><span class="cov8" title="1">{
+ status = fmt.Sprintf("Loaded %d videos (cache warning: %v), probing durations...", len(m.filtered), msg.cacheErr)
+ }</span>
+ } else<span class="cov8" title="1"> {
+ status = fmt.Sprintf("Loaded %d videos", len(m.filtered))
+ if msg.cacheErr != nil </span><span class="cov8" title="1">{
+ status = fmt.Sprintf("Loaded %d videos (cache warning: %v)", len(m.filtered), msg.cacheErr)
+ }</span>
+ }
+ <span class="cov8" title="1">if msg.tagErr != nil </span><span class="cov8" title="1">{
+ status = fmt.Sprintf("%s (tag warning: %v)", status, msg.tagErr)
+ }</span>
+ <span class="cov8" title="1">m.baseStatus = status
+ m.statusMessage = status</span>
+}
+
+func (m *model) startDurationWorkers() tea.Cmd <span class="cov8" title="1">{
+ if len(m.pendingDurations) == 0 </span><span class="cov0" title="0">{
+ return nil
+ }</span>
+ <span class="cov8" title="1">workers := runtime.NumCPU()
+ if workers &lt; 1 </span><span class="cov0" title="0">{
+ workers = 1
+ }</span>
+ <span class="cov8" title="1">if workers &gt; 6 </span><span class="cov8" title="1">{
+ workers = 6
+ }</span>
+ <span class="cov8" title="1">if workers &gt; len(m.pendingDurations) </span><span class="cov8" title="1">{
+ workers = len(m.pendingDurations)
+ }</span>
+ <span class="cov8" title="1">cmds := make([]tea.Cmd, 0, workers)
+ for i := 0; i &lt; workers; i++ </span><span class="cov8" title="1">{
+ cmd := m.dequeueDurationCmd()
+ if cmd != nil </span><span class="cov8" title="1">{
+ cmds = append(cmds, cmd)
+ }</span>
+ }
+ <span class="cov8" title="1">if len(cmds) == 0 </span><span class="cov0" title="0">{
+ return nil
+ }</span>
+ <span class="cov8" title="1">return tea.Batch(cmds...)</span>
+}
+
+func (m *model) dequeueDurationCmd() tea.Cmd <span class="cov8" title="1">{
+ if len(m.pendingDurations) == 0 </span><span class="cov0" title="0">{
+ return nil
+ }</span>
+ <span class="cov8" title="1">path := m.pendingDurations[0]
+ m.pendingDurations = m.pendingDurations[1:]
+ m.durationInFlight++
+ return probeDurationsCmd(path, m.cache)</span>
+}
+
+func (m model) activeCrop() string <span class="cov8" title="1">{
+ if m.cropEnabled &amp;&amp; m.cropValue != "" </span><span class="cov8" title="1">{
+ return m.cropValue
+ }</span>
+ <span class="cov8" title="1">return ""</span>
+}
+
+func (m model) handleProgressUpdate(msg progressUpdateMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if !m.loading </span><span class="cov0" title="0">{
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">if msg.total == 0 &amp;&amp; msg.done </span><span class="cov8" title="1">{
+ m.statusMessage = "No videos found"
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">if msg.done </span><span class="cov8" title="1">{
+ m.statusMessage = fmt.Sprintf("Loaded %d videos", msg.total)
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">m.statusMessage = fmt.Sprintf("Loading videos %d/%d...", msg.processed, msg.total)
+ return m, progressTickerCmd(m.progress)</span>
+}
+</pre>
+
+ <pre class="file" id="file7" style="display: none">package app
+
+import (
+ "fmt"
+ "path/filepath"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m model) handleDurationUpdate(msg durationUpdateMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if msg.path != "" </span><span class="cov8" title="1">{
+ m.updateVideoDuration(msg.path, msg.duration, msg.err)
+ m.durationDone++
+ m.updateStatusForDuration(msg)
+ }</span>
+ <span class="cov8" title="1">if m.durationInFlight &gt; 0 </span><span class="cov8" title="1">{
+ m.durationInFlight--
+ }</span>
+ <span class="cov8" title="1">selectedPath := m.currentSelectionPath()
+ m.applyFiltersAndSort()
+ m.restoreSelection(selectedPath)
+ if m.allDurationsResolved() </span><span class="cov8" title="1">{
+ m.onDurationsComplete()
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">cmd := m.dequeueDurationCmd()
+ return m, cmd</span>
+}
+
+func (m *model) updateStatusForDuration(msg durationUpdateMsg) <span class="cov8" title="1">{
+ if msg.err != nil </span><span class="cov8" title="1">{
+ m.statusMessage = fmt.Sprintf("Duration error for %s: %v", filepath.Base(msg.path), msg.err)
+ return
+ }</span>
+ <span class="cov8" title="1">if m.durationTotal &gt; 0 </span><span class="cov8" title="1">{
+ m.statusMessage = fmt.Sprintf("Probing durations %d/%d...", m.durationDone, m.durationTotal)
+ }</span>
+}
+
+func (m model) currentSelectionPath() string <span class="cov8" title="1">{
+ idx := m.table.Cursor()
+ if idx &lt; 0 || idx &gt;= len(m.filtered) </span><span class="cov0" title="0">{
+ return ""
+ }</span>
+ <span class="cov8" title="1">return m.filtered[idx].Path</span>
+}
+
+func (m *model) restoreSelection(path string) <span class="cov8" title="1">{
+ if path == "" </span><span class="cov0" title="0">{
+ return
+ }</span>
+ <span class="cov8" title="1">for i, video := range m.filtered </span><span class="cov8" title="1">{
+ if video.Path == path </span><span class="cov8" title="1">{
+ m.table.SetCursor(i)
+ return
+ }</span>
+ }
+}
+
+func (m *model) updateVideoDuration(path string, dur time.Duration, err error) <span class="cov8" title="1">{
+ for i := range m.videos </span><span class="cov8" title="1">{
+ if m.videos[i].Path != path </span><span class="cov0" title="0">{
+ continue</span>
+ }
+ <span class="cov8" title="1">m.videos[i].Duration = dur
+ m.videos[i].Err = err
+ return</span>
+ }
+}
+
+func (m model) allDurationsResolved() bool <span class="cov8" title="1">{
+ return m.durationDone &gt;= m.durationTotal &amp;&amp; m.durationInFlight == 0
+}</span>
+
+func (m *model) onDurationsComplete() <span class="cov8" title="1">{
+ if m.cache != nil </span><span class="cov8" title="1">{
+ if err := m.cache.Flush(); err != nil </span><span class="cov0" title="0">{
+ m.statusMessage = fmt.Sprintf("Duration cache flush error: %v", err)
+ }</span> else<span class="cov8" title="1"> {
+ m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered))
+ }</span>
+ <span class="cov8" title="1">m.resetDurationState()
+ return</span>
+ }
+ <span class="cov0" title="0">m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered))
+ m.resetDurationState()</span>
+}
+
+func (m *model) resetDurationState() <span class="cov8" title="1">{
+ m.pendingDurations = nil
+ m.durationTotal = 0
+ m.durationDone = 0
+ m.durationInFlight = 0
+}</span>
+
+
+
+</pre>
+
+ <pre class="file" id="file8" style="display: none">package app
+
+import (
+ "fmt"
+ "math/rand"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if cmd, handled := globalKeyHandler(msg); handled </span><span class="cov0" title="0">{
+ return m, cmd
+ }</span>
+ <span class="cov8" title="1">if m.loading </span><span class="cov0" title="0">{
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">if m.editingTags </span><span class="cov0" title="0">{
+ return m.handleTagKey(msg)
+ }</span>
+ <span class="cov8" title="1">if m.showFilters </span><span class="cov8" title="1">{
+ return m.handleFilterKey(msg)
+ }</span>
+ <span class="cov8" title="1">return m.handleTableKey(msg)</span>
+}
+
+func globalKeyHandler(msg tea.KeyMsg) (tea.Cmd, bool) <span class="cov8" title="1">{
+ switch msg.String() </span>{
+ case "ctrl+c", "q":<span class="cov0" title="0">
+ return tea.Quit, true</span>
+ default:<span class="cov8" title="1">
+ return nil, false</span>
+ }
+}
+
+func (m model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ switch msg.String() </span>{
+ case "esc":<span class="cov0" title="0">
+ m.showFilters = false
+ m.statusMessage = "Filter closed"
+ return m, nil</span>
+ case "enter":<span class="cov8" title="1">
+ cmd := m.applyFiltersFromInputs()
+ return m, cmd</span>
+ case "tab":<span class="cov8" title="1">
+ m.inputs.focus = (m.inputs.focus + 1) % len(m.inputs.fields)</span>
+ case "shift+tab":<span class="cov8" title="1">
+ m.inputs.focus = (m.inputs.focus - 1 + len(m.inputs.fields)) % len(m.inputs.fields)</span>
+ }
+ <span class="cov8" title="1">m.syncFilterFocus()
+ updated, cmd := m.updateFilterInputs(msg)
+ m.inputs = updated
+ return m, cmd</span>
+}
+
+func (m *model) applyFiltersFromInputs() tea.Cmd <span class="cov8" title="1">{
+ if err := m.applyFilterInputs(); err != nil </span><span class="cov0" title="0">{
+ m.statusMessage = err.Error()
+ return nil
+ }</span>
+ <span class="cov8" title="1">m.showFilters = false
+ m.applyFiltersAndSort()
+ m.statusMessage = fmt.Sprintf("Filters applied (%d videos)", len(m.filtered))
+ return nil</span>
+}
+
+func (m *model) syncFilterFocus() <span class="cov8" title="1">{
+ for i := range m.inputs.fields </span><span class="cov8" title="1">{
+ if i == m.inputs.focus </span><span class="cov8" title="1">{
+ m.inputs.fields[i].Focus()
+ continue</span>
+ }
+ <span class="cov8" title="1">m.inputs.fields[i].Blur()</span>
+ }
+}
+
+func (m model) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ switch msg.String() </span>{
+ case "/", "f":<span class="cov8" title="1">
+ return m.openFilters()</span>
+ case "enter":<span class="cov0" title="0">
+ return m.playSelection()</span>
+ case "n":<span class="cov0" title="0">
+ return m.sortAndReport(sortByName)</span>
+ case "l":<span class="cov8" title="1">
+ return m.sortAndReport(sortByDuration)</span>
+ case "a":<span class="cov0" title="0">
+ return m.sortAndReport(sortByAge)</span>
+ case "c":<span class="cov8" title="1">
+ return m.toggleCrop()</span>
+ case "t":<span class="cov0" title="0">
+ return m.openTagEditor()</span>
+ case "H":<span class="cov8" title="1">
+ return m.hideHelpBar()</span>
+ case "h":<span class="cov8" title="1">
+ return m.showHelpBar()</span>
+ case "r":<span class="cov8" title="1">
+ return m.resetFilterState()</span>
+ case "i":<span class="cov0" title="0">
+ return m, func() tea.Msg </span><span class="cov0" title="0">{ return reindexVideosMsg{} }</span>
+ case "x":<span class="cov8" title="1">
+ return m.selectRandomVideo()</span>
+ default:<span class="cov8" title="1">
+ return m.updateTable(msg)</span>
+ }
+}
+
+func (m model) openFilters() (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ m.showFilters = true
+ m.statusMessage = "Editing filters"
+ return m, nil
+}</span>
+
+func (m model) playSelection() (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if len(m.filtered) == 0 </span><span class="cov0" title="0">{
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">idx := m.table.Cursor()
+ if idx &lt; 0 || idx &gt;= len(m.filtered) </span><span class="cov0" title="0">{
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">video := m.filtered[idx]
+ m.statusMessage = fmt.Sprintf("Launching VLC: %s", video.Name)
+ return m, playVideoCmd(video.Path, m.activeCrop())</span>
+}
+
+func (m model) sortAndReport(field sortField) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ m.toggleSort(field)
+ m.applyFiltersAndSort()
+ m.statusMessage = fmt.Sprintf("Sorted %d videos", len(m.filtered))
+ return m, nil
+}</span>
+
+func (m model) toggleCrop() (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if m.cropValue == "" </span><span class="cov0" title="0">{
+ m.statusMessage = "No crop value set (start with --crop)"
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">m.cropEnabled = !m.cropEnabled
+ if m.cropEnabled </span><span class="cov8" title="1">{
+ m.statusMessage = fmt.Sprintf("Crop enabled (%s)", m.cropValue)
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">m.statusMessage = "Crop disabled"
+ return m, nil</span>
+}
+
+func (m model) resetFilterState() (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ m.resetFilters()
+ m.applyFiltersAndSort()
+ m.statusMessage = fmt.Sprintf("Filters cleared (%d videos)", len(m.filtered))
+ return m, nil
+}</span>
+
+func (m model) selectRandomVideo() (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if len(m.filtered) == 0 </span><span class="cov8" title="1">{
+ m.statusMessage = "No videos to select from"
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">idx := rand.Intn(len(m.filtered))
+ m.table.SetCursor(idx)
+ video := m.filtered[idx]
+ m.statusMessage = fmt.Sprintf("Randomly selected: %s", video.Name)
+ return m, nil</span>
+}
+</pre>
+
+ <pre class="file" id="file9" style="display: none">package app
+
+import (
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/table"
+)
+
+func (m *model) toggleSort(target sortField) <span class="cov8" title="1">{
+ if m.sortField == target </span><span class="cov8" title="1">{
+ m.sortAscending = !m.sortAscending
+ return
+ }</span>
+ <span class="cov8" title="1">m.sortField = target
+ m.sortAscending = true</span>
+}
+
+func (m *model) applyFiltersAndSort() <span class="cov8" title="1">{
+ filtered := make([]video, 0, len(m.videos))
+ for _, v := range m.videos </span><span class="cov8" title="1">{
+ if m.passesFilters(v) </span><span class="cov8" title="1">{
+ filtered = append(filtered, v)
+ }</span>
+ }
+ <span class="cov8" title="1">sort.Slice(filtered, func(i, j int) bool </span><span class="cov8" title="1">{
+ return m.less(filtered[i], filtered[j])
+ }</span>)
+ <span class="cov8" title="1">m.filtered = filtered
+ m.updateTableRows()</span>
+}
+
+func (m *model) less(a, b video) bool <span class="cov8" title="1">{
+ var less bool
+ switch m.sortField </span>{
+ case sortByName:<span class="cov8" title="1">
+ less = strings.ToLower(a.Name) &lt; strings.ToLower(b.Name)</span>
+ case sortByDuration:<span class="cov8" title="1">
+ less = a.Duration &lt; b.Duration</span>
+ case sortByAge:<span class="cov0" title="0">
+ less = a.ModTime.Before(b.ModTime)</span>
+ }
+ <span class="cov8" title="1">if m.sortAscending </span><span class="cov8" title="1">{
+ return less
+ }</span>
+ <span class="cov0" title="0">return !less</span>
+}
+
+func (m *model) updateTableRows() <span class="cov8" title="1">{
+ rows := make([]table.Row, 0, len(m.filtered))
+ for _, v := range m.filtered </span><span class="cov8" title="1">{
+ rows = append(rows, videoRow(v))
+ }</span>
+ <span class="cov8" title="1">m.table.SetRows(rows)
+ if len(rows) &gt; 0 </span><span class="cov8" title="1">{
+ m.table.SetCursor(0)
+ }</span>
+}
+</pre>
+
+ <pre class="file" id="file10" style="display: none">package app
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m model) openTagEditor() (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if len(m.filtered) == 0 </span><span class="cov0" title="0">{
+ m.statusMessage = "No videos to edit"
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">cursor := m.table.Cursor()
+ if cursor &lt; 0 || cursor &gt;= len(m.filtered) </span><span class="cov0" title="0">{
+ m.statusMessage = "No selection"
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">video := m.filtered[cursor]
+ m.editingTags = true
+ m.tagEditPath = video.Path
+ m.tagInput = cloneInput(m.tagInput)
+ m.tagInput.SetValue(strings.Join(video.Tags, ", "))
+ m.tagInput.CursorEnd()
+ m.tagInput.Focus()
+ m.statusMessage = fmt.Sprintf("Editing tags for %s", video.Name)
+ return m, nil</span>
+}
+
+func (m model) handleTagKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ switch msg.String() </span>{
+ case "esc":<span class="cov0" title="0">
+ m.editingTags = false
+ m.tagEditPath = ""
+ m.tagInput.Blur()
+ m.statusMessage = "Tag edit cancelled"
+ return m, nil</span>
+ case "enter":<span class="cov8" title="1">
+ return m.commitTags()</span>
+ }
+ <span class="cov0" title="0">var cmd tea.Cmd
+ m.tagInput, cmd = m.tagInput.Update(msg)
+ return m, cmd</span>
+}
+
+func (m model) commitTags() (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if m.tagEditPath == "" </span><span class="cov0" title="0">{
+ m.editingTags = false
+ m.tagInput.Blur()
+ m.statusMessage = "No video selected"
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">value := m.tagInput.Value()
+ tags := parseTagInput(value)
+ m.editingTags = false
+ m.tagInput.Blur()
+ path := m.tagEditPath
+ m.tagEditPath = ""
+ name := filepath.Base(path)
+ m.statusMessage = fmt.Sprintf("Saving tags for %s", name)
+ return m, saveTagsCmd(path, tags)</span>
+}
+
+func (m model) handleTagsSaved(msg tagsSavedMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{
+ if msg.err != nil </span><span class="cov0" title="0">{
+ m.editingTags = false
+ m.tagEditPath = ""
+ m.tagInput.Blur()
+ m.showHelp = true
+ m.statusMessage = fmt.Sprintf("Tag save error: %v", msg.err)
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">m.editingTags = false
+ m.tagEditPath = ""
+ m.tagInput.Blur()
+ m.showHelp = true
+ m.setVideoTags(msg.path, msg.tags)
+ m.applyFiltersAndSort()
+ m.restoreSelection(msg.path)
+ if len(msg.tags) == 0 </span><span class="cov0" title="0">{
+ m.statusMessage = "Tags cleared"
+ return m, nil
+ }</span>
+ <span class="cov8" title="1">m.statusMessage = fmt.Sprintf("Tags updated (%d)", len(msg.tags))
+ return m, nil</span>
+}
+
+func (m *model) setVideoTags(path string, tags []string) <span class="cov8" title="1">{
+ for i := range m.videos </span><span class="cov8" title="1">{
+ if m.videos[i].Path == path </span><span class="cov8" title="1">{
+ m.videos[i].Tags = append([]string{}, tags...)
+ return
+ }</span>
+ }
+}
+
+func parseTagInput(value string) []string <span class="cov8" title="1">{
+ if strings.TrimSpace(value) == "" </span><span class="cov8" title="1">{
+ return nil
+ }</span>
+ <span class="cov8" title="1">parts := strings.Split(value, ",")
+ var tags []string
+ seen := make(map[string]struct{}, len(parts))
+ for _, part := range parts </span><span class="cov8" title="1">{
+ trimmed := strings.TrimSpace(part)
+ if trimmed == "" </span><span class="cov8" title="1">{
+ continue</span>
+ }
+ <span class="cov8" title="1">lower := strings.ToLower(trimmed)
+ if _, ok := seen[lower]; ok </span><span class="cov8" title="1">{
+ continue</span>
+ }
+ <span class="cov8" title="1">seen[lower] = struct{}{}
+ tags = append(tags, trimmed)</span>
+ }
+ <span class="cov8" title="1">return tags</span>
+}
+
+func cloneInput(in textinput.Model) textinput.Model <span class="cov8" title="1">{
+ copy := in
+ return copy
+}</span>
+
+func (m model) renderTagModal() string <span class="cov0" title="0">{
+ var b strings.Builder
+ b.WriteString("Edit tags\n")
+ b.WriteString("(comma separated)\n\n")
+ b.WriteString(m.tagInput.View())
+ b.WriteString("\n\n")
+ b.WriteString("Enter to save, Esc to cancel")
+ return filterStyle.Render(b.String())
+}</span>
+</pre>
+
+ <pre class="file" id="file11" style="display: none">package app
+
+import (
+ "codeberg.org/snonux/yoga/internal/tags"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func saveTagsCmd(path string, entries []string) tea.Cmd <span class="cov8" title="1">{
+ // Copy slice to avoid accidental mutation after scheduling command.
+ values := append([]string{}, entries...)
+ return func() tea.Msg </span><span class="cov8" title="1">{
+ if err := tags.Save(path, values); err != nil </span><span class="cov0" title="0">{
+ return tagsSavedMsg{path: path, err: err}
+ }</span>
+ <span class="cov8" title="1">sanitized, err := tags.Load(path)
+ if err != nil </span><span class="cov0" title="0">{
+ return tagsSavedMsg{path: path, err: err}
+ }</span>
+ <span class="cov8" title="1">return tagsSavedMsg{path: path, tags: sanitized}</span>
+ }
+}
+</pre>
+
+ <pre class="file" id="file12" style="display: none">package app
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/bubbles/table"
+)
+
+func videoRow(v video) table.Row <span class="cov8" title="1">{
+ duration := "(unknown)"
+ if v.Duration &gt; 0 </span><span class="cov8" title="1">{
+ duration = formatDuration(v.Duration)
+ }</span>
+ <span class="cov8" title="1">age := humanizeAge(v.ModTime)
+ tags := formatTags(v.Tags)
+ if v.Err != nil </span><span class="cov8" title="1">{
+ duration = "!" + v.Err.Error()
+ }</span>
+ <span class="cov8" title="1">return table.Row{v.Name, duration, age, tags}</span>
+}
+
+func renderProgressBar(done, total, width int) string <span class="cov8" title="1">{
+ if width &lt;= 0 || total &lt;= 0 </span><span class="cov8" title="1">{
+ return ""
+ }</span>
+ <span class="cov8" title="1">if done &lt; 0 </span><span class="cov8" title="1">{
+ done = 0
+ }</span>
+ <span class="cov8" title="1">if done &gt; total </span><span class="cov0" title="0">{
+ done = total
+ }</span>
+ <span class="cov8" title="1">filled := int(float64(done) / float64(total) * float64(width))
+ if filled &gt; width </span><span class="cov0" title="0">{
+ filled = width
+ }</span>
+ <span class="cov8" title="1">bar := strings.Repeat("#", filled) + strings.Repeat("-", width-filled)
+ return fmt.Sprintf("[%s]", bar)</span>
+}
+
+func formatDuration(d time.Duration) string <span class="cov8" title="1">{
+ if d &lt;= 0 </span><span class="cov0" title="0">{
+ return "--"
+ }</span>
+ <span class="cov8" title="1">totalSeconds := int(d.Seconds() + 0.5)
+ hours := totalSeconds / 3600
+ minutes := (totalSeconds % 3600) / 60
+ seconds := totalSeconds % 60
+ if hours &gt; 0 </span><span class="cov0" title="0">{
+ return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
+ }</span>
+ <span class="cov8" title="1">return fmt.Sprintf("%02d:%02d", minutes, seconds)</span>
+}
+
+func humanizeAge(t time.Time) string <span class="cov8" title="1">{
+ if t.IsZero() </span><span class="cov8" title="1">{
+ return "--"
+ }</span>
+ <span class="cov8" title="1">dur := time.Since(t)
+ if dur &lt; time.Minute </span><span class="cov8" title="1">{
+ return "just now"
+ }</span>
+ <span class="cov8" title="1">if dur &lt; time.Hour </span><span class="cov0" title="0">{
+ return fmt.Sprintf("%dm ago", int(dur.Minutes()))
+ }</span>
+ <span class="cov8" title="1">if dur &lt; 24*time.Hour </span><span class="cov8" title="1">{
+ return fmt.Sprintf("%dh ago", int(dur.Hours()))
+ }</span>
+ <span class="cov0" title="0">return t.Format("2006-01-02")</span>
+}
+
+func trimPath(path string) string <span class="cov8" title="1">{
+ home, err := os.UserHomeDir()
+ if err == nil &amp;&amp; strings.HasPrefix(path, home) </span><span class="cov0" title="0">{
+ return "~" + strings.TrimPrefix(path, home)
+ }</span>
+ <span class="cov8" title="1">return path</span>
+}
+
+func formatTags(tags []string) string <span class="cov8" title="1">{
+ if len(tags) == 0 </span><span class="cov8" title="1">{
+ return "--"
+ }</span>
+ <span class="cov8" title="1">return strings.Join(tags, ", ")</span>
+}
+</pre>
+
+ <pre class="file" id="file13" style="display: none">package fsutil
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+)
+
+// ResolveRootPath expands and validates the supplied root path. When the
+// caller did not specify a value, defaultValue is used and created on demand.
+func ResolveRootPath(input, defaultValue string) (string, error) <span class="cov8" title="1">{
+ value, isDefault := normalizeRootInput(input, defaultValue)
+ expanded, err := expandPath(value)
+ if err != nil </span><span class="cov0" title="0">{
+ return "", fmt.Errorf("cannot expand root path %q: %w", value, err)
+ }</span>
+ <span class="cov8" title="1">abs, err := filepath.Abs(expanded)
+ if err != nil </span><span class="cov0" title="0">{
+ return "", fmt.Errorf("cannot resolve root path %q: %w", expanded, err)
+ }</span>
+ <span class="cov8" title="1">info, err := ensureRootExists(abs, isDefault)
+ if err != nil </span><span class="cov8" title="1">{
+ return "", err
+ }</span>
+ <span class="cov8" title="1">if !info.IsDir() &amp;&amp; !info.Mode().IsRegular() </span><span class="cov0" title="0">{
+ return "", fmt.Errorf("root path %q is not a file or directory", abs)
+ }</span>
+ <span class="cov8" title="1">return abs, nil</span>
+}
+
+func normalizeRootInput(input, defaultValue string) (value string, isDefault bool) <span class="cov8" title="1">{
+ trimmed := strings.TrimSpace(input)
+ if trimmed == "" </span><span class="cov8" title="1">{
+ return defaultValue, true
+ }</span>
+ <span class="cov8" title="1">return trimmed, false</span>
+}
+
+func ensureRootExists(path string, allowCreate bool) (fs.FileInfo, error) <span class="cov8" title="1">{
+ info, err := os.Stat(path)
+ if err == nil </span><span class="cov8" title="1">{
+ return info, nil
+ }</span>
+ <span class="cov8" title="1">if !errors.Is(err, fs.ErrNotExist) </span><span class="cov0" title="0">{
+ return nil, fmt.Errorf("cannot access root path %q: %w", path, err)
+ }</span>
+ <span class="cov8" title="1">if !allowCreate </span><span class="cov8" title="1">{
+ return nil, fmt.Errorf("root path does not exist: %s", path)
+ }</span>
+ <span class="cov8" title="1">if mkErr := os.MkdirAll(path, 0o755); mkErr != nil </span><span class="cov0" title="0">{
+ return nil, fmt.Errorf("cannot create default directory %q: %w", path, mkErr)
+ }</span>
+ <span class="cov8" title="1">info, err = os.Stat(path)
+ if err != nil </span><span class="cov0" title="0">{
+ return nil, fmt.Errorf("cannot stat default directory %q: %w", path, err)
+ }</span>
+ <span class="cov8" title="1">return info, nil</span>
+}
+
+func expandPath(p string) (string, error) <span class="cov8" title="1">{
+ if p == "" || p[0] != '~' </span><span class="cov8" title="1">{
+ return p, nil
+ }</span>
+ <span class="cov8" title="1">if len(p) == 1 </span><span class="cov8" title="1">{
+ home, err := os.UserHomeDir()
+ if err != nil </span><span class="cov0" title="0">{
+ return "", err
+ }</span>
+ <span class="cov8" title="1">return home, nil</span>
+ }
+ <span class="cov8" title="1">if p[1] == '/' </span><span class="cov8" title="1">{
+ home, err := os.UserHomeDir()
+ if err != nil </span><span class="cov0" title="0">{
+ return "", err
+ }</span>
+ <span class="cov8" title="1">return filepath.Join(home, p[2:]), nil</span>
+ }
+ <span class="cov8" title="1">username, rest := splitUserPath(p)
+ usr, err := user.Lookup(username)
+ if err != nil </span><span class="cov8" title="1">{
+ return "", err
+ }</span>
+ <span class="cov8" title="1">if rest == "" </span><span class="cov8" title="1">{
+ return usr.HomeDir, nil
+ }</span>
+ <span class="cov0" title="0">return filepath.Join(usr.HomeDir, rest), nil</span>
+}
+
+func splitUserPath(p string) (string, string) <span class="cov8" title="1">{
+ sep := strings.IndexRune(p, '/')
+ if sep == -1 </span><span class="cov8" title="1">{
+ return p[1:], ""
+ }</span>
+ <span class="cov8" title="1">return p[1:sep], p[sep:]</span>
+}
+</pre>
+
+ <pre class="file" id="file14" style="display: none">package tags
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// PathFor returns the path to the tag metadata file for the given video path.
+func PathFor(videoPath string) string <span class="cov8" title="1">{
+ ext := filepath.Ext(videoPath)
+ if strings.EqualFold(ext, ".mp4") </span><span class="cov8" title="1">{
+ return strings.TrimSuffix(videoPath, ext) + ".json"
+ }</span>
+ <span class="cov0" title="0">return videoPath + ".json"</span>
+}
+
+// Load reads the tags associated with a video. Missing files yield an empty slice.
+func Load(videoPath string) ([]string, error) <span class="cov8" title="1">{
+ metadataPath := PathFor(videoPath)
+ data, err := os.ReadFile(metadataPath)
+ if err != nil </span><span class="cov8" title="1">{
+ if errors.Is(err, os.ErrNotExist) </span><span class="cov8" title="1">{
+ return nil, nil
+ }</span>
+ <span class="cov0" title="0">return nil, err</span>
+ }
+ <span class="cov8" title="1">var parsed []string
+ if err := json.Unmarshal(data, &amp;parsed); err != nil </span><span class="cov0" title="0">{
+ return nil, err
+ }</span>
+ <span class="cov8" title="1">return sanitize(parsed), nil</span>
+}
+
+// Save persists the tags for a video to its metadata file.
+func Save(videoPath string, tagValues []string) error <span class="cov8" title="1">{
+ metadataPath := PathFor(videoPath)
+ cleaned := sanitize(tagValues)
+ payload, err := json.MarshalIndent(cleaned, "", " ")
+ if err != nil </span><span class="cov0" title="0">{
+ return err
+ }</span>
+ <span class="cov8" title="1">return os.WriteFile(metadataPath, payload, 0o644)</span>
+}
+
+func sanitize(raw []string) []string <span class="cov8" title="1">{
+ if len(raw) == 0 </span><span class="cov0" title="0">{
+ return []string{}
+ }</span>
+ <span class="cov8" title="1">seen := make(map[string]struct{}, len(raw))
+ var cleaned []string
+ for _, tag := range raw </span><span class="cov8" title="1">{
+ trimmed := strings.TrimSpace(tag)
+ if trimmed == "" </span><span class="cov0" title="0">{
+ continue</span>
+ }
+ <span class="cov8" title="1">normalized := trimmed
+ if _, ok := seen[normalized]; ok </span><span class="cov8" title="1">{
+ continue</span>
+ }
+ <span class="cov8" title="1">seen[normalized] = struct{}{}
+ cleaned = append(cleaned, normalized)</span>
+ }
+ <span class="cov8" title="1">sort.Strings(cleaned)
+ return cleaned</span>
+}
+</pre>
+
+ </div>
+ </body>
+ <script>
+ (function() {
+ var files = document.getElementById('files');
+ var visible;
+ files.addEventListener('change', onChange, false);
+ function select(part) {
+ if (visible)
+ visible.style.display = 'none';
+ visible = document.getElementById(part);
+ if (!visible)
+ return;
+ files.value = part;
+ visible.style.display = 'block';
+ location.hash = part;
+ }
+ function onChange() {
+ select(files.value);
+ window.scrollTo(0, 0);
+ }
+ if (location.hash != "") {
+ select(location.hash.substr(1));
+ }
+ if (!visible) {
+ select("file0");
+ }
+ })();
+ </script>
+</html>
diff --git a/coverage.out b/coverage.out
new file mode 100644
index 0000000..d7d58c6
--- /dev/null
+++ b/coverage.out
@@ -0,0 +1,582 @@
+mode: set
+codeberg.org/snonux/yoga/cmd/yoga/main.go:22.13,24.2 1 1
+codeberg.org/snonux/yoga/cmd/yoga/main.go:26.55,32.39 6 1
+codeberg.org/snonux/yoga/cmd/yoga/main.go:32.39,34.3 1 0
+codeberg.org/snonux/yoga/cmd/yoga/main.go:35.2,35.18 1 1
+codeberg.org/snonux/yoga/cmd/yoga/main.go:35.18,38.3 2 1
+codeberg.org/snonux/yoga/cmd/yoga/main.go:39.2,40.16 2 1
+codeberg.org/snonux/yoga/cmd/yoga/main.go:40.16,43.3 2 0
+codeberg.org/snonux/yoga/cmd/yoga/main.go:44.2,45.37 2 1
+codeberg.org/snonux/yoga/cmd/yoga/main.go:45.37,48.3 2 1
+codeberg.org/snonux/yoga/cmd/yoga/main.go:49.2,49.10 1 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:13.39,15.36 2 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:15.36,17.3 1 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:18.2,18.28 1 0
+codeberg.org/snonux/yoga/internal/tags/tags.go:22.47,25.16 3 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:25.16,26.37 1 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:26.37,28.4 1 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:29.3,29.18 1 0
+codeberg.org/snonux/yoga/internal/tags/tags.go:31.2,32.54 2 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:32.54,34.3 1 0
+codeberg.org/snonux/yoga/internal/tags/tags.go:35.2,35.30 1 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:39.55,43.16 4 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:43.16,45.3 1 0
+codeberg.org/snonux/yoga/internal/tags/tags.go:46.2,46.51 1 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:49.38,50.19 1 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:50.19,52.3 1 0
+codeberg.org/snonux/yoga/internal/tags/tags.go:53.2,55.26 3 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:55.26,57.20 2 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:57.20,58.12 1 0
+codeberg.org/snonux/yoga/internal/tags/tags.go:60.3,61.36 2 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:61.36,62.12 1 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:64.3,65.40 2 1
+codeberg.org/snonux/yoga/internal/tags/tags.go:67.2,68.16 2 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:15.66,18.16 3 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:18.16,20.3 1 0
+codeberg.org/snonux/yoga/internal/fsutil/path.go:21.2,22.16 2 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:22.16,24.3 1 0
+codeberg.org/snonux/yoga/internal/fsutil/path.go:25.2,26.16 2 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:26.16,28.3 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:29.2,29.47 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:29.47,31.3 1 0
+codeberg.org/snonux/yoga/internal/fsutil/path.go:32.2,32.17 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:35.84,37.19 2 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:37.19,39.3 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:40.2,40.23 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:43.75,45.16 2 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:45.16,47.3 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:48.2,48.37 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:48.37,50.3 1 0
+codeberg.org/snonux/yoga/internal/fsutil/path.go:51.2,51.18 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:51.18,53.3 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:54.2,54.53 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:54.53,56.3 1 0
+codeberg.org/snonux/yoga/internal/fsutil/path.go:57.2,58.16 2 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:58.16,60.3 1 0
+codeberg.org/snonux/yoga/internal/fsutil/path.go:61.2,61.18 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:64.43,65.28 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:65.28,67.3 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:68.2,68.17 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:68.17,70.17 2 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:70.17,72.4 1 0
+codeberg.org/snonux/yoga/internal/fsutil/path.go:73.3,73.19 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:75.2,75.17 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:75.17,77.17 2 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:77.17,79.4 1 0
+codeberg.org/snonux/yoga/internal/fsutil/path.go:80.3,80.41 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:82.2,84.16 3 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:84.16,86.3 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:87.2,87.16 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:87.16,89.3 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:90.2,90.46 1 0
+codeberg.org/snonux/yoga/internal/fsutil/path.go:93.47,95.15 2 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:95.15,97.3 1 1
+codeberg.org/snonux/yoga/internal/fsutil/path.go:98.2,98.26 1 1
+codeberg.org/snonux/yoga/internal/app/app.go:13.51,15.2 1 0
+codeberg.org/snonux/yoga/internal/app/app.go:18.30,20.16 2 1
+codeberg.org/snonux/yoga/internal/app/app.go:20.16,22.3 1 0
+codeberg.org/snonux/yoga/internal/app/app.go:23.2,24.41 2 1
+codeberg.org/snonux/yoga/internal/app/app.go:24.41,26.3 1 1
+codeberg.org/snonux/yoga/internal/app/app.go:27.2,27.12 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:25.51,27.2 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:29.61,32.16 3 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:32.16,33.37 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:33.37,35.4 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:36.3,36.20 1 0
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:38.2,38.20 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:38.20,40.3 1 0
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:41.2,41.61 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:41.61,44.3 2 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:45.2,45.19 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:48.85,52.9 4 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:52.9,54.3 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:55.2,55.77 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:55.77,59.3 3 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:60.2,60.32 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:60.32,62.3 1 0
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:63.2,63.74 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:66.88,67.26 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:67.26,69.3 1 0
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:70.2,72.22 3 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:72.22,74.3 1 0
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:75.2,81.12 3 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:84.39,85.14 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:85.14,87.3 1 0
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:88.2,89.14 2 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:89.14,92.3 2 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:93.2,94.30 2 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:94.30,96.3 1 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:97.2,100.16 4 1
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:100.16,102.3 1 0
+codeberg.org/snonux/yoga/internal/app/duration_cache.go:103.2,103.42 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:28.43,35.61 6 1
+codeberg.org/snonux/yoga/internal/app/filters.go:35.61,37.3 1 0
+codeberg.org/snonux/yoga/internal/app/filters.go:38.2,38.61 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:38.61,40.3 1 0
+codeberg.org/snonux/yoga/internal/app/filters.go:41.2,41.89 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:41.89,43.3 1 0
+codeberg.org/snonux/yoga/internal/app/filters.go:44.2,45.12 2 1
+codeberg.org/snonux/yoga/internal/app/filters.go:48.62,49.17 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:49.17,51.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:52.2,53.16 2 1
+codeberg.org/snonux/yoga/internal/app/filters.go:53.16,55.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:56.2,56.17 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:56.17,58.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:59.2,61.12 3 1
+codeberg.org/snonux/yoga/internal/app/filters.go:64.62,65.17 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:65.17,67.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:68.2,69.16 2 1
+codeberg.org/snonux/yoga/internal/app/filters.go:69.16,71.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:72.2,72.17 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:72.17,74.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:75.2,77.12 3 1
+codeberg.org/snonux/yoga/internal/app/filters.go:80.32,82.33 2 1
+codeberg.org/snonux/yoga/internal/app/filters.go:82.33,84.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:87.73,90.31 3 1
+codeberg.org/snonux/yoga/internal/app/filters.go:90.31,94.3 3 1
+codeberg.org/snonux/yoga/internal/app/filters.go:95.2,95.35 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:98.41,100.26 2 1
+codeberg.org/snonux/yoga/internal/app/filters.go:100.26,102.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:103.2,103.26 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:103.26,105.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:106.2,106.26 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:106.26,108.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:109.2,109.26 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:109.26,111.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:112.2,112.21 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:112.21,114.3 1 0
+codeberg.org/snonux/yoga/internal/app/filters.go:115.2,115.34 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:118.45,119.105 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:119.105,121.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:122.2,123.84 2 1
+codeberg.org/snonux/yoga/internal/app/filters.go:123.84,125.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:126.2,126.84 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:126.84,128.3 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:129.2,129.26 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:129.26,132.30 3 1
+codeberg.org/snonux/yoga/internal/app/filters.go:132.30,133.53 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:133.53,135.10 2 1
+codeberg.org/snonux/yoga/internal/app/filters.go:138.3,138.15 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:138.15,140.4 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:142.2,142.13 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:145.44,150.40 5 1
+codeberg.org/snonux/yoga/internal/app/filters.go:150.40,152.26 2 1
+codeberg.org/snonux/yoga/internal/app/filters.go:152.26,154.4 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:155.3,156.22 2 1
+codeberg.org/snonux/yoga/internal/app/filters.go:158.2,158.74 1 1
+codeberg.org/snonux/yoga/internal/app/filters.go:158.74,162.3 3 0
+codeberg.org/snonux/yoga/internal/app/filters.go:163.2,163.39 1 1
+codeberg.org/snonux/yoga/internal/app/load_progress.go:12.32,13.14 1 1
+codeberg.org/snonux/yoga/internal/app/load_progress.go:13.14,15.3 1 0
+codeberg.org/snonux/yoga/internal/app/load_progress.go:16.2,20.15 5 1
+codeberg.org/snonux/yoga/internal/app/load_progress.go:23.44,24.14 1 1
+codeberg.org/snonux/yoga/internal/app/load_progress.go:24.14,26.3 1 0
+codeberg.org/snonux/yoga/internal/app/load_progress.go:27.2,29.15 3 1
+codeberg.org/snonux/yoga/internal/app/load_progress.go:32.36,33.14 1 1
+codeberg.org/snonux/yoga/internal/app/load_progress.go:33.14,35.3 1 0
+codeberg.org/snonux/yoga/internal/app/load_progress.go:36.2,38.15 3 1
+codeberg.org/snonux/yoga/internal/app/load_progress.go:41.35,42.14 1 1
+codeberg.org/snonux/yoga/internal/app/load_progress.go:42.14,44.3 1 0
+codeberg.org/snonux/yoga/internal/app/load_progress.go:45.2,47.15 3 1
+codeberg.org/snonux/yoga/internal/app/load_progress.go:50.69,51.14 1 1
+codeberg.org/snonux/yoga/internal/app/load_progress.go:51.14,53.3 1 0
+codeberg.org/snonux/yoga/internal/app/load_progress.go:54.2,56.37 3 1
+codeberg.org/snonux/yoga/internal/app/loader.go:21.76,22.24 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:22.24,25.22 3 1
+codeberg.org/snonux/yoga/internal/app/loader.go:25.22,27.4 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:28.3,28.119 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:32.56,33.21 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:33.21,35.3 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:36.2,36.64 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:36.64,39.3 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:42.110,44.16 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:44.16,46.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:47.2,47.21 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:47.21,49.3 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:50.2,53.29 4 1
+codeberg.org/snonux/yoga/internal/app/loader.go:53.29,55.21 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:55.21,58.12 3 1
+codeberg.org/snonux/yoga/internal/app/loader.go:60.3,61.15 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:61.15,63.4 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:64.3,65.20 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:65.20,67.4 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:68.3,76.22 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:78.2,78.52 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:81.42,82.24 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:82.24,84.3 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:85.2,85.49 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:88.40,89.21 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:89.21,91.3 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:94.88,95.18 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:95.18,97.3 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:98.2,99.9 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:99.9,101.3 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:102.2,102.12 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:105.55,107.16 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:107.16,109.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:110.2,110.19 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:110.19,111.20 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:111.20,113.4 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:114.3,114.18 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:116.2,118.72 3 1
+codeberg.org/snonux/yoga/internal/app/loader.go:118.72,120.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:121.2,122.19 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:125.105,127.16 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:127.16,129.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:130.2,131.40 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:131.40,133.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:134.2,137.16 3 1
+codeberg.org/snonux/yoga/internal/app/loader.go:137.16,139.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:140.2,140.32 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:140.32,145.29 5 1
+codeberg.org/snonux/yoga/internal/app/loader.go:145.29,147.18 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:147.18,149.5 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:150.4,150.22 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:152.3,152.31 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:152.31,153.79 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:153.79,155.5 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:156.4,156.12 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:158.3,158.19 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:158.19,159.84 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:159.84,161.5 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:162.4,162.12 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:164.3,164.28 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:164.28,166.4 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:168.2,168.12 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:171.102,173.16 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:173.16,175.3 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:176.2,177.16 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:177.16,179.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:180.2,180.24 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:180.24,182.3 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:183.2,183.50 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:183.50,185.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:186.2,186.12 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:189.54,190.19 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:190.19,192.3 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:193.2,193.12 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:196.67,197.24 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:197.24,199.33 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:199.33,200.54 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:200.54,202.5 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:204.3,204.64 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:208.56,214.16 5 1
+codeberg.org/snonux/yoga/internal/app/loader.go:214.16,216.3 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:217.2,218.15 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:218.15,220.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:221.2,222.16 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:222.16,224.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:225.2,225.59 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:228.46,229.24 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:229.24,232.37 3 1
+codeberg.org/snonux/yoga/internal/app/loader.go:232.37,234.4 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:235.3,235.13 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:235.13,235.31 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:236.3,236.34 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:240.47,242.16 2 1
+codeberg.org/snonux/yoga/internal/app/loader.go:242.16,244.3 1 0
+codeberg.org/snonux/yoga/internal/app/loader.go:245.2,245.27 1 1
+codeberg.org/snonux/yoga/internal/app/loader.go:248.32,252.2 3 1
+codeberg.org/snonux/yoga/internal/app/loader.go:255.62,257.2 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:63.44,87.2 7 1
+codeberg.org/snonux/yoga/internal/app/model.go:89.31,103.2 4 1
+codeberg.org/snonux/yoga/internal/app/model.go:105.39,130.2 17 1
+codeberg.org/snonux/yoga/internal/app/model.go:132.38,138.2 5 1
+codeberg.org/snonux/yoga/internal/app/model.go:140.84,147.2 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:149.31,150.23 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:150.23,152.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:153.2,154.23 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:154.23,156.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:157.2,157.16 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:160.57,161.29 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:162.18,163.31 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:164.25,165.39 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:166.25,167.39 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:168.23,169.37 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:170.20,171.39 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:172.24,173.38 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:174.20,175.34 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:176.25,177.35 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:178.10,179.28 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:183.30,184.15 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:184.15,186.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:187.2,188.19 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:188.19,190.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:191.2,191.19 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:191.19,193.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:194.2,194.13 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:197.36,205.24 6 1
+codeberg.org/snonux/yoga/internal/app/model.go:205.24,207.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:208.2,209.16 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:209.16,212.3 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:213.2,213.34 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:216.36,219.16 3 1
+codeberg.org/snonux/yoga/internal/app/model.go:219.16,221.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:222.2,222.36 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:222.36,224.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:225.2,225.47 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:228.51,229.16 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:229.16,231.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:232.2,233.54 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:233.54,235.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:236.2,236.15 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:239.51,240.17 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:240.17,242.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:243.2,245.15 3 1
+codeberg.org/snonux/yoga/internal/app/model.go:248.77,253.16 5 1
+codeberg.org/snonux/yoga/internal/app/model.go:253.16,255.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:256.2,256.15 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:259.47,260.21 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:260.21,262.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:263.2,266.29 4 1
+codeberg.org/snonux/yoga/internal/app/model.go:266.29,268.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:269.2,274.31 6 1
+codeberg.org/snonux/yoga/internal/app/model.go:274.31,277.3 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:277.8,279.18 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:279.18,283.4 3 1
+codeberg.org/snonux/yoga/internal/app/model.go:284.3,284.18 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:284.18,288.4 3 1
+codeberg.org/snonux/yoga/internal/app/model.go:289.3,289.18 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:289.18,293.4 3 0
+codeberg.org/snonux/yoga/internal/app/model.go:294.3,294.18 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:294.18,297.4 2 0
+codeberg.org/snonux/yoga/internal/app/model.go:299.2,300.32 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:303.24,304.11 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:304.11,306.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:307.2,307.10 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:310.44,311.26 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:311.26,313.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:314.2,315.104 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:318.62,322.2 3 1
+codeberg.org/snonux/yoga/internal/app/model.go:324.56,325.20 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:325.20,328.3 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:329.2,330.10 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:333.79,336.2 2 0
+codeberg.org/snonux/yoga/internal/app/model.go:338.77,340.20 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:340.20,343.3 2 0
+codeberg.org/snonux/yoga/internal/app/model.go:345.2,345.24 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:345.24,347.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:347.8,349.30 2 0
+codeberg.org/snonux/yoga/internal/app/model.go:349.30,351.4 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:353.3,353.39 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:353.39,354.50 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:354.50,356.5 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:356.10,358.5 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:362.2,369.27 8 1
+codeberg.org/snonux/yoga/internal/app/model.go:369.27,371.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:372.2,373.15 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:376.60,377.26 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:377.26,381.3 3 0
+codeberg.org/snonux/yoga/internal/app/model.go:382.2,383.26 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:383.26,385.26 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:385.26,387.4 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:388.8,390.26 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:390.26,392.4 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:394.2,394.23 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:394.23,396.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:397.2,398.26 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:401.48,402.34 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:402.34,404.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:405.2,406.17 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:406.17,408.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:409.2,409.17 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:409.17,411.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:412.2,412.39 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:412.39,414.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:415.2,416.31 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:416.31,418.17 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:418.17,420.4 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:422.2,422.20 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:422.20,424.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:425.2,425.27 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:428.46,429.34 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:429.34,431.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:432.2,435.41 4 1
+codeberg.org/snonux/yoga/internal/app/model.go:438.36,439.40 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:439.40,441.3 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:442.2,442.11 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:445.81,446.16 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:446.16,448.3 1 0
+codeberg.org/snonux/yoga/internal/app/model.go:449.2,449.32 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:449.32,452.3 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:453.2,453.14 1 1
+codeberg.org/snonux/yoga/internal/app/model.go:453.14,456.3 2 1
+codeberg.org/snonux/yoga/internal/app/model.go:457.2,458.41 2 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:11.81,12.20 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:12.20,16.3 3 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:17.2,17.28 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:17.28,19.3 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:20.2,23.30 4 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:23.30,26.3 2 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:27.2,28.15 2 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:31.64,32.20 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:32.20,35.3 2 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:36.2,36.25 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:36.25,38.3 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:41.46,43.39 2 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:43.39,45.3 1 0
+codeberg.org/snonux/yoga/internal/app/model_durations.go:46.2,46.29 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:49.47,50.16 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:50.16,52.3 1 0
+codeberg.org/snonux/yoga/internal/app/model_durations.go:53.2,53.35 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:53.35,54.25 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:54.25,57.4 2 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:61.80,62.26 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:62.26,63.31 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:63.31,64.12 1 0
+codeberg.org/snonux/yoga/internal/app/model_durations.go:66.3,68.9 3 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:72.44,74.2 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:76.39,77.20 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:77.20,78.41 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:78.41,80.4 1 0
+codeberg.org/snonux/yoga/internal/app/model_durations.go:80.9,82.4 1 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:83.3,84.9 2 1
+codeberg.org/snonux/yoga/internal/app/model_durations.go:86.2,87.24 2 0
+codeberg.org/snonux/yoga/internal/app/model_durations.go:90.38,95.2 4 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:10.66,11.52 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:11.52,13.3 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:14.2,14.15 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:14.15,16.3 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:17.2,17.19 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:17.19,19.3 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:20.2,20.19 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:20.19,22.3 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:23.2,23.30 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:26.55,27.22 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:28.21,29.24 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:30.10,31.20 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:35.69,36.22 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:37.13,40.16 3 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:41.15,43.16 2 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:44.13,45.63 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:46.19,47.86 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:49.2,52.15 4 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:55.50,56.46 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:56.46,59.3 2 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:60.2,63.12 4 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:66.35,67.33 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:67.33,68.26 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:68.26,70.12 2 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:72.3,72.28 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:76.68,77.22 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:78.16,79.25 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:80.15,81.27 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:82.11,83.37 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:84.11,85.41 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:86.11,87.36 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:88.11,89.24 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:90.11,91.27 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:92.11,93.25 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:94.11,95.25 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:96.11,97.30 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:98.11,99.28 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:99.28,99.57 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:100.11,101.31 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:102.10,103.28 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:107.51,111.2 3 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:113.53,114.26 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:114.26,116.3 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:117.2,118.39 2 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:118.39,120.3 1 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:121.2,123.52 3 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:126.68,131.2 4 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:133.50,134.23 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:134.23,137.3 2 0
+codeberg.org/snonux/yoga/internal/app/model_keys.go:138.2,139.19 2 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:139.19,142.3 2 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:143.2,144.15 2 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:147.56,152.2 4 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:154.57,155.26 1 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:155.26,158.3 2 1
+codeberg.org/snonux/yoga/internal/app/model_keys.go:159.2,163.15 5 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:10.46,11.27 1 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:11.27,14.3 2 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:15.2,16.24 2 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:19.39,21.29 2 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:21.29,22.25 1 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:22.25,24.4 1 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:26.2,26.43 1 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:26.43,28.3 1 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:29.2,30.21 2 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:33.39,35.21 2 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:36.18,37.59 1 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:38.22,39.33 1 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:40.17,41.37 1 0
+codeberg.org/snonux/yoga/internal/app/model_sort.go:43.2,43.21 1 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:43.21,45.3 1 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:46.2,46.14 1 0
+codeberg.org/snonux/yoga/internal/app/model_sort.go:49.35,51.31 2 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:51.31,53.3 1 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:54.2,55.19 2 1
+codeberg.org/snonux/yoga/internal/app/model_sort.go:55.19,57.3 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:12.53,13.26 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:13.26,16.3 2 0
+codeberg.org/snonux/yoga/internal/app/model_tags.go:17.2,18.45 2 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:18.45,21.3 2 0
+codeberg.org/snonux/yoga/internal/app/model_tags.go:22.2,30.15 9 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:33.66,34.22 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:35.13,40.16 5 0
+codeberg.org/snonux/yoga/internal/app/model_tags.go:41.15,42.24 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:44.2,46.15 3 0
+codeberg.org/snonux/yoga/internal/app/model_tags.go:49.50,50.25 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:50.25,55.3 4 0
+codeberg.org/snonux/yoga/internal/app/model_tags.go:56.2,64.35 9 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:67.71,68.20 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:68.20,75.3 6 0
+codeberg.org/snonux/yoga/internal/app/model_tags.go:76.2,83.24 8 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:83.24,86.3 2 0
+codeberg.org/snonux/yoga/internal/app/model_tags.go:87.2,88.15 2 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:91.58,92.26 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:92.26,93.31 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:93.31,96.4 2 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:100.43,101.36 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:101.36,103.3 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:104.2,107.29 4 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:107.29,109.20 2 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:109.20,110.12 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:112.3,113.31 2 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:113.31,114.12 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:116.3,117.31 2 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:119.2,119.13 1 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:122.53,125.2 2 1
+codeberg.org/snonux/yoga/internal/app/model_tags.go:127.40,135.2 7 0
+codeberg.org/snonux/yoga/internal/app/tag_commands.go:8.57,11.24 2 1
+codeberg.org/snonux/yoga/internal/app/tag_commands.go:11.24,12.49 1 1
+codeberg.org/snonux/yoga/internal/app/tag_commands.go:12.49,14.4 1 0
+codeberg.org/snonux/yoga/internal/app/tag_commands.go:15.3,16.17 2 1
+codeberg.org/snonux/yoga/internal/app/tag_commands.go:16.17,18.4 1 0
+codeberg.org/snonux/yoga/internal/app/tag_commands.go:19.3,19.51 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:12.34,14.20 2 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:14.20,16.3 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:17.2,19.18 3 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:19.18,21.3 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:22.2,22.47 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:25.55,26.30 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:26.30,28.3 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:29.2,29.14 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:29.14,31.3 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:32.2,32.18 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:32.18,34.3 1 0
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:35.2,36.20 2 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:36.20,38.3 1 0
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:39.2,40.33 2 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:43.45,44.12 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:44.12,46.3 1 0
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:47.2,51.15 5 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:51.15,53.3 1 0
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:54.2,54.51 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:57.38,58.16 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:58.16,60.3 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:61.2,62.23 2 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:62.23,64.3 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:65.2,65.21 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:65.21,67.3 1 0
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:68.2,68.24 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:68.24,70.3 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:71.2,71.31 1 0
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:74.35,76.49 2 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:76.49,78.3 1 0
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:79.2,79.13 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:82.39,83.20 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:83.20,85.3 1 1
+codeberg.org/snonux/yoga/internal/app/view_helpers.go:86.2,86.33 1 1
diff --git a/internal/app/model_keys.go b/internal/app/model_keys.go
index 0e786d4..facf466 100644
--- a/internal/app/model_keys.go
+++ b/internal/app/model_keys.go
@@ -2,6 +2,7 @@ package app
import (
"fmt"
+ "math/rand"
tea "github.com/charmbracelet/bubbletea"
)
@@ -96,6 +97,8 @@ func (m model) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.resetFilterState()
case "i":
return m, func() tea.Msg { return reindexVideosMsg{} }
+ case "x":
+ return m.selectRandomVideo()
default:
return m.updateTable(msg)
}
@@ -147,3 +150,15 @@ func (m model) resetFilterState() (tea.Model, tea.Cmd) {
m.statusMessage = fmt.Sprintf("Filters cleared (%d videos)", len(m.filtered))
return m, nil
}
+
+func (m model) selectRandomVideo() (tea.Model, tea.Cmd) {
+ if len(m.filtered) == 0 {
+ m.statusMessage = "No videos to select from"
+ return m, nil
+ }
+ idx := rand.Intn(len(m.filtered))
+ m.table.SetCursor(idx)
+ video := m.filtered[idx]
+ m.statusMessage = fmt.Sprintf("Randomly selected: %s", video.Name)
+ return m, nil
+}
diff --git a/internal/app/model_test.go b/internal/app/model_test.go
index 4cb9bcf..62efc0d 100644
--- a/internal/app/model_test.go
+++ b/internal/app/model_test.go
@@ -2,6 +2,7 @@ package app
import (
"errors"
+ "fmt"
"os"
"path/filepath"
"strings"
@@ -717,3 +718,491 @@ func keyMsg(value string) tea.KeyMsg {
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value), Alt: false}
}
+
+func TestSelectRandomVideoWithVideos(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := []video{
+ {Name: "yoga1.mp4", Path: filepath.Join(root, "yoga1.mp4"), Duration: 10 * time.Minute},
+ {Name: "yoga2.mp4", Path: filepath.Join(root, "yoga2.mp4"), Duration: 20 * time.Minute},
+ {Name: "yoga3.mp4", Path: filepath.Join(root, "yoga3.mp4"), Duration: 30 * time.Minute},
+ }
+ m.loading = false
+ m.filtered = videos
+ m.table.SetRows([]table.Row{videoRow(videos[0]), videoRow(videos[1]), videoRow(videos[2])})
+
+ modelAny, cmd := m.selectRandomVideo()
+ m = modelAny.(model)
+
+ if cmd != nil {
+ t.Fatalf("expected no command")
+ }
+ if !strings.Contains(m.statusMessage, "Randomly selected:") {
+ t.Fatalf("expected random selection message, got %s", m.statusMessage)
+ }
+ cursor := m.table.Cursor()
+ if cursor < 0 || cursor >= len(videos) {
+ t.Fatalf("expected cursor in valid range, got %d", cursor)
+ }
+ if !strings.Contains(m.statusMessage, videos[cursor].Name) {
+ t.Fatalf("expected selected video name in message")
+ }
+}
+
+func TestSelectRandomVideoWithNoVideos(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filtered = []video{}
+ modelAny, cmd := m.selectRandomVideo()
+ m = modelAny.(model)
+
+ if cmd != nil {
+ t.Fatalf("expected no command")
+ }
+ if !strings.Contains(m.statusMessage, "No videos to select from") {
+ t.Fatalf("expected no videos message, got %s", m.statusMessage)
+ }
+}
+
+func TestSelectRandomVideoViaKeyHandler(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := []video{
+ {Name: "clip1.mp4", Path: filepath.Join(root, "clip1.mp4")},
+ {Name: "clip2.mp4", Path: filepath.Join(root, "clip2.mp4")},
+ }
+ m.loading = false
+ m.filtered = videos
+ m.table.SetRows([]table.Row{videoRow(videos[0]), videoRow(videos[1])})
+
+ modelAny, _ := m.handleKeyMsg(keyMsg("x"))
+ m = modelAny.(model)
+
+ if !strings.Contains(m.statusMessage, "Randomly selected:") {
+ t.Fatalf("expected random selection via key handler")
+ }
+}
+
+func TestSelectRandomVideoMultipleTimes(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := make([]video, 10)
+ rows := make([]table.Row, 10)
+ for i := 0; i < 10; i++ {
+ videos[i] = video{Name: fmt.Sprintf("video%d.mp4", i), Path: filepath.Join(root, fmt.Sprintf("video%d.mp4", i))}
+ rows[i] = videoRow(videos[i])
+ }
+ m.loading = false
+ m.filtered = videos
+ m.table.SetRows(rows)
+
+ selections := make(map[string]int)
+ for i := 0; i < 50; i++ {
+ modelAny, _ := m.selectRandomVideo()
+ m = modelAny.(model)
+ cursor := m.table.Cursor()
+ selections[videos[cursor].Name]++
+ }
+
+ if len(selections) < 5 {
+ t.Fatalf("expected randomness across multiple calls, only got %d unique selections", len(selections))
+ }
+}
+
+func TestSelectRandomVideoWithFilteredResults(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.videos = []video{
+ {Name: "morning flow.mp4", Path: filepath.Join(root, "morning.mp4"), Duration: 10 * time.Minute},
+ {Name: "evening flow.mp4", Path: filepath.Join(root, "evening.mp4"), Duration: 30 * time.Minute},
+ {Name: "power.mp4", Path: filepath.Join(root, "power.mp4"), Duration: 45 * time.Minute},
+ }
+
+ m.filters = filterState{name: "flow"}
+ m.applyFiltersAndSort()
+
+ if len(m.filtered) != 2 {
+ t.Fatalf("expected 2 filtered videos, got %d", len(m.filtered))
+ }
+
+ modelAny, _ := m.selectRandomVideo()
+ m = modelAny.(model)
+
+ cursor := m.table.Cursor()
+ selected := m.filtered[cursor]
+ if !strings.Contains(selected.Name, "flow") {
+ t.Fatalf("expected selected video to match filter, got %s", selected.Name)
+ }
+}
+
+func TestSelectRandomVideoPluralityOfSelections(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ videos := make([]video, 3)
+ rows := make([]table.Row, 3)
+ for i := 0; i < 3; i++ {
+ videos[i] = video{Name: fmt.Sprintf("yoga%d.mp4", i), Path: filepath.Join(root, fmt.Sprintf("yoga%d.mp4", i))}
+ rows[i] = videoRow(videos[i])
+ }
+ m.filtered = videos
+ m.table.SetRows(rows)
+
+ first, _ := m.selectRandomVideo()
+ firstModel := first.(model)
+ firstCursor := firstModel.table.Cursor()
+
+ second, _ := firstModel.selectRandomVideo()
+ secondModel := second.(model)
+ secondCursor := secondModel.table.Cursor()
+
+ if firstCursor == secondCursor {
+ third, _ := secondModel.selectRandomVideo()
+ thirdModel := third.(model)
+ thirdCursor := thirdModel.table.Cursor()
+ if secondCursor == thirdCursor {
+ t.Fatalf("expected randomness shown by at least some different selections")
+ }
+ }
+}
+
+func TestHandleKeyMsgDispatchRandom(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filtered = []video{
+ {Name: "a.mp4", Path: "a.mp4"},
+ {Name: "b.mp4", Path: "b.mp4"},
+ }
+ m.table.SetRows([]table.Row{videoRow(m.filtered[0]), videoRow(m.filtered[1])})
+
+ modelAny, cmd := m.handleKeyMsg(keyMsg("x"))
+ m = modelAny.(model)
+
+ if cmd != nil {
+ t.Fatalf("expected no command for random selection")
+ }
+ if !strings.Contains(m.statusMessage, "Randomly selected") {
+ t.Fatalf("expected random selection triggered via dispatch")
+ }
+}
+
+func TestHandleKeyMsgDispatchUnknownKey(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filtered = []video{{Name: "a.mp4", Path: "a.mp4"}}
+ m.table.SetRows([]table.Row{videoRow(m.filtered[0])})
+
+ modelAny, _ := m.handleKeyMsg(keyMsg("Z"))
+ _ = modelAny.(model)
+}
+
+func TestHandleKeyMsgTableKeyWhileLoading(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = true
+
+ modelAny, cmd := m.handleKeyMsg(keyMsg("x"))
+ m = modelAny.(model)
+
+ if cmd != nil {
+ t.Fatalf("expected no command while loading")
+ }
+ if !strings.Contains(m.statusMessage, "Scanning") {
+ t.Fatalf("should ignore key input while loading")
+ }
+}
+
+func TestHandleReindexVideosCmd(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filtered = []video{{Name: "test.mp4", Path: filepath.Join(root, "test.mp4")}}
+
+ modelAny, cmd := m.handleReindexVideos(reindexVideosMsg{})
+ m = modelAny.(model)
+
+ if cmd == nil {
+ t.Fatalf("expected command for re-index")
+ }
+ if !strings.Contains(m.statusMessage, "Re-indexing") {
+ t.Fatalf("expected re-indexing status, got %s", m.statusMessage)
+ }
+}
+
+func TestRenderModalRendering(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.editingTags = true
+ m.filtered = []video{{Name: "test.mp4", Path: "test.mp4", Tags: []string{"calm"}}}
+
+ view := m.View()
+ if !strings.Contains(view, "Tags:") {
+ t.Fatalf("expected tag input in view")
+ }
+}
+
+func TestSelectRandomVideoSingleItem(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filtered = []video{{Name: "only.mp4", Path: filepath.Join(root, "only.mp4")}}
+ m.table.SetRows([]table.Row{videoRow(m.filtered[0])})
+
+ modelAny, _ := m.selectRandomVideo()
+ m = modelAny.(model)
+
+ if m.table.Cursor() != 0 {
+ t.Fatalf("expected cursor at 0 for single item")
+ }
+ if !strings.Contains(m.statusMessage, "only.mp4") {
+ t.Fatalf("expected status with video name")
+ }
+}
+
+func TestHandleTableKeyAllShortcuts(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filtered = []video{{Name: "a.mp4", Path: filepath.Join(root, "a.mp4"), Duration: 10 * time.Minute}}
+ m.table.SetRows([]table.Row{videoRow(m.filtered[0])})
+
+ tests := []struct {
+ key string
+ fn func(model) model
+ }{
+ {"n", func(m model) model { modelAny, _ := m.handleKeyMsg(keyMsg("n")); return modelAny.(model) }},
+ {"l", func(m model) model { modelAny, _ := m.handleKeyMsg(keyMsg("l")); return modelAny.(model) }},
+ {"a", func(m model) model { modelAny, _ := m.handleKeyMsg(keyMsg("a")); return modelAny.(model) }},
+ {"r", func(m model) model { modelAny, _ := m.handleKeyMsg(keyMsg("r")); return modelAny.(model) }},
+ }
+
+ for _, test := range tests {
+ result := test.fn(m)
+ if result.statusMessage == "" {
+ t.Fatalf("expected status for key %s", test.key)
+ }
+ }
+}
+
+func TestUpdateWithPlayVideoMsg(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+
+ modelAny, _ := m.Update(playVideoMsg{path: "video.mp4"})
+ m = modelAny.(model)
+
+ if !strings.Contains(m.statusMessage, "Playing") {
+ t.Fatalf("expected playing status")
+ }
+}
+
+func TestUpdateWithWindowSizeMsg(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+
+ modelAny, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
+ m = modelAny.(model)
+
+ if m.viewportWidth != 100 {
+ t.Fatalf("expected viewport width updated")
+ }
+}
+
+func TestUpdateWithTagsSavedMsg(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ videoPath := filepath.Join(root, "test.mp4")
+ m.videos = []video{{Name: "test.mp4", Path: videoPath}}
+ m.filtered = m.videos
+
+ modelAny, _ := m.Update(tagsSavedMsg{path: videoPath, tags: []string{"new"}, err: nil})
+ m = modelAny.(model)
+
+ if !strings.Contains(m.statusMessage, "Tags updated") {
+ t.Fatalf("expected tags updated message")
+ }
+}
+
+func TestUpdateKeyMsgRouting(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filtered = []video{{Name: "a.mp4", Path: "a.mp4"}}
+ m.table.SetRows([]table.Row{videoRow(m.filtered[0])})
+
+ modelAny, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
+ m = modelAny.(model)
+
+ if !strings.Contains(m.statusMessage, "Randomly selected") {
+ t.Fatalf("expected key message routed to handler")
+ }
+}
+
+func TestSelectRandomVideoIntegrationWithFilter(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.videos = []video{
+ {Name: "morning1.mp4", Path: filepath.Join(root, "morning1.mp4"), Duration: 15 * time.Minute},
+ {Name: "morning2.mp4", Path: filepath.Join(root, "morning2.mp4"), Duration: 25 * time.Minute},
+ {Name: "evening1.mp4", Path: filepath.Join(root, "evening1.mp4"), Duration: 45 * time.Minute},
+ }
+
+ m.filters = filterState{minEnabled: true, minMinutes: 20}
+ m.applyFiltersAndSort()
+
+ if len(m.filtered) != 2 {
+ t.Fatalf("expected 2 filtered videos")
+ }
+
+ modelAny, _ := m.selectRandomVideo()
+ m = modelAny.(model)
+
+ selected := m.filtered[m.table.Cursor()]
+ if selected.Duration < 20*time.Minute {
+ t.Fatalf("expected selected video to respect filter")
+ }
+}
+
+func TestDurationCacheRecord(t *testing.T) {
+ tmpDir := t.TempDir()
+ videoPath := filepath.Join(tmpDir, "video.mp4")
+ if err := os.WriteFile(videoPath, []byte("test"), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ cacheFile := filepath.Join(tmpDir, "cache.json")
+ cache := newDurationCache(cacheFile)
+
+ info, err := os.Stat(videoPath)
+ if err != nil {
+ t.Fatalf("stat: %v", err)
+ }
+
+ if err := cache.Record(videoPath, info, 5*time.Minute); err != nil {
+ t.Fatalf("Record: %v", err)
+ }
+
+ result, ok := cache.Lookup(videoPath, info)
+ if !ok || result != 5*time.Minute {
+ t.Fatalf("expected duration to be recorded and retrieved")
+ }
+}
+
+func TestApplyFilterInputsCoverage(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.inputs.fields[0].SetValue("test")
+ m.inputs.fields[1].SetValue("5")
+ m.inputs.fields[2].SetValue("10")
+ m.inputs.fields[3].SetValue("tag")
+
+ if err := m.applyFilterInputs(); err != nil {
+ t.Fatalf("applyFilterInputs: %v", err)
+ }
+ if m.filters.name != "test" || m.filters.minMinutes != 5 || m.filters.maxMinutes != 10 || m.filters.tags != "tag" {
+ t.Fatalf("expected all filter fields populated")
+ }
+}
+
+func TestHideHelpBar(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ if !m.showHelp {
+ t.Fatalf("expected help to start shown")
+ }
+
+ modelAny, _ := m.hideHelpBar()
+ m = modelAny.(model)
+
+ if m.showHelp {
+ t.Fatalf("expected help to be hidden")
+ }
+}
+
+func TestCanSelectRandomWhenCached(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filtered = make([]video, 20)
+ for i := 0; i < 20; i++ {
+ m.filtered[i] = video{Name: fmt.Sprintf("v%d.mp4", i), Path: fmt.Sprintf("path%d", i)}
+ }
+
+ rows := make([]table.Row, 20)
+ for i := 0; i < 20; i++ {
+ rows[i] = videoRow(m.filtered[i])
+ }
+ m.table.SetRows(rows)
+
+ m.table.SetCursor(0)
+ modelAny, _ := m.selectRandomVideo()
+ m = modelAny.(model)
+
+ if m.table.Cursor() == 0 {
+ t.Fatalf("expected cursor to move to random position")
+ }
+}
diff --git a/yoga b/yoga
new file mode 100755
index 0000000..5f44779
--- /dev/null
+++ b/yoga
Binary files differ