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