Add per-conversion logs and surface them in queue UI

This commit is contained in:
Stu Leak 2025-12-08 11:31:12 -05:00
parent f900f6804d
commit 8e601bc7d2
3 changed files with 107 additions and 5 deletions

View File

@ -44,6 +44,7 @@ type Job struct {
Description string `json:"description"`
InputFile string `json:"input_file"`
OutputFile string `json:"output_file"`
LogPath string `json:"log_path,omitempty"`
Config map[string]interface{} `json:"config"`
Progress float64 `json:"progress"`
Error string `json:"error,omitempty"`

View File

@ -30,6 +30,7 @@ func BuildQueueView(
onClear func(),
onClearAll func(),
onCopyError func(string),
onViewLog func(string),
titleColor, bgColor, textColor color.Color,
) (fyne.CanvasObject, *container.Scroll) {
// Header
@ -73,7 +74,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, onCopyError, bgColor, textColor))
jobItems = append(jobItems, buildJobItem(job, onPause, onResume, onCancel, onRemove, onMoveUp, onMoveDown, onCopyError, onViewLog, bgColor, textColor))
}
}
@ -102,6 +103,7 @@ func buildJobItem(
onMoveUp func(string),
onMoveDown func(string),
onCopyError func(string),
onViewLog func(string),
bgColor, textColor color.Color,
) fyne.CanvasObject {
// Status color
@ -165,6 +167,11 @@ func buildJobItem(
widget.NewButton("Copy Error", func() { onCopyError(job.ID) }),
)
}
if job.LogPath != "" && onViewLog != nil {
buttons = append(buttons,
widget.NewButton("View Log", func() { onViewLog(job.ID) }),
)
}
buttons = append(buttons,
widget.NewButton("Remove", func() { onRemove(job.ID) }),
)

102
main.go
View File

