diff options
Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 1207 |
1 files changed, 0 insertions, 1207 deletions
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, ", ") -} |
