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