@ -60,6 +60,8 @@ var (
textColor = utils.MustHex("#E1EEFF")
queueColor = utils.MustHex("#5961FF")
conversionLogSuffix = ".videotools.log"
modulesList = []Module{
{"convert", "Convert", utils.MustHex("#8B44FF"), modules.HandleConvert}, // Violet
{"merge", "Merge", utils.MustHex("#4488FF"), modules.HandleMerge}, // Blue
@ -100,6 +102,29 @@ func resolveTargetAspect(val string, src *videoSource) float64 {
return 0
}
func createConversionLog(inputPath, outputPath string, args []string) (*os.File, string, error) {
logPath := outputPath + conversionLogSuffix
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
return nil, logPath, fmt.Errorf("create log dir: %w", err)
}
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return nil, logPath, err
}
header := fmt.Sprintf(`VideoTools Conversion Log
Started: %s
Input: %s
Output: %s
Command: ffmpeg %s
`, time.Now().Format(time.RFC3339), inputPath, outputPath, strings.Join(args, " "))
if _, err := f.WriteString(header); err != nil {
_ = f.Close()
return nil, logPath, err
}
return f, logPath, nil
}
type formatOption struct {
Label string
Ext string
@ -612,6 +637,28 @@ func (s *appState) refreshQueueView() {
}
s.window.Clipboard().SetContent(text)
},
func(id string) { // onViewLog
job, err := s.jobQueue.Get(id)
if err != nil {
logging.Debug(logging.CatSystem, "view log failed: %v", err)
return
}
path := strings.TrimSpace(job.LogPath)
if path == "" {
dialog.ShowInformation("No Log", "No log path recorded for this job.", s.window)
return
}
data, err := os.ReadFile(path)
if err != nil {
dialog.ShowError(fmt.Errorf("failed to read log: %w", err), s.window)
return
}
text := widget.NewMultiLineEntry()
text.SetText(string(data))
text.Wrapping = fyne.TextWrapWord
text.Disable()
dialog.ShowCustom("Conversion Log", "Close", container.NewVScroll(text), s.window)
},
utils.MustHex("#4CE870"), // titleColor
gridColor, // bgColor
textColor, // textColor
@ -1530,6 +1577,15 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
args = append(args, "-progress", "pipe:1", "-nostats")
args = append(args, outputPath)
logFile, logPath, logErr := createConversionLog(inputPath, outputPath, args)
if logErr != nil {
logging.Debug(logging.CatFFMPEG, "conversion log open failed: %v", logErr)
} else {
job.LogPath = logPath
fmt.Fprintf(logFile, "Status: started\n\n")
defer logFile.Close()
}
logging.Debug(logging.CatFFMPEG, "queue convert command: ffmpeg %s", strings.Join(args, " "))
// Also print to stdout for debugging
@ -1545,14 +1601,22 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
// Capture stderr for error messages
var stderrBuf strings.Builder
cmd.Stderr = &stderrBuf
if logFile != nil {
cmd.Stderr = io.MultiWriter(&stderrBuf, logFile)
} else {
cmd.Stderr = &stderrBuf
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start ffmpeg: %w", err)
}
// Parse progress
scanner := bufio.NewScanner(stdout)
stdoutReader := io.Reader(stdout)
if logFile != nil {
stdoutReader = io.TeeReader(stdout, logFile)
}
scanner := bufio.NewScanner(stdoutReader)
var duration float64
if d, ok := cfg["sourceDuration"].(float64); ok && d > 0 {
duration = d
@ -1658,6 +1722,9 @@ func (s *appState) executeConvertJob(ctx context.Context, job *queue.Job, progre
return fmt.Errorf("%s", errorMsg)
}
if logFile != nil {
fmt.Fprintf(logFile, "\nStatus: completed OK at %s\n", time.Now().Format(time.RFC3339))
}
logging.Debug(logging.CatFFMPEG, "queue conversion completed: %s", outputPath)
// Auto-compare if enabled
@ -5381,6 +5448,13 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
s.convertProgress = 0
s.convertActiveIn = src.Path
s.convertActiveOut = outPath
logFile, logPath, logErr := createConversionLog(src.Path, outPath, args)
if logErr != nil {
logging.Debug(logging.CatFFMPEG, "conversion log open failed: %v", logErr)
} else {
fmt.Fprintf(logFile, "Status: started\n\n")
}
_ = logPath
setStatus("Preparing conversion…")
// Widget states will be updated by the UI refresh ticker
@ -5391,6 +5465,9 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
setStatus("Running ffmpeg…")
}, false)
if logFile != nil {
defer logFile.Close()
}
started := time.Now()
cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...)
@ -5407,11 +5484,19 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
return
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if logFile != nil {
cmd.Stderr = io.MultiWriter(&stderr, logFile)
} else {
cmd.Stderr = &stderr
}
progressQuit := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stdout)
stdoutReader := io.Reader(stdout)
if logFile != nil {
stdoutReader = io.TeeReader(stdout, logFile)
}
scanner := bufio.NewScanner(stdoutReader)
var currentFPS float64
for scanner.Scan() {
select {
@ -5510,6 +5595,9 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
if err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
logging.Debug(logging.CatFFMPEG, "convert cancelled")
if logFile != nil {
fmt.Fprintf(logFile, "\nStatus: cancelled at %s\n", time.Now().Format(time.RFC3339))
}
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.convertBusy = false
s.convertActiveIn = ""
@ -5522,6 +5610,9 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
}
stderrOutput := strings.TrimSpace(stderr.String())
logging.Debug(logging.CatFFMPEG, "convert failed: %v stderr=%s", err, stderrOutput)
if logFile != nil {
fmt.Fprintf(logFile, "\nStatus: failed at %s\nError: %v\nStderr:\n%s\n", time.Now().Format(time.RFC3339), err, stderrOutput)
}
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
errorExplanation := interpretFFmpegError(err)
var errorMsg error
@ -5560,6 +5651,9 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
s.convertCancel = nil
return
}
if logFile != nil {
fmt.Fprintf(logFile, "\nStatus: completed OK at %s\n", time.Now().Format(time.RFC3339))
}
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
setStatus("Validating output…")
}, false)