feat: Use MusicDecrypto as a decryption driver.

This commit is contained in:
real-zony 2022-12-11 21:28:25 +08:00
parent fdc5f27692
commit a46abba2f9
11 changed files with 123 additions and 263 deletions

View File

@ -29,29 +29,31 @@ macOS 和 Linux 用户请打开终端,切换到软件目录,一样执行命
子命令为 `download`,可用于下载歌词数据和专辑图像,支持多个下载器进行下载。
```shell
./ZonyLrcTools.Cli.exe download -d|dir <WAIT_SCAN_DIRECTORY> [-l|--lyric] [-a|--album] [-n|--number]
.\ZonyLrcTools.Cli.exe download -d|dir <WAIT_SCAN_DIRECTORY> [-l|--lyric] [-a|--album] [-n|--number]
./ZonyLrcTools.Cli.exe download -h|--help
.\ZonyLrcTools.Cli.exe download -h|--help
```
**例子**
```shell
# 下载歌词
./ZonyLrcTools.Cli.exe download -d "C:\歌曲目录" -l -n 2
.\ZonyLrcTools.Cli.exe download -d "C:\歌曲目录" -l -n 2
# 下载专辑封面
./ZonyLrcTools.Cli.exe download -d "C:\歌曲目录" -a -n 2
.\ZonyLrcTools.Cli.exe download -d "C:\歌曲目录" -a -n 2
```
#### 加密格式转换
子命令为 `util`,可用于转换部分加密歌曲,**仅供个人研究学习使用,思路与源码都来自于网络**。
目前软件支持 NCM、QCM(开发中...🚧) 格式的音乐文件转换,命令如下。
具体支持的格式请参考项目 [MusicDecrypto](https://github.com/davidxuang/MusicDecrypto/blob/master/MusicDecrypto.Library/DecryptoFactory.cs#L23),本工具仅做一个集成,替换掉原本自己的一些实现。现在不需要指定对应的类型参数,程序会自动根据文件后缀选择适合的解密算法。
命令只需要一个参数 `-s`,指定需要转换的文件夹或者是文件路径。
```shell
./ZonyLrcTools.Cli.exe util -t=Ncm D:\CloudMusic
.\ZonyLrcTools.Cli.exe util -s D:\CloudMusic
```
### 配置文件

View File

@ -1,24 +1,16 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using McMaster.Extensions.CommandLineUtils;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using ZonyLrcTools.Common.Infrastructure.Exceptions;
using MusicDecrypto.Library;
using ZonyLrcTools.Common.Infrastructure.IO;
using ZonyLrcTools.Common.Infrastructure.Threading;
using ZonyLrcTools.Common.MusicDecryption;
namespace ZonyLrcTools.Cli.Commands.SubCommand
{
public enum SupportFileType
{
Ncm = 1,
Qcm = 2
}
/// <summary>
/// 工具类相关命令。
/// </summary>
@ -28,13 +20,9 @@ namespace ZonyLrcTools.Cli.Commands.SubCommand
private readonly ILogger<UtilityCommand> _logger;
private readonly IMusicDecryptor _musicDecryptor;
[Required(ErrorMessage = "音乐格式为必须参数,请指定 -t 参数。")]
[Option("-t|--type", CommandOptionType.SingleValue, Description = "需要转换的文件格式,参数[Ncm、Qcm]", ShowInHelpText = true)]
public SupportFileType Type { get; set; }
[Required(ErrorMessage = "文件路径为必须按参数,请传入有效路径。")]
[Argument(0, "FilePath", "指定需要转换的音乐文件路径,支持目录和文件路径。")]
public string FilePath { get; set; }
[Required(ErrorMessage = "请指定需要解密的歌曲文件或文件夹路径。")]
[Option("-s|--source", CommandOptionType.SingleValue, Description = "需要解密的歌曲文件或文件夹路径。", ShowInHelpText = true)]
public string Source { get; set; }
private readonly IFileScanner _fileScanner;
@ -49,57 +37,41 @@ namespace ZonyLrcTools.Cli.Commands.SubCommand
protected override async Task<int> OnExecuteAsync(CommandLineApplication app)
{
if (Directory.Exists(FilePath))
if (Directory.Exists(Source))
{
_logger.LogInformation("开始扫描文件夹,请稍等...");
var files = (await _fileScanner.ScanAsync(FilePath, new[] { "*.ncm" }))
var files = (await _fileScanner.ScanAsync(Source, DecryptoFactory.KnownExtensions))
.SelectMany(f => f.FilePaths)
.ToList();
_logger.LogInformation($"扫描完成,共 {files.Count} 个文件,准备转换。");
var wrapTask = new WarpTask(4);
var tasks = files.Select(path => wrapTask.RunAsync(() => Convert(path)));
var tasks = files.Select(path => wrapTask.RunAsync(async () =>
{
_logger.LogInformation($"开始转换文件:{path}");
var result = await _musicDecryptor.ConvertMusicAsync(path);
if (result.IsSuccess)
{
_logger.LogInformation($"转换完成,文件保存在:{result.OutputFilePath}");
}
else
{
_logger.LogError($"转换失败,原因:{result.ErrorMessage}");
}
}));
await Task.WhenAll(tasks);
}
else if (File.Exists(FilePath))
else if (File.Exists(Source))
{
await Convert(FilePath);
await _musicDecryptor.ConvertMusicAsync(Source);
}
_logger.LogInformation("所有文件已经转换完成...");
return 0;
}
private async Task Convert(string filePath)
{
if (Type != SupportFileType.Ncm)
{
throw new ErrorCodeException(ErrorCodes.OnlySupportNcmFormatFile);
}
var memoryStream = new MemoryStream();
await using var file = File.Open(filePath, FileMode.Open);
{
var buffer = new Memory<byte>(new byte[2048]);
while (await file.ReadAsync(buffer) > 0)
{
// TODO: Large Object Issue!!!!!
await memoryStream.WriteAsync(buffer);
}
}
// TODO: Large Object Issue!!!!!
var result = await _musicDecryptor.ConvertMusic(memoryStream.ToArray());
var newFileName = Path.Combine(Path.GetDirectoryName(filePath),
$"{Path.GetFileNameWithoutExtension(filePath)}.{((JObject)result.ExtensionObjects["JSON"]).SelectToken("$.format").Value<string>()}");
await using var musicFileStream = File.Create(newFileName);
await musicFileStream.WriteAsync(result.Data);
await musicFileStream.FlushAsync();
}
}
}

