package music import ( "bytes" "errors" "fmt" "io" "os" "path/filepath" "strings" "jsuse.com/dev/dec-music/decrypt" "jsuse.com/dev/dec-music/internal/fileutil" "jsuse.com/dev/dec-music/internal/sniff" "jsuse.com/dev/dec-music/log" _ "jsuse.com/dev/dec-music/decrypt/kgm" _ "jsuse.com/dev/dec-music/decrypt/kwm" _ "jsuse.com/dev/dec-music/decrypt/ncm" _ "jsuse.com/dev/dec-music/decrypt/qmc" _ "jsuse.com/dev/dec-music/decrypt/tm" _ "jsuse.com/dev/dec-music/decrypt/xiami" _ "jsuse.com/dev/dec-music/decrypt/ximalaya" ) const ( // ErrNoDecoder is returned when no decoder can handle the file. ErrNoDecoder = "no suitable decoder" ) var defaultLogger = log.New() // OnProcess is called after each file is processed (err empty on success). type OnProcess func(src, dst, err string) // DecryptFile decrypts a single file into outputDir. func DecryptFile(inputFile, outputDir string, noSubOutput bool, onProcess OnProcess) error { inputDir, err := filepath.Abs(filepath.Dir(inputFile)) if err != nil { return err } outDir, err := filepath.Abs(outputDir) if err != nil { return err } return decryptProcessFile(inputFile, outDir, inputDir, noSubOutput, false, false, onProcess) } func decryptProcessFile(inputFile, outputDir, inputDir string, noSubOutput, skipNoop, overwrite bool, onProcess OnProcess) error { proc := &processor{ onProcess: onProcess, logger: defaultLogger, inputDir: inputDir, outputDir: outputDir, singleOutput: noSubOutput, skipNoopDecoder: skipNoop, overwriteOutput: overwrite, } return proc.processFile(inputFile) } // DecryptProcess decrypts encrypted media under inputDir into outputDir. // When noSubOutput is false, relative subdirectory structure is preserved. func DecryptProcess(inputDir, outputDir string, noSubOutput bool, onProcess OnProcess) error { return decryptProcessEx(inputDir, outputDir, noSubOutput, false, false, onProcess) } // DecryptProcessCLI is like DecryptProcess but accepts skip-noop and overwrite (CLI helper). func DecryptProcessCLI(inputDir, outputDir string, noSubOutput, skipNoop, overwrite bool) error { return decryptProcessEx(inputDir, outputDir, noSubOutput, skipNoop, overwrite, nil) } // DecryptFileCLI decrypts one file (CLI helper). func DecryptFileCLI(inputFile, outputDir string, noSubOutput, skipNoop, overwrite bool) error { inputDir, err := filepath.Abs(filepath.Dir(inputFile)) if err != nil { return err } outDir, err := filepath.Abs(outputDir) if err != nil { return err } return decryptProcessFile(inputFile, outDir, inputDir, noSubOutput, skipNoop, overwrite, nil) } func decryptProcessEx(inputDir, outputDir string, noSubOutput, skipNoop, overwrite bool, onProcess OnProcess) error { proc := &processor{ onProcess: onProcess, logger: defaultLogger, inputDir: inputDir, outputDir: outputDir, singleOutput: noSubOutput, skipNoopDecoder: skipNoop, overwriteOutput: overwrite, } return proc.processDir(inputDir) } type processor struct { onProcess OnProcess logger *log.Logger inputDir string outputDir string skipNoopDecoder bool overwriteOutput bool singleOutput bool } func (p *processor) processDir(inputDir string) error { items, err := os.ReadDir(inputDir) if err != nil { return err } var lastError error for _, item := range items { if strings.EqualFold(item.Name(), "temp") { continue } filePath := filepath.Join(inputDir, item.Name()) if item.IsDir() { if err = p.processDir(filePath); err != nil { lastError = err } continue } if !fileutil.IsMediaFile(item.Name()) { continue } if err = p.processFile(filePath); err != nil { lastError = err if p.logger != nil { p.logger.Error("conversion failed:", item.Name(), err) } } } if lastError != nil { return fmt.Errorf("last error: %w", lastError) } return nil } func (p *processor) processFile(filePath string) error { factories := decrypt.GetDecoder(filePath, p.skipNoopDecoder) if len(factories) == 0 { return errors.New("skipping while no suitable decoder") } if err := p.process(filePath, factories); err != nil { if err.Error() == ErrNoDecoder { // Fallback: copy as-is when no decoder matches (keeps original extension). fallbackDst := filepath.Join(p.outputDir, filepath.Base(filePath)) copyErr := p.copyFile(filePath, fallbackDst) if copyErr == nil { p.logResult(filePath, fallbackDst) } return copyErr } return err } return nil } func (p *processor) logResult(inputFile, outPath string) { inputRelDir, err := filepath.Rel(p.inputDir, filepath.Dir(inputFile)) dstName := filepath.Base(outPath) if !p.singleOutput { dstName = filepath.Join(inputRelDir, filepath.Base(outPath)) } srcName := filepath.Join(inputRelDir, filepath.Base(inputFile)) if err != nil { if p.onProcess != nil { p.onProcess(srcName, dstName, err.Error()) } return } if p.onProcess != nil { p.onProcess(srcName, dstName, "") } else { log.PrintSuccess(srcName, dstName) } } func (p *processor) copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() destFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { return err } defer destFile.Close() _, err = io.Copy(destFile, sourceFile) return err } func (p *processor) findDecoder(factories []decrypt.Factory, params *decrypt.DecoderParams) (decrypt.Decoder, *decrypt.Factory, error) { for i := range factories { factory := &factories[i] dec := factory.Create(params) if err := dec.Validate(); err == nil { return dec, factory, nil } } return nil, nil, errors.New("no any decoder can resolve the file") } func (p *processor) process(inputFile string, factories []decrypt.Factory) error { file, err := os.Open(inputFile) if err != nil { return err } defer file.Close() params := &decrypt.DecoderParams{ Reader: file, Extension: filepath.Ext(inputFile), FilePath: inputFile, Logger: p.logger, } dec, factory, err := p.findDecoder(factories, params) if err != nil { return errors.New(ErrNoDecoder) } header := bytes.NewBuffer(nil) if _, err = io.CopyN(header, dec, 64); err != nil { return fmt.Errorf("read header failed: %w", err) } audio := io.MultiReader(header, dec) audioExt := sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3") inputRelDir, err := filepath.Rel(p.inputDir, filepath.Dir(inputFile)) if err != nil { return fmt.Errorf("get relative dir failed: %w", err) } inFilename := strings.TrimSuffix(filepath.Base(inputFile), factory.Suffix) outFolder := p.outputDir if !p.singleOutput { outFolder = filepath.Join(p.outputDir, inputRelDir) } if err = os.MkdirAll(outFolder, os.ModePerm); err != nil { return err } outPath := filepath.Join(outFolder, inFilename+audioExt) if !p.overwriteOutput { if _, err := os.Stat(outPath); err == nil { if p.logger != nil { p.logger.Info("output already exists, skipped:", outPath) } return nil } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("stat output file failed: %w", err) } } outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { return err } defer outFile.Close() if _, err = io.Copy(outFile, audio); err != nil { return err } p.logResult(inputFile, outPath) return nil }