【Node.js】如何将网易云音乐缓存音乐转化成 MP3 ?

581 阅读5分钟

前言:

在使用网易云音乐的时候,我们可能会把自己喜欢听的音乐缓存到本地。

但是直接缓存是具有局限性的 - 因为直接从网易云中下载的音乐是 .ncm 格式的,会存在以下的问题:

  1. 可移植性差
  2. 操作困难
  3. 会员过期后无法继续播放(心痛

本文将从这几个方面:

  • 什么是 NCM 文件?
  • 转换思路
  • 代码实现

讲述一下如何将网易云缓存格式的音乐转化成 .mp3格式的音乐。

什么是 NCM 文件?

网易云音乐的 .ncm 格式是一种专有的音频文件格式,一般情况下,它只能在网易云音乐客户端或 app 中播放。

转换思路:

整体思路:

  1. 读取 header
  2. 解密音频的 keyData
  3. 解密音频的 meta
  4. 获取 & 设置音频的封面图片

具体步骤:

  1. 首先:我们需要先指定 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 ...
}
  1. sourcePath 里面读取出 .ncm文件的 buffer :
const content = readFileSync(sourcePath);
const buff = Buffer.from(content);
  1. 读取 .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('文件头已损坏');
}
  1. 读取 32 位,每 4 个字节的密钥的真实长度:
const keyLength = buff.readUInt32LE(start);
start += 4;
  1. 根据密钥长度,读取密钥中对应长度的内容
temp = buff.slice(start, start + keyLength);
let cipherText = temp.map(t => {
  return t ^ 0x64;
});
start += keyLength;
  1. 获取解密的 key
const key = Buffer.from('687a4852416d736f356b496e62617857', 'hex');
  1. 使用第6步的 key 来处理第5步的 cipherText,解密的方式是:aes-128-ecb 解密
let decipher = createDecipheriv('aes-128-ecb', key, '');
let decodeText = decipher.update(cipherText);
decodeText += decipher.final();
  1. 使用 decodeText 获取对应的 keyData 的 buffer:
let keyData = decodeText.substr(17).trim();
let key2Len = keyData.length;
keyData = Buffer.from(keyData);
  1. keyDataRC4-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]];
}
  1. 读取 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);
  1. 去掉 22位 163 key (Don't modify):
metaContent = metaContent.toString();
metaContent = metaContent.substr(22);
metaContent = Buffer.from(metaContent, 'base64');
  1. 解密 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;
  1. 读取 4字节 crc32校验码:
let crc32 = buff.readUInt32LE(start);
start += 4;
  1. 读取4字节图片大小:
let imgLen = buff.readUInt32LE(start);
start += 4;
  1. 写入图片数据:
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的示例:

📎ncm-transfer.zip