View File

@ -1,5 +1,8 @@
namespace ZonyLrcTools.Common.Infrastructure.Logging;
/// <summary>
/// 日志记录器,包装了 CLI 和网页日志的两种输出方式。
/// </summary>
public interface IWarpLogger
{
Task DebugAsync(string message, Exception? exception = null);

View File

@ -113,6 +113,7 @@ public class LyricsDownloader : ILyricsDownloader, ISingletonDependency
}
}
// Convert UTF-8 to selected encoding.
private byte[] Utf8ToSelectedEncoding(LyricsItemCollection lyrics)
{
var supportEncodings = Encoding.GetEncodings();

View File

@ -1,14 +1,29 @@
namespace ZonyLrcTools.Common.MusicDecryption
{
public class DecryptionResult
public sealed class DecryptionResult
{
public byte[] Data { get; protected set; }
public bool IsSuccess { get; set; } = false;
public Dictionary<string, object> ExtensionObjects { get; set; }
public string? OutputFilePath { get; set; } = default;
public DecryptionResult(byte[] data)
public string? ErrorMessage { get; set; } = default;
public static DecryptionResult Failed(string errorMessage)
{
Data = data;
return new DecryptionResult
{
IsSuccess = false,
ErrorMessage = errorMessage
};
}
public static DecryptionResult Success(string outputFilePath)
{
return new DecryptionResult
{
IsSuccess = true,
OutputFilePath = outputFilePath
};
}
}
}

View File

@ -0,0 +1,44 @@
using MusicDecrypto.Library;
using ZonyLrcTools.Common.Infrastructure.DependencyInject;
using ZonyLrcTools.Common.Infrastructure.Logging;
namespace ZonyLrcTools.Common.MusicDecryption
{
/// <inheritdoc cref="ZonyLrcTools.Common.MusicDecryption.IMusicDecryptor" />
public class DefaultMusicDecryptor : IMusicDecryptor, ITransientDependency
{
private readonly IWarpLogger _warpLogger;
public DefaultMusicDecryptor(IWarpLogger warpLogger)
{
_warpLogger = warpLogger;
}
public async Task<DecryptionResult> ConvertMusicAsync(string filePath)
{
try
{
await using var buffer = new MarshalMemoryStream();
await using var file = new FileStream(filePath, FileMode.Open, FileAccess.Read);
buffer.SetLengthWithPadding(file.Length);
await file.CopyToAsync(buffer);
using var decrypto = DecryptoFactory.Create(buffer, Path.GetFileName(filePath), message => { });
var outFileName = decrypto.Decrypt().NewName;
var outFilePath = Path.Combine(Path.GetDirectoryName(filePath)!, outFileName);
if (!File.Exists(outFilePath))
{
await using var outFile = new FileStream(outFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
await buffer.CopyToAsync(outFile);
}
return DecryptionResult.Success(outFilePath);
}
catch (Exception e)
{
return DecryptionResult.Failed(e.Message);
}
}
}
}

View File

@ -1,15 +1,20 @@
namespace ZonyLrcTools.Common.MusicDecryption
using MusicDecrypto.Library;
namespace ZonyLrcTools.Common.MusicDecryption
{
/// <summary>
/// 音乐解密器,用于将加密的歌曲数据,转换为可识别的歌曲格式。
/// <remarks>
/// 这个类型仅仅是对 <see cref="DecryptoFactory"/> 相关功能的封装,方便在本工具进行单元测试。
/// </remarks>
/// </summary>
public interface IMusicDecryptor
{
/// <summary>
/// 将加密数据转换为可识别的歌曲格式。
/// 将加密数据歌曲文件转换为可识别的歌曲格式。
/// </summary>
/// <param name="sourceBytes">源加密的歌曲数据。</param>
/// <returns>解密完成的歌曲数据。</returns>
Task<DecryptionResult> ConvertMusic(byte[] sourceBytes);
/// <param name="filePath">加密歌曲文件的路径。</param>
/// <returns>转换结果。</returns>
Task<DecryptionResult> ConvertMusicAsync(string filePath);
}
}

View File

@ -1,180 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json.Linq;
using ZonyLrcTools.Common.Infrastructure.DependencyInject;
namespace ZonyLrcTools.Common.MusicDecryption
{
/// <summary>
/// NCM 音乐转换器,用于将 NCM 格式的音乐转换为可播放的格式。
/// </summary>
public class NcmMusicDecryptor : IMusicDecryptor, ITransientDependency
{
protected readonly byte[] AesCoreKey = { 0x68, 0x7A, 0x48, 0x52, 0x41, 0x6D, 0x73, 0x6F, 0x35, 0x6B, 0x49, 0x6E, 0x62, 0x61, 0x78, 0x57 };
protected readonly byte[] AesModifyKey = { 0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21, 0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28 };
public async Task<DecryptionResult> ConvertMusic(byte[] sourceBytes)
{
var stream = new MemoryStream(sourceBytes);
var streamReader = new BinaryReader(stream);
var lengthBytes = new byte[4];
lengthBytes = streamReader.ReadBytes(4);
if (BitConverter.ToInt32(lengthBytes) != 0x4e455443)
{
throw new Exception();
}
lengthBytes = streamReader.ReadBytes(4);
if (BitConverter.ToInt32(lengthBytes) != 0x4d414446)
{
throw new Exception();
}
stream.Seek(2, SeekOrigin.Current);
stream.Read(lengthBytes);
var keyBytes = new byte[BitConverter.ToInt32(lengthBytes)];
stream.Read(keyBytes);
// 对已经加密的数据进行异或操作。
for (int i = 0; i < keyBytes.Length; i++)
{
keyBytes[i] ^= 0x64;
}
var coreKeyBytes = GetBytesByOffset(DecryptAes128Ecb(AesCoreKey, keyBytes), 17);
var modifyDataBytes = new byte[streamReader.ReadInt32()];
stream.Read(modifyDataBytes);
for (int i = 0; i < modifyDataBytes.Length; i++)
{
modifyDataBytes[i] ^= 0x63;
}
var decryptBase64Bytes = Convert.FromBase64String(Encoding.UTF8.GetString(GetBytesByOffset(modifyDataBytes, 22)));
var decryptModifyData = DecryptAes128Ecb(AesModifyKey, decryptBase64Bytes);
var musicInfoJson = JObject.Parse(Encoding.UTF8.GetString(GetBytesByOffset(decryptModifyData, 6)));
// CRC 校验
stream.Seek(4, SeekOrigin.Current);
stream.Seek(5, SeekOrigin.Current);
GetAlbumImageBytes(stream, streamReader);
var sBox = BuildKeyBox(coreKeyBytes);
return new DecryptionResult(GetMusicBytes(sBox, stream).ToArray())
{
ExtensionObjects = new Dictionary<string, object>
{
{ "JSON", musicInfoJson }
}
};
}
private byte[] GetBytesByOffset(byte[] srcBytes, int offset = 0)
{
var resultBytes = new byte[srcBytes.Length - offset];
Array.Copy(srcBytes, offset, resultBytes, 0, srcBytes.Length - offset);
return resultBytes;
}
private byte[] DecryptAes128Ecb(byte[] keyBytes, byte[] data)
{
var aes = Aes.Create();
aes.Padding = PaddingMode.PKCS7;
aes.Mode = CipherMode.ECB;
using var decryptor = aes.CreateDecryptor(keyBytes, null);
var result = decryptor.TransformFinalBlock(data, 0, data.Length);
return result;
}
/// <summary>
/// RC4 加密,生成 KeyBox。
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
private byte[] BuildKeyBox(byte[] key)
{
byte[] box = new byte[256];
for (int i = 0; i < 256; ++i)
{
box[i] = (byte)i;
}
byte keyLength = (byte)key.Length;
byte c;
byte lastByte = 0;
byte keyOffset = 0;
byte swap;
for (int i = 0; i < 256; ++i)
{
swap = box[i];
c = (byte)((swap + lastByte + key[keyOffset++]) & 0xff);
if (keyOffset >= keyLength)
{
keyOffset = 0;
}
box[i] = box[c];
box[c] = swap;
lastByte = c;
}
return box;
}
/// <summary>
/// 获得歌曲的专辑图像信息。
/// </summary>
/// <param name="stream">原始文件流。</param>
/// <param name="streamReader">二进制读取器。</param>
private byte[] GetAlbumImageBytes(Stream stream, BinaryReader streamReader)
{
var imgLength = streamReader.ReadInt32();
if (imgLength <= 0)
{
return null;
}
var imgBuffer = streamReader.ReadBytes(imgLength);
return imgBuffer;
}
/// <summary>
/// 获得歌曲的完整数据。
/// </summary>
/// <param name="sBox"></param>
/// <param name="stream">原始文件流。</param>
private MemoryStream GetMusicBytes(byte[] sBox, Stream stream)
{
var n = 0x8000;
var memoryStream = new MemoryStream();
while (true)
{
var tb = new byte[n];
var result = stream.Read(tb);
if (result <= 0) break;
for (int i = 0; i < n; i++)
{
var j = (byte)((i + 1) & 0xff);
tb[i] ^= sBox[sBox[j] + sBox[(sBox[j] + j) & 0xff] & 0xff];
}
memoryStream.Write(tb);
}
memoryStream.Flush();
return memoryStream;
}
}
}

View File

@ -20,4 +20,8 @@
<Compile Remove="Lyrics\Providers\Kugeci\KugeciDownloader.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\vendor\MusicDecrypto\MusicDecrypto.Library\MusicDecrypto.Library.csproj" />
</ItemGroup>
</Project>

View File

@ -98,5 +98,12 @@ namespace ZonyLrcTools.Tests.Infrastructure.Lyrics
var lyric = await _lyricsProvider.DownloadAsync("Bones", "Image Dragons");
lyric.ToString().ShouldNotContain("Gimme, gimme, gimme some time to think");
}
[Fact]
public async Task DownloadAsync_Issue123_Test()
{
var lyric = await _lyricsProvider.DownloadAsync("橄榄树", "苏曼");
lyric.ToString().ShouldNotBeNullOrEmpty();
}
}
}

