From 53b1b839c57e886fedbb6b038870bdd1471dcfff Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Sun, 7 Dec 2025 12:03:21 -0500 Subject: [PATCH] Add queue error copy, auto naming helper, and metadata templating --- internal/metadata/naming.go | 83 +++++++++++++++++++++++++++++++++++++ internal/ui/queueview.go | 10 ++++- naming_helpers.go | 79 +++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 internal/metadata/naming.go create mode 100644 naming_helpers.go diff --git a/internal/metadata/naming.go b/internal/metadata/naming.go new file mode 100644 index 0000000..1bdbcd3 --- /dev/null +++ b/internal/metadata/naming.go @@ -0,0 +1,83 @@ +package metadata + +import ( + "regexp" + "strings" + "unicode" +) + +var tokenPattern = regexp.MustCompile(`<([a-zA-Z0-9_-]+)>`) + +// RenderTemplate applies a simple template to the provided metadata map. +// It returns the rendered string and a boolean indicating whether any tokens were resolved. +func RenderTemplate(pattern string, meta map[string]string, fallback string) (string, bool) { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + return fallback, false + } + + normalized := make(map[string]string, len(meta)) + for k, v := range meta { + key := strings.ToLower(strings.TrimSpace(k)) + if key == "" { + continue + } + val := sanitize(v) + if val != "" { + normalized[key] = val + } + } + + resolved := false + rendered := tokenPattern.ReplaceAllStringFunc(pattern, func(tok string) string { + match := tokenPattern.FindStringSubmatch(tok) + if len(match) != 2 { + return "" + } + key := strings.ToLower(match[1]) + if val := normalized[key]; val != "" { + resolved = true + return val + } + return "" + }) + + rendered = cleanup(rendered) + if rendered == "" { + return fallback, false + } + return rendered, resolved +} + +func sanitize(value string) string { + value = strings.TrimSpace(value) + value = strings.Map(func(r rune) rune { + switch r { + case '<', '>', '"', '/', '\\', '|', '?', '*', ':': + return -1 + } + if unicode.IsControl(r) { + return -1 + } + return r + }, value) + + // Collapse repeated whitespace + value = strings.Join(strings.Fields(value), " ") + return strings.Trim(value, " .-_") +} + +func cleanup(s string) string { + // Remove leftover template brackets or duplicate separators. + s = strings.ReplaceAll(s, "<>", "") + for strings.Contains(s, " ") { + s = strings.ReplaceAll(s, " ", " ") + } + for strings.Contains(s, "__") { + s = strings.ReplaceAll(s, "__", "_") + } + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + return strings.Trim(s, " .-_") +} diff --git a/internal/ui/queueview.go b/internal/ui/queueview.go index c6a3017..225c306 100644 --- a/internal/ui/queueview.go +++ b/internal/ui/queueview.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "image/color" + "strings" "time" "fyne.io/fyne/v2" @@ -28,6 +29,7 @@ func BuildQueueView( onStart func(), onClear func(), onClearAll func(), + onCopyError func(string), titleColor, bgColor, textColor color.Color, ) (fyne.CanvasObject, *container.Scroll) { // Header @@ -71,7 +73,7 @@ func BuildQueueView( jobItems = append(jobItems, container.NewCenter(emptyMsg)) } else { for _, job := range jobs { - jobItems = append(jobItems, buildJobItem(job, onPause, onResume, onCancel, onRemove, onMoveUp, onMoveDown, bgColor, textColor)) + jobItems = append(jobItems, buildJobItem(job, onPause, onResume, onCancel, onRemove, onMoveUp, onMoveDown, onCopyError, bgColor, textColor)) } } @@ -99,6 +101,7 @@ func buildJobItem( onRemove func(string), onMoveUp func(string), onMoveDown func(string), + onCopyError func(string), bgColor, textColor color.Color, ) fyne.CanvasObject { // Status color @@ -157,6 +160,11 @@ func buildJobItem( widget.NewButton("Cancel", func() { onCancel(job.ID) }), ) case queue.JobStatusCompleted, queue.JobStatusFailed, queue.JobStatusCancelled: + if job.Status == queue.JobStatusFailed && strings.TrimSpace(job.Error) != "" && onCopyError != nil { + buttons = append(buttons, + widget.NewButton("Copy Error", func() { onCopyError(job.ID) }), + ) + } buttons = append(buttons, widget.NewButton("Remove", func() { onRemove(job.ID) }), ) diff --git a/naming_helpers.go b/naming_helpers.go new file mode 100644 index 0000000..dd6a9b7 --- /dev/null +++ b/naming_helpers.go @@ -0,0 +1,79 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "git.leaktechnologies.dev/stu/VideoTools/internal/metadata" +) + +func defaultOutputBase(src *videoSource) string { + if src == nil { + return "converted" + } + base := strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName)) + return base + "-convert" +} + +// resolveOutputBase returns the output base for a source. +// keepExisting preserves manual edits when auto-naming is disabled; it is ignored when auto-naming is on. +func (s *appState) resolveOutputBase(src *videoSource, keepExisting bool) string { + fallback := defaultOutputBase(src) + + // Auto-naming overrides manual values. + if s.convert.UseAutoNaming && src != nil && strings.TrimSpace(s.convert.AutoNameTemplate) != "" { + if name, ok := metadata.RenderTemplate(s.convert.AutoNameTemplate, buildNamingMetadata(src), fallback); ok || name != "" { + return name + } + return fallback + } + + if keepExisting { + if base := strings.TrimSpace(s.convert.OutputBase); base != "" { + return base + } + } + return fallback +} + +func buildNamingMetadata(src *videoSource) map[string]string { + meta := map[string]string{} + if src == nil { + return meta + } + + meta["filename"] = strings.TrimSuffix(filepath.Base(src.Path), filepath.Ext(src.Path)) + meta["format"] = src.Format + meta["codec"] = src.VideoCodec + if src.Width > 0 && src.Height > 0 { + meta["width"] = fmt.Sprintf("%d", src.Width) + meta["height"] = fmt.Sprintf("%d", src.Height) + meta["resolution"] = fmt.Sprintf("%dx%d", src.Width, src.Height) + } + + for k, v := range src.Metadata { + meta[k] = v + } + + aliasMetadata(meta, "title", "title") + aliasMetadata(meta, "scene", "title", "comment", "description") + aliasMetadata(meta, "studio", "studio", "publisher", "label") + aliasMetadata(meta, "actress", "actress", "performer", "performers", "artist", "actors", "cast") + aliasMetadata(meta, "series", "series", "album") + aliasMetadata(meta, "date", "date", "year") + + return meta +} + +func aliasMetadata(meta map[string]string, target string, keys ...string) { + if meta[target] != "" { + return + } + for _, key := range keys { + if val := meta[strings.ToLower(key)]; strings.TrimSpace(val) != "" { + meta[target] = val + return + } + } +}