feat: Now, it's possible to fetch playlists from NetEase Cloud Music and download lyrics.

This commit is contained in:
real-zony 2023-02-24 00:05:23 +08:00
parent 5dbabab5a6
commit 18d9c2d32c
6 changed files with 246 additions and 93 deletions

View File

@ -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": "扫描文件时出现了错误。",

View File

@ -0,0 +1,93 @@
using System.Numerics;
using System.Security.Cryptography;
using System.Text;
namespace ZonyLrcTools.Common.Infrastructure.Encryption;
/// <summary>
/// 提供网易云音乐 API 的相关加密方法。
/// </summary>
/// <remarks>
/// 加密方法参考以下开源项目:
/// 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
/// </remarks>
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();
}
}

View File

@ -31,6 +31,11 @@ namespace ZonyLrcTools.Common.Infrastructure.Exceptions
/// 文本: 指定的编码不受支持,请检查配置,所有受支持的编码名称。
/// </summary>
public const int NotSupportedFileEncoding = 10005;
/// <summary>
/// 文本: 无法从网易云音乐获取歌曲列表。
/// </summary>
public const int UnableGetSongListFromNetEaseCloudMusic = 10006;
#endregion

View File

@ -0,0 +1,80 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ZonyLrcTools.Common.MusicScanner.JsonModel;
public sealed class GetMusicInfoFromNetEaseMusicSongListResponse
{
/// <summary>
/// 请求结果代码,为 200 时请求成功。
/// </summary>
[JsonProperty("code")]
public int Code { get; set; }
/// <summary>
/// 歌单信息。
/// </summary>
[JsonProperty("playlist")]
public PlayListModel? PlayList { get; set; }
}
public sealed class PlayListModel
{
/// <summary>
/// 歌单的歌曲列表。
/// </summary>
[JsonProperty("tracks")]
public ICollection<PlayListSongModel>? SongList { get; set; }
}
public sealed class PlayListSongModel
{
/// <summary>
/// 歌曲的名称。
/// </summary>
[JsonProperty("name")]
public string? Name { get; set; }
/// <summary>
/// 歌曲的艺术家信息,可能会有多位艺术家/歌手。
/// </summary>
[JsonProperty("al")]
[JsonConverter(typeof(PlayListSongArtistModelJsonConverter))]
public ICollection<PlayListSongArtistModel>? Artists { get; set; }
}
public sealed class PlayListSongArtistModel
{
/// <summary>
/// 艺术家的名称。
/// </summary>
[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<PlayListSongArtistModel> { token.ToObject<PlayListSongArtistModel>() };
default:
return null;
}
}
public override bool CanConvert(Type objectType)
{
return objectType == typeof(ICollection<PlayListSongArtistModel>);
}
}

View File

@ -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
{
}
/// <summary>
///
/// </summary>
/// <remarks>
/// 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
/// </remarks>
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<List<MusicInfo>> 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<GetMusicInfoFromNetEaseMusicSongListResponse>("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<string, string> 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<string, string>
{
{
"params", NetEaseMusicEncryptionHelper.AesEncode(
NetEaseMusicEncryptionHelper.AesEncode(
JsonConvert.SerializeObject(srcParams), NetEaseMusicEncryptionHelper.Nonce), secretKey)
},
{ "encSecKey", encSecKey }
};
}
}

View File

@ -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<NetEaseMusicSongListMusicScanner>();
var musicInfo = await musicScanner.GetMusicInfoFromNetEaseMusicSongListAsync("7224428149", new ManualDownloadOptions());
musicInfo.ShouldNotBeNull();
musicInfo.Count.ShouldBeGreaterThan(10);
}
}