View File

@ -1,7 +1,7 @@
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Linq;
using Shouldly;
using Xunit;
using ZonyLrcTools.Common.MusicDecryption;
@ -10,26 +10,13 @@ namespace ZonyLrcTools.Tests.Infrastructure.MusicDecryption
public class NcmMusicDecryptorTests : TestBase
{
[Fact]
public async Task ConvertMusic_Test()
public async Task ConvertMusicAsync_Test()
{
var decryptor = ServiceProvider.GetRequiredService<IMusicDecryptor>();
var ncmFilePath = Path.Combine(Directory.GetCurrentDirectory(), "MusicFiles", "Loren Gray - Queen.ncm");
await using var fs = File.Open(Path.Combine(Directory.GetCurrentDirectory(), "MusicFiles", "Loren Gray - Queen.ncm"), FileMode.Open);
using var reader = new BinaryReader(fs);
var response = await decryptor.ConvertMusic(reader.ReadBytes((int) fs.Length));
var musicFilePath = Path.Combine(Directory.GetCurrentDirectory(),
"MusicFiles",
$"Loren Gray - Queen.{((JObject) response.ExtensionObjects["JSON"]).SelectToken("$.format").Value<string>()}");
if (File.Exists(musicFilePath))
{
File.Delete(musicFilePath);
}
await using var musicFileStream = File.Create(musicFilePath);
await musicFileStream.WriteAsync(response.Data);
await musicFileStream.FlushAsync();
var result = await decryptor.ConvertMusicAsync(ncmFilePath);
result.IsSuccess.ShouldBeTrue();
}
}
}