diff --git a/src/ZonyLrcTools.Cli/Resources/error_msg.json b/src/ZonyLrcTools.Cli/Resources/error_msg.json index fab9a2d..9685d99 100644 --- a/src/ZonyLrcTools.Cli/Resources/error_msg.json +++ b/src/ZonyLrcTools.Cli/Resources/error_msg.json @@ -4,7 +4,8 @@ "10002": "需要扫描的目录不存在,请确认路径是否正确。", "10003": "不能获取文件的后缀信息。", "10004": "没有扫描到任何音乐文件。", - "10005": "指定的编码不受支持,请检查配置,所有受支持的编码名称,请参考: https://docs.microsoft.com/en-us/dotnet/api/system.text.encodinginfo.codepage?view=net-6.0#system-text-encodinginfo-codepage。" + "10005": "指定的编码不受支持,请检查配置,所有受支持的编码名称,请参考: https://docs.microsoft.com/en-us/dotnet/api/system.text.encodinginfo.codepage?view=net-6.0#system-text-encodinginfo-codepage。", + "10006": "无法从网易云音乐获取歌曲列表。" }, "Warning": { "50001": "扫描文件时出现了错误。", diff --git a/src/ZonyLrcTools.Common/Infrastructure/Encryption/NetEaseMusicEncryptionHelper.cs b/src/ZonyLrcTools.Common/Infrastructure/Encryption/NetEaseMusicEncryptionHelper.cs new file mode 100644 index 0000000..657e47e --- /dev/null +++ b/src/ZonyLrcTools.Common/Infrastructure/Encryption/NetEaseMusicEncryptionHelper.cs @@ -0,0 +1,93 @@ +using System.Numerics; +using System.Security.Cryptography; +using System.Text; + +namespace ZonyLrcTools.Common.Infrastructure.Encryption; + +/// +/// 提供网易云音乐 API 的相关加密方法。 +/// +/// +/// 加密方法参考以下开源项目: +/// 1. https://github.com/jitwxs/163MusicLyrics/blob/master/MusicLyricApp/Api/Music/NetEaseMusicNativeApi.cs +/// 2. https://github.com/mos9527/pyncm/blob/ad0a84b2ed5f1affa9890d5f54f6170c2cf99bbb/pyncm/utils/crypto.py#L53 +/// +public static class NetEaseMusicEncryptionHelper +{ + public const string Modulus = + "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"; + + public const string Nonce = "0CoJUm6Qyw8W8jud"; + public const string PubKey = "010001"; + public const string Vi = "0102030405060708"; + + public static string RsaEncode(string text) + { + var srText = new string(text.Reverse().ToArray()); + var a = BCHexDec(BitConverter.ToString(Encoding.Default.GetBytes(srText)).Replace("-", string.Empty)); + var b = BCHexDec(PubKey); + var c = BCHexDec(Modulus); + var key = BigInteger.ModPow(a, b, c).ToString("x"); + key = key.PadLeft(256, '0'); + + return key.Length > 256 ? key.Substring(key.Length - 256, 256) : key; + } + + public static BigInteger BCHexDec(string hex) + { + var dec = new BigInteger(0); + var len = hex.Length; + + for (var i = 0; i < len; i++) + { + dec += BigInteger.Multiply(new BigInteger(Convert.ToInt32(hex[i].ToString(), 16)), + BigInteger.Pow(new BigInteger(16), len - i - 1)); + } + + return dec; + } + + public static string AesEncode(string secretData, string secret = "TA3YiYCfY2dDJQgg") + { + byte[] encrypted; + var iv = Encoding.UTF8.GetBytes(Vi); + + using (var aes = Aes.Create()) + { + aes.Key = Encoding.UTF8.GetBytes(secret); + aes.IV = iv; + aes.Mode = CipherMode.CBC; + using (var encryptor = aes.CreateEncryptor()) + { + using (var stream = new MemoryStream()) + { + using (var cryptoStream = new CryptoStream(stream, encryptor, CryptoStreamMode.Write)) + { + using (var sw = new StreamWriter(cryptoStream)) + { + sw.Write(secretData); + } + + encrypted = stream.ToArray(); + } + } + } + } + + return Convert.ToBase64String(encrypted); + } + + public static string CreateSecretKey(int length) + { + const string str = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + var sb = new StringBuilder(length); + var rnd = new Random(); + + for (var i = 0; i < length; ++i) + { + sb.Append(str[rnd.Next(0, str.Length)]); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/ZonyLrcTools.Common/Infrastructure/Exceptions/ErrorCodes.cs b/src/ZonyLrcTools.Common/Infrastructure/Exceptions/ErrorCodes.cs index 46c3a95..fae29b0 100644 --- a/src/ZonyLrcTools.Common/Infrastructure/Exceptions/ErrorCodes.cs +++ b/src/ZonyLrcTools.Common/Infrastructure/Exceptions/ErrorCodes.cs @@ -31,6 +31,11 @@ namespace ZonyLrcTools.Common.Infrastructure.Exceptions /// 文本: 指定的编码不受支持,请检查配置,所有受支持的编码名称。 /// public const int NotSupportedFileEncoding = 10005; + + /// + /// 文本: 无法从网易云音乐获取歌曲列表。 + /// + public const int UnableGetSongListFromNetEaseCloudMusic = 10006; #endregion diff --git a/src/ZonyLrcTools.Common/MusicScanner/JsonModel/GetMusicInfoFromNetEaseMusicSongListResponse.cs b/src/ZonyLrcTools.Common/MusicScanner/JsonModel/GetMusicInfoFromNetEaseMusicSongListResponse.cs new file mode 100644 index 0000000..c1836c9 --- /dev/null +++ b/src/ZonyLrcTools.Common/MusicScanner/JsonModel/GetMusicInfoFromNetEaseMusicSongListResponse.cs @@ -0,0 +1,80 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace ZonyLrcTools.Common.MusicScanner.JsonModel; + +public sealed class GetMusicInfoFromNetEaseMusicSongListResponse +{ + /// + /// 请求结果代码,为 200 时请求成功。 + /// + [JsonProperty("code")] + public int Code { get; set; } + + /// + /// 歌单信息。 + /// + [JsonProperty("playlist")] + public PlayListModel? PlayList { get; set; } +} + +public sealed class PlayListModel +{ + /// + /// 歌单的歌曲列表。 + /// + [JsonProperty("tracks")] + public ICollection? SongList { get; set; } +} + +public sealed class PlayListSongModel +{ + /// + /// 歌曲的名称。 + /// + [JsonProperty("name")] + public string? Name { get; set; } + + /// + /// 歌曲的艺术家信息,可能会有多位艺术家/歌手。 + /// + [JsonProperty("al")] + [JsonConverter(typeof(PlayListSongArtistModelJsonConverter))] + public ICollection? Artists { get; set; } +} + +public sealed class PlayListSongArtistModel +{ + /// + /// 艺术家的名称。 + /// + [JsonProperty("name")] + public string? Name { get; set; } +} + +public class PlayListSongArtistModelJsonConverter : JsonConverter +{ + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + switch (token.Type) + { + case JTokenType.Array: + return token.ToObject(objectType); + case JTokenType.Object: + return new List { token.ToObject() }; + default: + return null; + } + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ICollection); + } +} \ No newline at end of file diff --git a/src/ZonyLrcTools.Common/MusicScanner/NetEaseMusicSongListMusicScanner.cs b/src/ZonyLrcTools.Common/MusicScanner/NetEaseMusicSongListMusicScanner.cs index f9b02c4..f88e438 100644 --- a/src/ZonyLrcTools.Common/MusicScanner/NetEaseMusicSongListMusicScanner.cs +++ b/src/ZonyLrcTools.Common/MusicScanner/NetEaseMusicSongListMusicScanner.cs @@ -1,109 +1,69 @@ -using System.Security.Cryptography; -using System.Text; +using System.Net.Http.Headers; +using Newtonsoft.Json; using ZonyLrcTools.Common.Infrastructure.DependencyInject; +using ZonyLrcTools.Common.Infrastructure.Encryption; +using ZonyLrcTools.Common.Infrastructure.Exceptions; +using ZonyLrcTools.Common.Infrastructure.Network; +using ZonyLrcTools.Common.MusicScanner.JsonModel; namespace ZonyLrcTools.Common.MusicScanner; public class NetEaseMusicSongListMusicScanner : ITransientDependency { -} + private readonly IWarpHttpClient _warpHttpClient; -public interface INetEaseMusicSongListMusicScanner -{ -} - -/// -/// -/// -/// -/// Reference github links: -/// https://github.com/Quandong-Zhang/easynet-playlist-downloader/blob/main/main.py -/// https://github.com/mos9527/pyncm/blob/master/pyncm/apis/login.py -/// -public class NetEaseMusicSessionManager : INetEaseMusicSongListMusicScanner, ISingletonDependency -{ -} - -public class NetEaseCrypto -{ - private readonly (long, long) WEAPI_RSA_PUBKEY = ( - Convert.ToInt64( - "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7", - 16), - Convert.ToInt64("10001", 16) // textbook rsa without padding - ); - - private const string Base62 = "PJArHa0dpwhvMNYqKnTbitWfEmosQ9527ZBx46IXUgOzD81VuSFyckLRljG3eC"; - private const string Base64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - - private const string WEAPI_AES_KEY = "0CoJUm6Qyw8W8jud"; // cbc - private const string WEAPI_AES_IV = "0102030405060708"; // cbc - private const string LINUXAPI_AES_KEY = "rFgB&h#%2?^eDg:Q"; // ecb - private const string EAPI_DIGEST_SALT = "nobody%(url)suse%(text)smd5forencrypt"; - private const string EAPI_DATA_SALT = "%(url)s-36cd479b6b5-%(text)s-36cd479b6b5-%(digest)s"; - private const string EAPI_AES_KEY = "e82ckenh8dichen8"; // ecb - - public string WeApiEncrypt(string @params, string aesKey2 = null) + public NetEaseMusicSongListMusicScanner(IWarpHttpClient warpHttpClient) { - aesKey2 ??= RandomString(16); - // 1st go,encrypt the text with aes_key and aes_iv. - @params = AesEncrypt(@params, WEAPI_AES_KEY, WEAPI_AES_IV, CipherMode.CBC); - // 2nd go,encrypt the ENCRYPTED text again,with the 2nd key and aes_iv. - @params = AesEncrypt(@params, aesKey2, WEAPI_AES_IV); - // 3rd go,generate RSA encrypted encSecKey. - var encSecKey = HexDigest(RsaEncrypt(aesKey2, WEAPI_RSA_PUBKEY.Item1, WEAPI_RSA_PUBKEY.Item2)); - return $"params={@params}&encSecKey={encSecKey}"; + _warpHttpClient = warpHttpClient; } - public string AesEncrypt(string data, string key, string iv, CipherMode mode = CipherMode.CBC) + public async Task> GetMusicInfoFromNetEaseMusicSongListAsync(string songListId, ManualDownloadOptions options) { - using (var aes = Aes.Create()) - { - aes.Key = Encoding.UTF8.GetBytes(key); - aes.IV = Encoding.UTF8.GetBytes(iv); - aes.Mode = mode; - aes.Padding = PaddingMode.PKCS7; - using (var encryptor = aes.CreateEncryptor(aes.Key, aes.IV)) + var secretKey = NetEaseMusicEncryptionHelper.CreateSecretKey(16); + var encSecKey = NetEaseMusicEncryptionHelper.RsaEncode(secretKey); + + var response = await _warpHttpClient.PostAsync("https://music.163.com/weapi/v6/playlist/detail?csrf_token=", requestOption: + request => { - var plainBytes = System.Text.Encoding.UTF8.GetBytes(Pkcs7Pad(data)); - var encryptedBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); - return Convert.ToBase64String(encryptedBytes); - } + request.Content = new FormUrlEncodedContent(HandleRequest(new + { + csrf_token = "", + id = songListId, + n = 100000, + offset = 0, + total = true, + limit = 1000, + }, secretKey, encSecKey)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); + }); + + if (response.Code != 200 || response.PlayList?.SongList == null) + { + throw new ErrorCodeException(ErrorCodes.NotSupportedFileEncoding); } + + return response.PlayList.SongList + .Where(song => !string.IsNullOrEmpty(song.Name)) + .Select(song => + { + var artistName = song.Artists?.FirstOrDefault()?.Name ?? string.Empty; + var fakeFilePath = Path.Combine(options.OutputDirectory, options.OutputFileNamePattern.Replace("{Name}", song.Name).Replace("{Artist}", artistName)); + + var info = new MusicInfo(fakeFilePath, song.Name!, artistName); + return info; + }).ToList(); } - private string Pkcs7Pad(string data, int bs = 16) + private Dictionary HandleRequest(object srcParams, string secretKey, string encSecKey) { - var padding = bs - data.Length % bs; - return data + new string((char)padding, padding); - } - - private string RandomString(int length, string chars = Base62) - { - var random = new Random(); - return string.Join("", Enumerable.Range(0, length).Select(_ => chars[random.Next(chars.Length)])); - } - - private string HexDigest(byte[] data) - { - return string.Join("", data.Select(b => b.ToString("X2"))); - } - - static byte[] RsaEncrypt(string data, long n, long e, bool reverse = true) - { - var m = reverse ? data.Reverse() : data; - var mBytes = Encoding.UTF8.GetBytes(string.Join("", m)); - var mHex = BitConverter.ToString(mBytes).Replace("-", string.Empty); - var mInt = int.Parse(mHex, System.Globalization.NumberStyles.HexNumber); - var r = (int)Math.Pow(mInt, e) % n; - return BitConverter.GetBytes(r); - } - - static string HexCompose(string hex) - { - return BitConverter.ToString(Enumerable.Range(0, hex.Length) - .Where(x => x % 2 == 0) - .Select(x => Convert.ToByte(hex.Substring(x, 2), 16)) - .ToArray()); + return new Dictionary + { + { + "params", NetEaseMusicEncryptionHelper.AesEncode( + NetEaseMusicEncryptionHelper.AesEncode( + JsonConvert.SerializeObject(srcParams), NetEaseMusicEncryptionHelper.Nonce), secretKey) + }, + { "encSecKey", encSecKey } + }; } } \ No newline at end of file diff --git a/tests/ZonyLrcTools.Tests/MusicScanner/NetEaseMusicSongListMusicScannerTests.cs b/tests/ZonyLrcTools.Tests/MusicScanner/NetEaseMusicSongListMusicScannerTests.cs index 968100d..59e4b93 100644 --- a/tests/ZonyLrcTools.Tests/MusicScanner/NetEaseMusicSongListMusicScannerTests.cs +++ b/tests/ZonyLrcTools.Tests/MusicScanner/NetEaseMusicSongListMusicScannerTests.cs @@ -1,5 +1,19 @@ -namespace ZonyLrcTools.Tests.MusicScanner; +using System.Threading.Tasks; +using Shouldly; +using Xunit; +using ZonyLrcTools.Common.MusicScanner; + +namespace ZonyLrcTools.Tests.MusicScanner; public class NetEaseMusicSongListMusicScannerTests : TestBase { + [Fact] + public async Task GetMusicInfoFromNetEaseMusicSongListAsync_Test() + { + var musicScanner = GetService(); + var musicInfo = await musicScanner.GetMusicInfoFromNetEaseMusicSongListAsync("7224428149", new ManualDownloadOptions()); + + musicInfo.ShouldNotBeNull(); + musicInfo.Count.ShouldBeGreaterThan(10); + } } \ No newline at end of file