feat(author): Implement real-time progress, add to queue, clear title

This commit introduces several enhancements to the Author module:
- **Real-time Progress Reporting:** Implemented granular, real-time progress updates for FFmpeg encoding steps during DVD authoring. The progress bar now updates smoothly, reflecting the actual video processing. Progress calculation is weighted by video durations for accuracy.
- **Add to Queue Functionality:** Added an 'Add to Queue' button to the Author module, allowing users to queue authoring jobs for later execution without immediate start. The authoring workflow was refactored to accept a 'startNow' parameter for this purpose.
- **Clear Output Title:** Modified the 'Clear All' functionality to also reset the DVD Output Title, preventing accidental naming conflicts for new projects.

Additionally, this commit includes a UI enhancement:
- **Main Menu Categorization:** Relocated 'Author', 'Rip', and 'Blu-Ray' modules to a new 'Disc' category on the main menu, improving logical grouping.

Fixes:
- Corrected a missing argument error in a call to .
- Added missing  import in .

Updates:
-  and  have been updated to reflect these changes.
This commit is contained in:
Stu Leak 2025-12-27 01:34:57 -05:00
parent 0193886676
commit c8f4eec0d1
4 changed files with 197 additions and 70 deletions

42
DONE.md
View File

@ -2,6 +2,27 @@
This file tracks completed features, fixes, and milestones. This file tracks completed features, fixes, and milestones.
## Version 0.1.0-dev20+ (2025-12-26) - Author Module & UI Enhancements
### Features
- ✅ **Author Module - Real-time Progress Reporting**
- Implemented granular progress updates for FFmpeg encoding steps in the Author module.
- Progress bar now updates smoothly during video processing, providing better feedback.
- Weighted progress calculation based on video durations for accurate overall progress.
- ✅ **Author Module - "Add to Queue" & Output Title Clear**
- Added an "Add to Queue" button to the Author module for non-immediate job execution.
- Refactored authoring workflow to support queuing jobs via a `startNow` parameter.
- Modified "Clear All" functionality to also clear the DVD Output Title, preventing naming conflicts.
- ✅ **Main Menu - "Disc" Category for Author, Rip, and Blu-Ray**
- Relocated "Author", "Rip", and "Blu-Ray" buttons to a new "Disc" category on the main menu.
- Improved logical grouping of disc-related functionalities.
- ✅ **Subtitles Module - Video File Path Population**
- Fixed an issue where dragging and dropping a video file onto the Subtitles module would not populate the "Video File Path" section.
- Ensured the video entry widget correctly reflects the dropped video's path.
## Version 0.1.0-dev20+ (2025-12-23) - Player UX & Installer Polish ## Version 0.1.0-dev20+ (2025-12-23) - Player UX & Installer Polish
### Features (2025-12-23 Session) ### Features (2025-12-23 Session)
@ -424,13 +445,11 @@ This file tracks completed features, fixes, and milestones.
- Filter chain combination support - Filter chain combination support
### Bug Fixes ### Bug Fixes
- ✅ Fixed snippet duration issues with dual-mode approach - ✅ Fixed incorrect thumbnail count in contact sheets (was generating 34 instead of 40 for 5x8 grid)
- Default Format: Uses stream copy (keyframe-level precision) - ✅ Fixed frame selection FPS assumption (hardcoded 30fps removed)
- Output Format: Re-encodes for frame-perfect duration - ✅ Fixed module visibility (added thumb module to enabled check)
- ✅ Fixed container/codec mismatch in snippet generation - ✅ Fixed undefined function call (openFileManager → openFolder)
- Now properly matches container to codec (MP4 for h264, source format for stream copy) - ✅ Fixed dynamic total count not updating when changing grid dimensions
- ✅ Fixed missing audio bitrate in thumbnail metadata
- ✅ Fixed contact sheet dimensions not accounting for padding
- ✅ Added missing `strings` import to thumbnail/generator.go - ✅ Added missing `strings` import to thumbnail/generator.go
- ✅ Updated snippet UI labels for clarity (Default Format vs Output Format) - ✅ Updated snippet UI labels for clarity (Default Format vs Output Format)
@ -709,7 +728,7 @@ This file tracks completed features, fixes, and milestones.
- Braille character animations - Braille character animations
- Shows current task during build and install - Shows current task during build and install
- Interactive path selection (system-wide or user-local) - Interactive path selection (system-wide or user-local)
- ✅ Added error dialogs with "Copy Error" button - Added error dialogs with "Copy Error" button
- One-click error message copying for debugging - One-click error message copying for debugging
- Applied to all major error scenarios - Applied to all major error scenarios
- Better user experience when reporting issues - Better user experience when reporting issues
@ -871,7 +890,6 @@ This file tracks completed features, fixes, and milestones.
- ✅ Category-based logging (SYS, UI, MODULE, etc.) - ✅ Category-based logging (SYS, UI, MODULE, etc.)
- ✅ Timestamp formatting - ✅ Timestamp formatting
- ✅ Debug output toggle via environment variable - ✅ Debug output toggle via environment variable
- ✅ Comprehensive debug messages throughout application
- ✅ Log file output (videotools.log) - ✅ Log file output (videotools.log)
### Error Handling ### Error Handling
@ -907,6 +925,10 @@ This file tracks completed features, fixes, and milestones.
- ✅ Audio decoding and playback - ✅ Audio decoding and playback
- ✅ Synchronization between audio and video - ✅ Synchronization between audio and video
- ✅ Embedded playback within application window - ✅ Embedded playback within application window
- ✅ Seek functionality with progress bar
- ✅ Player window sizing based on video aspect ratio
- ✅ Frame pump system for smooth playback
- ✅ Audio/video synchronization
- ✅ Checkpoint system for playback position - ✅ Checkpoint system for playback position
### UI/UX ### UI/UX
@ -1021,4 +1043,4 @@ This file tracks completed features, fixes, and milestones.
--- ---
*Last Updated: 2025-12-21* *Last Updated: 2025-12-21*

