From 90f97e0d14a95cc8407e3077414c3c9b6ea06357 Mon Sep 17 00:00:00 2001 From: York Date: Sat, 20 Sep 2025 13:46:30 +0800 Subject: [PATCH] Add post-download conversion feature --- config.yaml | 7 +++ main.go | 119 +++++++++++++++++++++++++++++++++++++++ utils/structs/structs.go | 7 +++ 3 files changed, 133 insertions(+) diff --git a/config.yaml b/config.yaml index 33ca5c2..f748af7 100644 --- a/config.yaml +++ b/config.yaml @@ -50,3 +50,10 @@ mv-max: 2160 # if your account is from Japan, you must use jp. # if the storefront is different from your account, you will see a "failed to get lyrics" error in most of the songs. By default the storefront is set to US if not set. storefront: "enter your account storefront" +# Conversion settings +convert-after-download: false # Enable post-download conversion (requires ffmpeg) +convert-format: "flac" # flac | mp3 | opus | wav | copy (no re-encode) +convert-keep-original: false # Keep original file after successful conversion +convert-skip-if-source-matches: true # If already in target format, skip +ffmpeg-path: "ffmpeg" # Override if ffmpeg is not in PATH +convert-extra-args: "" # Additional raw args appended (advanced) diff --git a/main.go b/main.go index 6ec333a..955298b 100644 --- a/main.go +++ b/main.go @@ -635,6 +635,121 @@ func handleSearch(searchType string, queryParts []string, token string) (string, // END: New functions for search functionality +// CONVERSION FEATURE: Determine if source codec is lossy (rough heuristic by extension/codec name). +func isLossySource(ext string, codec string) bool { + ext = strings.ToLower(ext) + if ext == ".m4a" && (codec == "AAC" || strings.Contains(codec, "AAC") || strings.Contains(codec, "ATMOS")) { + return true + } + if ext == ".mp3" || ext == ".opus" || ext == ".ogg" { + return true + } + return false +} + +// CONVERSION FEATURE: Build ffmpeg arguments for desired target. +func buildFFmpegArgs(ffmpegPath, inPath, outPath, targetFmt, extraArgs string) ([]string, error) { + args := []string{"-y", "-i", inPath, "-vn"} + switch targetFmt { + case "flac": + args = append(args, "-c:a", "flac") + case "mp3": + // VBR quality 2 ~ high quality + args = append(args, "-c:a", "libmp3lame", "-qscale:a", "2") + case "opus": + // Medium/high quality + args = append(args, "-c:a", "libopus", "-b:a", "192k", "-vbr", "on") + case "wav": + args = append(args, "-c:a", "pcm_s16le") + case "copy": + // Just container copy (probably pointless for same container) + args = append(args, "-c", "copy") + default: + return nil, fmt.Errorf("unsupported convert-format: %s", targetFmt) + } + if extraArgs != "" { + // naive split; for complex quoting you could enhance + args = append(args, strings.Fields(extraArgs)...) + } + args = append(args, outPath) + return args, nil +} + +// CONVERSION FEATURE: Perform conversion if enabled. +func convertIfNeeded(track *task.Track) { + if !Config.ConvertAfterDownload { + return + } + if Config.ConvertFormat == "" { + return + } + srcPath := track.SavePath + if srcPath == "" { + return + } + ext := strings.ToLower(filepath.Ext(srcPath)) + targetFmt := strings.ToLower(Config.ConvertFormat) + + // Map extension for output + if targetFmt == "copy" { + fmt.Println("Convert (copy) requested; skipping because it produces no new format.") + return + } + + if Config.ConvertSkipIfSourceMatch { + if ext == "."+targetFmt { + fmt.Printf("Conversion skipped (already %s)\n", targetFmt) + return + } + } + + outBase := strings.TrimSuffix(srcPath, ext) + outPath := outBase + "." + targetFmt + + // Warn about lossy -> lossless + if Config.ConvertWarnLossyToLossless && (targetFmt == "flac" || targetFmt == "wav") && + isLossySource(ext, track.Codec) { + fmt.Println("Warning: Converting lossy source to lossless container will not improve quality.") + } + + if _, err := exec.LookPath(Config.FFmpegPath); err != nil { + fmt.Printf("ffmpeg not found at '%s'; skipping conversion.\n", Config.FFmpegPath) + return + } + + args, err := buildFFmpegArgs(Config.FFmpegPath, srcPath, outPath, targetFmt, Config.ConvertExtraArgs) + if err != nil { + fmt.Println("Conversion config error:", err) + return + } + + fmt.Printf("Converting -> %s ...\n", targetFmt) + cmd := exec.Command(Config.FFmpegPath, args...) + cmd.Stdout = nil + cmd.Stderr = nil + start := time.Now() + if err := cmd.Run(); err != nil { + fmt.Println("Conversion failed:", err) + // leave original + return + } + fmt.Printf("Conversion completed in %s: %s\n", time.Since(start).Truncate(time.Millisecond), filepath.Base(outPath)) + + if !Config.ConvertKeepOriginal { + if err := os.Remove(srcPath); err != nil { + fmt.Println("Failed to remove original after conversion:", err) + } else { + track.SavePath = outPath + track.SaveName = filepath.Base(outPath) + fmt.Println("Original removed.") + } + } else { + // Keep both but point track to new file (optional decision) + track.SavePath = outPath + track.SaveName = filepath.Base(outPath) + } +} + func ripTrack(track *task.Track, token string, mediaUserToken string) { var err error counter.Total++ @@ -837,6 +952,10 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { counter.Unavailable++ return } + + // CONVERSION FEATURE hook + convertIfNeeded(track) + counter.Success++ okDict[track.PreID] = append(okDict[track.PreID], track.TaskNum) } diff --git a/utils/structs/structs.go b/utils/structs/structs.go index f882428..168f391 100644 --- a/utils/structs/structs.go +++ b/utils/structs/structs.go @@ -38,6 +38,13 @@ type ConfigSet struct { DlAlbumcoverForPlaylist bool `yaml:"dl-albumcover-for-playlist"` MVAudioType string `yaml:"mv-audio-type"` MVMax int `yaml:"mv-max"` + ConvertAfterDownload bool `yaml:"convert-after-download"` + ConvertFormat string `yaml:"convert-format"` + ConvertKeepOriginal bool `yaml:"convert-keep-original"` + ConvertSkipIfSourceMatch bool `yaml:"convert-skip-if-source-matches"` + FFmpegPath string `yaml:"ffmpeg-path"` + ConvertExtraArgs string `yaml:"convert-extra-args"` + ConvertWarnLossyToLossless bool `yaml:"convert-warn-lossy-to-lossless"` } type Counter struct {