修复了在Windows上使用Web访问出现找不到/tmp文件的错误,现在全部都流式放到内存当中了。
并且支持本地自签名部署了
This commit is contained in:
+239
-29
@@ -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-*")
|
||||
switch format {
|
||||
case Mp3:
|
||||
return embedMP3(audio, title, artist, album, coverData)
|
||||
case Flac:
|
||||
return embedFLAC(audio, title, artist, album, coverData)
|
||||
default:
|
||||
return audio, 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("create temp file: %w", err)
|
||||
}
|
||||
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,
|
||||
},
|
||||
return nil, fmt.Errorf("parse FLAC: %w", err)
|
||||
}
|
||||
|
||||
if _, err := ncm.FixMetadata(false); err != nil {
|
||||
return nil, fmt.Errorf("write metadata: %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
|
||||
}
|
||||
}
|
||||
|
||||
result, err := os.ReadFile(tmpPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read result: %w", err)
|
||||
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))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
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
|
||||
}
|
||||
|
||||
+66
-31
@@ -13,8 +13,10 @@ export default function App() {
|
||||
const [outputHandle, setOutputHandle] = useState<any>(null)
|
||||
const [fileItems, setFileItems] = useState<FileItem[]>([])
|
||||
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,36 +49,22 @@ export default function App() {
|
||||
setOutputHandle(handle)
|
||||
}, [])
|
||||
|
||||
const handleConvert = useCallback(async () => {
|
||||
if (!outputHandle || inputFiles.size === 0 || !wasmReady) return
|
||||
|
||||
abortRef.current = false
|
||||
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
|
||||
|
||||
const entry = entries[i]
|
||||
|
||||
const setStatus = (status: FileItem['status'], extra?: Partial<FileItem>) =>
|
||||
setFileItems((prev) => prev.map((item, idx) =>
|
||||
idx === i ? { ...item, status, ...extra } : item
|
||||
))
|
||||
|
||||
setStatus('converting')
|
||||
const processOne = useCallback(async (
|
||||
entry: { file: File; name: string },
|
||||
idx: number,
|
||||
updateStatus: (status: FileItem['status'], extra?: Partial<FileItem>) => void
|
||||
) => {
|
||||
if (abortRef.current) return
|
||||
|
||||
try {
|
||||
updateStatus('converting')
|
||||
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')
|
||||
updateStatus('fetching_cover')
|
||||
try {
|
||||
const resp = await fetch(result.coverUrl)
|
||||
if (resp.ok) {
|
||||
@@ -84,21 +72,18 @@ export default function App() {
|
||||
coverData = new Uint8Array(coverBuf)
|
||||
}
|
||||
} catch {
|
||||
// cover fetch failed, proceed without it
|
||||
// proceed without cover
|
||||
}
|
||||
}
|
||||
|
||||
// Embed metadata + cover
|
||||
updateStatus('writing')
|
||||
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, '')
|
||||
@@ -109,16 +94,52 @@ export default function App() {
|
||||
await writable.write(audio)
|
||||
await writable.close()
|
||||
|
||||
setStatus('done', { format: result.format })
|
||||
updateStatus('done', { format: result.format })
|
||||
} catch (e: any) {
|
||||
setStatus('error', { error: e.message })
|
||||
if (!abortRef.current) {
|
||||
updateStatus('error', { error: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
setProgress({ done: i + 1, total: entries.length })
|
||||
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())
|
||||
|
||||
let nextIdx = 0
|
||||
const workers: Promise<void>[] = []
|
||||
|
||||
const makeWorker = async () => {
|
||||
while (true) {
|
||||
const i = nextIdx++
|
||||
if (i >= entries.length || abortRef.current) break
|
||||
|
||||
const setStatus = (status: FileItem['status'], extra?: Partial<FileItem>) =>
|
||||
setFileItems((prev) => prev.map((item, idx) =>
|
||||
idx === i ? { ...item, status, ...extra } : item
|
||||
))
|
||||
|
||||
await processOne(entries[i], i, setStatus)
|
||||
}
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div className="mt-3 animate-fade-in flex items-center gap-3">
|
||||
<StatusBar progress={progress} />
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-slate-400">
|
||||
<span>并行</span>
|
||||
<select
|
||||
value={concurrency}
|
||||
onChange={(e) => setConcurrency(Number(e.target.value))}
|
||||
disabled={converting}
|
||||
className="rounded-lg bg-slate-800 border border-slate-700 px-2 py-2 text-slate-200
|
||||
focus:outline-none focus:border-indigo-500 disabled:opacity-40"
|
||||
>
|
||||
{[1, 2, 3, 4, 6, 8, 12, 16, 24, 32].map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
disabled={!canConvert}
|
||||
|
||||
Reference in New Issue
Block a user