前言:
在使用网易云音乐的时候,我们可能会把自己喜欢听的音乐缓存到本地。
但是直接缓存是具有局限性的 - 因为直接从网易云中下载的音乐是 .ncm 格式的,会存在以下的问题:
- 可移植性差
- 操作困难
- 会员过期后无法继续播放(心痛
)
本文将从这几个方面:
- 什么是 NCM 文件?
- 转换思路
- 代码实现
讲述一下如何将网易云缓存格式的音乐转化成 .mp3格式的音乐。
什么是 NCM 文件?
网易云音乐的 .ncm 格式是一种专有的音频文件格式,一般情况下,它只能在网易云音乐客户端或 app 中播放。
转换思路:
整体思路:
- 读取 header
- 解密音频的 keyData
- 解密音频的 meta
- 获取 & 设置音频的封面图片
具体步骤:
- 首先:我们需要先指定 NCM 格式的文件路径(
sourcePath最好是绝对路径)、需要输出的 mp3 的路径 (outputPath) 和输出的音频封面图片的路径 (outputImgPath)
const { readFileSync, writeFileSync } = require('fs');
const { createDecipheriv } = require('crypto');
/**
* @function outputMp3ByNCM
* @description 网易云音乐转码
* @param {string} sourcePath ncm 文件路径
* @param {string} outputPath 输出的 mp3 路径 (记得要以 mp3 结尾)
* @return {void} 没有返回值
*/
function outputMp3ByNCM(
sourcePath,
outputPath,
outputImgPath
) {
// do something ...
}
- 从
sourcePath里面读取出.ncm文件的 buffer :
const content = readFileSync(sourcePath);
const buff = Buffer.from(content);
- 读取
.ncm文件的 magic header, 具体步骤是:读取前面的 8 字节:
let start = 0;
let temp = buff.slice(start, start + 8);
start += 10; // 空余 2 字节
let header = Buffer.from([0x43, 0x54, 0x45, 0x4E, 0x46, 0x44, 0x41, 0x4D]);
if (!header.equals(temp)) {
throw new Error('文件头已损坏');
}
- 读取 32 位,每 4 个字节的密钥的真实长度:
const keyLength = buff.readUInt32LE(start);
start += 4;
- 根据密钥长度,读取密钥中对应长度的内容
temp = buff.slice(start, start + keyLength);
let cipherText = temp.map(t => {
return t ^ 0x64;
});
start += keyLength;
- 获取解密的 key
const key = Buffer.from('687a4852416d736f356b496e62617857', 'hex');
- 使用第6步的
key来处理第5步的cipherText,解密的方式是:aes-128-ecb解密
let decipher = createDecipheriv('aes-128-ecb', key, '');
let decodeText = decipher.update(cipherText);
decodeText += decipher.final();
- 使用
decodeText获取对应的keyData的 buffer:
let keyData = decodeText.substr(17).trim();
let key2Len = keyData.length;
keyData = Buffer.from(keyData);
- 将
keyData做RC4-KSA算法解密:
let keyBox = Buffer.alloc(256);
for (let i = 0; i < keyBox.length; i++) {
keyBox[i] = i;
}
let j = 0;
for (let i = 0; i < 256; i++) {
j = (keyBox[i] + j + keyData[i % key2Len]) & 0xff;
[keyBox[i], keyBox[j]] = [keyBox[j], keyBox[i]];
}
- 读取 meta 的长度
metaLen和 meta 的内容metaContent:
// 读取4字节获取meta 长度
let metaLen = buff.readUInt32LE(start);
start += 4;
// 读取 meta 内容
let metaContent = buff.slice(start, start + metaLen);
metaContent = metaContent.map((t) => t ^ 0x63);
- 去掉 22位 163 key (Don't modify):
metaContent = metaContent.toString();
metaContent = metaContent.substr(22);
metaContent = Buffer.from(metaContent, 'base64');
- 解密 meta 的内容
// 解密 meta 内容
const metaKey = Buffer.from("2331346C6A6B5F215C5D2630553C2728", 'hex');
let decipher2 = createDecipheriv('aes-128-ecb', metaKey, '');
let meta = decipher2.update(metaContent);
meta += decipher2.final('utf8');
meta = meta.substr(6);
meta = JSON.parse(meta);
start += metaLen;
// 5字节空白
start += 5;
- 读取 4字节 crc32校验码:
let crc32 = buff.readUInt32LE(start);
start += 4;
- 读取4字节图片大小:
let imgLen = buff.readUInt32LE(start);
start += 4;
- 写入图片数据:
let outputImgPullPath = outputImgPath ? outputImgPath : outputPath.split('.mp3')[0] + '.png';
writeFileSync(outputImgPullPath, buff.slice(start, start + imgLen))
start += imgLen;
let audioData = buff.slice(start);
let m = 0;
for (let i = 1; i < audioData.length + 1; i++) {
m = i & 0xff;
audioData[i - 1] ^= keyBox[(keyBox[m] + keyBox[(keyBox[m] + m) & 0xff]) & 0xff]
}
writeFileSync(outputPath, audioData);
完整代码:
/**
* @function outputMp3ByNCM
* @description 网易云音乐转码
* @param {string} sourcePath ncm 文件路径
* @param {string} outputPath 输出的 mp3 路径 (记得要以 mp3 结尾)
* @return {void} 没有返回值
*/
function outputMp3ByNCM (
sourcePath,
outputPath,
outputImgPath
) {
const content = readFileSync(sourcePath);
const buff = Buffer.from(content);
let start = 0;
// 1. 读取 8 字节,获取 magic header
let temp = buff.slice(start, start + 8);
start += 10; // 空余 2 字节
let header = Buffer.from([0x43, 0x54, 0x45, 0x4E, 0x46, 0x44, 0x41, 0x4D]);
if (!header.equals(temp)) {
throw new Error('文件头已损坏');
}
// 2. 读取 32 位 4字节的密钥长度
let keyLength = buff.readUInt32LE(start);
start += 4;
//3. 根据密钥长度读取密钥内容
temp = buff.slice(start, start + keyLength);
let cipherText = temp.map(t => {
return t ^ 0x64;
});
start += keyLength;
// 解密的key
let key = Buffer.from('687a4852416d736f356b496e62617857', 'hex');
// aes-128-ecb 解密
let decipher = createDecipheriv('aes-128-ecb', key, '');
let decodeText = decipher.update(cipherText);
decodeText += decipher.final();
// 得到 keyData
let keyData = decodeText.substr(17).trim();
let key2Len = keyData.length;
keyData = Buffer.from(keyData);
// 将 keyData 做 RC4-KSA 算法解密
let keyBox = Buffer.alloc(256);
for (let i = 0; i < keyBox.length; i++) {
keyBox[i] = i;
}
let j = 0;
for (let i = 0; i < 256; i++) {
j = (keyBox[i] + j + keyData[i % key2Len]) & 0xff;
[keyBox[i], keyBox[j]] = [keyBox[j], keyBox[i]];
}
// 读取4字节获取meta 长度
let metaLen = buff.readUInt32LE(start);
start += 4;
// 读取 meta 内容
let metaContent = buff.slice(start, start + metaLen);
metaContent = metaContent.map((t) => t ^ 0x63);
// 去掉 22位 163 key(Don't modify):
metaContent = metaContent.toString();
metaContent = metaContent.substr(22);
metaContent = Buffer.from(metaContent, 'base64');
// 解密 meta 内容
const metaKey = Buffer.from("2331346C6A6B5F215C5D2630553C2728", 'hex');
let decipher2 = createDecipheriv('aes-128-ecb', metaKey, '');
let meta = decipher2.update(metaContent);
meta += decipher2.final('utf8');
meta = meta.substr(6);
meta = JSON.parse(meta);
start += metaLen;
// 5字节空白
start += 5;
// 读取 4字节 crc32校验码
let crc32 = buff.readUInt32LE(start);
start += 4;
// 读取4字节图片大小
let imgLen = buff.readUInt32LE(start);
start += 4;
// 写入图片数据
let outputImgPullPath = outputImgPath ? outputImgPath : outputPath.split('.mp3')[0] + '.png';
writeFileSync(outputImgPullPath, buff.slice(start, start + imgLen))
start += imgLen;
let audioData = buff.slice(start);
let m = 0;
for (let i = 1; i < audioData.length + 1; i++) {
m = i & 0xff;
audioData[i - 1] ^= keyBox[(keyBox[m] + keyBox[(keyBox[m] + m) & 0xff]) & 0xff]
}
writeFileSync(outputPath, audioData);
}
线上转换:
以下是 Node.js 实现的切片上传 ncm的示例: