summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-10-02 08:38:03 +0300
committerPaul Buetow <paul@buetow.org>2025-10-02 08:38:03 +0300
commit0c1b108ff5fccf39ae5bc6dc06802ce565bda633 (patch)
tree914e65e04bae26d3eae565f9d6a64d08ade361d0
parent36be499ed342d92969ccaaff083c557a0951def9 (diff)
new version major refactorv0.2.0
-rw-r--r--AGENTS.md28
-rw-r--r--README.md45
-rw-r--r--cmd/yoga/main.go50
-rw-r--r--cmd/yoga/main_test.go85
-rw-r--r--internal/app/app.go28
-rw-r--r--internal/app/app_test.go38
-rw-r--r--internal/app/duration_cache.go104
-rw-r--r--internal/app/duration_cache_test.go76
-rw-r--r--internal/app/filters.go146
-rw-r--r--internal/app/filters_test.go35
-rw-r--r--internal/app/load_progress.go57
-rw-r--r--internal/app/load_progress_test.go25
-rw-r--r--internal/app/loader.go241
-rw-r--r--internal/app/loader_test.go162
-rw-r--r--internal/app/messages.go28
-rw-r--r--internal/app/model.go198
-rw-r--r--internal/app/model_durations.go181
-rw-r--r--internal/app/model_keys.go138
-rw-r--r--internal/app/model_sort.go58
-rw-r--r--internal/app/model_test.go482
-rw-r--r--internal/app/options.go7
-rw-r--r--internal/app/style.go19
-rw-r--r--internal/app/video.go12
-rw-r--r--internal/app/view_helpers.go80
-rw-r--r--internal/fsutil/path.go99
-rw-r--r--internal/fsutil/path_test.go112
-rw-r--r--internal/meta/version.go3
-rw-r--r--magefile.go78
-rw-r--r--main.go1207
-rw-r--r--main_test.go119
30 files changed, 2614 insertions, 1327 deletions
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..f92a896
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,28 @@
+# Repository Guidelines
+
+## Code structur
+
+- Minimal entrace point, in ./cmd/yoga/main.go, all other code goes to the ./internal directory.
+
+## Coding Style & Naming Conventions
+
+- Avoid duplication of code when the functions are larger than 5 lines.
+- If possible, construct individual methods so that they can be unit tested. But only if it doesn't add too much boilerplate to the code base.
+- Aim for at least 85% unit test coverage of all source code. The command to check the coverage is "mage coverage"
+- Ensure that all unit tests pass before commiting any changes.
+- Always run the gofumpt code reformatter on all go files modified.
+- There should be no source code file larger than 1000 lines. If so, split it up into multiple.
+- There should be no function larger then 50 lines. If so, refactor or split up into multiple smaller functions.
+- Code (when added): follow language idioms
+- Any type with more than 3 methods should be in it's own source code file, whereas the filename contains the name of the type.
+
+## Incrementing version
+
+- Never draft a changelog entry
+- Whenever incrementing the version, update the version number in the project, commit to git, tag the version and push to git.
+- When a major feature was introduced, increment ?.X.?
+- When only minor changes were done or only bugs were fixed, increment the version as ?.?.X
+
+## Documentation
+
+- Document in the README all options and basic behaviour and also how to use the Magefile.
diff --git a/README.md b/README.md
index 1ea7a30..ad3ed2e 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,48 @@
# Yoga
-A yoga video selector. Fully vibe-coded.
+Yoga is a TUI for browsing local yoga videos with quick filtering, duration probing, and one-key playback via VLC.
![Yoga](yoga.png)
+
+## Usage
+
+```bash
+yoga [--root PATH] [--crop WxH] [--version]
+```
+
+- `--root` sets the directory to scan for videos. When omitted, Yoga uses `~/Yoga` and creates it on first launch.
+- `--crop` supplies an optional VLC crop string (for example `5:4`). Toggle the crop at runtime with the `c` key.
+- `--version` prints the current version and exits.
+
+Yoga recognises common video extensions (`.mp4`, `.mkv`, `.mov`, `.avi`, `.wmv`, `.m4v`) and follows symlinks when scanning. Duration metadata is cached per directory in `.video_duration_cache.json`.
+
+### Keyboard Shortcuts
+
+- `↑/↓` – Navigate the table
+- `enter` – Play the selected video in VLC
+- `/` or `f` – Open the filter dialog
+- `r` – Reset filters
+- `n`, `l`, `a` – Sort by name, length, or age
+- `c` – Toggle VLC crop
+- `q` – Quit
+
+## Development
+
+The project uses [Mage](https://magefile.org/) for common tasks. Targets live in `magefile.go`.
+
+```bash
+mage build # go build ./cmd/yoga
+mage test # go test ./...
+mage install # go install ./cmd/yoga
+mage coverage # go test with coverage (fails if <85%)
+```
+
+Before sending changes:
+
+1. Format Go code with `gofumpt`.
+2. Run `mage test` and `mage coverage` to ensure the suite passes and coverage stays above 85%.
+3. Update documentation when flags or behaviour change.
+
+## Licensing
+
+This repository is released under the terms specified in the accompanying license file (if present).
diff --git a/cmd/yoga/main.go b/cmd/yoga/main.go
new file mode 100644
index 0000000..fbf11bd
--- /dev/null
+++ b/cmd/yoga/main.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "yoga/internal/app"
+ "yoga/internal/fsutil"
+ "yoga/internal/meta"
+)
+
+const defaultRoot = "~/Yoga"
+
+var (
+ runApp = app.Run
+ exit = os.Exit
+)
+
+func main() {
+ exit(run(os.Args[1:], os.Stdout, os.Stderr))
+}
+
+func run(args []string, stdout, stderr io.Writer) int {
+ fs := flag.NewFlagSet("yoga", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ rootFlag := fs.String("root", "", "Directory containing yoga videos (default ~/Yoga)")
+ cropFlag := fs.String("crop", "", "Optional crop aspect for VLC (e.g. 5:4)")
+ versionFlag := fs.Bool("version", false, "Print version and exit")
+ if err := fs.Parse(args); err != nil {
+ return 2
+ }
+ if *versionFlag {
+ fmt.Fprintf(stdout, "Yoga version %s\n", meta.Version)
+ return 0
+ }
+ root, err := fsutil.ResolveRootPath(*rootFlag, defaultRoot)
+ if err != nil {
+ fmt.Fprintf(stderr, "%v\n", err)
+ return 1
+ }
+ opts := app.Options{Root: root, Crop: strings.TrimSpace(*cropFlag)}
+ if err := runApp(opts); err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return 1
+ }
+ return 0
+}
diff --git a/cmd/yoga/main_test.go b/cmd/yoga/main_test.go
new file mode 100644
index 0000000..d06ff33
--- /dev/null
+++ b/cmd/yoga/main_test.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "yoga/internal/app"
+)
+
+func TestRunPrintsVersion(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ code := run([]string{"--version"}, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("expected exit code 0, got %d", code)
+ }
+ if !bytes.Contains(stdout.Bytes(), []byte("Yoga version")) {
+ t.Fatalf("expected version output, got %s", stdout.String())
+ }
+}
+
+func TestRunSuccess(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ root := t.TempDir()
+ orig := runApp
+ runApp = func(opts app.Options) error { return nil }
+ defer func() { runApp = orig }()
+ code := run([]string{"--root", root}, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("expected exit code 0, got %d", code)
+ }
+}
+
+func TestRunAppError(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ root := t.TempDir()
+ orig := runApp
+ runApp = func(app.Options) error { return errors.New("boom") }
+ defer func() { runApp = orig }()
+ code := run([]string{"--root", root}, &stdout, &stderr)
+ if code != 1 {
+ t.Fatalf("expected exit code 1, got %d", code)
+ }
+ if !bytes.Contains(stderr.Bytes(), []byte("error:")) {
+ t.Fatalf("expected error output, got %s", stderr.String())
+ }
+}
+
+func TestRunDefaultRootCreated(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ orig := runApp
+ runApp = func(opts app.Options) error {
+ if _, err := os.Stat(filepath.Join(home, "Yoga")); err != nil {
+ t.Fatalf("expected default directory: %v", err)
+ }
+ return nil
+ }
+ defer func() { runApp = orig }()
+ code := run(nil, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("expected exit code 0, got %d", code)
+ }
+}
+
+func TestMainUsesExit(t *testing.T) {
+ root := t.TempDir()
+ origRun := runApp
+ origExit := exit
+ runApp = func(opts app.Options) error { return nil }
+ var code int
+ exit = func(c int) { code = c }
+ defer func() {
+ runApp = origRun
+ exit = origExit
+ }()
+ os.Args = []string{"yoga", "--root", root}
+ main()
+ if code != 0 {
+ t.Fatalf("expected exit code 0, got %d", code)
+ }
+}
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..ca70f3c
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,28 @@
+package app
+
+import (
+ "fmt"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type teaProgram interface {
+ Run() (tea.Model, error)
+}
+
+var programFactory = func(m tea.Model) teaProgram {
+ return tea.NewProgram(m, tea.WithAltScreen())
+}
+
+// Run bootstraps the Bubble Tea program with the provided options.
+func Run(opts Options) error {
+ model, err := newModel(opts)
+ if err != nil {
+ return fmt.Errorf("create model: %w", err)
+ }
+ program := programFactory(model)
+ if _, err := program.Run(); err != nil {
+ return fmt.Errorf("run program: %w", err)
+ }
+ return nil
+}
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
new file mode 100644
index 0000000..ec96dbd
--- /dev/null
+++ b/internal/app/app_test.go
@@ -0,0 +1,38 @@
+package app
+
+import (
+ "errors"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type stubProgram struct {
+ err error
+}
+
+func (s stubProgram) Run() (tea.Model, error) {
+ return nil, s.err
+}
+
+func TestRunInvokesProgram(t *testing.T) {
+ t.Helper()
+ original := programFactory
+ defer func() { programFactory = original }()
+ programFactory = func(tea.Model) teaProgram { return stubProgram{} }
+ if err := Run(Options{Root: t.TempDir()}); err != nil {
+ t.Fatalf("Run returned error: %v", err)
+ }
+}
+
+func TestRunPropagatesError(t *testing.T) {
+ t.Helper()
+ original := programFactory
+ defer func() { programFactory = original }()
+ errRun := errors.New("boom")
+ programFactory = func(tea.Model) teaProgram { return stubProgram{err: errRun} }
+ err := Run(Options{Root: t.TempDir()})
+ if !errors.Is(err, errRun) {
+ t.Fatalf("expected error propagation, got %v", err)
+ }
+}
diff --git a/internal/app/duration_cache.go b/internal/app/duration_cache.go
new file mode 100644
index 0000000..43172b5
--- /dev/null
+++ b/internal/app/duration_cache.go
@@ -0,0 +1,104 @@
+package app
+
+import (
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "os"
+ "sync"
+ "time"
+)
+
+type cacheEntry struct {
+ DurationSeconds float64 `json:"duration_seconds"`
+ ModTimeUnix int64 `json:"mod_time_unix"`
+ Size int64 `json:"size"`
+}
+
+type durationCache struct {
+ path string
+ entries map[string]cacheEntry
+ mu sync.Mutex
+ dirty bool
+}
+
+func newDurationCache(path string) *durationCache {
+ return &durationCache{path: path, entries: make(map[string]cacheEntry)}
+}
+
+func loadDurationCache(path string) (*durationCache, error) {
+ cache := newDurationCache(path)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return cache, nil
+ }
+ return cache, err
+ }
+ if len(data) == 0 {
+ return cache, nil
+ }
+ if err := json.Unmarshal(data, &cache.entries); err != nil {
+ cache.entries = make(map[string]cacheEntry)
+ return cache, err
+ }
+ return cache, nil
+}
+
+func (c *durationCache) Lookup(path string, info os.FileInfo) (time.Duration, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ entry, ok := c.entries[path]
+ if !ok {
+ return 0, false
+ }
+ if entry.ModTimeUnix != info.ModTime().Unix() || entry.Size != info.Size() {
+ delete(c.entries, path)
+ c.dirty = true
+ return 0, false
+ }
+ if entry.DurationSeconds <= 0 {
+ return 0, false
+ }
+ return time.Duration(entry.DurationSeconds * float64(time.Second)), true
+}
+
+func (c *durationCache) Record(path string, info os.FileInfo, dur time.Duration) error {
+ if c == nil || dur <= 0 {
+ return nil
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.entries == nil {
+ c.entries = make(map[string]cacheEntry)
+ }
+ c.entries[path] = cacheEntry{
+ DurationSeconds: dur.Seconds(),
+ ModTimeUnix: info.ModTime().Unix(),
+ Size: info.Size(),
+ }
+ c.dirty = true
+ return nil
+}
+
+func (c *durationCache) Flush() error {
+ if c == nil {
+ return nil
+ }
+ c.mu.Lock()
+ if !c.dirty {
+ c.mu.Unlock()
+ return nil
+ }
+ snapshot := make(map[string]cacheEntry, len(c.entries))
+ for k, v := range c.entries {
+ snapshot[k] = v
+ }
+ c.dirty = false
+ c.mu.Unlock()
+ data, err := json.MarshalIndent(snapshot, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(c.path, data, 0o644)
+}
diff --git a/internal/app/duration_cache_test.go b/internal/app/duration_cache_test.go
new file mode 100644
index 0000000..3830277
--- /dev/null
+++ b/internal/app/duration_cache_test.go
@@ -0,0 +1,76 @@
+package app
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestDurationCacheRecordLifecycle(t *testing.T) {
+ dir := t.TempDir()
+ cachePath := filepath.Join(dir, "cache.json")
+ cache, err := loadDurationCache(cachePath)
+ if err != nil {
+ t.Fatalf("load cache: %v", err)
+ }
+ video := filepath.Join(dir, "video.mp4")
+ if err := os.WriteFile(video, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write video: %v", err)
+ }
+ info, err := os.Stat(video)
+ if err != nil {
+ t.Fatalf("stat video: %v", err)
+ }
+ duration := 90 * time.Second
+ if err := cache.Record(video, info, duration); err != nil {
+ t.Fatalf("record: %v", err)
+ }
+ if err := cache.Flush(); err != nil {
+ t.Fatalf("flush: %v", err)
+ }
+ cache2, err := loadDurationCache(cachePath)
+ if err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ dur, ok := cache2.Lookup(video, info)
+ if !ok {
+ t.Fatalf("expected cached entry")
+ }
+ if dur != duration {
+ t.Fatalf("expected %v, got %v", duration, dur)
+ }
+}
+
+func TestDurationCacheInvalidatesOnChange(t *testing.T) {
+ dir := t.TempDir()
+ cache := newDurationCache(filepath.Join(dir, "cache.json"))
+ video := filepath.Join(dir, "video.mp4")
+ if err := os.WriteFile(video, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ info, _ := os.Stat(video)
+ _ = cache.Record(video, info, 30*time.Second)
+ if err := os.WriteFile(video, []byte("xx"), 0o644); err != nil {
+ t.Fatalf("rewrite: %v", err)
+ }
+ info, _ = os.Stat(video)
+ if dur, ok := cache.Lookup(video, info); ok || dur != 0 {
+ t.Fatalf("expected cache miss after change")
+ }
+}
+
+func TestLoadDurationCacheInvalidJSON(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "cache.json")
+ if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil {
+ t.Fatalf("write cache: %v", err)
+ }
+ cache, err := loadDurationCache(path)
+ if err == nil {
+ t.Fatalf("expected error for invalid json")
+ }
+ if len(cache.entries) != 0 {
+ t.Fatalf("expected cache to reset entries")
+ }
+}
diff --git a/internal/app/filters.go b/internal/app/filters.go
new file mode 100644
index 0000000..691be41
--- /dev/null
+++ b/internal/app/filters.go
@@ -0,0 +1,146 @@
+package app
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type filterState struct {
+ name string
+ minEnabled bool
+ minMinutes int
+ maxEnabled bool
+ maxMinutes int
+}
+
+type filterInputs struct {
+ fields []textinput.Model
+ focus int
+}
+
+func (m *model) applyFilterInputs() error {
+ name := strings.TrimSpace(m.inputs.fields[0].Value())
+ minText := strings.TrimSpace(m.inputs.fields[1].Value())
+ maxText := strings.TrimSpace(m.inputs.fields[2].Value())
+
+ filters := filterState{name: name}
+ if err := populateMinFilter(&filters, minText); err != nil {
+ return err
+ }
+ if err := populateMaxFilter(&filters, maxText); err != nil {
+ return err
+ }
+ if filters.minEnabled && filters.maxEnabled && filters.minMinutes > filters.maxMinutes {
+ return errors.New("min minutes cannot exceed max minutes")
+ }
+ m.filters = filters
+ return nil
+}
+
+func populateMinFilter(dst *filterState, value string) error {
+ if value == "" {
+ return nil
+ }
+ minutes, err := strconv.Atoi(value)
+ if err != nil {
+ return fmt.Errorf("invalid min minutes: %q", value)
+ }
+ if minutes < 0 {
+ return errors.New("min minutes must be positive")
+ }
+ dst.minEnabled = true
+ dst.minMinutes = minutes
+ return nil
+}
+
+func populateMaxFilter(dst *filterState, value string) error {
+ if value == "" {
+ return nil
+ }
+ minutes, err := strconv.Atoi(value)
+ if err != nil {
+ return fmt.Errorf("invalid max minutes: %q", value)
+ }
+ if minutes < 0 {
+ return errors.New("max minutes must be positive")
+ }
+ dst.maxEnabled = true
+ dst.maxMinutes = minutes
+ return nil
+}
+
+func (m *model) resetFilters() {
+ m.filters = filterState{}
+ for i := range m.inputs.fields {
+ m.inputs.fields[i].SetValue("")
+ }
+}
+
+func (m *model) updateFilterInputs(msg tea.Msg) (filterInputs, tea.Cmd) {
+ inputs := m.inputs
+ var cmds []tea.Cmd
+ for i := range inputs.fields {
+ var cmd tea.Cmd
+ inputs.fields[i], cmd = inputs.fields[i].Update(msg)
+ cmds = append(cmds, cmd)
+ }
+ return inputs, tea.Batch(cmds...)
+}
+
+func (m model) describeFilters() string {
+ parts := []string{}
+ if m.filters.name != "" {
+ parts = append(parts, fmt.Sprintf("name contains %q", m.filters.name))
+ }
+ if m.filters.minEnabled {
+ parts = append(parts, fmt.Sprintf(">=%d min", m.filters.minMinutes))
+ }
+ if m.filters.maxEnabled {
+ parts = append(parts, fmt.Sprintf("<=%d min", m.filters.maxMinutes))
+ }
+ if len(parts) == 0 {
+ return "(none)"
+ }
+ return strings.Join(parts, ", ")
+}
+
+func (m *model) passesFilters(v video) bool {
+ if m.filters.name != "" && !strings.Contains(strings.ToLower(v.Name), strings.ToLower(m.filters.name)) {
+ return false
+ }
+ durMinutes := int(v.Duration.Round(time.Minute) / time.Minute)
+ if m.filters.minEnabled && (v.Duration == 0 || durMinutes < m.filters.minMinutes) {
+ return false
+ }
+ if m.filters.maxEnabled && (v.Duration == 0 || durMinutes > m.filters.maxMinutes) {
+ return false
+ }
+ return true
+}
+
+func (m *model) renderFilterModal() string {
+ var b strings.Builder
+ b.WriteString("Filter videos\n")
+ b.WriteString("(Enter to apply, Esc to cancel)\n\n")
+ labels := []string{"Name contains:", "Min length (minutes):", "Max length (minutes):"}
+ for i, field := range m.inputs.fields {
+ line := fmt.Sprintf("%s %s", labels[i], field.View())
+ if i == m.inputs.focus {
+ line = highlightStyle.Render(line)
+ }
+ b.WriteString(line)
+ b.WriteString("\n")
+ }
+ if m.filters.minEnabled || m.filters.maxEnabled || m.filters.name != "" {
+ b.WriteString("\nCurrent filter: ")
+ b.WriteString(m.describeFilters())
+ b.WriteString("\n")
+ }
+ return filterStyle.Render(b.String())
+}
diff --git a/internal/app/filters_test.go b/internal/app/filters_test.go
new file mode 100644
index 0000000..10eed13
--- /dev/null
+++ b/internal/app/filters_test.go
@@ -0,0 +1,35 @@
+package app
+
+import "testing"
+
+func TestPopulateMinFilterErrors(t *testing.T) {
+ var state filterState
+ if err := populateMinFilter(&state, "-1"); err == nil {
+ t.Fatal("expected error for negative minutes")
+ }
+ if err := populateMinFilter(&state, "abc"); err == nil {
+ t.Fatal("expected error for invalid integer")
+ }
+ if err := populateMinFilter(&state, "10"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !state.minEnabled || state.minMinutes != 10 {
+ t.Fatalf("expected state updated, got %+v", state)
+ }
+}
+
+func TestPopulateMaxFilterErrors(t *testing.T) {
+ var state filterState
+ if err := populateMaxFilter(&state, "-1"); err == nil {
+ t.Fatal("expected error for negative minutes")
+ }
+ if err := populateMaxFilter(&state, "abc"); err == nil {
+ t.Fatal("expected error for invalid integer")
+ }
+ if err := populateMaxFilter(&state, "20"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !state.maxEnabled || state.maxMinutes != 20 {
+ t.Fatalf("expected state updated, got %+v", state)
+ }
+}
diff --git a/internal/app/load_progress.go b/internal/app/load_progress.go
new file mode 100644
index 0000000..38679fa
--- /dev/null
+++ b/internal/app/load_progress.go
@@ -0,0 +1,57 @@
+package app
+
+import "sync"
+
+type loadProgress struct {
+ mu sync.Mutex
+ total int
+ processed int
+ done bool
+}
+
+func (p *loadProgress) Reset() {
+ if p == nil {
+ return
+ }
+ p.mu.Lock()
+ p.total = 0
+ p.processed = 0
+ p.done = false
+ p.mu.Unlock()
+}
+
+func (p *loadProgress) SetTotal(total int) {
+ if p == nil {
+ return
+ }
+ p.mu.Lock()
+ p.total = total
+ p.mu.Unlock()
+}
+
+func (p *loadProgress) Increment() {
+ if p == nil {
+ return
+ }
+ p.mu.Lock()
+ p.processed++
+ p.mu.Unlock()
+}
+
+func (p *loadProgress) MarkDone() {
+ if p == nil {
+ return
+ }
+ p.mu.Lock()
+ p.done = true
+ p.mu.Unlock()
+}
+
+func (p *loadProgress) Snapshot() (processed, total int, done bool) {
+ if p == nil {
+ return 0, 0, true
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.processed, p.total, p.done
+}
diff --git a/internal/app/load_progress_test.go b/internal/app/load_progress_test.go
new file mode 100644
index 0000000..c46636d
--- /dev/null
+++ b/internal/app/load_progress_test.go
@@ -0,0 +1,25 @@
+package app
+
+import "testing"
+
+func TestLoadProgressLifecycle(t *testing.T) {
+ var progress loadProgress
+ progress.SetTotal(5)
+ for i := 0; i < 3; i++ {
+ progress.Increment()
+ }
+ processed, total, done := progress.Snapshot()
+ if processed != 3 || total != 5 || done {
+ t.Fatalf("unexpected snapshot %d/%d done=%v", processed, total, done)
+ }
+ progress.MarkDone()
+ _, _, done = progress.Snapshot()
+ if !done {
+ t.Fatal("expected done")
+ }
+ progress.Reset()
+ processed, total, done = progress.Snapshot()
+ if processed != 0 || total != 0 || done {
+ t.Fatalf("expected reset to zero, got %d/%d done=%v", processed, total, done)
+ }
+}
diff --git a/internal/app/loader.go b/internal/app/loader.go
new file mode 100644
index 0000000..37c8c94
--- /dev/null
+++ b/internal/app/loader.go
@@ -0,0 +1,241 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func loadVideosCmd(root, cachePath string, progress *loadProgress) tea.Cmd {
+ return func() tea.Msg {
+ cache, cacheErr := loadDurationCache(cachePath)
+ videos, pending, err := loadVideos(root, cache, progress)
+ if progress != nil {
+ progress.MarkDone()
+ }
+ return videosLoadedMsg{videos: videos, err: err, cacheErr: cacheErr, pending: pending, cache: cache}
+ }
+}
+
+func progressTickerCmd(progress *loadProgress) tea.Cmd {
+ if progress == nil {
+ return nil
+ }
+ return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg {
+ processed, total, done := progress.Snapshot()
+ return progressUpdateMsg{processed: processed, total: total, done: done}
+ })
+}
+
+func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error) {
+ paths, err := collectVideoPaths(root)
+ if err != nil {
+ return nil, nil, err
+ }
+ if progress != nil {
+ progress.SetTotal(len(paths))
+ }
+ videos := make([]video, 0, len(paths))
+ pending := make([]string, 0)
+ for _, path := range paths {
+ info, statErr := os.Stat(path)
+ if statErr != nil {
+ videos = append(videos, video{Name: filepath.Base(path), Path: path, Err: statErr})
+ increment(progress)
+ continue
+ }
+ dur := cachedDuration(cache, path, info)
+ if dur == 0 {
+ pending = append(pending, path)
+ }
+ videos = append(videos, video{
+ Name: filepath.Base(path),
+ Path: path,
+ Duration: dur,
+ ModTime: info.ModTime(),
+ Size: info.Size(),
+ })
+ increment(progress)
+ }
+ return videos, pending, nil
+}
+
+func increment(progress *loadProgress) {
+ if progress != nil {
+ progress.Increment()
+ }
+}
+
+func cachedDuration(cache *durationCache, path string, info os.FileInfo) time.Duration {
+ if cache == nil {
+ return 0
+ }
+ dur, ok := cache.Lookup(path, info)
+ if !ok {
+ return 0
+ }
+ return dur
+}
+
+func collectVideoPaths(root string) ([]string, error) {
+ info, err := os.Stat(root)
+ if err != nil {
+ return nil, err
+ }
+ if !info.IsDir() {
+ if isVideo(root) {
+ return []string{root}, nil
+ }
+ return nil, nil
+ }
+ visited := make(map[string]struct{})
+ var paths []string
+ if err := traverseVideoPaths(root, root, visited, &paths); err != nil {
+ return nil, err
+ }
+ sort.Strings(paths)
+ return paths, nil
+}
+
+func traverseVideoPaths(displayPath, realPath string, visited map[string]struct{}, acc *[]string) error {
+ resolved, err := filepath.EvalSymlinks(realPath)
+ if err != nil {
+ resolved = realPath
+ }
+ resolved = filepath.Clean(resolved)
+ if _, seen := visited[resolved]; seen {
+ return nil
+ }
+ visited[resolved] = struct{}{}
+
+ entries, err := os.ReadDir(resolved)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ displayChild := filepath.Join(displayPath, entry.Name())
+ realChild := filepath.Join(resolved, entry.Name())
+ mode := entry.Type()
+ var info os.FileInfo
+ if mode == fs.FileMode(0) {
+ info, err = entry.Info()
+ if err != nil {
+ return err
+ }
+ mode = info.Mode()
+ }
+ if mode&os.ModeSymlink != 0 {
+ if err := handleSymlink(displayChild, realChild, visited, acc); err != nil {
+ return err
+ }
+ continue
+ }
+ if mode.IsDir() {
+ if err := traverseVideoPaths(displayChild, realChild, visited, acc); err != nil {
+ return err
+ }
+ continue
+ }
+ if isVideo(displayChild) {
+ *acc = append(*acc, displayChild)
+ }
+ }
+ return nil
+}
+
+func handleSymlink(displayChild, realChild string, visited map[string]struct{}, acc *[]string) error {
+ targetPath, err := filepath.EvalSymlinks(realChild)
+ if err != nil {
+ return recordIfVideo(displayChild, acc)
+ }
+ targetInfo, err := os.Stat(targetPath)
+ if err != nil {
+ return recordIfVideo(displayChild, acc)
+ }
+ if targetInfo.IsDir() {
+ return traverseVideoPaths(displayChild, targetPath, visited, acc)
+ }
+ if isVideo(displayChild) || isVideo(targetPath) {
+ *acc = append(*acc, displayChild)
+ }
+ return nil
+}
+
+func recordIfVideo(path string, acc *[]string) error {
+ if isVideo(path) {
+ *acc = append(*acc, path)
+ }
+ return nil
+}
+
+func probeDurationsCmd(path string, cache *durationCache) tea.Cmd {
+ return func() tea.Msg {
+ dur, err := probeDuration(path)
+ if err == nil && cache != nil {
+ if info, statErr := os.Stat(path); statErr == nil {
+ _ = cache.Record(path, info, dur)
+ }
+ }
+ return durationUpdateMsg{path: path, duration: dur, err: err}
+ }
+}
+
+func probeDuration(path string) (time.Duration, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path)
+ out, err := cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+ raw := strings.TrimSpace(string(out))
+ if raw == "" {
+ return 0, errors.New("empty duration")
+ }
+ seconds, err := strconv.ParseFloat(raw, 64)
+ if err != nil {
+ return 0, err
+ }
+ return time.Duration(seconds * float64(time.Second)), nil
+}
+
+func playVideoCmd(path, crop string) tea.Cmd {
+ return func() tea.Msg {
+ args := buildVLCArgs(path, crop)
+ cmd := exec.Command("vlc", args...)
+ if err := cmd.Start(); err != nil {
+ return playVideoMsg{path: path, err: err}
+ }
+ go func() { _ = cmd.Wait() }()
+ return playVideoMsg{path: path}
+ }
+}
+
+func buildVLCArgs(path, crop string) []string {
+ args := []string{}
+ if crop != "" {
+ args = append(args, "--crop", crop)
+ }
+ return append(args, path)
+}
+
+func isVideo(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ _, ok := videoExtensions[ext]
+ return ok
+}
+
+// CollectVideoPathsForTest exposes collectVideoPaths for unit testing.
+func CollectVideoPathsForTest(root string) ([]string, error) {
+ return collectVideoPaths(root)
+}
diff --git a/internal/app/loader_test.go b/internal/app/loader_test.go
new file mode 100644
index 0000000..538bca0
--- /dev/null
+++ b/internal/app/loader_test.go
@@ -0,0 +1,162 @@
+package app
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+)
+
+func TestCollectVideoPathsDetectsMP4(t *testing.T) {
+ dir := t.TempDir()
+ lower := filepath.Join(dir, "video.mp4")
+ upper := filepath.Join(dir, "UPPER.MP4")
+ for _, path := range []string{lower, upper} {
+ if err := os.WriteFile(path, []byte("dummy"), 0o644); err != nil {
+ t.Fatalf("write %s: %v", path, err)
+ }
+ }
+ paths, err := CollectVideoPathsForTest(dir)
+ if err != nil {
+ t.Fatalf("collect paths: %v", err)
+ }
+ if len(paths) != 2 {
+ t.Fatalf("expected 2 paths, got %d", len(paths))
+ }
+ want := map[string]struct{}{lower: {}, upper: {}}
+ for _, got := range paths {
+ if _, ok := want[got]; !ok {
+ t.Fatalf("unexpected path %s", got)
+ }
+ }
+}
+
+func TestCollectVideoPathsFollowsSymlink(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink permissions vary on Windows")
+ }
+ root := t.TempDir()
+ storage := t.TempDir()
+ video := filepath.Join(storage, "movie.mp4")
+ if err := os.WriteFile(video, []byte("dummy"), 0o644); err != nil {
+ t.Fatalf("write video: %v", err)
+ }
+ link := filepath.Join(root, "videos")
+ if err := os.Symlink(storage, link); err != nil {
+ t.Skipf("symlink not supported: %v", err)
+ }
+ paths, err := CollectVideoPathsForTest(root)
+ if err != nil {
+ t.Fatalf("collect paths: %v", err)
+ }
+ expected := filepath.Join(link, "movie.mp4")
+ if len(paths) != 1 || paths[0] != expected {
+ t.Fatalf("expected %s, got %v", expected, paths)
+ }
+}
+
+func TestLoadVideosWithCache(t *testing.T) {
+ dir := t.TempDir()
+ video := filepath.Join(dir, "video.mp4")
+ if err := os.WriteFile(video, []byte("dummy"), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ cache := newDurationCache(filepath.Join(dir, "cache.json"))
+ info, err := os.Stat(video)
+ if err != nil {
+ t.Fatalf("stat: %v", err)
+ }
+ _ = cache.Record(video, info, time.Minute)
+ progress := &loadProgress{}
+ progress.Reset()
+ videos, pending, err := loadVideos(dir, cache, progress)
+ if err != nil {
+ t.Fatalf("loadVideos: %v", err)
+ }
+ if len(videos) != 1 || len(pending) != 0 {
+ t.Fatalf("expected cached video without pending: videos=%d pending=%d", len(videos), len(pending))
+ }
+ if videos[0].Duration != time.Minute {
+ t.Fatalf("expected cached duration")
+ }
+}
+
+func TestProbeDurationsCmdHandlesMissingBinary(t *testing.T) {
+ cmd := probeDurationsCmd("/no/such/file.mp4", nil)
+ msg := cmd()
+ update := msg.(durationUpdateMsg)
+ if update.err == nil {
+ t.Fatalf("expected error from ffprobe")
+ }
+}
+
+func TestProbeDurationSuccess(t *testing.T) {
+ dir := t.TempDir()
+ script := filepath.Join(dir, "ffprobe")
+ if err := os.WriteFile(script, []byte("#!/bin/sh\necho 5\n"), 0o755); err != nil {
+ t.Fatalf("write script: %v", err)
+ }
+ oldPath := os.Getenv("PATH")
+ t.Setenv("PATH", dir+":"+oldPath)
+ dur, err := probeDuration("dummy.mp4")
+ if err != nil {
+ t.Fatalf("probeDuration: %v", err)
+ }
+ if dur != 5*time.Second {
+ t.Fatalf("expected 5s duration, got %v", dur)
+ }
+}
+
+func TestPlayVideoCmdMissingBinary(t *testing.T) {
+ cmd := playVideoCmd("/no/such/file.mp4", "")
+ msg := cmd()
+ result := msg.(playVideoMsg)
+ if result.path != "/no/such/file.mp4" {
+ t.Fatalf("unexpected path %s", result.path)
+ }
+}
+
+func TestRecordIfVideo(t *testing.T) {
+ var acc []string
+ if err := recordIfVideo("test.mp4", &acc); err != nil {
+ t.Fatalf("recordIfVideo: %v", err)
+ }
+ if len(acc) != 1 {
+ t.Fatalf("expected video recorded")
+ }
+}
+
+func TestHandleSymlinkBrokenVideo(t *testing.T) {
+ dir := t.TempDir()
+ symlink := filepath.Join(dir, "clip.mp4")
+ target := filepath.Join(dir, "missing.mp4")
+ if err := os.Symlink(target, symlink); err != nil {
+ t.Skipf("symlink unsupported: %v", err)
+ }
+ var acc []string
+ if err := handleSymlink(symlink, symlink, map[string]struct{}{}, &acc); err != nil {
+ t.Fatalf("handleSymlink: %v", err)
+ }
+ if len(acc) != 1 {
+ t.Fatalf("expected symlink video recorded")
+ }
+}
+
+func TestLoadVideosHandlesStatError(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink permissions vary on Windows")
+ }
+ dir := t.TempDir()
+ broken := filepath.Join(dir, "broken.mp4")
+ if err := os.Symlink(filepath.Join(dir, "missing.mp4"), broken); err != nil {
+ t.Skipf("symlink unsupported: %v", err)
+ }
+ videos, _, err := loadVideos(dir, nil, nil)
+ if err != nil {
+ t.Fatalf("loadVideos: %v", err)
+ }
+ if len(videos) != 1 || videos[0].Err == nil {
+ t.Fatalf("expected stat error recorded, got %+v", videos)
+ }
+}
diff --git a/internal/app/messages.go b/internal/app/messages.go
new file mode 100644
index 0000000..a905263
--- /dev/null
+++ b/internal/app/messages.go
@@ -0,0 +1,28 @@
+package app
+
+import "time"
+
+type videosLoadedMsg struct {
+ videos []video
+ err error
+ cacheErr error
+ pending []string
+ cache *durationCache
+}
+
+type playVideoMsg struct {
+ path string
+ err error
+}
+
+type progressUpdateMsg struct {
+ processed int
+ total int
+ done bool
+}
+
+type durationUpdateMsg struct {
+ path string
+ duration time.Duration
+ err error
+}
diff --git a/internal/app/model.go b/internal/app/model.go
new file mode 100644
index 0000000..8cdcddc
--- /dev/null
+++ b/internal/app/model.go
@@ -0,0 +1,198 @@
+package app
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/table"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type sortField int
+
+const (
+ sortByName sortField = iota
+ sortByDuration
+ sortByAge
+)
+
+type model struct {
+ table table.Model
+ videos []video
+ filtered []video
+ filters filterState
+ inputs filterInputs
+ showFilters bool
+ sortField sortField
+ sortAscending bool
+ statusMessage string
+ loading bool
+ err error
+ root string
+ progress *loadProgress
+ cachePath string
+ cache *durationCache
+ pendingDurations []string
+ durationTotal int
+ durationDone int
+ durationInFlight int
+ cropValue string
+ cropEnabled bool
+}
+
+func newModel(opts Options) (model, error) {
+ tbl := buildTable()
+ inputs := buildFilterInputs()
+ inputs.fields[0].Focus()
+
+ progress := &loadProgress{}
+ cachePath := filepath.Join(opts.Root, ".video_duration_cache.json")
+
+ return model{
+ table: tbl,
+ inputs: inputs,
+ sortField: sortByName,
+ sortAscending: true,
+ statusMessage: "Scanning for videos...",
+ loading: true,
+ root: opts.Root,
+ progress: progress,
+ cachePath: cachePath,
+ cropValue: opts.Crop,
+ cropEnabled: opts.Crop != "",
+ }, nil
+}
+
+func buildTable() table.Model {
+ columns := []table.Column{
+ {Title: headerStyle.Render("Name"), Width: 50},
+ {Title: headerStyle.Render("Duration"), Width: 12},
+ {Title: headerStyle.Render("Age"), Width: 14},
+ {Title: headerStyle.Render("Path"), Width: 40},
+ }
+ tbl := table.New(
+ table.WithColumns(columns),
+ table.WithFocused(true),
+ table.WithHeight(15),
+ )
+ tbl.SetStyles(table.DefaultStyles())
+ return tbl
+}
+
+func buildFilterInputs() filterInputs {
+ 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
+
+ return filterInputs{
+ fields: []textinput.Model{nameInput, minInput, maxInput},
+ focus: 0,
+ }
+}
+
+func (m model) Init() tea.Cmd {
+ if m.progress != nil {
+ m.progress.Reset()
+ }
+ loadCmd := loadVideosCmd(m.root, m.cachePath, m.progress)
+ if m.progress != nil {
+ return tea.Batch(loadCmd, progressTickerCmd(m.progress))
+ }
+ return loadCmd
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch typed := msg.(type) {
+ case tea.KeyMsg:
+ return m.handleKeyMsg(typed)
+ case progressUpdateMsg:
+ return m.handleProgressUpdate(typed)
+ case durationUpdateMsg:
+ return m.handleDurationUpdate(typed)
+ case videosLoadedMsg:
+ return m.handleVideosLoaded(typed)
+ case playVideoMsg:
+ return m.handlePlayVideo(typed), nil
+ default:
+ return m.updateTable(msg)
+ }
+}
+
+func (m model) View() string {
+ if m.loading {
+ return statusStyle.Render("Loading videos, please wait...")
+ }
+ body := m.renderBody()
+ if m.showFilters {
+ return body + "\n\n" + m.renderFilterModal()
+ }
+ return body
+}
+
+func (m model) renderBody() string {
+ helpLines := []string{
+ "↑/↓ navigate • enter play • s sort • / filter • c copy path • q quit",
+ }
+ info := statusStyle.Render(m.statusMessage)
+ progressLine := m.renderProgressLine()
+ content := tableStyle.Render(m.table.View())
+ help := strings.Join(helpLines, "\n")
+ parts := []string{content}
+ if progressLine != "" {
+ parts = append(parts, progressLine)
+ }
+ parts = append(parts, info, help)
+ return strings.Join(parts, "\n")
+}
+
+func (m model) renderProgressLine() string {
+ if m.durationTotal == 0 {
+ return ""
+ }
+ bar := renderProgressBar(m.durationDone, m.durationTotal, 24)
+ return statusStyle.Render(fmt.Sprintf("Duration scan %s %d/%d", bar, m.durationDone, m.durationTotal))
+}
+
+func (m model) updateTable(msg tea.Msg) (tea.Model, tea.Cmd) {
+ tbl, cmd := m.table.Update(msg)
+ m.table = tbl
+ return m, cmd
+}
+
+func (m model) handlePlayVideo(msg playVideoMsg) model {
+ if msg.err != nil {
+ m.statusMessage = fmt.Sprintf("Failed to launch VLC: %v", msg.err)
+ return m
+ }
+ m.statusMessage = fmt.Sprintf("Playing via VLC: %s", trimPath(msg.path))
+ return m
+}
+
+func (m model) handleProgressUpdate(msg progressUpdateMsg) (tea.Model, tea.Cmd) {
+ if !m.loading {
+ return m, nil
+ }
+ if msg.total == 0 && msg.done {
+ m.statusMessage = "No videos found"
+ return m, nil
+ }
+ if msg.done {
+ m.statusMessage = fmt.Sprintf("Loaded %d videos", msg.total)
+ return m, nil
+ }
+ m.statusMessage = fmt.Sprintf("Loading videos %d/%d...", msg.processed, msg.total)
+ return m, progressTickerCmd(m.progress)
+}
diff --git a/internal/app/model_durations.go b/internal/app/model_durations.go
new file mode 100644
index 0000000..b92e816
--- /dev/null
+++ b/internal/app/model_durations.go
@@ -0,0 +1,181 @@
+package app
+
+import (
+ "fmt"
+ "path/filepath"
+ "runtime"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m model) handleDurationUpdate(msg durationUpdateMsg) (tea.Model, tea.Cmd) {
+ if msg.path != "" {
+ m.updateVideoDuration(msg.path, msg.duration, msg.err)
+ m.durationDone++
+ m.updateStatusForDuration(msg)
+ }
+ if m.durationInFlight > 0 {
+ m.durationInFlight--
+ }
+ selectedPath := m.currentSelectionPath()
+ m.applyFiltersAndSort()
+ m.restoreSelection(selectedPath)
+ if m.allDurationsResolved() {
+ m.onDurationsComplete()
+ return m, nil
+ }
+ cmd := m.dequeueDurationCmd()
+ return m, cmd
+}
+
+func (m *model) updateStatusForDuration(msg durationUpdateMsg) {
+ if msg.err != nil {
+ m.statusMessage = fmt.Sprintf("Duration error for %s: %v", filepath.Base(msg.path), msg.err)
+ return
+ }
+ if m.durationTotal > 0 {
+ m.statusMessage = fmt.Sprintf("Probing durations %d/%d...", m.durationDone, m.durationTotal)
+ }
+}
+
+func (m model) currentSelectionPath() string {
+ idx := m.table.Cursor()
+ if idx < 0 || idx >= len(m.filtered) {
+ return ""
+ }
+ return m.filtered[idx].Path
+}
+
+func (m *model) restoreSelection(path string) {
+ if path == "" {
+ return
+ }
+ for i, video := range m.filtered {
+ if video.Path == path {
+ m.table.SetCursor(i)
+ return
+ }
+ }
+}
+
+func (m *model) updateVideoDuration(path string, dur time.Duration, err error) {
+ for i := range m.videos {
+ if m.videos[i].Path != path {
+ continue
+ }
+ m.videos[i].Duration = dur
+ m.videos[i].Err = err
+ return
+ }
+}
+
+func (m model) allDurationsResolved() bool {
+ return m.durationDone >= m.durationTotal && m.durationInFlight == 0
+}
+
+func (m *model) onDurationsComplete() {
+ if m.cache != nil {
+ if err := m.cache.Flush(); err != nil {
+ m.statusMessage = fmt.Sprintf("Duration cache flush error: %v", err)
+ } else {
+ m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered))
+ }
+ m.resetDurationState()
+ return
+ }
+ m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered))
+ m.resetDurationState()
+}
+
+func (m *model) resetDurationState() {
+ m.pendingDurations = nil
+ m.durationTotal = 0
+ m.durationDone = 0
+ m.durationInFlight = 0
+}
+
+func (m *model) dequeueDurationCmd() tea.Cmd {
+ if len(m.pendingDurations) == 0 {
+ return nil
+ }
+ path := m.pendingDurations[0]
+ m.pendingDurations = m.pendingDurations[1:]
+ m.durationInFlight++
+ return probeDurationsCmd(path, m.cache)
+}
+
+func (m *model) startDurationWorkers() tea.Cmd {
+ if len(m.pendingDurations) == 0 {
+ return nil
+ }
+ workers := runtime.NumCPU()
+ if workers < 1 {
+ workers = 1
+ }
+ if workers > 6 {
+ workers = 6
+ }
+ if workers > len(m.pendingDurations) {
+ workers = len(m.pendingDurations)
+ }
+ cmds := make([]tea.Cmd, 0, workers)
+ for i := 0; i < workers; i++ {
+ cmd := m.dequeueDurationCmd()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ if len(cmds) == 0 {
+ return nil
+ }
+ return tea.Batch(cmds...)
+}
+
+func (m model) activeCrop() string {
+ if m.cropEnabled && m.cropValue != "" {
+ return m.cropValue
+ }
+ return ""
+}
+
+func (m model) handleVideosLoaded(msg videosLoadedMsg) (tea.Model, tea.Cmd) {
+ m.loading = false
+ if msg.err != nil {
+ m.err = msg.err
+ m.statusMessage = fmt.Sprintf("error: %v", msg.err)
+ }
+ m.videos = msg.videos
+ 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 {
+ return m, nil
+ }
+ cmd := m.startDurationWorkers()
+ return m, cmd
+}
+
+func (m *model) updateStatusAfterLoad(msg videosLoadedMsg) {
+ if len(m.filtered) == 0 {
+ m.statusMessage = "No videos found"
+ return
+ }
+ if len(msg.pending) > 0 {
+ if msg.cacheErr != nil {
+ m.statusMessage = fmt.Sprintf("Loaded %d videos (cache warning: %v), probing durations...", len(m.filtered), msg.cacheErr)
+ return
+ }
+ m.statusMessage = fmt.Sprintf("Loaded %d videos, probing durations...", len(m.filtered))
+ return
+ }
+ if msg.cacheErr != nil {
+ m.statusMessage = fmt.Sprintf("Loaded %d videos (cache warning: %v)", len(m.filtered), msg.cacheErr)
+ return
+ }
+ m.statusMessage = fmt.Sprintf("Loaded %d videos", len(m.filtered))
+}
diff --git a/internal/app/model_keys.go b/internal/app/model_keys.go
new file mode 100644
index 0000000..d02cf46
--- /dev/null
+++ b/internal/app/model_keys.go
@@ -0,0 +1,138 @@
+package app
+
+import (
+ "fmt"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ if cmd, handled := globalKeyHandler(msg); handled {
+ return m, cmd
+ }
+ if m.loading {
+ return m, nil
+ }
+ if m.showFilters {
+ return m.handleFilterKey(msg)
+ }
+ return m.handleTableKey(msg)
+}
+
+func globalKeyHandler(msg tea.KeyMsg) (tea.Cmd, bool) {
+ switch msg.String() {
+ case "ctrl+c", "q":
+ return tea.Quit, true
+ default:
+ return nil, false
+ }
+}
+
+func (m model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "esc":
+ m.showFilters = false
+ m.statusMessage = "Filter closed"
+ return m, nil
+ case "enter":
+ cmd := m.applyFiltersFromInputs()
+ return m, cmd
+ case "tab":
+ m.inputs.focus = (m.inputs.focus + 1) % len(m.inputs.fields)
+ case "shift+tab":
+ m.inputs.focus = (m.inputs.focus - 1 + len(m.inputs.fields)) % len(m.inputs.fields)
+ }
+ m.syncFilterFocus()
+ updated, cmd := m.updateFilterInputs(msg)
+ m.inputs = updated
+ return m, cmd
+}
+
+func (m *model) applyFiltersFromInputs() tea.Cmd {
+ if err := m.applyFilterInputs(); err != nil {
+ m.statusMessage = err.Error()
+ return nil
+ }
+ m.showFilters = false
+ m.applyFiltersAndSort()
+ m.statusMessage = fmt.Sprintf("Filters applied (%d videos)", len(m.filtered))
+ return nil
+}
+
+func (m *model) syncFilterFocus() {
+ for i := range m.inputs.fields {
+ if i == m.inputs.focus {
+ m.inputs.fields[i].Focus()
+ continue
+ }
+ m.inputs.fields[i].Blur()
+ }
+}
+
+func (m model) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "/", "f":
+ return m.openFilters()
+ case "enter":
+ return m.playSelection()
+ case "n":
+ return m.sortAndReport(sortByName)
+ case "l":
+ return m.sortAndReport(sortByDuration)
+ case "a":
+ return m.sortAndReport(sortByAge)
+ case "c":
+ return m.toggleCrop()
+ case "r":
+ return m.resetFilterState()
+ default:
+ return m.updateTable(msg)
+ }
+}
+
+func (m model) openFilters() (tea.Model, tea.Cmd) {
+ m.showFilters = true
+ m.statusMessage = "Editing filters"
+ return m, nil
+}
+
+func (m model) playSelection() (tea.Model, tea.Cmd) {
+ if len(m.filtered) == 0 {
+ return m, nil
+ }
+ idx := m.table.Cursor()
+ if idx < 0 || idx >= len(m.filtered) {
+ return m, nil
+ }
+ video := m.filtered[idx]
+ m.statusMessage = fmt.Sprintf("Launching VLC: %s", video.Name)
+ return m, playVideoCmd(video.Path, m.activeCrop())
+}
+
+func (m model) sortAndReport(field sortField) (tea.Model, tea.Cmd) {
+ m.toggleSort(field)
+ m.applyFiltersAndSort()
+ m.statusMessage = fmt.Sprintf("Sorted %d videos", len(m.filtered))
+ return m, nil
+}
+
+func (m model) toggleCrop() (tea.Model, tea.Cmd) {
+ if m.cropValue == "" {
+ m.statusMessage = "No crop value set (start with --crop)"
+ return m, nil
+ }
+ m.cropEnabled = !m.cropEnabled
+ if m.cropEnabled {
+ m.statusMessage = fmt.Sprintf("Crop enabled (%s)", m.cropValue)
+ return m, nil
+ }
+ m.statusMessage = "Crop disabled"
+ return m, nil
+}
+
+func (m model) resetFilterState() (tea.Model, tea.Cmd) {
+ m.resetFilters()
+ m.applyFiltersAndSort()
+ m.statusMessage = fmt.Sprintf("Filters cleared (%d videos)", len(m.filtered))
+ return m, nil
+}
diff --git a/internal/app/model_sort.go b/internal/app/model_sort.go
new file mode 100644
index 0000000..e3120c4
--- /dev/null
+++ b/internal/app/model_sort.go
@@ -0,0 +1,58 @@
+package app
+
+import (
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/table"
+)
+
+func (m *model) toggleSort(target sortField) {
+ if m.sortField == target {
+ m.sortAscending = !m.sortAscending
+ return
+ }
+ m.sortField = target
+ m.sortAscending = true
+}
+
+func (m *model) applyFiltersAndSort() {
+ filtered := make([]video, 0, len(m.videos))
+ for _, v := range m.videos {
+ if m.passesFilters(v) {
+ filtered = append(filtered, v)
+ }
+ }
+ sort.Slice(filtered, func(i, j int) bool {
+ return m.less(filtered[i], filtered[j])
+ })
+ m.filtered = filtered
+ m.updateTableRows()
+}
+
+func (m *model) less(a, b video) bool {
+ var less bool
+ switch m.sortField {
+ case sortByName:
+ less = strings.ToLower(a.Name) < strings.ToLower(b.Name)
+ case sortByDuration:
+ less = a.Duration < b.Duration
+ case sortByAge:
+ less = a.ModTime.Before(b.ModTime)
+ }
+ if m.sortAscending {
+ return less
+ }
+ return !less
+}
+
+func (m *model) updateTableRows() {
+ rows := make([]table.Row, 0, len(m.filtered))
+ for _, v := range m.filtered {
+ rows = append(rows, videoRow(v))
+ }
+ m.table.SetRows(rows)
+ if len(rows) > 0 {
+ m.table.SetCursor(0)
+ }
+}
diff --git a/internal/app/model_test.go b/internal/app/model_test.go
new file mode 100644
index 0000000..53e391e
--- /dev/null
+++ b/internal/app/model_test.go
@@ -0,0 +1,482 @@
+package app
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestModelHandleVideosLoadedAndSort(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := []video{
+ {Name: "B.mp4", Path: filepath.Join(root, "B.mp4"), Duration: time.Minute, ModTime: time.Now()},
+ {Name: "A.mp4", Path: filepath.Join(root, "A.mp4"), Duration: 2 * time.Minute, ModTime: time.Now().Add(-time.Hour)},
+ }
+ msg := videosLoadedMsg{videos: videos, pending: nil, cache: newDurationCache(filepath.Join(root, "cache.json"))}
+ modelAny, cmd := m.handleVideosLoaded(msg)
+ if cmd != nil {
+ t.Fatalf("expected no duration command")
+ }
+ m = modelAny.(model)
+ if len(m.filtered) != 2 {
+ t.Fatalf("expected 2 videos, got %d", len(m.filtered))
+ }
+ if m.filtered[0].Name != "A.mp4" {
+ t.Fatalf("expected sorted by name ascending")
+ }
+ modelAny, _ = m.handleKeyMsg(keyMsg("l"))
+ m = modelAny.(model)
+ if m.filtered[0].Name != "B.mp4" {
+ t.Fatalf("expected shortest duration first")
+ }
+}
+
+func TestModelHandleDurationUpdateCompletes(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ pendingPath := filepath.Join(root, "pending.mp4")
+ videos := []video{{Name: "pending.mp4", Path: pendingPath}}
+ msg := videosLoadedMsg{videos: videos, pending: []string{pendingPath}, cache: newDurationCache(filepath.Join(root, "cache.json"))}
+ modelAny, cmd := m.handleVideosLoaded(msg)
+ if cmd == nil {
+ t.Fatalf("expected duration command")
+ }
+ m = modelAny.(model)
+ durMsg := durationUpdateMsg{path: pendingPath, duration: time.Minute}
+ modelAny, next := m.handleDurationUpdate(durMsg)
+ m = modelAny.(model)
+ if next != nil {
+ t.Fatalf("expected no further command after completion")
+ }
+ if m.durationDone != 0 || m.pendingDurations != nil {
+ t.Fatalf("expected duration queue cleared")
+ }
+}
+
+func TestModelFiltersWorkflow(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := []video{{Name: "morning flow.mp4", Path: filepath.Join(root, "morning.mp4"), Duration: 10 * time.Minute}}
+ modelAny, _ := m.handleVideosLoaded(videosLoadedMsg{videos: videos, cache: newDurationCache(filepath.Join(root, "cache.json"))})
+ m = modelAny.(model)
+ modelAny, _ = m.handleKeyMsg(keyMsg("/"))
+ m = modelAny.(model)
+ if !m.showFilters {
+ t.Fatalf("expected filters to open")
+ }
+ m.inputs.fields[0].SetValue("morning")
+ inputs, _ := m.updateFilterInputs(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+ m.inputs = inputs
+ m.inputs.fields[0].SetValue("morning")
+ if modal := m.renderFilterModal(); !strings.Contains(modal, "Filter videos") {
+ t.Fatalf("expected filter modal content")
+ }
+ modelAny, _ = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyEnter})
+ m = modelAny.(model)
+ if len(m.filtered) != 1 {
+ t.Fatalf("expected filtered result")
+ }
+}
+
+func TestRenderProgressBar(t *testing.T) {
+ bar := renderProgressBar(5, 10, 10)
+ if bar != "[#####-----]" {
+ t.Fatalf("unexpected bar %s", bar)
+ }
+ if renderProgressBar(0, 0, 10) != "" {
+ t.Fatalf("expected empty bar for zero total")
+ }
+ if renderProgressBar(-1, 10, 5) == "" {
+ t.Fatalf("expected bar even when done negative")
+ }
+}
+
+func TestModelViewAndProgress(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.statusMessage = "Ready"
+ m.filtered = []video{}
+ view := m.View()
+ if view == "" {
+ t.Fatalf("expected non-empty view")
+ }
+ m.durationTotal = 10
+ m.durationDone = 5
+ if line := m.renderProgressLine(); !strings.Contains(line, "5/10") {
+ t.Fatalf("unexpected progress line %s", line)
+ }
+}
+
+func TestModelInitAndUpdate(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ cmd := m.Init()
+ if cmd == nil {
+ t.Fatalf("expected init command")
+ }
+ m.loading = false
+ modelAny, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
+ m = modelAny.(model)
+ if m.filters.name != "" || !strings.Contains(m.statusMessage, "Filters cleared") {
+ t.Fatalf("expected filters reset via update path")
+ }
+ m.loading = true
+ modelAny, cmd = m.Update(progressUpdateMsg{processed: 1, total: 2, done: false})
+ m = modelAny.(model)
+ if cmd == nil || !strings.Contains(m.statusMessage, "Loading videos") {
+ t.Fatalf("unexpected status %s", m.statusMessage)
+ }
+}
+
+func TestHandlePlayVideoStatuses(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m = m.handlePlayVideo(playVideoMsg{path: "video.mp4", err: errors.New("fail")})
+ if !strings.Contains(m.statusMessage, "Failed") {
+ t.Fatalf("expected failure message")
+ }
+ m = m.handlePlayVideo(playVideoMsg{path: "video.mp4"})
+ if !strings.Contains(m.statusMessage, "Playing") {
+ t.Fatalf("expected playing message")
+ }
+}
+
+func TestDescribeFilters(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.filters = filterState{name: "flow", minEnabled: true, minMinutes: 5, maxEnabled: true, maxMinutes: 20}
+ desc := m.describeFilters()
+ if !strings.Contains(desc, "flow") || !strings.Contains(desc, ">=5") {
+ t.Fatalf("unexpected description %s", desc)
+ }
+}
+
+func TestPlaySelectionCommand(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: "clip", Path: "clip.mp4"}}
+ cmdModel, cmd := m.playSelection()
+ if cmd == nil {
+ t.Fatalf("expected command to play video")
+ }
+ if cmdModel.(model).statusMessage == "" {
+ t.Fatalf("expected status message set")
+ }
+}
+
+func TestUpdateTableFallback(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}})
+}
+
+func TestProgressTickerNil(t *testing.T) {
+ if progressTickerCmd(nil) != nil {
+ t.Fatalf("expected nil command for nil progress")
+ }
+}
+
+func TestHandleFilterKeyTabs(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.showFilters = true
+ m.inputs = buildFilterInputs()
+ m.inputs.fields[0].Focus()
+ modelAny, _ := m.handleFilterKey(tea.KeyMsg{Type: tea.KeyTab})
+ m = modelAny.(model)
+ if !m.inputs.fields[1].Focused() {
+ t.Fatalf("expected focus to move forward")
+ }
+ modelAny, _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyShiftTab})
+ m = modelAny.(model)
+ if !m.inputs.fields[0].Focused() {
+ t.Fatalf("expected focus to move back")
+ }
+}
+
+func TestUpdateStatusAfterLoadBranches(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := []video{{Name: "a", Path: "a.mp4"}}
+ msg := videosLoadedMsg{videos: videos, pending: []string{"a.mp4"}, cacheErr: errors.New("cache"), cache: newDurationCache("cache.json")}
+ modelAny, cmd := m.handleVideosLoaded(msg)
+ m = modelAny.(model)
+ if cmd == nil {
+ t.Fatalf("expected pending duration command")
+ }
+ if !strings.Contains(m.statusMessage, "cache warning") {
+ t.Fatalf("expected cache warning status")
+ }
+}
+
+func TestModelUpdateWithVideosLoaded(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := []video{{Name: "a", Path: "a.mp4"}}
+ msg := videosLoadedMsg{videos: videos}
+ modelAny, _ := m.Update(msg)
+ m = modelAny.(model)
+ if len(m.videos) != 1 {
+ t.Fatalf("expected videos loaded")
+ }
+}
+
+func TestModelUpdatePlayVideoMsg(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m = m.handlePlayVideo(playVideoMsg{path: "a.mp4"})
+ modelAny, _ := m.Update(playVideoMsg{path: "a.mp4"})
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "Playing") {
+ t.Fatalf("expected playing status, got %s", m.statusMessage)
+ }
+}
+
+func TestModelUpdateDurationMsg(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.videos = []video{{Name: "a", Path: "a.mp4"}}
+ m.filtered = m.videos
+ m.pendingDurations = []string{"a.mp4"}
+ m.durationTotal = 1
+ m.cache = newDurationCache("cache.json")
+ update := durationUpdateMsg{path: "a.mp4", duration: time.Second}
+ modelAny, _ := m.Update(update)
+ m = modelAny.(model)
+ if m.durationTotal != 0 {
+ t.Fatalf("expected duration queue cleared")
+ }
+}
+
+func TestUpdateStatusForDurationError(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.videos = []video{{Name: "a", Path: "a.mp4"}, {Name: "b", Path: "b.mp4"}}
+ m.filtered = m.videos
+ m.pendingDurations = []string{"a.mp4", "b.mp4"}
+ m.durationTotal = 2
+ m.durationInFlight = 1
+ m.cache = newDurationCache("cache.json")
+ msg := durationUpdateMsg{path: "a.mp4", err: errors.New("ffprobe")}
+ modelAny, _ := m.Update(msg)
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "Duration error") {
+ t.Fatalf("expected error status, got %s", m.statusMessage)
+ }
+}
+
+func TestHandleProgressUpdateDone(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = true
+ modelAny, _ := m.handleProgressUpdate(progressUpdateMsg{total: 2, done: true})
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "Loaded") {
+ t.Fatalf("expected loaded status, got %s", m.statusMessage)
+ }
+}
+
+func TestUpdateStatusAfterLoadCacheWarning(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ msg := videosLoadedMsg{videos: []video{{Name: "a", Path: "a.mp4"}}, cacheErr: errors.New("oops"), cache: newDurationCache("cache.json")}
+ modelAny, _ := m.Update(msg)
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "cache warning") {
+ t.Fatalf("expected cache warning, got %s", m.statusMessage)
+ }
+}
+
+func TestPassesFiltersBounds(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.filters = filterState{minEnabled: true, minMinutes: 5, maxEnabled: true, maxMinutes: 15}
+ video := video{Name: "clip", Duration: 10 * time.Minute}
+ if !m.passesFilters(video) {
+ t.Fatalf("expected video within bounds")
+ }
+ m.filters.maxMinutes = 5
+ if m.passesFilters(video) {
+ t.Fatalf("expected video to fail with tighter max")
+ }
+ m.filters = filterState{name: "yoga"}
+ if m.passesFilters(video) {
+ t.Fatalf("expected name filter to exclude video")
+ }
+}
+
+func TestProgressTickerCmdTick(t *testing.T) {
+ progress := &loadProgress{}
+ progress.SetTotal(2)
+ cmd := progressTickerCmd(progress)
+ if cmd == nil {
+ t.Fatalf("expected command")
+ }
+ msg := cmd().(progressUpdateMsg)
+ if msg.total != 2 {
+ t.Fatalf("unexpected ticker message %#v", msg)
+ }
+}
+
+func TestProgressUpdateMessages(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ modelAny, cmd := m.handleProgressUpdate(progressUpdateMsg{processed: 1, total: 3, done: false})
+ if cmd == nil {
+ t.Fatalf("expected ticker command")
+ }
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "Loading videos") {
+ t.Fatalf("unexpected status %s", m.statusMessage)
+ }
+ modelAny, cmd = m.handleProgressUpdate(progressUpdateMsg{total: 0, done: true})
+ m = modelAny.(model)
+ if cmd != nil || m.statusMessage != "No videos found" {
+ t.Fatalf("expected no videos message")
+ }
+}
+
+func TestToggleCrop(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir(), Crop: "5:4"})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ modelAny, _ := m.handleKeyMsg(keyMsg("c"))
+ m = modelAny.(model)
+ if m.statusMessage != "Crop disabled" {
+ t.Fatalf("expected crop disabled, got %s", m.statusMessage)
+ }
+ modelAny, _ = m.handleKeyMsg(keyMsg("c"))
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "Crop enabled") {
+ t.Fatalf("expected crop enabled, got %s", m.statusMessage)
+ }
+ if m.activeCrop() == "" {
+ t.Fatalf("expected active crop")
+ }
+}
+
+func TestToggleSort(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.toggleSort(sortByDuration)
+ if m.sortField != sortByDuration || !m.sortAscending {
+ t.Fatalf("expected sort by duration ascending")
+ }
+ m.toggleSort(sortByDuration)
+ if m.sortAscending {
+ t.Fatalf("expected sort order to flip")
+ }
+}
+
+func TestResetFilters(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filters = filterState{name: "x"}
+ modelAny, _ := m.handleKeyMsg(keyMsg("r"))
+ m = modelAny.(model)
+ if m.filters.name != "" {
+ t.Fatalf("expected filters cleared")
+ }
+}
+
+func TestSyncFilterFocus(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.showFilters = true
+ m.inputs.focus = 1
+ m.syncFilterFocus()
+ if !m.inputs.fields[1].Focused() {
+ t.Fatalf("expected second field focused")
+ }
+}
+
+func TestLoadVideosCmdProducesMessage(t *testing.T) {
+ root := t.TempDir()
+ video := filepath.Join(root, "clip.mp4")
+ if err := os.WriteFile(video, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ cmd := loadVideosCmd(root, filepath.Join(root, "cache.json"), &loadProgress{})
+ msg := cmd()
+ if _, ok := msg.(videosLoadedMsg); !ok {
+ t.Fatalf("expected videosLoadedMsg")
+ }
+}
+
+func TestProgressTickerCmdProducesMsg(t *testing.T) {
+ progress := &loadProgress{}
+ progress.SetTotal(1)
+ cmd := progressTickerCmd(progress)
+ if cmd == nil {
+ t.Fatalf("expected ticker command")
+ }
+}
+
+func keyMsg(value string) tea.KeyMsg {
+ if len(value) == 1 {
+ return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)}
+ }
+ return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value), Alt: false}
+}
diff --git a/internal/app/options.go b/internal/app/options.go
new file mode 100644
index 0000000..9292e53
--- /dev/null
+++ b/internal/app/options.go
@@ -0,0 +1,7 @@
+package app
+
+// Options configures the Yoga application runtime.
+type Options struct {
+ Root string
+ Crop string
+}
diff --git a/internal/app/style.go b/internal/app/style.go
new file mode 100644
index 0000000..de26b8a
--- /dev/null
+++ b/internal/app/style.go
@@ -0,0 +1,19 @@
+package app
+
+import "github.com/charmbracelet/lipgloss"
+
+var (
+ videoExtensions = map[string]struct{}{
+ ".mp4": {},
+ ".mkv": {},
+ ".mov": {},
+ ".avi": {},
+ ".wmv": {},
+ ".m4v": {},
+ }
+ tableStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("63")).Padding(0, 1)
+ headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99")).Bold(true)
+ filterStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("105")).Padding(1, 2)
+ statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
+ highlightStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
+)
diff --git a/internal/app/video.go b/internal/app/video.go
new file mode 100644
index 0000000..f969ce6
--- /dev/null
+++ b/internal/app/video.go
@@ -0,0 +1,12 @@
+package app
+
+import "time"
+
+type video struct {
+ Name string
+ Path string
+ Duration time.Duration
+ ModTime time.Time
+ Size int64
+ Err error
+}
diff --git a/internal/app/view_helpers.go b/internal/app/view_helpers.go
new file mode 100644
index 0000000..b023d62
--- /dev/null
+++ b/internal/app/view_helpers.go
@@ -0,0 +1,80 @@
+package app
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/bubbles/table"
+)
+
+func videoRow(v video) table.Row {
+ duration := "(unknown)"
+ if v.Duration > 0 {
+ duration = formatDuration(v.Duration)
+ }
+ age := humanizeAge(v.ModTime)
+ path := trimPath(v.Path)
+ if v.Err != nil {
+ duration = "!" + v.Err.Error()
+ }
+ return table.Row{v.Name, duration, age, path}
+}
+
+func renderProgressBar(done, total, width int) string {
+ if width <= 0 || total <= 0 {
+ return ""
+ }
+ if done < 0 {
+ done = 0
+ }
+ if done > total {
+ done = total
+ }
+ filled := int(float64(done) / float64(total) * float64(width))
+ if filled > width {
+ filled = width
+ }
+ bar := strings.Repeat("#", filled) + strings.Repeat("-", width-filled)
+ return fmt.Sprintf("[%s]", bar)
+}
+
+func formatDuration(d time.Duration) string {
+ if d <= 0 {
+ return "--"
+ }
+ totalSeconds := int(d.Seconds() + 0.5)
+ hours := totalSeconds / 3600
+ minutes := (totalSeconds % 3600) / 60
+ seconds := totalSeconds % 60
+ if hours > 0 {
+ return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
+ }
+ return fmt.Sprintf("%02d:%02d", minutes, seconds)
+}
+
+func humanizeAge(t time.Time) string {
+ if t.IsZero() {
+ return "--"
+ }
+ dur := time.Since(t)
+ if dur < time.Minute {
+ return "just now"
+ }
+ if dur < time.Hour {
+ return fmt.Sprintf("%dm ago", int(dur.Minutes()))
+ }
+ if dur < 24*time.Hour {
+ return fmt.Sprintf("%dh ago", int(dur.Hours()))
+ }
+ return t.Format("2006-01-02")
+}
+
+func trimPath(path string) string {
+ home, err := os.UserHomeDir()
+ if err == nil && strings.HasPrefix(path, home) {
+ return "~" + strings.TrimPrefix(path, home)
+ }
+ return path
+}
diff --git a/internal/fsutil/path.go b/internal/fsutil/path.go
new file mode 100644
index 0000000..c872a5b
--- /dev/null
+++ b/internal/fsutil/path.go
@@ -0,0 +1,99 @@
+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) {
+ value, isDefault := normalizeRootInput(input, defaultValue)
+ expanded, err := expandPath(value)
+ if err != nil {
+ return "", fmt.Errorf("cannot expand root path %q: %w", value, err)
+ }
+ abs, err := filepath.Abs(expanded)
+ if err != nil {
+ return "", fmt.Errorf("cannot resolve root path %q: %w", expanded, err)
+ }
+ info, err := ensureRootExists(abs, isDefault)
+ if err != nil {
+ return "", err
+ }
+ if !info.IsDir() && !info.Mode().IsRegular() {
+ return "", fmt.Errorf("root path %q is not a file or directory", abs)
+ }
+ return abs, nil
+}
+
+func normalizeRootInput(input, defaultValue string) (value string, isDefault bool) {
+ trimmed := strings.TrimSpace(input)
+ if trimmed == "" {
+ return defaultValue, true
+ }
+ return trimmed, false
+}
+
+func ensureRootExists(path string, allowCreate bool) (fs.FileInfo, error) {
+ info, err := os.Stat(path)
+ if err == nil {
+ return info, nil
+ }
+ if !errors.Is(err, fs.ErrNotExist) {
+ return nil, fmt.Errorf("cannot access root path %q: %w", path, err)
+ }
+ if !allowCreate {
+ return nil, fmt.Errorf("root path does not exist: %s", path)
+ }
+ if mkErr := os.MkdirAll(path, 0o755); mkErr != nil {
+ return nil, fmt.Errorf("cannot create default directory %q: %w", path, mkErr)
+ }
+ info, err = os.Stat(path)
+ if err != nil {
+ return nil, fmt.Errorf("cannot stat default directory %q: %w", path, err)
+ }
+ return info, nil
+}
+
+func expandPath(p string) (string, error) {
+ if p == "" || p[0] != '~' {
+ return p, nil
+ }
+ if len(p) == 1 {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return home, nil
+ }
+ if p[1] == '/' {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, p[2:]), nil
+ }
+ username, rest := splitUserPath(p)
+ usr, err := user.Lookup(username)
+ if err != nil {
+ return "", err
+ }
+ if rest == "" {
+ return usr.HomeDir, nil
+ }
+ return filepath.Join(usr.HomeDir, rest), nil
+}
+
+func splitUserPath(p string) (string, string) {
+ sep := strings.IndexRune(p, '/')
+ if sep == -1 {
+ return p[1:], ""
+ }
+ return p[1:sep], p[sep:]
+}
diff --git a/internal/fsutil/path_test.go b/internal/fsutil/path_test.go
new file mode 100644
index 0000000..4b88573
--- /dev/null
+++ b/internal/fsutil/path_test.go
@@ -0,0 +1,112 @@
+package fsutil
+
+import (
+ "os"
+ "os/user"
+ "path/filepath"
+ "testing"
+)
+
+func TestResolveRootPathCreatesDefault(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ root, err := ResolveRootPath("", "~/Yoga")
+ if err != nil {
+ t.Fatalf("resolve root: %v", err)
+ }
+ expected := filepath.Join(home, "Yoga")
+ if root != expected {
+ t.Fatalf("expected %s, got %s", expected, root)
+ }
+ info, err := os.Stat(expected)
+ if err != nil {
+ t.Fatalf("stat default: %v", err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("expected directory at %s", expected)
+ }
+}
+
+func TestResolveRootPathRequiresExisting(t *testing.T) {
+ tmp := t.TempDir()
+ missing := filepath.Join(tmp, "missing")
+ if _, err := ResolveRootPath(missing, "~/Yoga"); err == nil {
+ t.Fatalf("expected error for %s", missing)
+ }
+}
+
+func TestResolveRootPathAllowsFile(t *testing.T) {
+ tmp := t.TempDir()
+ file := filepath.Join(tmp, "video.mp4")
+ if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+ got, err := ResolveRootPath(file, "~/Yoga")
+ if err != nil {
+ t.Fatalf("resolve root: %v", err)
+ }
+ if got != file {
+ t.Fatalf("expected file path returned, got %s", got)
+ }
+}
+
+func TestExpandPathWithHome(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ custom := filepath.Join(home, "custom")
+ if err := os.MkdirAll(custom, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ got, err := ResolveRootPath("~/custom", "~/Yoga")
+ if err != nil {
+ t.Fatalf("resolve root: %v", err)
+ }
+ expected := filepath.Join(home, "custom")
+ if got != expected {
+ t.Fatalf("expected %s, got %s", expected, got)
+ }
+ if v, err := expandPath("~"); err != nil || v != home {
+ t.Fatalf("expandPath ~ failed: %v %s", err, v)
+ }
+ if _, err := expandPath("~no_such_user/foo"); err == nil {
+ t.Fatalf("expected error for unknown user")
+ }
+ if path, err := expandPath("relative/path"); err != nil || path != "relative/path" {
+ t.Fatalf("expected relative path unchanged, got %s %v", path, err)
+ }
+ if current, err := user.Current(); err == nil {
+ value := "~" + current.Username
+ if p, err := expandPath(value); err != nil || p != current.HomeDir {
+ t.Fatalf("expected home dir %s, got %s (%v)", current.HomeDir, p, err)
+ }
+ }
+}
+
+func TestSplitUserPath(t *testing.T) {
+ user, rest := splitUserPath("~alice/videos")
+ if user != "alice" || rest != "/videos" {
+ t.Fatalf("unexpected split %s %s", user, rest)
+ }
+ user, rest = splitUserPath("~bob")
+ if user != "bob" || rest != "" {
+ t.Fatalf("unexpected split %s %s", user, rest)
+ }
+}
+
+func TestNormalizeRootInput(t *testing.T) {
+ const fallback = "~/Yoga"
+ value, isDefault := normalizeRootInput("", fallback)
+ if !isDefault || value != fallback {
+ t.Fatalf("unexpected normalize result %s %v", value, isDefault)
+ }
+ value, isDefault = normalizeRootInput(" /tmp ", fallback)
+ if isDefault || value != "/tmp" {
+ t.Fatalf("unexpected normalize result %s %v", value, isDefault)
+ }
+}
+
+func TestEnsureRootExistsErrors(t *testing.T) {
+ if _, err := ensureRootExists(filepath.Join(t.TempDir(), "missing"), false); err == nil {
+ t.Fatalf("expected error when creation not allowed")
+ }
+}
diff --git a/internal/meta/version.go b/internal/meta/version.go
new file mode 100644
index 0000000..687450b
--- /dev/null
+++ b/internal/meta/version.go
@@ -0,0 +1,3 @@
+package meta
+
+const Version = "v0.2.0"
diff --git a/magefile.go b/magefile.go
new file mode 100644
index 0000000..8b14824
--- /dev/null
+++ b/magefile.go
@@ -0,0 +1,78 @@
+//go:build mage
+
+package main
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+var Default = Build
+
+// Build compiles the yoga binary.
+func Build() error {
+ return run("go", "build", "./cmd/yoga")
+}
+
+// Test runs the unit test suite.
+func Test() error {
+ return run("go", "test", "./...")
+}
+
+// Install installs the yoga binary into GOPATH/bin or GOBIN.
+func Install() error {
+ return run("go", "install", "./cmd/yoga")
+}
+
+// Coverage runs the unit tests with coverage and enforces the minimum target.
+func Coverage() error {
+ profile := filepath.Join(os.TempDir(), "yoga-coverage.out")
+ if err := run("go", "test", "-coverprofile="+profile, "./..."); err != nil {
+ return err
+ }
+ defer os.Remove(profile)
+ out, err := exec.Command("go", "tool", "cover", "-func="+profile).CombinedOutput()
+ if err != nil {
+ fmt.Print(string(out))
+ return err
+ }
+ fmt.Print(string(out))
+ total, err := parseTotalCoverage(string(out))
+ if err != nil {
+ return err
+ }
+ if total < 85.0 {
+ return fmt.Errorf("coverage %.1f%% below required 85%%", total)
+ }
+ return nil
+}
+
+func parseTotalCoverage(report string) (float64, error) {
+ lines := strings.Split(strings.TrimSpace(report), "\n")
+ if len(lines) == 0 {
+ return 0, errors.New("empty coverage report")
+ }
+ last := lines[len(lines)-1]
+ fields := strings.Fields(last)
+ if len(fields) < 3 {
+ return 0, fmt.Errorf("unexpected coverage line: %s", last)
+ }
+ value := strings.TrimSuffix(fields[len(fields)-1], "%")
+ percent, err := strconv.ParseFloat(value, 64)
+ if err != nil {
+ return 0, err
+ }
+ return percent, nil
+}
+
+func run(command string, args ...string) error {
+ cmd := exec.Command(command, args...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
diff --git a/main.go b/main.go
deleted file mode 100644
index 06e16ea..0000000
--- a/main.go
+++ /dev/null
@@ -1,1207 +0,0 @@
-package main
-
-import (
- "context"
- "encoding/json"
- "errors"
- "flag"
- "fmt"
- "io/fs"
- "os"
- "os/exec"
- "os/user"
- "path/filepath"
- "runtime"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "github.com/charmbracelet/bubbles/table"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-const Version = "v0.1.0"
-
-var (
- videoExtensions = map[string]struct{}{
- ".mp4": {},
- ".mkv": {},
- ".mov": {},
- ".avi": {},
- ".wmv": {},
- ".m4v": {},
- }
- tableStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("63")).Padding(0, 1)
- headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99")).Bold(true)
- filterStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("105")).Padding(1, 2)
- statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
- highlightStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
-)
-
-type video struct {
- Name string
- Path string
- Duration time.Duration
- ModTime time.Time
- Size int64
- Err error
-}
-
-type videosLoadedMsg struct {
- videos []video
- err error
- cacheErr error
- pending []string
- cache *durationCache
-}
-
-type playVideoMsg struct {
- path string
- err error
-}
-
-type progressUpdateMsg struct {
- processed int
- total int
- done bool
-}
-
-type durationUpdateMsg struct {
- path string
- duration time.Duration
- err error
-}
-
-type loadProgress struct {
- mu sync.Mutex
- total int
- processed int
- done bool
-}
-
-func (p *loadProgress) Reset() {
- if p == nil {
- return
- }
- p.mu.Lock()
- defer p.mu.Unlock()
- p.total = 0
- p.processed = 0
- p.done = false
-}
-
-func (p *loadProgress) SetTotal(total int) {
- if p == nil {
- return
- }
- p.mu.Lock()
- p.total = total
- p.mu.Unlock()
-}
-
-func (p *loadProgress) Increment() {
- if p == nil {
- return
- }
- p.mu.Lock()
- p.processed++
- p.mu.Unlock()
-}
-
-func (p *loadProgress) MarkDone() {
- if p == nil {
- return
- }
- p.mu.Lock()
- p.done = true
- p.mu.Unlock()
-}
-
-func (p *loadProgress) Snapshot() (processed, total int, done bool) {
- if p == nil {
- return 0, 0, true
- }
- p.mu.Lock()
- defer p.mu.Unlock()
- return p.processed, p.total, p.done
-}
-
-type cacheEntry struct {
- DurationSeconds float64 `json:"duration_seconds"`
- ModTimeUnix int64 `json:"mod_time_unix"`
- Size int64 `json:"size"`
-}
-
-type durationCache struct {
- path string
- entries map[string]cacheEntry
- mu sync.Mutex
- dirty bool
-}
-
-func newDurationCache(path string) *durationCache {
- return &durationCache{
- path: path,
- entries: make(map[string]cacheEntry),
- }
-}
-
-func loadDurationCache(path string) (*durationCache, error) {
- cache := newDurationCache(path)
- data, err := os.ReadFile(path)
- if err != nil {
- if errors.Is(err, fs.ErrNotExist) {
- return cache, nil
- }
- return cache, err
- }
- if len(data) == 0 {
- return cache, nil
- }
- if err := json.Unmarshal(data, &cache.entries); err != nil {
- cache.entries = make(map[string]cacheEntry)
- return cache, err
- }
- return cache, nil
-}
-
-func (c *durationCache) Lookup(path string, info os.FileInfo) (time.Duration, bool) {
- c.mu.Lock()
- defer c.mu.Unlock()
- entry, ok := c.entries[path]
- if !ok {
- return 0, false
- }
- if entry.ModTimeUnix != info.ModTime().Unix() || entry.Size != info.Size() {
- delete(c.entries, path)
- c.dirty = true
- return 0, false
- }
- if entry.DurationSeconds <= 0 {
- return 0, false
- }
- return time.Duration(entry.DurationSeconds * float64(time.Second)), true
-}
-
-func (c *durationCache) Record(path string, info os.FileInfo, dur time.Duration) error {
- if c == nil || dur <= 0 {
- return nil
- }
- c.mu.Lock()
- defer c.mu.Unlock()
- if c.entries == nil {
- c.entries = make(map[string]cacheEntry)
- }
- c.entries[path] = cacheEntry{
- DurationSeconds: dur.Seconds(),
- ModTimeUnix: info.ModTime().Unix(),
- Size: info.Size(),
- }
- c.dirty = true
- return nil
-}
-
-func (c *durationCache) Flush() error {
- if c == nil {
- return nil
- }
- c.mu.Lock()
- if !c.dirty {
- c.mu.Unlock()
- return nil
- }
- snapshot := make(map[string]cacheEntry, len(c.entries))
- for k, v := range c.entries {
- snapshot[k] = v
- }
- c.dirty = false
- c.mu.Unlock()
-
- data, err := json.MarshalIndent(snapshot, "", " ")
- if err != nil {
- return err
- }
- tmp := c.path + ".tmp"
- if err := os.WriteFile(tmp, data, 0o644); err != nil {
- return err
- }
- return os.Rename(tmp, c.path)
-}
-
-type sortField int
-
-const (
- sortByName sortField = iota
- sortByDuration
- sortByAge
-)
-
-type filterState struct {
- name string
- minEnabled bool
- minMinutes int
- maxEnabled bool
- maxMinutes int
-}
-
-type filterInputs struct {
- fields []textinput.Model
- focus int
-}
-
-type model struct {
- table table.Model
- videos []video
- filtered []video
- filters filterState
- inputs filterInputs
- showFilters bool
- sortField sortField
- sortAscending bool
- statusMessage string
- loading bool
- err error
- root string
- progress *loadProgress
- cachePath string
- cache *durationCache
- pendingDurations []string
- durationTotal int
- durationDone int
- durationInFlight int
- cropValue string
- cropEnabled bool
-}
-
-func main() {
- rootFlag := flag.String("root", "", "Directory containing yoga videos (default ~/Yoga)")
- crop := flag.String("crop", "", "Optional crop aspect for VLC (e.g. 5:4)")
- printVersion := flag.Bool("version", false, "Print version and exit")
- flag.Parse()
-
- if *printVersion {
- fmt.Println("Yoga version", Version)
- os.Exit(0)
- }
-
- root, err := resolveRootPath(*rootFlag)
- if err != nil {
- fmt.Fprintf(os.Stderr, "%v\n", err)
- os.Exit(1)
- }
-
- m := newModel(root, strings.TrimSpace(*crop))
- if err := tea.NewProgram(m, tea.WithAltScreen()).Start(); err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
- }
-}
-
-func resolveRootPath(flagValue string) (string, error) {
- value := strings.TrimSpace(flagValue)
- isDefault := value == ""
- if isDefault {
- value = "~/Yoga"
- }
- expanded, err := expandPath(value)
- if err != nil {
- return "", fmt.Errorf("cannot expand root path %q: %w", value, err)
- }
- abs, err := filepath.Abs(expanded)
- if err != nil {
- return "", fmt.Errorf("cannot resolve root path %q: %w", expanded, err)
- }
- info, statErr := os.Stat(abs)
- if statErr != nil {
- if errors.Is(statErr, fs.ErrNotExist) {
- if isDefault {
- if mkErr := os.MkdirAll(abs, 0o755); mkErr != nil {
- return "", fmt.Errorf("cannot create default directory %q: %w", abs, mkErr)
- }
- info, statErr = os.Stat(abs)
- if statErr != nil {
- return "", fmt.Errorf("cannot stat default directory %q: %w", abs, statErr)
- }
- } else {
- return "", fmt.Errorf("root path does not exist: %s", abs)
- }
- } else {
- return "", fmt.Errorf("cannot access root path %q: %w", abs, statErr)
- }
- }
- if info.IsDir() || info.Mode().IsRegular() {
- return abs, nil
- }
- return "", fmt.Errorf("root path %q is not a file or directory", abs)
-}
-
-func expandPath(p string) (string, error) {
- if p == "" || p[0] != '~' {
- return p, nil
- }
- if len(p) == 1 {
- home, err := os.UserHomeDir()
- if err != nil {
- return "", err
- }
- return home, nil
- }
- if p[1] == '/' {
- home, err := os.UserHomeDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(home, p[2:]), nil
- }
- sep := strings.IndexRune(p, '/')
- var username, rest string
- if sep == -1 {
- username = p[1:]
- } else {
- username = p[1:sep]
- rest = p[sep:]
- }
- usr, err := user.Lookup(username)
- if err != nil {
- return "", err
- }
- if rest == "" {
- return usr.HomeDir, nil
- }
- return filepath.Join(usr.HomeDir, rest), nil
-}
-
-func newModel(root, vlcCrop string) model {
- columns := []table.Column{
- {Title: headerStyle.Render("Name"), Width: 50},
- {Title: headerStyle.Render("Duration"), Width: 12},
- {Title: headerStyle.Render("Age"), Width: 14},
- {Title: headerStyle.Render("Path"), Width: 40},
- }
-
- tbl := table.New(
- table.WithColumns(columns),
- table.WithFocused(true),
- table.WithHeight(15),
- )
- tbl.SetStyles(table.DefaultStyles())
-
- 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
- minInput.SetValue("")
-
- maxInput := textinput.New()
- maxInput.Placeholder = "max minutes"
- maxInput.Prompt = "Max minutes: "
- maxInput.CharLimit = 4
- maxInput.SetValue("")
-
- inputs := filterInputs{
- fields: []textinput.Model{nameInput, minInput, maxInput},
- focus: 0,
- }
- inputs.fields[0].Focus()
-
- progress := &loadProgress{}
- cachePath := filepath.Join(root, ".video_duration_cache.json")
-
- return model{
- table: tbl,
- inputs: inputs,
- sortField: sortByName,
- sortAscending: true,
- statusMessage: "Scanning for videos...",
- loading: true,
- root: root,
- progress: progress,
- cachePath: cachePath,
- cropValue: vlcCrop,
- cropEnabled: vlcCrop != "",
- }
-}
-
-func (m model) Init() tea.Cmd {
- if m.progress != nil {
- m.progress.Reset()
- }
- loadCmd := loadVideosCmd(m.root, m.cachePath, m.progress)
- if m.progress != nil {
- return tea.Batch(loadCmd, progressTickerCmd(m.progress))
- }
- return loadCmd
-}
-
-func loadVideosCmd(root, cachePath string, progress *loadProgress) tea.Cmd {
- return func() tea.Msg {
- cache, cacheErr := loadDurationCache(cachePath)
- vids, pending, err := loadVideos(root, cache, progress)
- if progress != nil {
- progress.MarkDone()
- }
- return videosLoadedMsg{videos: vids, err: err, cacheErr: cacheErr, pending: pending, cache: cache}
- }
-}
-
-func progressTickerCmd(progress *loadProgress) tea.Cmd {
- if progress == nil {
- return nil
- }
- return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg {
- processed, total, done := progress.Snapshot()
- return progressUpdateMsg{processed: processed, total: total, done: done}
- })
-}
-
-func collectVideoPaths(root string) ([]string, error) {
- info, err := os.Stat(root)
- if err != nil {
- return nil, err
- }
- if !info.IsDir() {
- if isVideo(root) {
- return []string{root}, nil
- }
- return nil, nil
- }
- visited := make(map[string]struct{})
- var paths []string
- if err := traverseVideoPaths(root, root, visited, &paths); err != nil {
- return nil, err
- }
- sort.Strings(paths)
- return paths, nil
-}
-
-func traverseVideoPaths(displayPath, realPath string, visited map[string]struct{}, acc *[]string) error {
- resolved, err := filepath.EvalSymlinks(realPath)
- if err != nil {
- resolved = realPath
- }
- resolved = filepath.Clean(resolved)
- if _, seen := visited[resolved]; seen {
- return nil
- }
- visited[resolved] = struct{}{}
-
- entries, err := os.ReadDir(resolved)
- if err != nil {
- return err
- }
- for _, entry := range entries {
- displayChild := filepath.Join(displayPath, entry.Name())
- realChild := filepath.Join(resolved, entry.Name())
- mode := entry.Type()
- var info os.FileInfo
- if mode == fs.FileMode(0) {
- var err error
- info, err = entry.Info()
- if err != nil {
- return err
- }
- mode = info.Mode()
- }
- if mode&os.ModeSymlink != 0 {
- targetPath, err := filepath.EvalSymlinks(realChild)
- if err != nil {
- if isVideo(displayChild) {
- *acc = append(*acc, displayChild)
- }
- continue
- }
- targetInfo, err := os.Stat(targetPath)
- if err != nil {
- if isVideo(displayChild) {
- *acc = append(*acc, displayChild)
- }
- continue
- }
- if targetInfo.IsDir() {
- if err := traverseVideoPaths(displayChild, targetPath, visited, acc); err != nil {
- return err
- }
- continue
- }
- if isVideo(displayChild) || isVideo(targetPath) {
- *acc = append(*acc, displayChild)
- }
- continue
- }
- if mode.IsDir() {
- if err := traverseVideoPaths(displayChild, realChild, visited, acc); err != nil {
- return err
- }
- continue
- }
- if isVideo(displayChild) {
- *acc = append(*acc, displayChild)
- }
- }
- return nil
-}
-
-func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error) {
- paths, err := collectVideoPaths(root)
- if err != nil {
- return nil, nil, err
- }
- if progress != nil {
- progress.SetTotal(len(paths))
- }
-
- videos := make([]video, 0, len(paths))
- pending := make([]string, 0)
- for _, path := range paths {
- info, statErr := os.Stat(path)
- if statErr != nil {
- videos = append(videos, video{Name: filepath.Base(path), Path: path, Err: statErr})
- if progress != nil {
- progress.Increment()
- }
- continue
- }
- var dur time.Duration
- if cache != nil {
- if cached, ok := cache.Lookup(path, info); ok {
- dur = cached
- } else {
- pending = append(pending, path)
- }
- } else {
- pending = append(pending, path)
- }
- videos = append(videos, video{
- Name: filepath.Base(path),
- Path: path,
- Duration: dur,
- ModTime: info.ModTime(),
- Size: info.Size(),
- Err: nil,
- })
- if progress != nil {
- progress.Increment()
- }
- }
-
- return videos, pending, nil
-}
-
-func playVideoCmd(path, crop string) tea.Cmd {
- return func() tea.Msg {
- args := []string{}
- if crop != "" {
- args = append(args, "--crop", crop)
- }
- args = append(args, path)
- cmd := exec.Command("vlc", args...)
- if err := cmd.Start(); err != nil {
- return playVideoMsg{path: path, err: err}
- }
- go func() {
- _ = cmd.Wait()
- }()
- return playVideoMsg{path: path}
- }
-}
-
-func probeDurationsCmd(path string, cache *durationCache) tea.Cmd {
- return func() tea.Msg {
- dur, err := probeDuration(path)
- if err == nil && cache != nil {
- if info, statErr := os.Stat(path); statErr == nil {
- _ = cache.Record(path, info, dur)
- }
- }
- return durationUpdateMsg{path: path, duration: dur, err: err}
- }
-}
-
-func probeDuration(path string) (time.Duration, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- defer cancel()
-
- cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path)
- out, err := cmd.Output()
- if err != nil {
- return 0, err
- }
- raw := strings.TrimSpace(string(out))
- if raw == "" {
- return 0, errors.New("empty duration")
- }
- f, err := strconv.ParseFloat(raw, 64)
- if err != nil {
- return 0, err
- }
- return time.Duration(f * float64(time.Second)), nil
-}
-
-func isVideo(path string) bool {
- ext := strings.ToLower(filepath.Ext(path))
- _, ok := videoExtensions[ext]
- return ok
-}
-
-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- return m.handleKeyMsg(msg)
- case progressUpdateMsg:
- if m.loading {
- if msg.total > 0 && !msg.done {
- m.statusMessage = fmt.Sprintf("Loading videos %d/%d...", msg.processed, msg.total)
- } else if msg.done {
- if msg.total == 0 {
- m.statusMessage = "No videos found"
- } else {
- m.statusMessage = fmt.Sprintf("Loaded %d videos", msg.total)
- }
- }
- }
- if msg.done {
- return m, nil
- }
- return m, progressTickerCmd(m.progress)
- case durationUpdateMsg:
- if msg.path != "" {
- m.updateVideoDuration(msg.path, msg.duration, msg.err)
- m.durationDone++
- if msg.err != nil {
- m.statusMessage = fmt.Sprintf("Duration error for %s: %v", filepath.Base(msg.path), msg.err)
- } else if m.durationTotal > 0 {
- m.statusMessage = fmt.Sprintf("Probing durations %d/%d...", m.durationDone, m.durationTotal)
- }
- }
- if m.durationInFlight > 0 {
- m.durationInFlight--
- }
- selectedPath := ""
- if idx := m.table.Cursor(); idx >= 0 && idx < len(m.filtered) {
- selectedPath = m.filtered[idx].Path
- }
- m.applyFiltersAndSort()
- if selectedPath != "" {
- m.restoreSelection(selectedPath)
- }
- if m.durationDone >= m.durationTotal && m.durationInFlight == 0 {
- if m.cache != nil {
- if err := m.cache.Flush(); err != nil {
- m.statusMessage = fmt.Sprintf("Duration cache flush error: %v", err)
- } else {
- m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered))
- }
- } else {
- m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered))
- }
- m.pendingDurations = nil
- m.durationTotal = 0
- m.durationDone = 0
- m.durationInFlight = 0
- return m, nil
- }
- if cmd := m.dequeueDurationCmd(); cmd != nil {
- return m, cmd
- }
- return m, nil
- case videosLoadedMsg:
- m.loading = false
- if msg.err != nil {
- m.err = msg.err
- m.statusMessage = fmt.Sprintf("error: %v", msg.err)
- }
- m.videos = msg.videos
- m.cache = msg.cache
- m.pendingDurations = msg.pending
- m.durationTotal = len(msg.pending)
- m.durationDone = 0
- m.applyFiltersAndSort()
- if len(m.filtered) == 0 {
- m.statusMessage = "No videos found"
- } else {
- if len(msg.pending) > 0 {
- if msg.cacheErr != nil {
- m.statusMessage = fmt.Sprintf("Loaded %d videos (cache warning: %v), probing durations...", len(m.filtered), msg.cacheErr)
- } else {
- m.statusMessage = fmt.Sprintf("Loaded %d videos, probing durations...", len(m.filtered))
- }
- } else if msg.cacheErr != nil {
- m.statusMessage = fmt.Sprintf("Loaded %d videos (cache warning: %v)", len(m.filtered), msg.cacheErr)
- } else {
- m.statusMessage = fmt.Sprintf("Loaded %d videos", len(m.filtered))
- }
- }
- m.durationInFlight = 0
- if len(msg.pending) == 0 {
- return m, nil
- }
- cmd := m.startDurationWorkers()
- if cmd == nil {
- return m, nil
- }
- return m, cmd
- case playVideoMsg:
- if msg.err != nil {
- m.statusMessage = fmt.Sprintf("Failed to launch VLC: %v", msg.err)
- return m, nil
- }
- m.statusMessage = fmt.Sprintf("Playing via VLC: %s", trimPath(msg.path))
- return m, nil
- }
-
- if m.showFilters {
- updated, cmd := m.updateFilterInputs(msg)
- m.inputs = updated
- return m, cmd
- }
-
- tbl, cmd := m.table.Update(msg)
- m.table = tbl
- return m, cmd
-}
-
-func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
- switch msg.String() {
- case "ctrl+c", "q":
- return m, tea.Quit
- }
-
- if m.loading {
- return m, nil
- }
-
- if m.showFilters {
- switch msg.String() {
- case "esc":
- m.showFilters = false
- m.statusMessage = "Filter closed"
- return m, nil
- case "enter":
- if err := m.applyFilterInputs(); err != nil {
- m.statusMessage = err.Error()
- } else {
- m.showFilters = false
- m.applyFiltersAndSort()
- m.statusMessage = fmt.Sprintf("Filters applied (%d videos)", len(m.filtered))
- }
- return m, nil
- case "tab":
- m.inputs.focus = (m.inputs.focus + 1) % len(m.inputs.fields)
- case "shift+tab":
- m.inputs.focus = (m.inputs.focus - 1 + len(m.inputs.fields)) % len(m.inputs.fields)
- default:
- // no-op; handled below
- }
- for i := range m.inputs.fields {
- if i == m.inputs.focus {
- m.inputs.fields[i].Focus()
- } else {
- m.inputs.fields[i].Blur()
- }
- }
- updated, cmd := m.updateFilterInputs(msg)
- m.inputs = updated
- return m, cmd
- }
-
- switch msg.String() {
- case "f":
- m.showFilters = true
- m.statusMessage = "Editing filters"
- return m, nil
- case "enter":
- if len(m.filtered) == 0 {
- return m, nil
- }
- idx := m.table.Cursor()
- if idx < 0 || idx >= len(m.filtered) {
- return m, nil
- }
- vid := m.filtered[idx]
- m.statusMessage = fmt.Sprintf("Launching VLC: %s", vid.Name)
- return m, playVideoCmd(vid.Path, m.activeCrop())
- case "n":
- m.toggleSort(sortByName)
- case "l":
- m.toggleSort(sortByDuration)
- case "a":
- m.toggleSort(sortByAge)
- case "c":
- if m.cropValue == "" {
- m.statusMessage = "No crop value set (start with --crop)"
- return m, nil
- }
- m.cropEnabled = !m.cropEnabled
- if m.cropEnabled {
- m.statusMessage = fmt.Sprintf("Crop enabled (%s)", m.cropValue)
- } else {
- m.statusMessage = "Crop disabled"
- }
- return m, nil
- case "r":
- m.resetFilters()
- m.applyFiltersAndSort()
- m.statusMessage = fmt.Sprintf("Filters cleared (%d videos)", len(m.filtered))
- default:
- tbl, cmd := m.table.Update(msg)
- m.table = tbl
- return m, cmd
- }
-
- m.applyFiltersAndSort()
- m.statusMessage = fmt.Sprintf("Sorted %d videos", len(m.filtered))
- return m, nil
-}
-
-func (m model) updateFilterInputs(msg tea.Msg) (filterInputs, tea.Cmd) {
- inputs := m.inputs
- var cmds []tea.Cmd
- for i := range inputs.fields {
- var cmd tea.Cmd
- inputs.fields[i], cmd = inputs.fields[i].Update(msg)
- cmds = append(cmds, cmd)
- }
- return inputs, tea.Batch(cmds...)
-}
-
-func (m *model) applyFilterInputs() error {
- name := strings.TrimSpace(m.inputs.fields[0].Value())
- minText := strings.TrimSpace(m.inputs.fields[1].Value())
- maxText := strings.TrimSpace(m.inputs.fields[2].Value())
-
- filters := filterState{name: name}
-
- if minText != "" {
- minVal, err := strconv.Atoi(minText)
- if err != nil {
- return fmt.Errorf("invalid min minutes: %q", minText)
- }
- if minVal < 0 {
- return fmt.Errorf("min minutes must be positive")
- }
- filters.minEnabled = true
- filters.minMinutes = minVal
- }
-
- if maxText != "" {
- maxVal, err := strconv.Atoi(maxText)
- if err != nil {
- return fmt.Errorf("invalid max minutes: %q", maxText)
- }
- if maxVal < 0 {
- return fmt.Errorf("max minutes must be positive")
- }
- filters.maxEnabled = true
- filters.maxMinutes = maxVal
- }
-
- if filters.minEnabled && filters.maxEnabled && filters.minMinutes > filters.maxMinutes {
- return errors.New("min minutes cannot exceed max minutes")
- }
-
- m.filters = filters
- return nil
-}
-
-func (m *model) resetFilters() {
- m.filters = filterState{}
- for i := range m.inputs.fields {
- m.inputs.fields[i].SetValue("")
- }
-}
-
-func (m *model) updateVideoDuration(path string, dur time.Duration, err error) {
- for i := range m.videos {
- if m.videos[i].Path == path {
- m.videos[i].Duration = dur
- if err != nil {
- m.videos[i].Err = err
- } else {
- m.videos[i].Err = nil
- }
- break
- }
- }
-}
-
-func (m *model) restoreSelection(path string) {
- for i, v := range m.filtered {
- if v.Path == path {
- m.table.SetCursor(i)
- return
- }
- }
-}
-
-func (m model) activeCrop() string {
- if m.cropEnabled && m.cropValue != "" {
- return m.cropValue
- }
- return ""
-}
-
-func (m *model) dequeueDurationCmd() tea.Cmd {
- if len(m.pendingDurations) == 0 {
- return nil
- }
- path := m.pendingDurations[0]
- m.pendingDurations = m.pendingDurations[1:]
- m.durationInFlight++
- return probeDurationsCmd(path, m.cache)
-}
-
-func (m *model) startDurationWorkers() tea.Cmd {
- if len(m.pendingDurations) == 0 {
- return nil
- }
- workers := runtime.NumCPU()
- if workers < 1 {
- workers = 1
- }
- if workers > 6 {
- workers = 6
- }
- if workers > len(m.pendingDurations) {
- workers = len(m.pendingDurations)
- }
- cmds := make([]tea.Cmd, 0, workers)
- for i := 0; i < workers; i++ {
- if cmd := m.dequeueDurationCmd(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- if len(cmds) == 0 {
- return nil
- }
- return tea.Batch(cmds...)
-}
-
-func (m *model) toggleSort(target sortField) {
- if m.sortField == target {
- m.sortAscending = !m.sortAscending
- } else {
- m.sortField = target
- m.sortAscending = true
- }
-}
-
-func (m *model) applyFiltersAndSort() {
- filtered := make([]video, 0, len(m.videos))
- for _, v := range m.videos {
- if !m.passesFilters(v) {
- continue
- }
- filtered = append(filtered, v)
- }
-
- sort.Slice(filtered, func(i, j int) bool {
- a, b := filtered[i], filtered[j]
- less := false
- switch m.sortField {
- case sortByName:
- less = strings.ToLower(a.Name) < strings.ToLower(b.Name)
- case sortByDuration:
- less = a.Duration < b.Duration
- case sortByAge:
- less = a.ModTime.Before(b.ModTime)
- }
- if m.sortAscending {
- return less
- }
- return !less
- })
-
- m.filtered = filtered
- rows := make([]table.Row, 0, len(filtered))
- for _, v := range filtered {
- rows = append(rows, videoRow(v))
- }
- m.table.SetRows(rows)
- if len(rows) > 0 {
- m.table.SetCursor(0)
- }
-}
-
-func (m model) passesFilters(v video) bool {
- f := m.filters
- if f.name != "" && !strings.Contains(strings.ToLower(v.Name), strings.ToLower(f.name)) {
- return false
- }
- durMinutes := int(v.Duration.Round(time.Minute) / time.Minute)
- if f.minEnabled && (v.Duration == 0 || durMinutes < f.minMinutes) {
- return false
- }
- if f.maxEnabled && (v.Duration == 0 || durMinutes > f.maxMinutes) {
- return false
- }
- return true
-}
-
-func videoRow(v video) table.Row {
- duration := "(unknown)"
- if v.Duration > 0 {
- duration = formatDuration(v.Duration)
- }
- age := humanizeAge(v.ModTime)
- path := trimPath(v.Path)
- if v.Err != nil {
- duration = "!" + v.Err.Error()
- }
- return table.Row{v.Name, duration, age, path}
-}
-
-func renderProgressBar(done, total, width int) string {
- if width <= 0 || total <= 0 {
- return ""
- }
- if done < 0 {
- done = 0
- }
- if done > total {
- done = total
- }
- filled := int(float64(done) / float64(total) * float64(width))
- if filled > width {
- filled = width
- }
- bar := strings.Repeat("#", filled) + strings.Repeat("-", width-filled)
- return fmt.Sprintf("[%s]", bar)
-}
-
-func formatDuration(d time.Duration) string {
- if d <= 0 {
- return "--"
- }
- totalSeconds := int(d.Seconds() + 0.5)
- hours := totalSeconds / 3600
- minutes := (totalSeconds % 3600) / 60
- seconds := totalSeconds % 60
- if hours > 0 {
- return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
- }
- return fmt.Sprintf("%02d:%02d", minutes, seconds)
-}
-
-func humanizeAge(t time.Time) string {
- if t.IsZero() {
- return "--"
- }
- now := time.Now()
- dur := now.Sub(t)
- if dur < time.Minute {
- return "just now"
- }
- if dur < time.Hour {
- return fmt.Sprintf("%dm ago", int(dur.Minutes()))
- }
- if dur < 24*time.Hour {
- return fmt.Sprintf("%dh ago", int(dur.Hours()))
- }
- return t.Format("2006-01-02")
-}
-
-func trimPath(path string) string {
- home, err := os.UserHomeDir()
- if err == nil {
- if strings.HasPrefix(path, home) {
- return "~" + strings.TrimPrefix(path, home)
- }
- }
- return path
-}
-
-func (m model) View() string {
- if m.loading {
- return statusStyle.Render("Loading videos, please wait...")
- }
-
- if m.err != nil && len(m.filtered) == 0 {
- return statusStyle.Render(fmt.Sprintf("Failed to load videos: %v", m.err))
- }
-
- cropHelp := "Crop: (no crop configured; start with --crop)"
- if m.cropValue != "" {
- state := "off"
- if m.cropEnabled {
- state = "on"
- }
- cropHelp = fmt.Sprintf("Crop: c=toggle (%s %s)", state, m.cropValue)
- }
- helpLines := []string{
- "Controls: ↑/↓ move • Enter selects (noop) • q quits",
- "Sorting: n=name • l=length • a=age • r=reset filters",
- "Filters: f=toggle filter editor (tab to navigate, enter to apply, esc to cancel)",
- cropHelp,
- }
- info := statusStyle.Render(m.statusMessage)
-
- progressLine := ""
- if m.durationTotal > 0 {
- bar := renderProgressBar(m.durationDone, m.durationTotal, 24)
- progressLine = statusStyle.Render(fmt.Sprintf("Duration scan %s %d/%d", bar, m.durationDone, m.durationTotal))
- }
-
- content := tableStyle.Render(m.table.View())
- help := strings.Join(helpLines, "\n")
-
- var parts []string
- parts = append(parts, content)
- if progressLine != "" {
- parts = append(parts, progressLine)
- }
- parts = append(parts, info, help)
- body := strings.Join(parts, "\n")
-
- if m.showFilters {
- return body + "\n\n" + m.renderFilterModal()
- }
-
- return body
-}
-
-func (m model) renderFilterModal() string {
- var b strings.Builder
- b.WriteString("Filter videos\n")
- b.WriteString("(Enter to apply, Esc to cancel)\n\n")
- labels := []string{"Name contains:", "Min length (minutes):", "Max length (minutes):"}
- for i, field := range m.inputs.fields {
- line := fmt.Sprintf("%s %s", labels[i], field.View())
- if i == m.inputs.focus {
- line = highlightStyle.Render(line)
- }
- b.WriteString(line)
- b.WriteString("\n")
- }
- if m.filters.minEnabled || m.filters.maxEnabled || m.filters.name != "" {
- b.WriteString("\nCurrent filter: ")
- b.WriteString(m.describeFilters())
- b.WriteString("\n")
- }
- return filterStyle.Render(b.String())
-}
-
-func (m model) describeFilters() string {
- parts := []string{}
- if m.filters.name != "" {
- parts = append(parts, fmt.Sprintf("name contains %q", m.filters.name))
- }
- if m.filters.minEnabled {
- parts = append(parts, fmt.Sprintf(">=%d min", m.filters.minMinutes))
- }
- if m.filters.maxEnabled {
- parts = append(parts, fmt.Sprintf("<=%d min", m.filters.maxMinutes))
- }
- if len(parts) == 0 {
- return "(none)"
- }
- return strings.Join(parts, ", ")
-}
diff --git a/main_test.go b/main_test.go
deleted file mode 100644
index 741311c..0000000
--- a/main_test.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package main
-
-import (
- "os"
- "path/filepath"
- "runtime"
- "testing"
-)
-
-func TestLoadVideosDetectsMP4(t *testing.T) {
- dir := t.TempDir()
- videoPath := filepath.Join(dir, "video.mp4")
- if err := os.WriteFile(videoPath, []byte("dummy"), 0o644); err != nil {
- t.Fatalf("failed to create test video: %v", err)
- }
- upperPath := filepath.Join(dir, "UPPER.MP4")
- if err := os.WriteFile(upperPath, []byte("dummy"), 0o644); err != nil {
- t.Fatalf("failed to create upper test video: %v", err)
- }
-
- vids, pending, err := loadVideos(dir, nil, nil)
- if err != nil {
- t.Fatalf("loadVideos returned error: %v", err)
- }
- if len(vids) != 2 {
- t.Fatalf("expected 2 videos, got %d", len(vids))
- }
- paths := map[string]bool{videoPath: false, upperPath: false}
- for _, v := range vids {
- if _, ok := paths[v.Path]; ok {
- paths[v.Path] = true
- }
- }
- for p, seen := range paths {
- if !seen {
- t.Fatalf("missing video %s", p)
- }
- }
- if len(pending) != 2 {
- t.Fatalf("expected pending durations for both videos, got %d", len(pending))
- }
-}
-
-func TestLoadVideosFollowSymlinkDirectories(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink permissions vary on Windows")
- }
-
- root := t.TempDir()
- storage := t.TempDir()
-
- if err := os.WriteFile(filepath.Join(storage, "movie.mp4"), []byte("dummy"), 0o644); err != nil {
- t.Fatalf("failed to create storage video: %v", err)
- }
-
- linkPath := filepath.Join(root, "videos")
- if err := os.Symlink(storage, linkPath); err != nil {
- t.Skipf("symlink not supported: %v", err)
- }
-
- vids, _, err := loadVideos(root, nil, nil)
- if err != nil {
- t.Fatalf("loadVideos returned error: %v", err)
- }
-
- expected := filepath.Join(linkPath, "movie.mp4")
- found := false
- for _, v := range vids {
- if v.Path == expected {
- found = true
- break
- }
- }
- if !found {
- t.Fatalf("expected to find video at %s, paths=%v", expected, vids)
- }
-}
-
-func TestResolveRootPathDefaultCreatesDirectory(t *testing.T) {
- tmp := t.TempDir()
- t.Setenv("HOME", tmp)
-
- got, err := resolveRootPath("")
- if err != nil {
- t.Fatalf("resolveRootPath returned error: %v", err)
- }
- want := filepath.Join(tmp, "Yoga")
- if got != want {
- t.Fatalf("expected %s, got %s", want, got)
- }
- info, err := os.Stat(want)
- if err != nil {
- t.Fatalf("stat expected dir failed: %v", err)
- }
- if !info.IsDir() {
- t.Fatalf("expected %s to be a directory", want)
- }
-}
-
-func TestResolveRootPathRequiresExistingDirectory(t *testing.T) {
- tmp := t.TempDir()
- missing := filepath.Join(tmp, "missing")
- if _, err := resolveRootPath(missing); err == nil {
- t.Fatalf("expected error for missing path %s", missing)
- }
-}
-
-func TestExpandPathHome(t *testing.T) {
- tmp := t.TempDir()
- t.Setenv("HOME", tmp)
- got, err := expandPath("~/custom")
- if err != nil {
- t.Fatalf("expandPath error: %v", err)
- }
- want := filepath.Join(tmp, "custom")
- if got != want {
- t.Fatalf("expected %s, got %s", want, got)
- }
-}