31
TODO.md
View File

@ -41,6 +41,8 @@ This file tracks upcoming features, improvements, and known issues.
- Lossless option only for H.265/AV1 - Lossless option only for H.265/AV1
- Dynamic dropdown based on codec - Dynamic dropdown based on codec
- Lossless + Target Size mode support - Lossless + Target Size mode support
- Dynamic dropdown based on codec
- Lossless + Target Size mode support
- Audio bitrate estimation when metadata is missing - Audio bitrate estimation when metadata is missing
- Target size unit selector and numeric entry - Target size unit selector and numeric entry
- Snippet history updates in sidebar - Snippet history updates in sidebar
@ -70,7 +72,7 @@ This file tracks upcoming features, improvements, and known issues.
- Frame interpolation presets in Filters with Upscale linkage - Frame interpolation presets in Filters with Upscale linkage
- Real-ESRGAN AI upscale controls with ncnn pipeline (models, presets, tiles, TTA) - Real-ESRGAN AI upscale controls with ncnn pipeline (models, presets, tiles, TTA)
*Last Updated: 2025-12-21* *Last Updated: 2025-12-26*
## Priority Features for dev20+ ## Priority Features for dev20+
@ -112,7 +114,30 @@ This file tracks upcoming features, improvements, and known issues.
- Creative effects (grayscale, vignette) - Creative effects (grayscale, vignette)
- Real-time preview system - Real-time preview system
- [ ] **DVD Authoring module** - [ ] **Upscale module implementation**
- Design UI for upscaling
- Implement traditional scaling (Lanczos, Bicubic)
- Integrate Waifu2x (if feasible)
- Integrate Real-ESRGAN (if feasible)
- Add resolution presets
- Quality vs. speed slider
- Before/after comparison
- Batch upscaling
- [ ] **Audio module implementation**
- Design audio extraction UI
- Implement audio track extraction
- Audio track replacement/addition
- Multi-track management
- Volume normalization
- Audio delay correction
- Format conversion
- Channel mapping
- Audio-only operations
- [x] **DVD Authoring module**
- [x] **Real-time progress reporting for FFmpeg encoding**
- [x] **"Add to Queue" and "Clear Output Title" functionality**
- Output VIDEO_TS folder + burn-ready ISO - Output VIDEO_TS folder + burn-ready ISO
- Auto-detect NTSC/PAL with manual override - Auto-detect NTSC/PAL with manual override
- Preserve all audio tracks - Preserve all audio tracks
@ -844,4 +869,4 @@ Built-in Video File Explorer/Manager for comprehensive file management without l
- [ ] AI upscaling integration options - [ ] AI upscaling integration options
- [ ] Disc copy protection legal landscape - [ ] Disc copy protection legal landscape
- [ ] Cross-platform video codecs support - [ ] Cross-platform video codecs support
- [ ] HDR/Dolby Vision handling - [ ] HDR/Dolby Vision handling

