From faef905f185f180bc3e8893bfc169124c77bafd6 Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Tue, 23 Dec 2025 21:19:44 -0500 Subject: [PATCH] Add Rip module for DVD/ISO/VIDEO_TS --- internal/modules/handlers.go | 6 + main.go | 40 ++- rip_module.go | 516 +++++++++++++++++++++++++++++++++++ 3 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 rip_module.go diff --git a/internal/modules/handlers.go b/internal/modules/handlers.go index 0fd3cf7..5979bc4 100644 --- a/internal/modules/handlers.go +++ b/internal/modules/handlers.go @@ -51,6 +51,12 @@ func HandleAuthor(files []string) { // File loading is managed in buildAuthorView() } +// HandleRip handles the rip module (placeholder) +func HandleRip(files []string) { + logging.Debug(logging.CatModule, "rip handler invoked with %v", files) + fmt.Println("rip", files) +} + // HandleSubtitles handles the subtitles module (placeholder) func HandleSubtitles(files []string) { logging.Debug(logging.CatModule, "subtitles handler invoked with %v", files) diff --git a/main.go b/main.go index b9f1026..18473ce 100644 --- a/main.go +++ b/main.go @@ -88,6 +88,7 @@ var ( {"upscale", "Upscale", utils.MustHex("#AAFF44"), "Advanced", modules.HandleUpscale}, // Yellow-Green {"audio", "Audio", utils.MustHex("#FFD744"), "Convert", modules.HandleAudio}, // Yellow {"author", "Author", utils.MustHex("#FFAA44"), "Convert", modules.HandleAuthor}, // Orange + {"rip", "Rip", utils.MustHex("#FF9944"), "Convert", modules.HandleRip}, // Orange {"subtitles", "Subtitles", utils.MustHex("#44A6FF"), "Convert", modules.HandleSubtitles}, // Azure {"thumb", "Thumb", utils.MustHex("#FF8844"), "Screenshots", modules.HandleThumb}, // Orange {"compare", "Compare", utils.MustHex("#FF44AA"), "Inspect", modules.HandleCompare}, // Pink @@ -927,6 +928,17 @@ type appState struct { authorStatusLabel *widget.Label authorVideoTSPath string + // Rip module state + ripSourcePath string + ripOutputPath string + ripFormat string + ripLogText string + ripLogEntry *widget.Entry + ripLogScroll *container.Scroll + ripProgress float64 + ripProgressBar *widget.ProgressBar + ripStatusLabel *widget.Label + // Subtitles module state subtitleVideoPath string subtitleFilePath string @@ -1547,7 +1559,7 @@ func (s *appState) showMainMenu() { Label: m.Label, Color: m.Color, Category: m.Category, - Enabled: m.ID == "convert" || m.ID == "compare" || m.ID == "inspect" || m.ID == "merge" || m.ID == "thumb" || m.ID == "player" || m.ID == "filters" || m.ID == "upscale" || m.ID == "author" || m.ID == "subtitles", // Enabled modules + Enabled: m.ID == "convert" || m.ID == "compare" || m.ID == "inspect" || m.ID == "merge" || m.ID == "thumb" || m.ID == "player" || m.ID == "filters" || m.ID == "upscale" || m.ID == "author" || m.ID == "subtitles" || m.ID == "rip", // Enabled modules }) } @@ -2285,6 +2297,8 @@ func (s *appState) showModule(id string) { s.showUpscaleView() case "author": s.showAuthorView() + case "rip": + s.showRipView() case "subtitles": s.showSubtitlesView() case "mainmenu": @@ -2537,6 +2551,15 @@ func (s *appState) isSubtitleFile(path string) bool { return false } +func firstLocalDropPath(items []fyne.URI) string { + for _, uri := range items { + if uri.Scheme() == "file" { + return uri.Path() + } + } + return "" +} + // findVideoFiles recursively finds all video files in a directory func (s *appState) findVideoFiles(dir string) []string { var videos []string @@ -3217,6 +3240,8 @@ func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCall return s.executeSnippetJob(ctx, job, progressCallback) case queue.JobTypeAuthor: return s.executeAuthorJob(ctx, job, progressCallback) + case queue.JobTypeRip: + return s.executeRipJob(ctx, job, progressCallback) default: return fmt.Errorf("unknown job type: %s", job.Type) } @@ -9606,6 +9631,19 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) { return } + // If in rip module, accept DVD/ISO/VIDEO_TS paths + if s.active == "rip" { + path := firstLocalDropPath(items) + if path == "" { + logging.Debug(logging.CatUI, "no valid paths in dropped items") + return + } + s.ripSourcePath = path + s.ripOutputPath = defaultRipOutputPath(path, s.ripFormat) + s.showRipView() + return + } + // If in compare module, handle up to 2 video files if s.active == "compare" { // Collect all video files from the dropped items diff --git a/rip_module.go b/rip_module.go new file mode 100644 index 0000000..419fbbe --- /dev/null +++ b/rip_module.go @@ -0,0 +1,516 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + "git.leaktechnologies.dev/stu/VideoTools/internal/logging" + "git.leaktechnologies.dev/stu/VideoTools/internal/queue" + "git.leaktechnologies.dev/stu/VideoTools/internal/ui" + "git.leaktechnologies.dev/stu/VideoTools/internal/utils" +) + +const ( + ripFormatLosslessMKV = "Lossless MKV (Copy)" + ripFormatH264MKV = "H.264 MKV (CRF 18)" + ripFormatH264MP4 = "H.264 MP4 (CRF 18)" +) + +func (s *appState) showRipView() { + s.stopPreview() + s.lastModule = s.active + s.active = "rip" + + if s.ripFormat == "" { + s.ripFormat = ripFormatLosslessMKV + } + if s.ripStatusLabel != nil { + s.ripStatusLabel.SetText("Ready") + } + s.setContent(buildRipView(s)) +} + +func buildRipView(state *appState) fyne.CanvasObject { + ripColor := moduleColor("rip") + + backBtn := widget.NewButton("< BACK", func() { + state.showMainMenu() + }) + backBtn.Importance = widget.LowImportance + + queueBtn := widget.NewButton("View Queue", func() { + state.showQueue() + }) + state.queueBtn = queueBtn + state.updateQueueButtonLabel() + + topBar := ui.TintedBar(ripColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) + bottomBar := moduleFooter(ripColor, layout.NewSpacer(), state.statsBar) + + sourceEntry := widget.NewEntry() + sourceEntry.SetPlaceHolder("Drop DVD/ISO/VIDEO_TS path here") + sourceEntry.SetText(state.ripSourcePath) + sourceEntry.OnChanged = func(val string) { + state.ripSourcePath = strings.TrimSpace(val) + state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat) + } + + outputEntry := widget.NewEntry() + outputEntry.SetPlaceHolder("Output path") + outputEntry.SetText(state.ripOutputPath) + outputEntry.OnChanged = func(val string) { + state.ripOutputPath = strings.TrimSpace(val) + } + + formatSelect := widget.NewSelect([]string{ripFormatLosslessMKV, ripFormatH264MKV, ripFormatH264MP4}, func(val string) { + state.ripFormat = val + state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat) + outputEntry.SetText(state.ripOutputPath) + }) + formatSelect.SetSelected(state.ripFormat) + + statusLabel := widget.NewLabel("Ready") + statusLabel.Wrapping = fyne.TextWrapWord + state.ripStatusLabel = statusLabel + + progressBar := widget.NewProgressBar() + progressBar.SetValue(state.ripProgress / 100.0) + state.ripProgressBar = progressBar + + logEntry := widget.NewMultiLineEntry() + logEntry.Wrapping = fyne.TextWrapOff + logEntry.Disable() + logEntry.SetText(state.ripLogText) + state.ripLogEntry = logEntry + logScroll := container.NewVScroll(logEntry) + logScroll.SetMinSize(fyne.NewSize(0, 200)) + state.ripLogScroll = logScroll + + addQueueBtn := widget.NewButton("Add Rip to Queue", func() { + if err := state.addRipToQueue(false); err != nil { + dialog.ShowError(err, state.window) + return + } + dialog.ShowInformation("Queue", "Rip job added to queue.", state.window) + if state.jobQueue != nil && !state.jobQueue.IsRunning() { + state.jobQueue.Start() + } + }) + addQueueBtn.Importance = widget.MediumImportance + + runNowBtn := widget.NewButton("Rip Now", func() { + if err := state.addRipToQueue(true); err != nil { + dialog.ShowError(err, state.window) + return + } + if state.jobQueue != nil && !state.jobQueue.IsRunning() { + state.jobQueue.Start() + } + dialog.ShowInformation("Rip", "Rip started! Track progress in Job Queue.", state.window) + }) + runNowBtn.Importance = widget.HighImportance + + controls := container.NewVBox( + widget.NewLabelWithStyle("Source", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + ui.NewDroppable(sourceEntry, func(items []fyne.URI) { + path := firstLocalPath(items) + if path != "" { + state.ripSourcePath = path + sourceEntry.SetText(path) + state.ripOutputPath = defaultRipOutputPath(path, state.ripFormat) + outputEntry.SetText(state.ripOutputPath) + } + }), + widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + formatSelect, + widget.NewLabelWithStyle("Output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + outputEntry, + container.NewHBox(addQueueBtn, runNowBtn), + widget.NewSeparator(), + widget.NewLabelWithStyle("Status", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + statusLabel, + progressBar, + widget.NewSeparator(), + widget.NewLabelWithStyle("Rip Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + logScroll, + ) + + return container.NewBorder(topBar, bottomBar, nil, nil, container.NewPadded(controls)) +} + +func (s *appState) addRipToQueue(startNow bool) error { + if s.jobQueue == nil { + return fmt.Errorf("queue not initialized") + } + if strings.TrimSpace(s.ripSourcePath) == "" { + return fmt.Errorf("set a DVD/ISO/VIDEO_TS source path") + } + if strings.TrimSpace(s.ripOutputPath) == "" { + s.ripOutputPath = defaultRipOutputPath(s.ripSourcePath, s.ripFormat) + } + job := &queue.Job{ + Type: queue.JobTypeRip, + Title: fmt.Sprintf("Rip DVD: %s", filepath.Base(s.ripSourcePath)), + Description: fmt.Sprintf("Output: %s", utils.ShortenMiddle(filepath.Base(s.ripOutputPath), 40)), + InputFile: s.ripSourcePath, + OutputFile: s.ripOutputPath, + Config: map[string]interface{}{ + "sourcePath": s.ripSourcePath, + "outputPath": s.ripOutputPath, + "format": s.ripFormat, + }, + } + s.resetRipLog() + s.setRipStatus("Queued rip job...") + s.setRipProgress(0) + s.jobQueue.Add(job) + if startNow && !s.jobQueue.IsRunning() { + s.jobQueue.Start() + } + return nil +} + +func (s *appState) executeRipJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { + cfg := job.Config + if cfg == nil { + return fmt.Errorf("rip job config missing") + } + sourcePath := toString(cfg["sourcePath"]) + outputPath := toString(cfg["outputPath"]) + format := toString(cfg["format"]) + if sourcePath == "" || outputPath == "" { + return fmt.Errorf("rip job missing paths") + } + logFile, logPath, logErr := createRipLog(sourcePath, outputPath, format) + if logErr != nil { + logging.Debug(logging.CatSystem, "rip log open failed: %v", logErr) + } else { + job.LogPath = logPath + defer logFile.Close() + } + + appendLog := func(line string) { + if logFile != nil { + fmt.Fprintln(logFile, line) + } + app := fyne.CurrentApp() + if app != nil && app.Driver() != nil { + app.Driver().DoFromGoroutine(func() { + s.appendRipLog(line) + }, false) + } + } + updateProgress := func(percent float64) { + progressCallback(percent) + app := fyne.CurrentApp() + if app != nil && app.Driver() != nil { + app.Driver().DoFromGoroutine(func() { + s.setRipProgress(percent) + }, false) + } + } + + appendLog(fmt.Sprintf("Rip started: %s", time.Now().Format(time.RFC3339))) + appendLog(fmt.Sprintf("Source: %s", sourcePath)) + appendLog(fmt.Sprintf("Output: %s", outputPath)) + appendLog(fmt.Sprintf("Format: %s", format)) + + videoTSPath, cleanup, err := resolveVideoTSPath(sourcePath) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } + + sets, err := collectVOBSets(videoTSPath) + if err != nil { + return err + } + if len(sets) == 0 { + return fmt.Errorf("no VOB files found in VIDEO_TS") + } + + set := sets[0] + appendLog(fmt.Sprintf("Using title set: %s", set.Name)) + listFile, err := buildConcatList(set.Files) + if err != nil { + return err + } + defer os.Remove(listFile) + + args := buildRipFFmpegArgs(listFile, outputPath, format) + appendLog(fmt.Sprintf(">> ffmpeg %s", strings.Join(args, " "))) + updateProgress(10) + if err := runCommandWithLogger(ctx, platformConfig.FFmpegPath, args, appendLog); err != nil { + return err + } + updateProgress(100) + appendLog("Rip completed successfully.") + return nil +} + +func defaultRipOutputPath(sourcePath, format string) string { + if sourcePath == "" { + return "" + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + home = "." + } + baseDir := filepath.Join(home, "Videos", "DVD_Rips") + name := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath)) + if strings.EqualFold(name, "video_ts") { + name = filepath.Base(filepath.Dir(sourcePath)) + } + name = sanitizeForPath(name) + if name == "" { + name = "dvd_rip" + } + ext := ".mkv" + if format == ripFormatH264MP4 { + ext = ".mp4" + } + return uniqueFilePath(filepath.Join(baseDir, name+ext)) +} + +func createRipLog(inputPath, outputPath, format string) (*os.File, string, error) { + base := strings.TrimSuffix(filepath.Base(outputPath), filepath.Ext(outputPath)) + if base == "" { + base = "rip" + } + logPath := filepath.Join(getLogsDir(), base+"-rip"+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 Rip Log +Started: %s +Source: %s +Output: %s +Format: %s + +`, time.Now().Format(time.RFC3339), inputPath, outputPath, format) + if _, err := f.WriteString(header); err != nil { + _ = f.Close() + return nil, logPath, err + } + return f, logPath, nil +} + +func resolveVideoTSPath(path string) (string, func(), error) { + info, err := os.Stat(path) + if err != nil { + return "", nil, fmt.Errorf("source not found: %w", err) + } + if info.IsDir() { + if strings.EqualFold(filepath.Base(path), "VIDEO_TS") { + return path, nil, nil + } + videoTS := filepath.Join(path, "VIDEO_TS") + if info, err := os.Stat(videoTS); err == nil && info.IsDir() { + return videoTS, nil, nil + } + return "", nil, fmt.Errorf("no VIDEO_TS folder found in %s", path) + } + if strings.HasSuffix(strings.ToLower(path), ".iso") { + tempDir, err := os.MkdirTemp(utils.TempDir(), "videotools-iso-") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp dir: %w", err) + } + cleanup := func() { + _ = os.RemoveAll(tempDir) + } + tool, args, err := buildISOExtractCommand(path, tempDir) + if err != nil { + cleanup() + return "", nil, err + } + if err := runCommandWithLogger(context.Background(), tool, args, nil); err != nil { + cleanup() + return "", nil, err + } + videoTS := filepath.Join(tempDir, "VIDEO_TS") + if info, err := os.Stat(videoTS); err == nil && info.IsDir() { + return videoTS, cleanup, nil + } + cleanup() + return "", nil, fmt.Errorf("VIDEO_TS not found in ISO") + } + return "", nil, fmt.Errorf("unsupported source: %s", path) +} + +func buildISOExtractCommand(isoPath, destDir string) (string, []string, error) { + if _, err := exec.LookPath("xorriso"); err == nil { + return "xorriso", []string{"-osirrox", "on", "-indev", isoPath, "-extract", "/VIDEO_TS", destDir}, nil + } + if _, err := exec.LookPath("bsdtar"); err == nil { + return "bsdtar", []string{"-C", destDir, "-xf", isoPath, "VIDEO_TS"}, nil + } + return "", nil, fmt.Errorf("no ISO extraction tool found (install xorriso or bsdtar)") +} + +type vobSet struct { + Name string + Files []string + Size int64 +} + +func collectVOBSets(videoTS string) ([]vobSet, error) { + entries, err := os.ReadDir(videoTS) + if err != nil { + return nil, fmt.Errorf("read VIDEO_TS: %w", err) + } + sets := map[string]*vobSet{} + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".vob") { + continue + } + if !strings.HasPrefix(strings.ToUpper(name), "VTS_") { + continue + } + parts := strings.Split(strings.TrimSuffix(name, ".VOB"), "_") + if len(parts) < 3 { + continue + } + setKey := strings.Join(parts[:2], "_") + if sets[setKey] == nil { + sets[setKey] = &vobSet{Name: setKey} + } + full := filepath.Join(videoTS, name) + info, err := os.Stat(full) + if err != nil { + continue + } + sets[setKey].Files = append(sets[setKey].Files, full) + sets[setKey].Size += info.Size() + } + var result []vobSet + for _, set := range sets { + sort.Strings(set.Files) + result = append(result, *set) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Size > result[j].Size + }) + return result, nil +} + +func buildConcatList(files []string) (string, error) { + if len(files) == 0 { + return "", fmt.Errorf("no VOB files to concatenate") + } + listFile, err := os.CreateTemp(utils.TempDir(), "vt-rip-list-*.txt") + if err != nil { + return "", err + } + writer := bufio.NewWriter(listFile) + for _, f := range files { + fmt.Fprintf(writer, "file '%s'\n", strings.ReplaceAll(f, "'", "'\\''")) + } + _ = writer.Flush() + _ = listFile.Close() + return listFile.Name(), nil +} + +func buildRipFFmpegArgs(listFile, outputPath, format string) []string { + args := []string{ + "-y", + "-hide_banner", + "-loglevel", "error", + "-f", "concat", + "-safe", "0", + "-i", listFile, + } + switch format { + case ripFormatH264MKV: + args = append(args, + "-c:v", "libx264", + "-crf", "18", + "-preset", "medium", + "-c:a", "copy", + ) + case ripFormatH264MP4: + args = append(args, + "-c:v", "libx264", + "-crf", "18", + "-preset", "medium", + "-c:a", "aac", + "-b:a", "192k", + ) + default: + args = append(args, "-c", "copy") + } + args = append(args, outputPath) + return args +} + +func firstLocalPath(items []fyne.URI) string { + for _, uri := range items { + if uri.Scheme() == "file" { + return uri.Path() + } + } + return "" +} + +func (s *appState) resetRipLog() { + s.ripLogText = "" + if s.ripLogEntry != nil { + s.ripLogEntry.SetText("") + } + if s.ripLogScroll != nil { + s.ripLogScroll.ScrollToTop() + } +} + +func (s *appState) appendRipLog(line string) { + if strings.TrimSpace(line) == "" { + return + } + s.ripLogText += line + "\n" + if s.ripLogEntry != nil { + s.ripLogEntry.SetText(s.ripLogText) + } + if s.ripLogScroll != nil { + s.ripLogScroll.ScrollToBottom() + } +} + +func (s *appState) setRipStatus(text string) { + if text == "" { + text = "Ready" + } + if s.ripStatusLabel != nil { + s.ripStatusLabel.SetText(text) + } +} + +func (s *appState) setRipProgress(percent float64) { + if percent < 0 { + percent = 0 + } + if percent > 100 { + percent = 100 + } + s.ripProgress = percent + if s.ripProgressBar != nil { + s.ripProgressBar.SetValue(percent / 100.0) + } +}