summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-10-01 23:46:08 +0300
committerPaul Buetow <paul@buetow.org>2025-10-01 23:46:08 +0300
commit36be499ed342d92969ccaaff083c557a0951def9 (patch)
tree783dcf6b8b0bef03faddbdfceac878db5ac33674
parente8342798e51d9678d6109cf4c62309f13b1269c4 (diff)
Release v0.1.0v0.1.0
-rw-r--r--main.go101
-rw-r--r--main_test.go119
2 files changed, 208 insertions, 12 deletions
diff --git a/main.go b/main.go
index 762581d..06e16ea 100644
--- a/main.go
+++ b/main.go
@@ -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)
+ }
+}