summaryrefslogtreecommitdiff
path: root/internal/app/loader.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/app/loader.go')
-rw-r--r--internal/app/loader.go241
1 files changed, 241 insertions, 0 deletions
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)
+}