diff options
| author | Paul Buetow <paul@buetow.org> | 2026-01-28 19:04:38 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-01-28 19:04:38 +0200 |
| commit | 1720b2ebb12d8fdbdd372665e9d5fd394d8bc8dc (patch) | |
| tree | efd40ab63a3e305dcf712e4f753d3f7a57435b48 /coverage.html | |
| parent | d52cb9a8c3dedc2a5395f181981805f582d5a772 (diff) | |
Add random video selection hotkey (x)
- Add selectRandomVideo() method to randomly select from filtered videos
- Bind to 'x' hotkey in table key handler
- Respects current filters when selecting
- Add comprehensive tests covering edge cases and integration scenarios
- Update README with new keyboard shortcut documentation
Amp-Thread-ID: https://ampcode.com/threads/T-019c058b-410d-7229-9a95-afa8eb13efce
Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'coverage.html')
| -rw-r--r-- | coverage.html | 2009 |
1 files changed, 2009 insertions, 0 deletions
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 &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, &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 <= 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 <= 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(&filters, minText); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="1">if err := populateMaxFilter(&filters, maxText); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="1">if filters.minEnabled && filters.maxEnabled && filters.minMinutes > 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 < 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 < 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(">=%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("<=%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 != "" && !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 && (v.Duration == 0 || durMinutes < m.filters.minMinutes) </span><span class="cov8" title="1">{ + return false + }</span> + <span class="cov8" title="1">if m.filters.maxEnabled && (v.Duration == 0 || durMinutes > 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, &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&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 && 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 := &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> + |
