From e84c52d35ee5d833e5c013b7761692e399898e98 Mon Sep 17 00:00:00 2001 From: Misaki Date: Mon, 8 Jun 2026 23:49:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E5=9C=A8Windows?= =?UTF-8?q?=E4=B8=8A=E4=BD=BF=E7=94=A8Web=E8=AE=BF=E9=97=AE=E5=87=BA?= =?UTF-8?q?=E7=8E=B0=E6=89=BE=E4=B8=8D=E5=88=B0/tmp=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=E9=94=99=E8=AF=AF=EF=BC=8C=E7=8E=B0=E5=9C=A8=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E9=83=BD=E6=B5=81=E5=BC=8F=E6=94=BE=E5=88=B0=E5=86=85?= =?UTF-8?q?=E5=AD=98=E5=BD=93=E4=B8=AD=E4=BA=86=E3=80=82=20=E5=B9=B6?= =?UTF-8?q?=E4=B8=94=E6=94=AF=E6=8C=81=E6=9C=AC=E5=9C=B0=E8=87=AA=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E9=83=A8=E7=BD=B2=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ncmcrypt/embed.go | 280 ++++++++++++++++++++++++++++++++++++++++------ web/src/App.tsx | 147 ++++++++++++++---------- 2 files changed, 336 insertions(+), 91 deletions(-) diff --git a/ncmcrypt/embed.go b/ncmcrypt/embed.go index 3d2510c..7a87eff 100644 --- a/ncmcrypt/embed.go +++ b/ncmcrypt/embed.go @@ -1,44 +1,254 @@ package ncmcrypt import ( + "bytes" + "encoding/binary" "fmt" - "os" + + "github.com/go-flac/go-flac" ) func EmbedMetadata(audio []byte, format NcmFormat, title, artist, album string, coverData []byte) ([]byte, error) { - tmpFile, err := os.CreateTemp("", "ncmdump-embed-*") - if err != nil { - return nil, fmt.Errorf("create temp file: %w", err) + switch format { + case Mp3: + return embedMP3(audio, title, artist, album, coverData) + case Flac: + return embedFLAC(audio, title, artist, album, coverData) + default: + return audio, nil } - tmpPath := tmpFile.Name() - defer os.Remove(tmpPath) - - if _, err := tmpFile.Write(audio); err != nil { - tmpFile.Close() - return nil, fmt.Errorf("write temp: %w", err) - } - tmpFile.Close() - - ncm := &NeteaseCloudMusic{ - mPng: [8]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, - mFormat: format, - mDumpFilePath: tmpPath, - mImageData: coverData, - mMetadata: &NeteaseClousMusicMetadata{ - mName: title, - mArtist: artist, - mAlbum: album, - }, - } - - if _, err := ncm.FixMetadata(false); err != nil { - return nil, fmt.Errorf("write metadata: %w", err) - } - - result, err := os.ReadFile(tmpPath) - if err != nil { - return nil, fmt.Errorf("read result: %w", err) - } - - return result, nil +} + +func synchSafe(v int) []byte { + b := make([]byte, 4) + b[0] = byte((v >> 21) & 0x7F) + b[1] = byte((v >> 14) & 0x7F) + b[2] = byte((v >> 7) & 0x7F) + b[3] = byte(v & 0x7F) + return b +} + +func textFrame(id string, text string) []byte { + if text == "" { + return nil + } + data := append([]byte{0x03}, []byte(text)...) + size := len(data) + frame := make([]byte, 10+size) + copy(frame[0:4], id) + copy(frame[4:8], synchSafe(size)) + frame[8] = 0x00 + frame[9] = 0x00 + copy(frame[10:], data) + return frame +} + +func apicFrame(mime string, picType byte, description string, data []byte) []byte { + if len(data) == 0 { + return nil + } + inner := make([]byte, 0, 1+len(mime)+1+1+len(description)+1+len(data)) + inner = append(inner, 0x00) + inner = append(inner, []byte(mime)...) + inner = append(inner, 0x00) + inner = append(inner, picType) + inner = append(inner, []byte(description)...) + inner = append(inner, 0x00) + inner = append(inner, data...) + + size := len(inner) + frame := make([]byte, 10+size) + copy(frame[0:4], "APIC") + copy(frame[4:8], synchSafe(size)) + frame[8] = 0x00 + frame[9] = 0x00 + copy(frame[10:], inner) + return frame +} + +func findMpegSync(data []byte) int { + for i := 0; i < len(data)-1; i++ { + if data[i] == 0xFF && (data[i+1]&0xE0) == 0xE0 { + return i + } + } + return len(data) +} + +func skipID3v2(data []byte) int { + if len(data) >= 10 && string(data[0:3]) == "ID3" { + size := int(data[6])<<21 | int(data[7])<<14 | int(data[8])<<7 | int(data[9]) + return 10 + size + } + return 0 +} + +func imageMime(data []byte) string { + if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + return "image/png" + } + return "image/jpeg" +} + +func embedMP3(audio []byte, title, artist, album string, coverData []byte) ([]byte, error) { + id3End := skipID3v2(audio) + frames := bytes.NewBuffer(nil) + + if f := textFrame("TIT2", title); f != nil { + frames.Write(f) + } + if f := textFrame("TPE1", artist); f != nil { + frames.Write(f) + } + if f := textFrame("TALB", album); f != nil { + frames.Write(f) + } + if f := apicFrame(imageMime(coverData), 0x03, "", coverData); f != nil { + frames.Write(f) + } + + if frames.Len() == 0 { + return audio, nil + } + + body := frames.Bytes() + tagSize := len(body) + + tag := make([]byte, 10+tagSize) + copy(tag[0:3], "ID3") + tag[3] = 0x03 + tag[4] = 0x00 + tag[5] = 0x00 + copy(tag[6:10], synchSafe(tagSize)) + copy(tag[10:], body) + + out := make([]byte, 0, len(tag)+len(audio)-id3End) + out = append(out, tag...) + out = append(out, audio[id3End:]...) + + return out, nil +} + +func embedFLAC(audio []byte, title, artist, album string, coverData []byte) ([]byte, error) { + f, err := flac.ParseBytes(bytes.NewReader(audio)) + if err != nil { + return nil, fmt.Errorf("parse FLAC: %w", err) + } + + metaOffset := 4 + for _, block := range f.Meta { + metaOffset += 4 + len(block.Data) + } + audioFrames := audio[metaOffset:] + + var comments map[string]string + if title != "" || artist != "" || album != "" { + comments = make(map[string]string) + if title != "" { + comments["TITLE"] = title + } + if artist != "" { + comments["ARTIST"] = artist + } + if album != "" { + comments["ALBUM"] = album + } + } + + newMeta := make([]*flac.MetaDataBlock, 0, len(f.Meta)+2) + for _, block := range f.Meta { + if block.Type == flac.Picture || block.Type == flac.VorbisComment { + continue + } + newMeta = append(newMeta, block) + } + if comments != nil { + newMeta = append(newMeta, buildVorbisComment(comments)) + } + if len(coverData) > 0 { + newMeta = append(newMeta, buildPictureBlock(coverData)) + } + + var buf bytes.Buffer + buf.WriteString("fLaC") + for i, block := range newMeta { + header := byte(block.Type) + if i == len(newMeta)-1 { + header |= 0x80 + } + buf.WriteByte(header) + size := make([]byte, 3) + size[0] = byte(len(block.Data) >> 16) + size[1] = byte(len(block.Data) >> 8) + size[2] = byte(len(block.Data)) + buf.Write(size) + buf.Write(block.Data) + } + buf.Write(audioFrames) + + return buf.Bytes(), nil +} + +func buildVorbisComment(comments map[string]string) *flac.MetaDataBlock { + vendorLen := 0 + var vendor string + dataLen := 4 + vendorLen + 4 + for key, val := range comments { + dataLen += 4 + len(key) + 1 + len(val) + } + + data := make([]byte, dataLen) + pos := 0 + pos += putU32le(data[pos:], uint32(vendorLen)) + pos += copy(data[pos:], vendor) + pos += putU32le(data[pos:], uint32(len(comments))) + for key, val := range comments { + entry := key + "=" + val + pos += putU32le(data[pos:], uint32(len(entry))) + pos += copy(data[pos:], entry) + } + + return &flac.MetaDataBlock{ + Type: flac.VorbisComment, + Data: data, + } +} + +func buildPictureBlock(imageData []byte) *flac.MetaDataBlock { + mime := imageMime(imageData) + picType := uint32(3) + width := uint32(0) + height := uint32(0) + depth := uint32(24) + colors := uint32(0) + + dataLen := 4 + len(mime) + 4 + 4 + 4 + 4 + 4 + len(imageData) + data := make([]byte, dataLen) + pos := 0 + pos += putU32be(data[pos:], picType) + pos += putU32be(data[pos:], uint32(len(mime))) + pos += copy(data[pos:], mime) + pos += putU32be(data[pos:], uint32(len(""))) + pos += copy(data[pos:], "") + pos += putU32be(data[pos:], width) + pos += putU32be(data[pos:], height) + pos += putU32be(data[pos:], depth) + pos += putU32be(data[pos:], colors) + pos += putU32be(data[pos:], uint32(len(imageData))) + copy(data[pos:], imageData) + + return &flac.MetaDataBlock{ + Type: flac.Picture, + Data: data, + } +} + +func putU32le(b []byte, v uint32) int { + binary.LittleEndian.PutUint32(b, v) + return 4 +} + +func putU32be(b []byte, v uint32) int { + binary.BigEndian.PutUint32(b, v) + return 4 } diff --git a/web/src/App.tsx b/web/src/App.tsx index 7a0a4a8..f4c162a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,8 +13,10 @@ export default function App() { const [outputHandle, setOutputHandle] = useState(null) const [fileItems, setFileItems] = useState([]) const [converting, setConverting] = useState(false) + const [concurrency, setConcurrency] = useState(3) const [progress, setProgress] = useState({ done: 0, total: 0 }) const abortRef = useRef(false) + const doneCountRef = useRef(0) useEffect(() => { if (isWasmSupported()) { @@ -47,78 +49,97 @@ export default function App() { setOutputHandle(handle) }, []) + const processOne = useCallback(async ( + entry: { file: File; name: string }, + idx: number, + updateStatus: (status: FileItem['status'], extra?: Partial) => void + ) => { + if (abortRef.current) return + + try { + updateStatus('converting') + const buf = await entry.file.arrayBuffer() + const result = await convertNcm(buf) + let audio = result.data + + let coverData: Uint8Array | undefined + if (result.coverUrl) { + updateStatus('fetching_cover') + try { + const resp = await fetch(result.coverUrl) + if (resp.ok) { + const coverBuf = await resp.arrayBuffer() + coverData = new Uint8Array(coverBuf) + } + } catch { + // proceed without cover + } + } + + updateStatus('writing') + if (result.title || result.artist || result.album || coverData) { + audio = await embedMetadata( + audio, + result.format, + { title: result.title, artist: result.artist, album: result.album }, + coverData + ) + } + + const base = entry.name.replace(/\.ncm$/i, '') + const outName = `${base}.${result.format}` + + const fileHandle = await outputHandle.getFileHandle(outName, { create: true }) + const writable = await fileHandle.createWritable() + await writable.write(audio) + await writable.close() + + updateStatus('done', { format: result.format }) + } catch (e: any) { + if (!abortRef.current) { + updateStatus('error', { error: e.message }) + } + } + + doneCountRef.current++ + setProgress({ done: doneCountRef.current, total: inputFiles.size }) + }, [inputFiles, outputHandle]) + const handleConvert = useCallback(async () => { if (!outputHandle || inputFiles.size === 0 || !wasmReady) return abortRef.current = false + doneCountRef.current = 0 setConverting(true) setProgress({ done: 0, total: inputFiles.size }) const entries = Array.from(inputFiles.values()) - for (let i = 0; i < entries.length; i++) { - if (abortRef.current) break + let nextIdx = 0 + const workers: Promise[] = [] - const entry = entries[i] + const makeWorker = async () => { + while (true) { + const i = nextIdx++ + if (i >= entries.length || abortRef.current) break - const setStatus = (status: FileItem['status'], extra?: Partial) => - setFileItems((prev) => prev.map((item, idx) => - idx === i ? { ...item, status, ...extra } : item - )) + const setStatus = (status: FileItem['status'], extra?: Partial) => + setFileItems((prev) => prev.map((item, idx) => + idx === i ? { ...item, status, ...extra } : item + )) - setStatus('converting') - - try { - const buf = await entry.file.arrayBuffer() - const result = await convertNcm(buf) - let audio = result.data - - // Fetch cover image if available - let coverData: Uint8Array | undefined - if (result.coverUrl) { - setStatus('fetching_cover') - try { - const resp = await fetch(result.coverUrl) - if (resp.ok) { - const coverBuf = await resp.arrayBuffer() - coverData = new Uint8Array(coverBuf) - } - } catch { - // cover fetch failed, proceed without it - } - } - - // Embed metadata + cover - if (result.title || result.artist || result.album || coverData) { - setStatus('writing') - audio = await embedMetadata( - audio, - result.format, - { title: result.title, artist: result.artist, album: result.album }, - coverData - ) - } else { - setStatus('writing') - } - - const base = entry.name.replace(/\.ncm$/i, '') - const outName = `${base}.${result.format}` - - const fileHandle = await outputHandle.getFileHandle(outName, { create: true }) - const writable = await fileHandle.createWritable() - await writable.write(audio) - await writable.close() - - setStatus('done', { format: result.format }) - } catch (e: any) { - setStatus('error', { error: e.message }) + await processOne(entries[i], i, setStatus) } - - setProgress({ done: i + 1, total: entries.length }) } + const n = Math.min(concurrency, entries.length) + for (let w = 0; w < n; w++) { + workers.push(makeWorker()) + } + + await Promise.all(workers) setConverting(false) - }, [inputFiles, outputHandle, wasmReady]) + }, [inputFiles, outputHandle, wasmReady, processOne, concurrency]) const handleAbort = useCallback(() => { abortRef.current = true @@ -168,6 +189,20 @@ export default function App() { {inputFiles.size > 0 && (
+
+ 并行 + +