diff options
| author | Paul Buetow <paul@buetow.org> | 2025-10-01 23:46:08 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-10-01 23:46:08 +0300 |
| commit | 36be499ed342d92969ccaaff083c557a0951def9 (patch) | |
| tree | 783dcf6b8b0bef03faddbdfceac878db5ac33674 | |
| parent | e8342798e51d9678d6109cf4c62309f13b1269c4 (diff) | |
Release v0.1.0v0.1.0
| -rw-r--r-- | main.go | 101 | ||||
| -rw-r--r-- | main_test.go | 119 |
2 files changed, 208 insertions, 12 deletions
@@ -9,6 +9,7 @@ import ( "io/fs" "os" "os/exec" + "os/user" "path/filepath" "runtime" "sort" @@ -23,7 +24,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -const Version = "v0.0.0" +const Version = "v0.1.0" var ( videoExtensions = map[string]struct{}{ @@ -277,6 +278,7 @@ type model struct { } 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() @@ -286,7 +288,12 @@ func main() { os.Exit(0) } - root := mustWorkspaceRoot() + 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) @@ -294,13 +301,78 @@ func main() { } } -func mustWorkspaceRoot() string { - cwd, err := os.Getwd() +func resolveRootPath(flagValue string) (string, error) { + value := strings.TrimSpace(flagValue) + isDefault := value == "" + if isDefault { + value = "~/Yoga" + } + expanded, err := expandPath(value) if err != nil { - fmt.Fprintf(os.Stderr, "cannot determine working directory: %v\n", err) - os.Exit(1) + return "", fmt.Errorf("cannot expand root path %q: %w", value, err) } - return cwd + 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 { @@ -429,11 +501,16 @@ func traverseVideoPaths(displayPath, realPath string, visited map[string]struct{ for _, entry := range entries { displayChild := filepath.Join(displayPath, entry.Name()) realChild := filepath.Join(resolved, entry.Name()) - fi, err := entry.Info() - if err != nil { - return err + 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() } - mode := fi.Mode() if mode&os.ModeSymlink != 0 { targetPath, err := filepath.EvalSymlinks(realChild) if err != nil { @@ -460,7 +537,7 @@ func traverseVideoPaths(displayPath, realPath string, visited map[string]struct{ } continue } - if fi.IsDir() { + if mode.IsDir() { if err := traverseVideoPaths(displayChild, realChild, visited, acc); err != nil { return err } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..741311c --- /dev/null +++ b/main_test.go @@ -0,0 +1,119 @@ +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) + } +} |
