package main
import (
"flag"
"fmt"
"io"
"os"
"strings"
"codeberg.org/snonux/yoga/internal/app"
"codeberg.org/snonux/yoga/internal/fsutil"
"codeberg.org/snonux/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
}
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
}
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)
}
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
tags string
}
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())
tags := strings.TrimSpace(m.inputs.fields[3].Value())
filters := filterState{name: name, tags: tags}
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.tags != "" {
parts = append(parts, fmt.Sprintf("tags contain %q", m.filters.tags))
}
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
}
if m.filters.tags != "" {
query := strings.ToLower(m.filters.tags)
matched := false
for _, tag := range v.Tags {
if strings.Contains(strings.ToLower(tag), query) {
matched = true
break
}
}
if !matched {
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):", "Tags contain:"}
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())
}
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
}
package app
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"codeberg.org/snonux/yoga/internal/tags"
)
func loadVideosCmd(root, cachePath string, progress *loadProgress) tea.Cmd {
return func() tea.Msg {
cache, cacheErr := loadDurationCache(cachePath)
videos, pending, tagErr, err := loadVideos(root, cache, progress)
if progress != nil {
progress.MarkDone()
}
return videosLoadedMsg{videos: videos, err: err, cacheErr: cacheErr, pending: pending, cache: cache, tagErr: tagErr}
}
}
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, error) {
paths, err := collectVideoPaths(root)
if err != nil {
return nil, nil, nil, err
}
if progress != nil {
progress.SetTotal(len(paths))
}
videos := make([]video, 0, len(paths))
pending := make([]string, 0)
var tagErrors []string
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)
}
tagList, tagErr := tags.Load(path)
if tagErr != nil {
tagErrors = append(tagErrors, fmt.Sprintf("%s: %v", filepath.Base(path), tagErr))
}
videos = append(videos, video{
Name: filepath.Base(path),
Path: path,
Duration: dur,
ModTime: info.ModTime(),
Size: info.Size(),
Tags: tagList,
})
increment(progress)
}
return videos, pending, joinErrors(tagErrors), nil
}
func joinErrors(messages []string) error {
if len(messages) == 0 {
return nil
}
return errors.New(strings.Join(messages, "; "))
}
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)
}
package app
import (
"fmt"
"path/filepath"
"runtime"
"strings"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type sortField int
const (
sortByName sortField = iota
sortByDuration
sortByAge
)
const (
preferredNameColumnWidth = 40
preferredDurationColumnWidth = 12
preferredAgeColumnWidth = 14
preferredTagsColumnWidth = 28
nameColumnFloorWidth = 16
durationColumnFloorWidth = 8
ageColumnFloorWidth = 10
tagsColumnFloorWidth = 12
)
type model struct {
table table.Model
videos []video
filtered []video
filters filterState
inputs filterInputs
showFilters bool
editingTags 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
tagInput textinput.Model
tagEditPath string
baseStatus string
showHelp bool
viewportWidth int
}
func newModel(opts Options) (model, error) {
tbl := buildTable()
inputs := buildFilterInputs()
inputs.fields[0].Focus()
tagInput := buildTagInput()
progress := &loadProgress{}
cachePath := filepath.Join(opts.Root, ".video_duration_cache.json")
return model{
table: tbl,
inputs: inputs,
tagInput: tagInput,
sortField: sortByName,
sortAscending: true,
statusMessage: "Scanning for videos...",
loading: true,
root: opts.Root,
progress: progress,
cachePath: cachePath,
cropValue: opts.Crop,
cropEnabled: opts.Crop != "",
showHelp: true,
}, nil
}
func buildTable() table.Model {
columns := makeColumns(
preferredNameColumnWidth,
preferredDurationColumnWidth,
preferredAgeColumnWidth,
preferredTagsColumnWidth,
)
tbl := table.New(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(15),
)
tbl.SetStyles(table.DefaultStyles())
return tbl
}
func buildFilterInputs() filterInputs {
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
maxInput := textinput.New()
maxInput.Placeholder = "max minutes"
maxInput.Prompt = "Max minutes: "
maxInput.CharLimit = 4
tagInput := textinput.New()
tagInput.Placeholder = "tag substring"
tagInput.Prompt = "Tags: "
tagInput.CharLimit = 256
return filterInputs{
fields: []textinput.Model{nameInput, minInput, maxInput, tagInput},
focus: 0,
}
}
func buildTagInput() textinput.Model {
input := textinput.New()
input.Placeholder = "comma-separated tags"
input.Prompt = "Tags: "
input.CharLimit = 512
return input
}
func makeColumns(nameWidth, durationWidth, ageWidth, tagsWidth int) []table.Column {
return []table.Column{
{Title: headerStyle.Render("Name"), Width: nameWidth},
{Title: headerStyle.Render("Duration"), Width: durationWidth},
{Title: headerStyle.Render("Age"), Width: ageWidth},
{Title: headerStyle.Render("Tags"), Width: tagsWidth},
}
}
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 (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch typed := msg.(type) {
case tea.KeyMsg:
return m.handleKeyMsg(typed)
case progressUpdateMsg:
return m.handleProgressUpdate(typed)
case durationUpdateMsg:
return m.handleDurationUpdate(typed)
case videosLoadedMsg:
return m.handleVideosLoaded(typed)
case playVideoMsg:
return m.handlePlayVideo(typed), nil
case reindexVideosMsg:
return m.handleReindexVideos(typed)
case tagsSavedMsg:
return m.handleTagsSaved(typed)
case tea.WindowSizeMsg:
return m.handleWindowSize(typed)
default:
return m.updateTable(msg)
}
}
func (m model) View() string {
if m.loading {
return statusStyle.Render("Loading videos, please wait...")
}
body := m.renderBody()
if m.editingTags {
return body + "\n\n" + m.renderTagModal()
}
if m.showFilters {
return body + "\n\n" + m.renderFilterModal()
}
return body
}
func (m model) renderBody() string {
helpLines := []string{
"↑/↓ navigate • enter play • s sort • / filter • c crop • t edit tags • i re-index • q quit",
}
info := statusStyle.Render(m.statusText())
progressLine := m.renderProgressLine()
content := tableStyle.Render(m.table.View())
parts := []string{content}
if progressLine != "" {
parts = append(parts, progressLine)
}
parts = append(parts, info)
if m.showHelp {
help := strings.Join(helpLines, "\n")
parts = append(parts, help)
}
return strings.Join(parts, "\n")
}
func (m model) statusText() string {
status := strings.TrimSpace(m.statusMessage)
base := strings.TrimSpace(m.baseStatus)
if base == "" {
return status
}
if status == "" || status == base {
return base
}
return fmt.Sprintf("%s • %s", base, status)
}
func (m model) showHelpBar() (tea.Model, tea.Cmd) {
if m.showHelp {
return m, nil
}
m.showHelp = true
if strings.Contains(m.statusMessage, "Help hidden") {
m.statusMessage = ""
}
return m, nil
}
func (m model) hideHelpBar() (tea.Model, tea.Cmd) {
if !m.showHelp {
return m, nil
}
m.showHelp = false
m.statusMessage = "Help hidden (press h to show)"
return m, nil
}
func (m model) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.viewportWidth = msg.Width
m.resizeColumns(msg.Width)
tbl, cmd := m.table.Update(msg)
m.table = tbl
if cmd == nil {
return m, nil
}
return m, cmd
}
func (m *model) resizeColumns(totalWidth int) {
if totalWidth <= 0 {
return
}
frame := tableStyle.GetHorizontalFrameSize()
contentWidth := totalWidth - frame
minWidth := nameColumnFloorWidth + durationColumnFloorWidth + ageColumnFloorWidth + tagsColumnFloorWidth
if contentWidth < minWidth {
contentWidth = minWidth
}
preferred := preferredNameColumnWidth + preferredDurationColumnWidth + preferredAgeColumnWidth + preferredTagsColumnWidth
nameWidth := preferredNameColumnWidth
durationWidth := preferredDurationColumnWidth
ageWidth := preferredAgeColumnWidth
tagsWidth := preferredTagsColumnWidth
if contentWidth >= preferred {
extra := contentWidth - preferred
nameWidth += extra
} else {
deficit := preferred - contentWidth
if deficit > 0 {
reduce := min(deficit, nameWidth-nameColumnFloorWidth)
nameWidth -= reduce
deficit -= reduce
}
if deficit > 0 {
reduce := min(deficit, tagsWidth-tagsColumnFloorWidth)
tagsWidth -= reduce
deficit -= reduce
}
if deficit > 0 {
reduce := min(deficit, ageWidth-ageColumnFloorWidth)
ageWidth -= reduce
deficit -= reduce
}
if deficit > 0 {
reduce := min(deficit, durationWidth-durationColumnFloorWidth)
durationWidth -= reduce
}
}
m.table.SetColumns(makeColumns(nameWidth, durationWidth, ageWidth, tagsWidth))
m.table.SetWidth(contentWidth)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func (m model) renderProgressLine() string {
if m.durationTotal == 0 {
return ""
}
bar := renderProgressBar(m.durationDone, m.durationTotal, 24)
return statusStyle.Render(fmt.Sprintf("Duration scan %s %d/%d", bar, m.durationDone, m.durationTotal))
}
func (m model) updateTable(msg tea.Msg) (tea.Model, tea.Cmd) {
tbl, cmd := m.table.Update(msg)
m.table = tbl
return m, cmd
}
func (m model) handlePlayVideo(msg playVideoMsg) model {
if msg.err != nil {
m.statusMessage = fmt.Sprintf("Failed to launch VLC: %v", msg.err)
return m
}
m.statusMessage = fmt.Sprintf("Playing via VLC: %s", trimPath(msg.path))
return m
}
func (m model) handleReindexVideos(msg reindexVideosMsg) (tea.Model, tea.Cmd) {
m.statusMessage = "Re-indexing videos..."
return m, loadVideosCmd(m.root, m.cachePath, m.progress)
}
func (m model) handleVideosLoaded(msg videosLoadedMsg) (tea.Model, tea.Cmd) {
m.loading = false
if msg.err != nil {
m.err = msg.err
m.statusMessage = fmt.Sprintf("error: %v", msg.err)
}
if len(m.videos) == 0 {
m.videos = msg.videos
} else {
existingVideos := make(map[string]int)
for i, v := range m.videos {
existingVideos[v.Path] = i
}
for _, newVideo := range msg.videos {
if i, ok := existingVideos[newVideo.Path]; ok {
m.videos[i] = newVideo
} else {
m.videos = append(m.videos, newVideo)
}
}
}
m.cache = msg.cache
m.pendingDurations = msg.pending
m.durationTotal = len(msg.pending)
m.durationDone = 0
m.applyFiltersAndSort()
m.updateStatusAfterLoad(msg)
m.durationInFlight = 0
if len(msg.pending) == 0 {
return m, nil
}
cmd := m.startDurationWorkers()
return m, cmd
}
func (m *model) updateStatusAfterLoad(msg videosLoadedMsg) {
if len(m.filtered) == 0 {
m.baseStatus = "No videos found"
m.statusMessage = m.baseStatus
return
}
status := ""
if len(msg.pending) > 0 {
status = fmt.Sprintf("Loaded %d videos, probing durations...", len(m.filtered))
if msg.cacheErr != nil {
status = fmt.Sprintf("Loaded %d videos (cache warning: %v), probing durations...", len(m.filtered), msg.cacheErr)
}
} else {
status = fmt.Sprintf("Loaded %d videos", len(m.filtered))
if msg.cacheErr != nil {
status = fmt.Sprintf("Loaded %d videos (cache warning: %v)", len(m.filtered), msg.cacheErr)
}
}
if msg.tagErr != nil {
status = fmt.Sprintf("%s (tag warning: %v)", status, msg.tagErr)
}
m.baseStatus = status
m.statusMessage = status
}
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++ {
cmd := m.dequeueDurationCmd()
if cmd != nil {
cmds = append(cmds, cmd)
}
}
if len(cmds) == 0 {
return nil
}
return tea.Batch(cmds...)
}
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) activeCrop() string {
if m.cropEnabled && m.cropValue != "" {
return m.cropValue
}
return ""
}
func (m model) handleProgressUpdate(msg progressUpdateMsg) (tea.Model, tea.Cmd) {
if !m.loading {
return m, nil
}
if msg.total == 0 && msg.done {
m.statusMessage = "No videos found"
return m, nil
}
if msg.done {
m.statusMessage = fmt.Sprintf("Loaded %d videos", msg.total)
return m, nil
}
m.statusMessage = fmt.Sprintf("Loading videos %d/%d...", msg.processed, msg.total)
return m, progressTickerCmd(m.progress)
}
package app
import (
"fmt"
"path/filepath"
"time"
tea "github.com/charmbracelet/bubbletea"
)
func (m model) handleDurationUpdate(msg durationUpdateMsg) (tea.Model, tea.Cmd) {
if msg.path != "" {
m.updateVideoDuration(msg.path, msg.duration, msg.err)
m.durationDone++
m.updateStatusForDuration(msg)
}
if m.durationInFlight > 0 {
m.durationInFlight--
}
selectedPath := m.currentSelectionPath()
m.applyFiltersAndSort()
m.restoreSelection(selectedPath)
if m.allDurationsResolved() {
m.onDurationsComplete()
return m, nil
}
cmd := m.dequeueDurationCmd()
return m, cmd
}
func (m *model) updateStatusForDuration(msg durationUpdateMsg) {
if msg.err != nil {
m.statusMessage = fmt.Sprintf("Duration error for %s: %v", filepath.Base(msg.path), msg.err)
return
}
if m.durationTotal > 0 {
m.statusMessage = fmt.Sprintf("Probing durations %d/%d...", m.durationDone, m.durationTotal)
}
}
func (m model) currentSelectionPath() string {
idx := m.table.Cursor()
if idx < 0 || idx >= len(m.filtered) {
return ""
}
return m.filtered[idx].Path
}
func (m *model) restoreSelection(path string) {
if path == "" {
return
}
for i, video := range m.filtered {
if video.Path == path {
m.table.SetCursor(i)
return
}
}
}
func (m *model) updateVideoDuration(path string, dur time.Duration, err error) {
for i := range m.videos {
if m.videos[i].Path != path {
continue
}
m.videos[i].Duration = dur
m.videos[i].Err = err
return
}
}
func (m model) allDurationsResolved() bool {
return m.durationDone >= m.durationTotal && m.durationInFlight == 0
}
func (m *model) onDurationsComplete() {
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))
}
m.resetDurationState()
return
}
m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered))
m.resetDurationState()
}
func (m *model) resetDurationState() {
m.pendingDurations = nil
m.durationTotal = 0
m.durationDone = 0
m.durationInFlight = 0
}
package app
import (
"fmt"
"math/rand"
tea "github.com/charmbracelet/bubbletea"
)
func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if cmd, handled := globalKeyHandler(msg); handled {
return m, cmd
}
if m.loading {
return m, nil
}
if m.editingTags {
return m.handleTagKey(msg)
}
if m.showFilters {
return m.handleFilterKey(msg)
}
return m.handleTableKey(msg)
}
func globalKeyHandler(msg tea.KeyMsg) (tea.Cmd, bool) {
switch msg.String() {
case "ctrl+c", "q":
return tea.Quit, true
default:
return nil, false
}
}
func (m model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.showFilters = false
m.statusMessage = "Filter closed"
return m, nil
case "enter":
cmd := m.applyFiltersFromInputs()
return m, cmd
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)
}
m.syncFilterFocus()
updated, cmd := m.updateFilterInputs(msg)
m.inputs = updated
return m, cmd
}
func (m *model) applyFiltersFromInputs() tea.Cmd {
if err := m.applyFilterInputs(); err != nil {
m.statusMessage = err.Error()
return nil
}
m.showFilters = false
m.applyFiltersAndSort()
m.statusMessage = fmt.Sprintf("Filters applied (%d videos)", len(m.filtered))
return nil
}
func (m *model) syncFilterFocus() {
for i := range m.inputs.fields {
if i == m.inputs.focus {
m.inputs.fields[i].Focus()
continue
}
m.inputs.fields[i].Blur()
}
}
func (m model) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "/", "f":
return m.openFilters()
case "enter":
return m.playSelection()
case "n":
return m.sortAndReport(sortByName)
case "l":
return m.sortAndReport(sortByDuration)
case "a":
return m.sortAndReport(sortByAge)
case "c":
return m.toggleCrop()
case "t":
return m.openTagEditor()
case "H":
return m.hideHelpBar()
case "h":
return m.showHelpBar()
case "r":
return m.resetFilterState()
case "i":
return m, func() tea.Msg { return reindexVideosMsg{} }
case "x":
return m.selectRandomVideo()
default:
return m.updateTable(msg)
}
}
func (m model) openFilters() (tea.Model, tea.Cmd) {
m.showFilters = true
m.statusMessage = "Editing filters"
return m, nil
}
func (m model) playSelection() (tea.Model, tea.Cmd) {
if len(m.filtered) == 0 {
return m, nil
}
idx := m.table.Cursor()
if idx < 0 || idx >= len(m.filtered) {
return m, nil
}
video := m.filtered[idx]
m.statusMessage = fmt.Sprintf("Launching VLC: %s", video.Name)
return m, playVideoCmd(video.Path, m.activeCrop())
}
func (m model) sortAndReport(field sortField) (tea.Model, tea.Cmd) {
m.toggleSort(field)
m.applyFiltersAndSort()
m.statusMessage = fmt.Sprintf("Sorted %d videos", len(m.filtered))
return m, nil
}
func (m model) toggleCrop() (tea.Model, tea.Cmd) {
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)
return m, nil
}
m.statusMessage = "Crop disabled"
return m, nil
}
func (m model) resetFilterState() (tea.Model, tea.Cmd) {
m.resetFilters()
m.applyFiltersAndSort()
m.statusMessage = fmt.Sprintf("Filters cleared (%d videos)", len(m.filtered))
return m, nil
}
func (m model) selectRandomVideo() (tea.Model, tea.Cmd) {
if len(m.filtered) == 0 {
m.statusMessage = "No videos to select from"
return m, nil
}
idx := rand.Intn(len(m.filtered))
m.table.SetCursor(idx)
video := m.filtered[idx]
m.statusMessage = fmt.Sprintf("Randomly selected: %s", video.Name)
return m, nil
}
package app
import (
"sort"
"strings"
"github.com/charmbracelet/bubbles/table"
)
func (m *model) toggleSort(target sortField) {
if m.sortField == target {
m.sortAscending = !m.sortAscending
return
}
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) {
filtered = append(filtered, v)
}
}
sort.Slice(filtered, func(i, j int) bool {
return m.less(filtered[i], filtered[j])
})
m.filtered = filtered
m.updateTableRows()
}
func (m *model) less(a, b video) bool {
var less bool
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
}
func (m *model) updateTableRows() {
rows := make([]table.Row, 0, len(m.filtered))
for _, v := range m.filtered {
rows = append(rows, videoRow(v))
}
m.table.SetRows(rows)
if len(rows) > 0 {
m.table.SetCursor(0)
}
}
package app
import (
"fmt"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
func (m model) openTagEditor() (tea.Model, tea.Cmd) {
if len(m.filtered) == 0 {
m.statusMessage = "No videos to edit"
return m, nil
}
cursor := m.table.Cursor()
if cursor < 0 || cursor >= len(m.filtered) {
m.statusMessage = "No selection"
return m, nil
}
video := m.filtered[cursor]
m.editingTags = true
m.tagEditPath = video.Path
m.tagInput = cloneInput(m.tagInput)
m.tagInput.SetValue(strings.Join(video.Tags, ", "))
m.tagInput.CursorEnd()
m.tagInput.Focus()
m.statusMessage = fmt.Sprintf("Editing tags for %s", video.Name)
return m, nil
}
func (m model) handleTagKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.editingTags = false
m.tagEditPath = ""
m.tagInput.Blur()
m.statusMessage = "Tag edit cancelled"
return m, nil
case "enter":
return m.commitTags()
}
var cmd tea.Cmd
m.tagInput, cmd = m.tagInput.Update(msg)
return m, cmd
}
func (m model) commitTags() (tea.Model, tea.Cmd) {
if m.tagEditPath == "" {
m.editingTags = false
m.tagInput.Blur()
m.statusMessage = "No video selected"
return m, nil
}
value := m.tagInput.Value()
tags := parseTagInput(value)
m.editingTags = false
m.tagInput.Blur()
path := m.tagEditPath
m.tagEditPath = ""
name := filepath.Base(path)
m.statusMessage = fmt.Sprintf("Saving tags for %s", name)
return m, saveTagsCmd(path, tags)
}
func (m model) handleTagsSaved(msg tagsSavedMsg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.editingTags = false
m.tagEditPath = ""
m.tagInput.Blur()
m.showHelp = true
m.statusMessage = fmt.Sprintf("Tag save error: %v", msg.err)
return m, nil
}
m.editingTags = false
m.tagEditPath = ""
m.tagInput.Blur()
m.showHelp = true
m.setVideoTags(msg.path, msg.tags)
m.applyFiltersAndSort()
m.restoreSelection(msg.path)
if len(msg.tags) == 0 {
m.statusMessage = "Tags cleared"
return m, nil
}
m.statusMessage = fmt.Sprintf("Tags updated (%d)", len(msg.tags))
return m, nil
}
func (m *model) setVideoTags(path string, tags []string) {
for i := range m.videos {
if m.videos[i].Path == path {
m.videos[i].Tags = append([]string{}, tags...)
return
}
}
}
func parseTagInput(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
var tags []string
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
lower := strings.ToLower(trimmed)
if _, ok := seen[lower]; ok {
continue
}
seen[lower] = struct{}{}
tags = append(tags, trimmed)
}
return tags
}
func cloneInput(in textinput.Model) textinput.Model {
copy := in
return copy
}
func (m model) renderTagModal() string {
var b strings.Builder
b.WriteString("Edit tags\n")
b.WriteString("(comma separated)\n\n")
b.WriteString(m.tagInput.View())
b.WriteString("\n\n")
b.WriteString("Enter to save, Esc to cancel")
return filterStyle.Render(b.String())
}
package app
import (
"codeberg.org/snonux/yoga/internal/tags"
tea "github.com/charmbracelet/bubbletea"
)
func saveTagsCmd(path string, entries []string) tea.Cmd {
// Copy slice to avoid accidental mutation after scheduling command.
values := append([]string{}, entries...)
return func() tea.Msg {
if err := tags.Save(path, values); err != nil {
return tagsSavedMsg{path: path, err: err}
}
sanitized, err := tags.Load(path)
if err != nil {
return tagsSavedMsg{path: path, err: err}
}
return tagsSavedMsg{path: path, tags: sanitized}
}
}
package app
import (
"fmt"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/table"
)
func videoRow(v video) table.Row {
duration := "(unknown)"
if v.Duration > 0 {
duration = formatDuration(v.Duration)
}
age := humanizeAge(v.ModTime)
tags := formatTags(v.Tags)
if v.Err != nil {
duration = "!" + v.Err.Error()
}
return table.Row{v.Name, duration, age, tags}
}
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 "--"
}
dur := time.Since(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 && strings.HasPrefix(path, home) {
return "~" + strings.TrimPrefix(path, home)
}
return path
}
func formatTags(tags []string) string {
if len(tags) == 0 {
return "--"
}
return strings.Join(tags, ", ")
}
package fsutil
import (
"errors"
"fmt"
"io/fs"
"os"
"os/user"
"path/filepath"
"strings"
)
// ResolveRootPath expands and validates the supplied root path. When the
// caller did not specify a value, defaultValue is used and created on demand.
func ResolveRootPath(input, defaultValue string) (string, error) {
value, isDefault := normalizeRootInput(input, defaultValue)
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, err := ensureRootExists(abs, isDefault)
if err != nil {
return "", err
}
if !info.IsDir() && !info.Mode().IsRegular() {
return "", fmt.Errorf("root path %q is not a file or directory", abs)
}
return abs, nil
}
func normalizeRootInput(input, defaultValue string) (value string, isDefault bool) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return defaultValue, true
}
return trimmed, false
}
func ensureRootExists(path string, allowCreate bool) (fs.FileInfo, error) {
info, err := os.Stat(path)
if err == nil {
return info, nil
}
if !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("cannot access root path %q: %w", path, err)
}
if !allowCreate {
return nil, fmt.Errorf("root path does not exist: %s", path)
}
if mkErr := os.MkdirAll(path, 0o755); mkErr != nil {
return nil, fmt.Errorf("cannot create default directory %q: %w", path, mkErr)
}
info, err = os.Stat(path)
if err != nil {
return nil, fmt.Errorf("cannot stat default directory %q: %w", path, err)
}
return info, nil
}
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
}
username, rest := splitUserPath(p)
usr, err := user.Lookup(username)
if err != nil {
return "", err
}
if rest == "" {
return usr.HomeDir, nil
}
return filepath.Join(usr.HomeDir, rest), nil
}
func splitUserPath(p string) (string, string) {
sep := strings.IndexRune(p, '/')
if sep == -1 {
return p[1:], ""
}
return p[1:sep], p[sep:]
}
package tags
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
)
// PathFor returns the path to the tag metadata file for the given video path.
func PathFor(videoPath string) string {
ext := filepath.Ext(videoPath)
if strings.EqualFold(ext, ".mp4") {
return strings.TrimSuffix(videoPath, ext) + ".json"
}
return videoPath + ".json"
}
// Load reads the tags associated with a video. Missing files yield an empty slice.
func Load(videoPath string) ([]string, error) {
metadataPath := PathFor(videoPath)
data, err := os.ReadFile(metadataPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var parsed []string
if err := json.Unmarshal(data, &parsed); err != nil {
return nil, err
}
return sanitize(parsed), nil
}
// Save persists the tags for a video to its metadata file.
func Save(videoPath string, tagValues []string) error {
metadataPath := PathFor(videoPath)
cleaned := sanitize(tagValues)
payload, err := json.MarshalIndent(cleaned, "", " ")
if err != nil {
return err
}
return os.WriteFile(metadataPath, payload, 0o644)
}
func sanitize(raw []string) []string {
if len(raw) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(raw))
var cleaned []string
for _, tag := range raw {
trimmed := strings.TrimSpace(tag)
if trimmed == "" {
continue
}
normalized := trimmed
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
cleaned = append(cleaned, normalized)
}
sort.Strings(cleaned)
return cleaned
}