diff options
30 files changed, 2614 insertions, 1327 deletions
diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f92a896 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Repository Guidelines + +## Code structur + +- Minimal entrace point, in ./cmd/yoga/main.go, all other code goes to the ./internal directory. + +## Coding Style & Naming Conventions + +- Avoid duplication of code when the functions are larger than 5 lines. +- If possible, construct individual methods so that they can be unit tested. But only if it doesn't add too much boilerplate to the code base. +- Aim for at least 85% unit test coverage of all source code. The command to check the coverage is "mage coverage" +- Ensure that all unit tests pass before commiting any changes. +- Always run the gofumpt code reformatter on all go files modified. +- There should be no source code file larger than 1000 lines. If so, split it up into multiple. +- There should be no function larger then 50 lines. If so, refactor or split up into multiple smaller functions. +- Code (when added): follow language idioms +- Any type with more than 3 methods should be in it's own source code file, whereas the filename contains the name of the type. + +## Incrementing version + +- Never draft a changelog entry +- Whenever incrementing the version, update the version number in the project, commit to git, tag the version and push to git. +- When a major feature was introduced, increment ?.X.? +- When only minor changes were done or only bugs were fixed, increment the version as ?.?.X + +## Documentation + +- Document in the README all options and basic behaviour and also how to use the Magefile. @@ -1,5 +1,48 @@ # Yoga -A yoga video selector. Fully vibe-coded. +Yoga is a TUI for browsing local yoga videos with quick filtering, duration probing, and one-key playback via VLC.  + +## Usage + +```bash +yoga [--root PATH] [--crop WxH] [--version] +``` + +- `--root` sets the directory to scan for videos. When omitted, Yoga uses `~/Yoga` and creates it on first launch. +- `--crop` supplies an optional VLC crop string (for example `5:4`). Toggle the crop at runtime with the `c` key. +- `--version` prints the current version and exits. + +Yoga recognises common video extensions (`.mp4`, `.mkv`, `.mov`, `.avi`, `.wmv`, `.m4v`) and follows symlinks when scanning. Duration metadata is cached per directory in `.video_duration_cache.json`. + +### Keyboard Shortcuts + +- `↑/↓` – Navigate the table +- `enter` – Play the selected video in VLC +- `/` or `f` – Open the filter dialog +- `r` – Reset filters +- `n`, `l`, `a` – Sort by name, length, or age +- `c` – Toggle VLC crop +- `q` – Quit + +## Development + +The project uses [Mage](https://magefile.org/) for common tasks. Targets live in `magefile.go`. + +```bash +mage build # go build ./cmd/yoga +mage test # go test ./... +mage install # go install ./cmd/yoga +mage coverage # go test with coverage (fails if <85%) +``` + +Before sending changes: + +1. Format Go code with `gofumpt`. +2. Run `mage test` and `mage coverage` to ensure the suite passes and coverage stays above 85%. +3. Update documentation when flags or behaviour change. + +## Licensing + +This repository is released under the terms specified in the accompanying license file (if present). diff --git a/cmd/yoga/main.go b/cmd/yoga/main.go new file mode 100644 index 0000000..fbf11bd --- /dev/null +++ b/cmd/yoga/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "strings" + + "yoga/internal/app" + "yoga/internal/fsutil" + "yoga/internal/meta" +) + +const defaultRoot = "~/Yoga" + +var ( + runApp = app.Run + exit = os.Exit +) + +func main() { + exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("yoga", flag.ContinueOnError) + fs.SetOutput(stderr) + rootFlag := fs.String("root", "", "Directory containing yoga videos (default ~/Yoga)") + cropFlag := fs.String("crop", "", "Optional crop aspect for VLC (e.g. 5:4)") + versionFlag := fs.Bool("version", false, "Print version and exit") + if err := fs.Parse(args); err != nil { + return 2 + } + if *versionFlag { + fmt.Fprintf(stdout, "Yoga version %s\n", meta.Version) + return 0 + } + root, err := fsutil.ResolveRootPath(*rootFlag, defaultRoot) + if err != nil { + fmt.Fprintf(stderr, "%v\n", err) + return 1 + } + opts := app.Options{Root: root, Crop: strings.TrimSpace(*cropFlag)} + if err := runApp(opts); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 +} diff --git a/cmd/yoga/main_test.go b/cmd/yoga/main_test.go new file mode 100644 index 0000000..d06ff33 --- /dev/null +++ b/cmd/yoga/main_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "yoga/internal/app" +) + +func TestRunPrintsVersion(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"--version"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d", code) + } + if !bytes.Contains(stdout.Bytes(), []byte("Yoga version")) { + t.Fatalf("expected version output, got %s", stdout.String()) + } +} + +func TestRunSuccess(t *testing.T) { + var stdout, stderr bytes.Buffer + root := t.TempDir() + orig := runApp + runApp = func(opts app.Options) error { return nil } + defer func() { runApp = orig }() + code := run([]string{"--root", root}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d", code) + } +} + +func TestRunAppError(t *testing.T) { + var stdout, stderr bytes.Buffer + root := t.TempDir() + orig := runApp + runApp = func(app.Options) error { return errors.New("boom") } + defer func() { runApp = orig }() + code := run([]string{"--root", root}, &stdout, &stderr) + if code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } + if !bytes.Contains(stderr.Bytes(), []byte("error:")) { + t.Fatalf("expected error output, got %s", stderr.String()) + } +} + +func TestRunDefaultRootCreated(t *testing.T) { + var stdout, stderr bytes.Buffer + home := t.TempDir() + t.Setenv("HOME", home) + orig := runApp + runApp = func(opts app.Options) error { + if _, err := os.Stat(filepath.Join(home, "Yoga")); err != nil { + t.Fatalf("expected default directory: %v", err) + } + return nil + } + defer func() { runApp = orig }() + code := run(nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d", code) + } +} + +func TestMainUsesExit(t *testing.T) { + root := t.TempDir() + origRun := runApp + origExit := exit + runApp = func(opts app.Options) error { return nil } + var code int + exit = func(c int) { code = c } + defer func() { + runApp = origRun + exit = origExit + }() + os.Args = []string{"yoga", "--root", root} + main() + if code != 0 { + t.Fatalf("expected exit code 0, got %d", code) + } +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..ca70f3c --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,28 @@ +package app + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +type teaProgram interface { + Run() (tea.Model, error) +} + +var programFactory = func(m tea.Model) teaProgram { + return tea.NewProgram(m, tea.WithAltScreen()) +} + +// Run bootstraps the Bubble Tea program with the provided options. +func Run(opts Options) error { + model, err := newModel(opts) + if err != nil { + return fmt.Errorf("create model: %w", err) + } + program := programFactory(model) + if _, err := program.Run(); err != nil { + return fmt.Errorf("run program: %w", err) + } + return nil +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..ec96dbd --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,38 @@ +package app + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +type stubProgram struct { + err error +} + +func (s stubProgram) Run() (tea.Model, error) { + return nil, s.err +} + +func TestRunInvokesProgram(t *testing.T) { + t.Helper() + original := programFactory + defer func() { programFactory = original }() + programFactory = func(tea.Model) teaProgram { return stubProgram{} } + if err := Run(Options{Root: t.TempDir()}); err != nil { + t.Fatalf("Run returned error: %v", err) + } +} + +func TestRunPropagatesError(t *testing.T) { + t.Helper() + original := programFactory + defer func() { programFactory = original }() + errRun := errors.New("boom") + programFactory = func(tea.Model) teaProgram { return stubProgram{err: errRun} } + err := Run(Options{Root: t.TempDir()}) + if !errors.Is(err, errRun) { + t.Fatalf("expected error propagation, got %v", err) + } +} diff --git a/internal/app/duration_cache.go b/internal/app/duration_cache.go new file mode 100644 index 0000000..43172b5 --- /dev/null +++ b/internal/app/duration_cache.go @@ -0,0 +1,104 @@ +package app + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "sync" + "time" +) + +type cacheEntry struct { + DurationSeconds float64 `json:"duration_seconds"` + ModTimeUnix int64 `json:"mod_time_unix"` + Size int64 `json:"size"` +} + +type durationCache struct { + path string + entries map[string]cacheEntry + mu sync.Mutex + dirty bool +} + +func newDurationCache(path string) *durationCache { + return &durationCache{path: path, entries: make(map[string]cacheEntry)} +} + +func loadDurationCache(path string) (*durationCache, error) { + cache := newDurationCache(path) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return cache, nil + } + return cache, err + } + if len(data) == 0 { + return cache, nil + } + if err := json.Unmarshal(data, &cache.entries); err != nil { + cache.entries = make(map[string]cacheEntry) + return cache, err + } + return cache, nil +} + +func (c *durationCache) Lookup(path string, info os.FileInfo) (time.Duration, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.entries[path] + if !ok { + return 0, false + } + if entry.ModTimeUnix != info.ModTime().Unix() || entry.Size != info.Size() { + delete(c.entries, path) + c.dirty = true + return 0, false + } + if entry.DurationSeconds <= 0 { + return 0, false + } + return time.Duration(entry.DurationSeconds * float64(time.Second)), true +} + +func (c *durationCache) Record(path string, info os.FileInfo, dur time.Duration) error { + if c == nil || dur <= 0 { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil { + c.entries = make(map[string]cacheEntry) + } + c.entries[path] = cacheEntry{ + DurationSeconds: dur.Seconds(), + ModTimeUnix: info.ModTime().Unix(), + Size: info.Size(), + } + c.dirty = true + return nil +} + +func (c *durationCache) Flush() error { + if c == nil { + return nil + } + c.mu.Lock() + if !c.dirty { + c.mu.Unlock() + return nil + } + snapshot := make(map[string]cacheEntry, len(c.entries)) + for k, v := range c.entries { + snapshot[k] = v + } + c.dirty = false + c.mu.Unlock() + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return err + } + return os.WriteFile(c.path, data, 0o644) +} diff --git a/internal/app/duration_cache_test.go b/internal/app/duration_cache_test.go new file mode 100644 index 0000000..3830277 --- /dev/null +++ b/internal/app/duration_cache_test.go @@ -0,0 +1,76 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestDurationCacheRecordLifecycle(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "cache.json") + cache, err := loadDurationCache(cachePath) + if err != nil { + t.Fatalf("load cache: %v", err) + } + video := filepath.Join(dir, "video.mp4") + if err := os.WriteFile(video, []byte("x"), 0o644); err != nil { + t.Fatalf("write video: %v", err) + } + info, err := os.Stat(video) + if err != nil { + t.Fatalf("stat video: %v", err) + } + duration := 90 * time.Second + if err := cache.Record(video, info, duration); err != nil { + t.Fatalf("record: %v", err) + } + if err := cache.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + cache2, err := loadDurationCache(cachePath) + if err != nil { + t.Fatalf("reload: %v", err) + } + dur, ok := cache2.Lookup(video, info) + if !ok { + t.Fatalf("expected cached entry") + } + if dur != duration { + t.Fatalf("expected %v, got %v", duration, dur) + } +} + +func TestDurationCacheInvalidatesOnChange(t *testing.T) { + dir := t.TempDir() + cache := newDurationCache(filepath.Join(dir, "cache.json")) + video := filepath.Join(dir, "video.mp4") + if err := os.WriteFile(video, []byte("x"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + info, _ := os.Stat(video) + _ = cache.Record(video, info, 30*time.Second) + if err := os.WriteFile(video, []byte("xx"), 0o644); err != nil { + t.Fatalf("rewrite: %v", err) + } + info, _ = os.Stat(video) + if dur, ok := cache.Lookup(video, info); ok || dur != 0 { + t.Fatalf("expected cache miss after change") + } +} + +func TestLoadDurationCacheInvalidJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.json") + if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil { + t.Fatalf("write cache: %v", err) + } + cache, err := loadDurationCache(path) + if err == nil { + t.Fatalf("expected error for invalid json") + } + if len(cache.entries) != 0 { + t.Fatalf("expected cache to reset entries") + } +} diff --git a/internal/app/filters.go b/internal/app/filters.go new file mode 100644 index 0000000..691be41 --- /dev/null +++ b/internal/app/filters.go @@ -0,0 +1,146 @@ +package app + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type filterState struct { + name string + minEnabled bool + minMinutes int + maxEnabled bool + maxMinutes int +} + +type filterInputs struct { + fields []textinput.Model + focus int +} + +func (m *model) applyFilterInputs() error { + name := strings.TrimSpace(m.inputs.fields[0].Value()) + minText := strings.TrimSpace(m.inputs.fields[1].Value()) + maxText := strings.TrimSpace(m.inputs.fields[2].Value()) + + filters := filterState{name: name} + if err := populateMinFilter(&filters, minText); err != nil { + return err + } + if err := populateMaxFilter(&filters, maxText); err != nil { + return err + } + if filters.minEnabled && filters.maxEnabled && filters.minMinutes > filters.maxMinutes { + return errors.New("min minutes cannot exceed max minutes") + } + m.filters = filters + return nil +} + +func populateMinFilter(dst *filterState, value string) error { + if value == "" { + return nil + } + minutes, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid min minutes: %q", value) + } + if minutes < 0 { + return errors.New("min minutes must be positive") + } + dst.minEnabled = true + dst.minMinutes = minutes + return nil +} + +func populateMaxFilter(dst *filterState, value string) error { + if value == "" { + return nil + } + minutes, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid max minutes: %q", value) + } + if minutes < 0 { + return errors.New("max minutes must be positive") + } + dst.maxEnabled = true + dst.maxMinutes = minutes + return nil +} + +func (m *model) resetFilters() { + m.filters = filterState{} + for i := range m.inputs.fields { + m.inputs.fields[i].SetValue("") + } +} + +func (m *model) updateFilterInputs(msg tea.Msg) (filterInputs, tea.Cmd) { + inputs := m.inputs + var cmds []tea.Cmd + for i := range inputs.fields { + var cmd tea.Cmd + inputs.fields[i], cmd = inputs.fields[i].Update(msg) + cmds = append(cmds, cmd) + } + return inputs, tea.Batch(cmds...) +} + +func (m model) describeFilters() string { + parts := []string{} + if m.filters.name != "" { + parts = append(parts, fmt.Sprintf("name contains %q", m.filters.name)) + } + if m.filters.minEnabled { + parts = append(parts, fmt.Sprintf(">=%d min", m.filters.minMinutes)) + } + if m.filters.maxEnabled { + parts = append(parts, fmt.Sprintf("<=%d min", m.filters.maxMinutes)) + } + if len(parts) == 0 { + return "(none)" + } + return strings.Join(parts, ", ") +} + +func (m *model) passesFilters(v video) bool { + if m.filters.name != "" && !strings.Contains(strings.ToLower(v.Name), strings.ToLower(m.filters.name)) { + return false + } + durMinutes := int(v.Duration.Round(time.Minute) / time.Minute) + if m.filters.minEnabled && (v.Duration == 0 || durMinutes < m.filters.minMinutes) { + return false + } + if m.filters.maxEnabled && (v.Duration == 0 || durMinutes > m.filters.maxMinutes) { + return false + } + return true +} + +func (m *model) renderFilterModal() string { + var b strings.Builder + b.WriteString("Filter videos\n") + b.WriteString("(Enter to apply, Esc to cancel)\n\n") + labels := []string{"Name contains:", "Min length (minutes):", "Max length (minutes):"} + for i, field := range m.inputs.fields { + line := fmt.Sprintf("%s %s", labels[i], field.View()) + if i == m.inputs.focus { + line = highlightStyle.Render(line) + } + b.WriteString(line) + b.WriteString("\n") + } + if m.filters.minEnabled || m.filters.maxEnabled || m.filters.name != "" { + b.WriteString("\nCurrent filter: ") + b.WriteString(m.describeFilters()) + b.WriteString("\n") + } + return filterStyle.Render(b.String()) +} diff --git a/internal/app/filters_test.go b/internal/app/filters_test.go new file mode 100644 index 0000000..10eed13 --- /dev/null +++ b/internal/app/filters_test.go @@ -0,0 +1,35 @@ +package app + +import "testing" + +func TestPopulateMinFilterErrors(t *testing.T) { + var state filterState + if err := populateMinFilter(&state, "-1"); err == nil { + t.Fatal("expected error for negative minutes") + } + if err := populateMinFilter(&state, "abc"); err == nil { + t.Fatal("expected error for invalid integer") + } + if err := populateMinFilter(&state, "10"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !state.minEnabled || state.minMinutes != 10 { + t.Fatalf("expected state updated, got %+v", state) + } +} + +func TestPopulateMaxFilterErrors(t *testing.T) { + var state filterState + if err := populateMaxFilter(&state, "-1"); err == nil { + t.Fatal("expected error for negative minutes") + } + if err := populateMaxFilter(&state, "abc"); err == nil { + t.Fatal("expected error for invalid integer") + } + if err := populateMaxFilter(&state, "20"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !state.maxEnabled || state.maxMinutes != 20 { + t.Fatalf("expected state updated, got %+v", state) + } +} diff --git a/internal/app/load_progress.go b/internal/app/load_progress.go new file mode 100644 index 0000000..38679fa --- /dev/null +++ b/internal/app/load_progress.go @@ -0,0 +1,57 @@ +package app + +import "sync" + +type loadProgress struct { + mu sync.Mutex + total int + processed int + done bool +} + +func (p *loadProgress) Reset() { + if p == nil { + return + } + p.mu.Lock() + p.total = 0 + p.processed = 0 + p.done = false + p.mu.Unlock() +} + +func (p *loadProgress) SetTotal(total int) { + if p == nil { + return + } + p.mu.Lock() + p.total = total + p.mu.Unlock() +} + +func (p *loadProgress) Increment() { + if p == nil { + return + } + p.mu.Lock() + p.processed++ + p.mu.Unlock() +} + +func (p *loadProgress) MarkDone() { + if p == nil { + return + } + p.mu.Lock() + p.done = true + p.mu.Unlock() +} + +func (p *loadProgress) Snapshot() (processed, total int, done bool) { + if p == nil { + return 0, 0, true + } + p.mu.Lock() + defer p.mu.Unlock() + return p.processed, p.total, p.done +} diff --git a/internal/app/load_progress_test.go b/internal/app/load_progress_test.go new file mode 100644 index 0000000..c46636d --- /dev/null +++ b/internal/app/load_progress_test.go @@ -0,0 +1,25 @@ +package app + +import "testing" + +func TestLoadProgressLifecycle(t *testing.T) { + var progress loadProgress + progress.SetTotal(5) + for i := 0; i < 3; i++ { + progress.Increment() + } + processed, total, done := progress.Snapshot() + if processed != 3 || total != 5 || done { + t.Fatalf("unexpected snapshot %d/%d done=%v", processed, total, done) + } + progress.MarkDone() + _, _, done = progress.Snapshot() + if !done { + t.Fatal("expected done") + } + progress.Reset() + processed, total, done = progress.Snapshot() + if processed != 0 || total != 0 || done { + t.Fatalf("expected reset to zero, got %d/%d done=%v", processed, total, done) + } +} diff --git a/internal/app/loader.go b/internal/app/loader.go new file mode 100644 index 0000000..37c8c94 --- /dev/null +++ b/internal/app/loader.go @@ -0,0 +1,241 @@ +package app + +import ( + "context" + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func loadVideosCmd(root, cachePath string, progress *loadProgress) tea.Cmd { + return func() tea.Msg { + cache, cacheErr := loadDurationCache(cachePath) + videos, pending, err := loadVideos(root, cache, progress) + if progress != nil { + progress.MarkDone() + } + return videosLoadedMsg{videos: videos, err: err, cacheErr: cacheErr, pending: pending, cache: cache} + } +} + +func progressTickerCmd(progress *loadProgress) tea.Cmd { + if progress == nil { + return nil + } + return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg { + processed, total, done := progress.Snapshot() + return progressUpdateMsg{processed: processed, total: total, done: done} + }) +} + +func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error) { + paths, err := collectVideoPaths(root) + if err != nil { + return nil, nil, err + } + if progress != nil { + progress.SetTotal(len(paths)) + } + videos := make([]video, 0, len(paths)) + pending := make([]string, 0) + for _, path := range paths { + info, statErr := os.Stat(path) + if statErr != nil { + videos = append(videos, video{Name: filepath.Base(path), Path: path, Err: statErr}) + increment(progress) + continue + } + dur := cachedDuration(cache, path, info) + if dur == 0 { + pending = append(pending, path) + } + videos = append(videos, video{ + Name: filepath.Base(path), + Path: path, + Duration: dur, + ModTime: info.ModTime(), + Size: info.Size(), + }) + increment(progress) + } + return videos, pending, nil +} + +func increment(progress *loadProgress) { + if progress != nil { + progress.Increment() + } +} + +func cachedDuration(cache *durationCache, path string, info os.FileInfo) time.Duration { + if cache == nil { + return 0 + } + dur, ok := cache.Lookup(path, info) + if !ok { + return 0 + } + return dur +} + +func collectVideoPaths(root string) ([]string, error) { + info, err := os.Stat(root) + if err != nil { + return nil, err + } + if !info.IsDir() { + if isVideo(root) { + return []string{root}, nil + } + return nil, nil + } + visited := make(map[string]struct{}) + var paths []string + if err := traverseVideoPaths(root, root, visited, &paths); err != nil { + return nil, err + } + sort.Strings(paths) + return paths, nil +} + +func traverseVideoPaths(displayPath, realPath string, visited map[string]struct{}, acc *[]string) error { + resolved, err := filepath.EvalSymlinks(realPath) + if err != nil { + resolved = realPath + } + resolved = filepath.Clean(resolved) + if _, seen := visited[resolved]; seen { + return nil + } + visited[resolved] = struct{}{} + + entries, err := os.ReadDir(resolved) + if err != nil { + return err + } + for _, entry := range entries { + displayChild := filepath.Join(displayPath, entry.Name()) + realChild := filepath.Join(resolved, entry.Name()) + mode := entry.Type() + var info os.FileInfo + if mode == fs.FileMode(0) { + info, err = entry.Info() + if err != nil { + return err + } + mode = info.Mode() + } + if mode&os.ModeSymlink != 0 { + if err := handleSymlink(displayChild, realChild, visited, acc); err != nil { + return err + } + continue + } + if mode.IsDir() { + if err := traverseVideoPaths(displayChild, realChild, visited, acc); err != nil { + return err + } + continue + } + if isVideo(displayChild) { + *acc = append(*acc, displayChild) + } + } + return nil +} + +func handleSymlink(displayChild, realChild string, visited map[string]struct{}, acc *[]string) error { + targetPath, err := filepath.EvalSymlinks(realChild) + if err != nil { + return recordIfVideo(displayChild, acc) + } + targetInfo, err := os.Stat(targetPath) + if err != nil { + return recordIfVideo(displayChild, acc) + } + if targetInfo.IsDir() { + return traverseVideoPaths(displayChild, targetPath, visited, acc) + } + if isVideo(displayChild) || isVideo(targetPath) { + *acc = append(*acc, displayChild) + } + return nil +} + +func recordIfVideo(path string, acc *[]string) error { + if isVideo(path) { + *acc = append(*acc, path) + } + return nil +} + +func probeDurationsCmd(path string, cache *durationCache) tea.Cmd { + return func() tea.Msg { + dur, err := probeDuration(path) + if err == nil && cache != nil { + if info, statErr := os.Stat(path); statErr == nil { + _ = cache.Record(path, info, dur) + } + } + return durationUpdateMsg{path: path, duration: dur, err: err} + } +} + +func probeDuration(path string) (time.Duration, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path) + out, err := cmd.Output() + if err != nil { + return 0, err + } + raw := strings.TrimSpace(string(out)) + if raw == "" { + return 0, errors.New("empty duration") + } + seconds, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, err + } + return time.Duration(seconds * float64(time.Second)), nil +} + +func playVideoCmd(path, crop string) tea.Cmd { + return func() tea.Msg { + args := buildVLCArgs(path, crop) + cmd := exec.Command("vlc", args...) + if err := cmd.Start(); err != nil { + 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) - } -} |
