package main import ( "bufio" "context" "encoding/json" "errors" "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)" ) type ripConfig struct { Format string `json:"format"` } func defaultRipConfig() ripConfig { return ripConfig{ Format: ripFormatLosslessMKV, } } func loadPersistedRipConfig() (ripConfig, error) { var cfg ripConfig path := moduleConfigPath("rip") data, err := os.ReadFile(path) if err != nil { return cfg, err } if err := json.Unmarshal(data, &cfg); err != nil { return cfg, err } if cfg.Format == "" { cfg.Format = ripFormatLosslessMKV } return cfg, nil } func savePersistedRipConfig(cfg ripConfig) error { path := moduleConfigPath("rip") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0o644) } func (s *appState) applyRipConfig(cfg ripConfig) { s.ripFormat = cfg.Format } func (s *appState) persistRipConfig() { cfg := ripConfig{ Format: s.ripFormat, } if err := savePersistedRipConfig(cfg); err != nil { logging.Debug(logging.CatSystem, "failed to persist rip config: %v", err) } } func (s *appState) showRipView() { s.stopPreview() s.lastModule = s.active s.active = "rip" if cfg, err := loadPersistedRipConfig(); err == nil { s.applyRipConfig(cfg) } else if !errors.Is(err, os.ErrNotExist) { logging.Debug(logging.CatSystem, "failed to load persisted rip config: %v", err) } 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) state.persistRipConfig() }) 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 applyControls := func() { formatSelect.SetSelected(state.ripFormat) outputEntry.SetText(state.ripOutputPath) } loadCfgBtn := widget.NewButton("Load Config", func() { cfg, err := loadPersistedRipConfig() if err != nil { if errors.Is(err, os.ErrNotExist) { dialog.ShowInformation("No Config", "No saved config found yet. It will save automatically after your first change.", state.window) } else { dialog.ShowError(fmt.Errorf("failed to load config: %w", err), state.window) } return } state.applyRipConfig(cfg) state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat) applyControls() }) saveCfgBtn := widget.NewButton("Save Config", func() { cfg := ripConfig{ Format: state.ripFormat, } if err := savePersistedRipConfig(cfg); err != nil { dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window) return } dialog.ShowInformation("Config Saved", fmt.Sprintf("Saved to %s", moduleConfigPath("rip")), state.window) }) resetBtn := widget.NewButton("Reset", func() { cfg := defaultRipConfig() state.applyRipConfig(cfg) state.ripOutputPath = defaultRipOutputPath(state.ripSourcePath, state.ripFormat) applyControls() state.persistRipConfig() }) 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(), container.NewHBox(resetBtn, loadCfgBtn, saveCfgBtn), 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) } }