View File

@ -13,6 +13,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -250,17 +251,27 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
state.authorChapters = nil state.authorChapters = nil
state.authorChapterSource = "" state.authorChapterSource = ""
state.authorVideoTSPath = "" state.authorVideoTSPath = ""
state.authorTitle = ""
rebuildList() rebuildList()
state.updateAuthorSummary() state.updateAuthorSummary()
}) })
clearBtn.Importance = widget.MediumImportance clearBtn.Importance = widget.MediumImportance
addQueueBtn := widget.NewButton("Add to Queue", func() {
if len(state.authorClips) == 0 {
dialog.ShowInformation("No Clips", "Please add video clips first", state.window)
return
}
state.startAuthorGeneration(false)
})
addQueueBtn.Importance = widget.MediumImportance
compileBtn := widget.NewButton("COMPILE TO DVD", func() { compileBtn := widget.NewButton("COMPILE TO DVD", func() {
if len(state.authorClips) == 0 { if len(state.authorClips) == 0 {
dialog.ShowInformation("No Clips", "Please add video clips first", state.window) dialog.ShowInformation("No Clips", "Please add video clips first", state.window)
return return
} }
state.startAuthorGeneration() state.startAuthorGeneration(true)
}) })
compileBtn.Importance = widget.HighImportance compileBtn.Importance = widget.HighImportance
@ -302,7 +313,7 @@ func buildVideoClipsTab(state *appState) fyne.CanvasObject {
controls := container.NewBorder( controls := container.NewBorder(
widget.NewLabel("Videos:"), widget.NewLabel("Videos:"),
container.NewVBox(chapterToggle, container.NewHBox(addBtn, clearBtn, compileBtn)), container.NewVBox(chapterToggle, container.NewHBox(addBtn, clearBtn, addQueueBtn, compileBtn)),
nil, nil,
nil, nil,
listArea, listArea,
@ -730,7 +741,7 @@ func buildAuthorDiscTab(state *appState) fyne.CanvasObject {
dialog.ShowInformation("No Content", "Please add video clips or select a single video file", state.window) dialog.ShowInformation("No Content", "Please add video clips or select a single video file", state.window)
return return
} }
state.startAuthorGeneration() state.startAuthorGeneration(true)
}) })
generateBtn.Importance = widget.HighImportance generateBtn.Importance = widget.HighImportance
@ -1258,7 +1269,7 @@ func (s *appState) setAuthorProgress(percent float64) {
} }
} }
func (s *appState) startAuthorGeneration() { func (s *appState) startAuthorGeneration(startNow bool) {
if s.authorVideoTSPath != "" { if s.authorVideoTSPath != "" {
title := authorOutputTitle(s) title := authorOutputTitle(s)
outputPath := authorDefaultOutputPath("iso", title, []string{s.authorVideoTSPath}) outputPath := authorDefaultOutputPath("iso", title, []string{s.authorVideoTSPath})
@ -1266,7 +1277,7 @@ func (s *appState) startAuthorGeneration() {
dialog.ShowError(fmt.Errorf("failed to resolve output path"), s.window) dialog.ShowError(fmt.Errorf("failed to resolve output path"), s.window)
return return
} }
if err := s.addAuthorVideoTSToQueue(s.authorVideoTSPath, title, outputPath, true); err != nil { if err := s.addAuthorVideoTSToQueue(s.authorVideoTSPath, title, outputPath, startNow); err != nil {
dialog.ShowError(err, s.window) dialog.ShowError(err, s.window)
} }
return return
@ -1296,7 +1307,7 @@ func (s *appState) startAuthorGeneration() {
} }
continuePrompt := func() { continuePrompt := func() {
uiCall(func() { uiCall(func() {
s.promptAuthorOutput(paths, region, aspect, title) s.promptAuthorOutput(paths, region, aspect, title, startNow)
}) })
} }
if len(warnings) > 0 { if len(warnings) > 0 {
@ -1313,7 +1324,7 @@ func (s *appState) startAuthorGeneration() {
continuePrompt() continuePrompt()
} }
func (s *appState) promptAuthorOutput(paths []string, region, aspect, title string) { func (s *appState) promptAuthorOutput(paths []string, region, aspect, title string, startNow bool) {
outputType := strings.ToLower(strings.TrimSpace(s.authorOutputType)) outputType := strings.ToLower(strings.TrimSpace(s.authorOutputType))
if outputType == "" { if outputType == "" {
outputType = "dvd" outputType = "dvd"
@ -1321,10 +1332,10 @@ func (s *appState) promptAuthorOutput(paths []string, region, aspect, title stri
outputPath := authorDefaultOutputPath(outputType, title, paths) outputPath := authorDefaultOutputPath(outputType, title, paths)
if outputType == "iso" { if outputType == "iso" {
s.generateAuthoring(paths, region, aspect, title, outputPath, true) s.generateAuthoring(paths, region, aspect, title, outputPath, true, startNow)
return return
} }
s.generateAuthoring(paths, region, aspect, title, outputPath, false) s.generateAuthoring(paths, region, aspect, title, outputPath, false, startNow)
} }
func authorWarnings(state *appState) []string { func authorWarnings(state *appState) []string {
@ -1502,8 +1513,8 @@ func uniqueFilePath(path string) string {
return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext) return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext)
} }
func (s *appState) generateAuthoring(paths []string, region, aspect, title, outputPath string, makeISO bool) { func (s *appState) generateAuthoring(paths []string, region, aspect, title, outputPath string, makeISO, startNow bool) {
if err := s.addAuthorToQueue(paths, region, aspect, title, outputPath, makeISO, true); err != nil { if err := s.addAuthorToQueue(paths, region, aspect, title, outputPath, makeISO, startNow); err != nil {
dialog.ShowError(err, s.window) dialog.ShowError(err, s.window)
} }
} }
@ -1636,20 +1647,22 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
return err return err
} }
totalSteps := len(paths) + 2 var totalDuration float64
for _, path := range paths {
src, err := probeVideo(path)
if err == nil {
totalDuration += src.Duration
}
}
encodingProgressShare := 80.0
otherStepsProgressShare := 20.0
otherStepsCount := 2.0
if makeISO { if makeISO {
totalSteps++ otherStepsCount++
}
step := 0
advance := func(message string) {
step++
if logFn != nil && message != "" {
logFn(message)
}
if progressFn != nil && totalSteps > 0 {
progressFn(float64(step) / float64(totalSteps) * 100.0)
}
} }
progressForOtherStep := otherStepsProgressShare / otherStepsCount
var accumulatedProgress float64
var mpgPaths []string var mpgPaths []string
for i, path := range paths { for i, path := range paths {
@ -1661,36 +1674,43 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
if err != nil { if err != nil {
return fmt.Errorf("failed to probe %s: %w", filepath.Base(path), err) return fmt.Errorf("failed to probe %s: %w", filepath.Base(path), err)
} }
clipProgressShare := 0.0
if totalDuration > 0 {
clipProgressShare = (src.Duration / totalDuration) * encodingProgressShare
}
ffmpegProgressFn := func(stepPct float64) {
overallPct := accumulatedProgress + (stepPct / 100.0 * clipProgressShare)
if progressFn != nil {
progressFn(overallPct)
}
}
args := buildAuthorFFmpegArgs(path, outPath, region, aspect, src.IsProgressive()) args := buildAuthorFFmpegArgs(path, outPath, region, aspect, src.IsProgressive())
if logFn != nil { if logFn != nil {
logFn(fmt.Sprintf(">> ffmpeg %s", strings.Join(args, " "))) logFn(fmt.Sprintf(">> ffmpeg %s", strings.Join(args, " ")))
} }
if err := runCommandWithLogger(ctx, platformConfig.FFmpegPath, args, logFn); err != nil {
if err := runAuthorFFmpeg(ctx, args, src.Duration, logFn, ffmpegProgressFn); err != nil {
return err return err
} }
// Remultiplex the MPEG to fix timestamps for DVD compliance accumulatedProgress += clipProgressShare
// This resolves "SCR moves backwards" errors from dvdauthor if progressFn != nil {
remuxPath := filepath.Join(workDir, fmt.Sprintf("title_%02d_remux.mpg", i+1)) progressFn(accumulatedProgress)
remuxArgs := []string{
"-fflags", "+genpts",
"-i", outPath,
"-c", "copy",
"-f", "dvd",
"-y",
remuxPath,
} }
remuxPath := filepath.Join(workDir, fmt.Sprintf("title_%02d_remux.mpg", i+1))
remuxArgs := []string{"-fflags", "+genpts", "-i", outPath, "-c", "copy", "-f", "dvd", "-y", remuxPath}
if logFn != nil { if logFn != nil {
logFn(fmt.Sprintf(">> ffmpeg %s (remuxing for DVD compliance)", strings.Join(remuxArgs, " "))) logFn(fmt.Sprintf(">> ffmpeg %s (remuxing for DVD compliance)", strings.Join(remuxArgs, " ")))
} }
if err := runCommandWithLogger(ctx, platformConfig.FFmpegPath, remuxArgs, logFn); err != nil { if err := runCommandWithLogger(ctx, platformConfig.FFmpegPath, remuxArgs, logFn); err != nil {
return fmt.Errorf("remux failed: %w", err) return fmt.Errorf("remux failed: %w", err)
} }
// Remove original encode, use remuxed version
os.Remove(outPath) os.Remove(outPath)
mpgPaths = append(mpgPaths, remuxPath) mpgPaths = append(mpgPaths, remuxPath)
advance("")
} }
if len(chapters) == 0 && treatAsChapters && len(clips) > 1 { if len(chapters) == 0 && treatAsChapters && len(clips) > 1 {
@ -1701,7 +1721,6 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
chapters = embed chapters = embed
} }
} }
if treatAsChapters && len(mpgPaths) > 1 { if treatAsChapters && len(mpgPaths) > 1 {
concatPath := filepath.Join(workDir, "titles_joined.mpg") concatPath := filepath.Join(workDir, "titles_joined.mpg")
if logFn != nil { if logFn != nil {
@ -1712,7 +1731,6 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
} }
mpgPaths = []string{concatPath} mpgPaths = []string{concatPath}
} }
if len(mpgPaths) > 1 { if len(mpgPaths) > 1 {
chapters = nil chapters = nil
} }
@ -1722,23 +1740,21 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
return err return err
} }
if logFn != nil { logFn("Authoring DVD structure...")
logFn("Authoring DVD structure...") logFn(fmt.Sprintf(">> dvdauthor -o %s -x %s", discRoot, xmlPath))
logFn(fmt.Sprintf(">> dvdauthor -o %s -x %s", discRoot, xmlPath))
}
if err := runCommandWithLogger(ctx, "dvdauthor", []string{"-o", discRoot, "-x", xmlPath}, logFn); err != nil { if err := runCommandWithLogger(ctx, "dvdauthor", []string{"-o", discRoot, "-x", xmlPath}, logFn); err != nil {
return err return err
} }
advance("") accumulatedProgress += progressForOtherStep
progressFn(accumulatedProgress)
if logFn != nil { logFn("Building DVD tables...")
logFn("Building DVD tables...") logFn(fmt.Sprintf(">> dvdauthor -o %s -T", discRoot))
logFn(fmt.Sprintf(">> dvdauthor -o %s -T", discRoot))
}
if err := runCommandWithLogger(ctx, "dvdauthor", []string{"-o", discRoot, "-T"}, logFn); err != nil { if err := runCommandWithLogger(ctx, "dvdauthor", []string{"-o", discRoot, "-T"}, logFn); err != nil {
return err return err
} }
advance("") accumulatedProgress += progressForOtherStep
progressFn(accumulatedProgress)
if err := os.MkdirAll(filepath.Join(discRoot, "AUDIO_TS"), 0755); err != nil { if err := os.MkdirAll(filepath.Join(discRoot, "AUDIO_TS"), 0755); err != nil {
return fmt.Errorf("failed to create AUDIO_TS: %w", err) return fmt.Errorf("failed to create AUDIO_TS: %w", err)
@ -1749,19 +1765,83 @@ func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, reg
if err != nil { if err != nil {
return err return err
} }
if logFn != nil { logFn("Creating ISO image...")
logFn("Creating ISO image...") logFn(fmt.Sprintf(">> %s %s", tool, strings.Join(args, " ")))
logFn(fmt.Sprintf(">> %s %s", tool, strings.Join(args, " ")))
}
if err := runCommandWithLogger(ctx, tool, args, logFn); err != nil { if err := runCommandWithLogger(ctx, tool, args, logFn); err != nil {
return err return err
} }
advance("") accumulatedProgress += progressForOtherStep
progressFn(accumulatedProgress)
} }
progressFn(100.0)
return nil return nil
} }
func runAuthorFFmpeg(ctx context.Context, args []string, duration float64, logFn func(string), progressFn func(float64)) error {
finalArgs := append([]string{"-progress", "pipe:1", "-nostats"}, args...)
cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, finalArgs...)
utils.ApplyNoWindow(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("ffmpeg stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("ffmpeg stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("ffmpeg start failed: %w", err)
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
if logFn != nil {
logFn(scanner.Text())
}
}
}()
go func() {
defer wg.Done()
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
key, val := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
if key == "out_time_ms" {
if ms, err := strconv.ParseInt(val, 10, 64); err == nil && ms > 0 {
currentSec := float64(ms) / 1000000.0
if duration > 0 {
stepPct := (currentSec / duration) * 100.0
if stepPct > 100 {
stepPct = 100
}
if progressFn != nil {
progressFn(stepPct)
}
}
}
}
if logFn != nil {
logFn(line)
}
}
}()
err = cmd.Wait()
wg.Wait()
if err != nil {
return fmt.Errorf("ffmpeg failed: %w", err)
}
return nil
}
func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
cfg := job.Config cfg := job.Config
if cfg == nil { if cfg == nil {

View File

@ -86,9 +86,9 @@ var (
{"filters", "Filters", utils.MustHex("#44FF88"), "Convert", modules.HandleFilters}, // Green {"filters", "Filters", utils.MustHex("#44FF88"), "Convert", modules.HandleFilters}, // Green
{"upscale", "Upscale", utils.MustHex("#AAFF44"), "Advanced", modules.HandleUpscale}, // Yellow-Green {"upscale", "Upscale", utils.MustHex("#AAFF44"), "Advanced", modules.HandleUpscale}, // Yellow-Green
{"audio", "Audio", utils.MustHex("#FFD744"), "Convert", modules.HandleAudio}, // Yellow {"audio", "Audio", utils.MustHex("#FFD744"), "Convert", modules.HandleAudio}, // Yellow
{"author", "Author", utils.MustHex("#FFAA44"), "DVD", modules.HandleAuthor}, // Orange {"author", "Author", utils.MustHex("#FFAA44"), "Disc", modules.HandleAuthor}, // Orange
{"rip", "Rip", utils.MustHex("#FF9944"), "DVD", modules.HandleRip}, // Orange {"rip", "Rip", utils.MustHex("#FF9944"), "Disc", modules.HandleRip}, // Orange
{"bluray", "Blu-Ray", utils.MustHex("#4D7CFE"), "Blu-Ray", modules.HandleBluRay}, // Blue {"bluray", "Blu-Ray", utils.MustHex("#4D7CFE"), "Disc", modules.HandleBluRay}, // Blue
{"subtitles", "Subtitles", utils.MustHex("#44A6FF"), "Convert", modules.HandleSubtitles}, // Azure {"subtitles", "Subtitles", utils.MustHex("#44A6FF"), "Convert", modules.HandleSubtitles}, // Azure
{"thumb", "Thumb", utils.MustHex("#FF8844"), "Screenshots", modules.HandleThumb}, // Orange {"thumb", "Thumb", utils.MustHex("#FF8844"), "Screenshots", modules.HandleThumb}, // Orange
{"compare", "Compare", utils.MustHex("#FF44AA"), "Inspect", modules.HandleCompare}, // Pink {"compare", "Compare", utils.MustHex("#FF44AA"), "Inspect", modules.HandleCompare}, // Pink