init: dec-music 项目初始化
This commit is contained in:
+276
@@ -0,0 +1,276 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user