277 lines
7.3 KiB
Go
277 lines
7.3 KiB
Go
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
|
|
}
|