完善音乐下载、下载管理和开发工具
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Numerics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace YMhut.Box.Core.Music;
|
||||
|
||||
internal sealed record KugouDeviceCredential(
|
||||
int Version,
|
||||
string Guid,
|
||||
string Mid,
|
||||
string Dev,
|
||||
string Mac,
|
||||
string Dfid);
|
||||
|
||||
internal sealed record KugouAccountCredential(
|
||||
int Version,
|
||||
string UserId,
|
||||
string Token,
|
||||
string Nickname,
|
||||
string AvatarUrl,
|
||||
string VipLevel,
|
||||
string VipToken = "",
|
||||
string T1 = "");
|
||||
|
||||
internal sealed record KugouApiResponse(JsonObject Json, IReadOnlyDictionary<string, string> Cookies);
|
||||
|
||||
internal sealed class KugouApiException(
|
||||
string message,
|
||||
bool authenticationFailure = false,
|
||||
HttpStatusCode? statusCode = null,
|
||||
Exception? innerException = null) : Exception(message, innerException)
|
||||
{
|
||||
public bool AuthenticationFailure { get; } = authenticationFailure;
|
||||
|
||||
public HttpStatusCode? StatusCode { get; } = statusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal, HTTPS-only KuGou protocol adapter based on KuGouMusicApi commit
|
||||
/// 06560e3e053bda1ab830750db6f645bab703f824.
|
||||
/// </summary>
|
||||
internal sealed class KugouApiClient : IDisposable
|
||||
{
|
||||
internal const int AppId = 1005;
|
||||
internal const int ClientVersion = 20489;
|
||||
internal const int SourceAppId = 2919;
|
||||
private const string Gateway = "https://gateway.kugou.com";
|
||||
private const string WebSalt = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt";
|
||||
private const string AndroidSalt = "OIlwieks28dk2k092lksi2UIkp";
|
||||
private const string TrackKeySalt = "57ae12eb6890223e355ccfcb74edf70d";
|
||||
private const string AndroidUserAgent = "Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi";
|
||||
private const string RsaPublicKey = """
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
""";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
WriteIndented = false
|
||||
};
|
||||
private static readonly Regex SensitiveProviderField = new(
|
||||
"(?i)([\\\"']?\\b(?:token|vip_token|t1|dfid|mid)\\b[\\\"']?\\s*[:=]\\s*[\\\"']?)[^,\\s;\\\"'}]+",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly HttpClient _client;
|
||||
private readonly bool _disposeHandler;
|
||||
private readonly Func<string>? _playlistKeyFactory;
|
||||
|
||||
public KugouApiClient(HttpMessageHandler? handler = null, Func<string>? playlistKeyFactory = null)
|
||||
{
|
||||
_disposeHandler = handler is null;
|
||||
_playlistKeyFactory = playlistKeyFactory;
|
||||
_client = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
|
||||
_client.Timeout = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
public KugouDeviceCredential Device { get; set; } = CreateDeviceCredential();
|
||||
|
||||
public static KugouDeviceCredential CreateDeviceCredential()
|
||||
{
|
||||
var guid = Md5Hex(global::System.Guid.NewGuid().ToString("D", CultureInfo.InvariantCulture));
|
||||
return new KugouDeviceCredential(
|
||||
1,
|
||||
guid,
|
||||
CalculateMid(guid),
|
||||
RandomString(10),
|
||||
"02:00:00:00:00:00",
|
||||
"-");
|
||||
}
|
||||
|
||||
public async Task<KugouDeviceCredential> RegisterDeviceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["availableRamSize"] = 4_983_533_568L,
|
||||
["availableRomSize"] = 48_114_719,
|
||||
["availableSDSize"] = 48_114_717,
|
||||
["basebandVer"] = "",
|
||||
["batteryLevel"] = 100,
|
||||
["batteryStatus"] = 3,
|
||||
["brand"] = "Windows",
|
||||
["buildSerial"] = "unknown",
|
||||
["device"] = "desktop",
|
||||
["imei"] = Device.Guid,
|
||||
["imsi"] = "",
|
||||
["manufacturer"] = "Microsoft",
|
||||
["uuid"] = Device.Guid,
|
||||
["accelerometer"] = false,
|
||||
["accelerometerValue"] = "",
|
||||
["gravity"] = false,
|
||||
["gravityValue"] = "",
|
||||
["gyroscope"] = false,
|
||||
["gyroscopeValue"] = "",
|
||||
["light"] = false,
|
||||
["lightValue"] = "",
|
||||
["magnetic"] = false,
|
||||
["magneticValue"] = "",
|
||||
["orientation"] = false,
|
||||
["orientationValue"] = "",
|
||||
["pressure"] = false,
|
||||
["pressureValue"] = "",
|
||||
["step_counter"] = false,
|
||||
["step_counterValue"] = "",
|
||||
["temperature"] = false,
|
||||
["temperatureValue"] = ""
|
||||
};
|
||||
var envelope = EncryptPlaylistPayload(payload.ToJsonString(JsonOptions), _playlistKeyFactory?.Invoke());
|
||||
var rsa = RsaEncrypt(new JsonObject
|
||||
{
|
||||
["aes"] = envelope.Key,
|
||||
["uid"] = 0,
|
||||
["token"] = ""
|
||||
}.ToJsonString(JsonOptions), uppercase: false);
|
||||
var response = await SendAndroidRawAsync(
|
||||
HttpMethod.Post,
|
||||
"https://userservice.kugou.com",
|
||||
"/risk/v2/r_register_dev",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["part"] = "1",
|
||||
["platid"] = "1",
|
||||
["p"] = rsa
|
||||
},
|
||||
envelope.Value,
|
||||
null,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var json = ParseEncryptedJson(response, envelope.Key, "设备注册");
|
||||
EnsureProviderSuccess(json, "设备注册", accountRequest: false);
|
||||
var dfid = Text(json["data"], "dfid");
|
||||
if (string.IsNullOrWhiteSpace(dfid)) return Device;
|
||||
Device = Device with { Dfid = dfid };
|
||||
return Device;
|
||||
}
|
||||
|
||||
public async Task<string> CreateQrKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await SendWebAsync(
|
||||
"https://login-user.kugou.com",
|
||||
"/v2/qrcode",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["appid"] = "1001",
|
||||
["type"] = "1",
|
||||
["plat"] = "4",
|
||||
["qrcode_txt"] = $"https://h5.kugou.com/apps/loginQRCode/html/index.html?appid={AppId}&",
|
||||
["srcappid"] = SourceAppId.ToString(CultureInfo.InvariantCulture)
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var key = Text(result.Json["data"], "qrcode") ?? Text(result.Json, "qrcode");
|
||||
if (string.IsNullOrWhiteSpace(key)) throw new KugouApiException("酷狗未返回二维码登录标识。");
|
||||
return key;
|
||||
}
|
||||
|
||||
public Task<KugouApiResponse> CheckQrAsync(string key, CancellationToken cancellationToken)
|
||||
=> SendWebAsync(
|
||||
"https://login-user.kugou.com",
|
||||
"/v2/get_userinfo_qrcode",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["plat"] = "4",
|
||||
["appid"] = AppId.ToString(CultureInfo.InvariantCulture),
|
||||
["srcappid"] = SourceAppId.ToString(CultureInfo.InvariantCulture),
|
||||
["qrcode"] = key,
|
||||
["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
public Task<KugouApiResponse> SendAndroidAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
IReadOnlyDictionary<string, string>? query,
|
||||
JsonNode? data,
|
||||
KugouAccountCredential? account,
|
||||
string? router,
|
||||
CancellationToken cancellationToken,
|
||||
string baseUrl = Gateway,
|
||||
bool addTrackKey = false)
|
||||
{
|
||||
var body = data?.ToJsonString(JsonOptions) ?? string.Empty;
|
||||
return SendAndroidCoreAsync(method, baseUrl, path, query, body, "application/json", account, router, addTrackKey, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteCollectedPlaylistAsync(
|
||||
KugouAccountCredential account,
|
||||
long listId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var envelope = EncryptPlaylistPayload(new JsonObject
|
||||
{
|
||||
["listid"] = listId,
|
||||
["total_ver"] = 0,
|
||||
["type"] = 1
|
||||
}.ToJsonString(JsonOptions), _playlistKeyFactory?.Invoke());
|
||||
var rsa = RsaEncrypt(new JsonObject
|
||||
{
|
||||
["aes"] = envelope.Key,
|
||||
["uid"] = account.UserId,
|
||||
["token"] = account.Token
|
||||
}.ToJsonString(JsonOptions), uppercase: true);
|
||||
var clientTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
|
||||
var response = await SendAndroidRawAsync(
|
||||
HttpMethod.Post,
|
||||
Gateway,
|
||||
"/v2/delete_list",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["clienttime"] = clientTime,
|
||||
["key"] = SignParamsKey(clientTime),
|
||||
["last_area"] = "gztx",
|
||||
["clientver"] = ClientVersion.ToString(CultureInfo.InvariantCulture),
|
||||
["appid"] = AppId.ToString(CultureInfo.InvariantCulture),
|
||||
["last_time"] = clientTime,
|
||||
["p"] = rsa
|
||||
},
|
||||
envelope.Value,
|
||||
account,
|
||||
"cloudlist.service.kugou.com",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var json = ParseEncryptedJson(response, envelope.Key, "取消收藏歌单");
|
||||
EnsureProviderSuccess(json, "取消收藏歌单", accountRequest: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
if (_disposeHandler)
|
||||
{
|
||||
// HttpClient already owns and disposes its internally-created handler.
|
||||
}
|
||||
}
|
||||
|
||||
internal static string SignatureWeb(IReadOnlyDictionary<string, string> parameters)
|
||||
=> Md5Hex(WebSalt + string.Concat(parameters.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => $"{pair.Key}={pair.Value}")) + WebSalt);
|
||||
|
||||
internal static string SignatureAndroid(IReadOnlyDictionary<string, string> parameters, string body = "")
|
||||
=> Md5Hex(AndroidSalt + string.Concat(parameters.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => $"{pair.Key}={pair.Value}")) + body + AndroidSalt);
|
||||
|
||||
internal static string SignatureRegister(IReadOnlyDictionary<string, string> parameters)
|
||||
=> Md5Hex("1014" + string.Concat(parameters.Values.OrderBy(value => value, StringComparer.Ordinal)) + "1014");
|
||||
|
||||
internal static string SignParamsKey(string value)
|
||||
=> Md5Hex($"{AppId}{AndroidSalt}{ClientVersion}{value}");
|
||||
|
||||
internal static string CalculateMid(string value)
|
||||
=> BigInteger.Parse("0" + Md5Hex(value), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture)
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
internal static string TrackKey(string hash, string mid, string userId)
|
||||
=> Md5Hex($"{hash}{TrackKeySalt}{AppId}{mid}{userId}");
|
||||
|
||||
internal static string? Text(JsonNode? node, string property)
|
||||
{
|
||||
var value = node?[property];
|
||||
if (value is null) return null;
|
||||
if (value is JsonValue text && text.TryGetValue<string>(out var result)) return result;
|
||||
if (value is JsonValue number && number.TryGetValue<long>(out var integer)) return integer.ToString(CultureInfo.InvariantCulture);
|
||||
if (value is JsonValue boolean && boolean.TryGetValue<bool>(out var flag)) return flag ? "true" : "false";
|
||||
return value.ToJsonString(JsonOptions).Trim('"');
|
||||
}
|
||||
|
||||
internal static int Integer(JsonNode? node, string property)
|
||||
=> int.TryParse(Text(node, property), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : 0;
|
||||
|
||||
internal static long Long(JsonNode? node, string property)
|
||||
=> long.TryParse(Text(node, property), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : 0;
|
||||
|
||||
internal static IReadOnlyList<JsonNode> Array(JsonNode? node)
|
||||
=> node is JsonArray array ? array.Where(item => item is not null).Cast<JsonNode>().ToArray() : [];
|
||||
|
||||
private async Task<KugouApiResponse> SendWebAsync(
|
||||
string baseUrl,
|
||||
string path,
|
||||
IReadOnlyDictionary<string, string> values,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parameters = DefaultParameters();
|
||||
foreach (var pair in values) parameters[pair.Key] = pair.Value;
|
||||
parameters["signature"] = SignatureWeb(parameters);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri(baseUrl, path, parameters));
|
||||
ApplyHeaders(request, parameters["clienttime"], web: true, router: null);
|
||||
return await SendJsonAsync(request, "酷狗登录", accountRequest: false, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<KugouApiResponse> SendAndroidCoreAsync(
|
||||
HttpMethod method,
|
||||
string baseUrl,
|
||||
string path,
|
||||
IReadOnlyDictionary<string, string>? query,
|
||||
string body,
|
||||
string contentType,
|
||||
KugouAccountCredential? account,
|
||||
string? router,
|
||||
bool addTrackKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parameters = DefaultParameters(account);
|
||||
if (query is not null)
|
||||
{
|
||||
foreach (var pair in query) parameters[pair.Key] = pair.Value;
|
||||
}
|
||||
if (addTrackKey)
|
||||
{
|
||||
var hash = parameters.GetValueOrDefault("hash") ?? string.Empty;
|
||||
parameters["key"] = TrackKey(hash, parameters["mid"], parameters.GetValueOrDefault("userid") ?? "0");
|
||||
}
|
||||
parameters["signature"] = SignatureAndroid(parameters, body);
|
||||
using var request = new HttpRequestMessage(method, BuildUri(baseUrl, path, parameters));
|
||||
ApplyHeaders(request, parameters["clienttime"], web: false, router);
|
||||
if (method != HttpMethod.Get && method != HttpMethod.Head)
|
||||
{
|
||||
request.Content = new StringContent(body, Encoding.UTF8, contentType);
|
||||
}
|
||||
return await SendJsonAsync(request, OperationName(path), account is not null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<byte[]> SendAndroidRawAsync(
|
||||
HttpMethod method,
|
||||
string baseUrl,
|
||||
string path,
|
||||
IReadOnlyDictionary<string, string>? query,
|
||||
string body,
|
||||
KugouAccountCredential? account,
|
||||
string? router,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parameters = DefaultParameters(account);
|
||||
if (query is not null)
|
||||
{
|
||||
foreach (var pair in query) parameters[pair.Key] = pair.Value;
|
||||
}
|
||||
parameters["signature"] = SignatureAndroid(parameters, body);
|
||||
using var request = new HttpRequestMessage(method, BuildUri(baseUrl, path, parameters));
|
||||
ApplyHeaders(request, parameters["clienttime"], web: false, router);
|
||||
request.Content = new StringContent(body, Encoding.UTF8, "text/plain");
|
||||
try
|
||||
{
|
||||
using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new KugouApiException($"酷狗服务返回 HTTP {(int)response.StatusCode}。", response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new KugouApiException("连接酷狗服务超时。", innerException: null);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw new KugouApiException("无法连接酷狗服务。", statusCode: exception.StatusCode, innerException: exception);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<KugouApiResponse> SendJsonAsync(
|
||||
HttpRequestMessage request,
|
||||
string operation,
|
||||
bool accountRequest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new KugouApiException(
|
||||
$"{operation}失败,酷狗服务返回 HTTP {(int)response.StatusCode}。",
|
||||
response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden,
|
||||
response.StatusCode);
|
||||
}
|
||||
var json = ParseJson(text, operation);
|
||||
EnsureProviderSuccess(json, operation, accountRequest);
|
||||
var cookies = response.Headers.TryGetValues("Set-Cookie", out var values)
|
||||
? ParseResponseCookies(values)
|
||||
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
return new KugouApiResponse(json, cookies);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new KugouApiException($"{operation}超时。");
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw new KugouApiException($"{operation}无法连接酷狗服务。", statusCode: exception.StatusCode, innerException: exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> DefaultParameters(KugouAccountCredential? account = null)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["dfid"] = string.IsNullOrWhiteSpace(Device.Dfid) ? "-" : Device.Dfid,
|
||||
["mid"] = Device.Mid,
|
||||
["uuid"] = "-",
|
||||
["appid"] = AppId.ToString(CultureInfo.InvariantCulture),
|
||||
["clientver"] = ClientVersion.ToString(CultureInfo.InvariantCulture),
|
||||
["clienttime"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
if (account is not null)
|
||||
{
|
||||
result["token"] = account.Token;
|
||||
result["userid"] = account.UserId;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ApplyHeaders(HttpRequestMessage request, string clientTime, bool web, string? router)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", web
|
||||
? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36"
|
||||
: AndroidUserAgent);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.TryAddWithoutValidation("dfid", string.IsNullOrWhiteSpace(Device.Dfid) ? "-" : Device.Dfid);
|
||||
request.Headers.TryAddWithoutValidation("clienttime", clientTime);
|
||||
request.Headers.TryAddWithoutValidation("mid", Device.Mid);
|
||||
request.Headers.TryAddWithoutValidation("kg-rc", "1");
|
||||
request.Headers.TryAddWithoutValidation("kg-thash", "5d816a0");
|
||||
request.Headers.TryAddWithoutValidation("kg-rec", "1");
|
||||
request.Headers.TryAddWithoutValidation("kg-rf", "B9EDA08A64250DEFFBCADDEE00F8F25F");
|
||||
if (!string.IsNullOrWhiteSpace(router)) request.Headers.TryAddWithoutValidation("x-router", router);
|
||||
}
|
||||
|
||||
private static Uri BuildUri(string baseUrl, string path, IReadOnlyDictionary<string, string> parameters)
|
||||
{
|
||||
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var origin) || origin.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
throw new InvalidOperationException("Kugou API requests must use HTTPS.");
|
||||
}
|
||||
var query = string.Join("&", parameters.Select(pair =>
|
||||
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
|
||||
return new Uri(origin, path + "?" + query);
|
||||
}
|
||||
|
||||
private static JsonObject ParseJson(string text, string operation)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(text) as JsonObject ?? throw new JsonException();
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new KugouApiException($"{operation}返回了无效数据。", innerException: exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureProviderSuccess(JsonObject json, string operation, bool accountRequest)
|
||||
{
|
||||
var status = Integer(json, "status");
|
||||
var errorCode = Integer(json, "error_code");
|
||||
if ((status != 0 || !json.ContainsKey("status")) && errorCode == 0) return;
|
||||
var code = errorCode != 0 ? errorCode : Integer(json, "errcode");
|
||||
var message = Text(json, "error") ?? Text(json, "msg") ?? Text(json, "message") ?? "服务方拒绝了请求";
|
||||
var authenticationFailure = accountRequest && IsAuthenticationFailure(code, message);
|
||||
throw new KugouApiException($"{operation}失败:{SanitizeProviderMessage(message)}{(code == 0 ? string.Empty : $" [{code}]")}", authenticationFailure);
|
||||
}
|
||||
|
||||
private static bool IsAuthenticationFailure(int code, string message)
|
||||
=> code is 401 or 403 or 1002 or 1003 or 20001 or 20002 or 20017 or 20018 or 20022 or 30000 ||
|
||||
message.Contains("token", StringComparison.OrdinalIgnoreCase) ||
|
||||
message.Contains("登录", StringComparison.OrdinalIgnoreCase) ||
|
||||
message.Contains("login", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
internal static string SanitizeProviderMessage(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "服务方拒绝了请求";
|
||||
var compact = SensitiveProviderField.Replace(value.Replace('\r', ' ').Replace('\n', ' ').Trim(), "$1[redacted]");
|
||||
return compact.Length <= 160 ? compact : compact[..160];
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ParseResponseCookies(IEnumerable<string> values)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var header in values)
|
||||
{
|
||||
var pair = header.Split(';', 2)[0].Split('=', 2);
|
||||
if (pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0])) result[pair[0].Trim()] = pair[1].Trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static (string Key, string Value) EncryptPlaylistPayload(string plainText, string? keySeed = null)
|
||||
{
|
||||
keySeed = string.IsNullOrWhiteSpace(keySeed) ? RandomString(6).ToLowerInvariant() : keySeed;
|
||||
var digest = Md5Hex(keySeed);
|
||||
var key = Encoding.UTF8.GetBytes(digest[..16]);
|
||||
var iv = Encoding.UTF8.GetBytes(digest[16..32]);
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = key;
|
||||
aes.IV = iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var encrypted = encryptor.TransformFinalBlock(Encoding.UTF8.GetBytes(plainText), 0, Encoding.UTF8.GetByteCount(plainText));
|
||||
return (keySeed, Convert.ToBase64String(encrypted));
|
||||
}
|
||||
|
||||
internal static string DecryptPlaylistPayload(byte[] cipherText, string keySeed)
|
||||
{
|
||||
var digest = Md5Hex(keySeed);
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = Encoding.UTF8.GetBytes(digest[..16]);
|
||||
aes.IV = Encoding.UTF8.GetBytes(digest[16..32]);
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
var decrypted = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length);
|
||||
return Encoding.UTF8.GetString(decrypted);
|
||||
}
|
||||
|
||||
internal static string RsaEncrypt(string value, bool uppercase)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(RsaPublicKey);
|
||||
var encrypted = rsa.Encrypt(Encoding.UTF8.GetBytes(value), RSAEncryptionPadding.Pkcs1);
|
||||
var hex = Convert.ToHexString(encrypted);
|
||||
return uppercase ? hex : hex.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static JsonObject ParseEncryptedJson(byte[] cipherText, string keySeed, string operation)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ParseJson(DecryptPlaylistPayload(cipherText, keySeed), operation);
|
||||
}
|
||||
catch (KugouApiException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (CryptographicException exception)
|
||||
{
|
||||
throw new KugouApiException($"{operation}返回了无法解密的数据。", innerException: exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Md5Hex(string value)
|
||||
=> Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
|
||||
private static string RandomString(int length)
|
||||
{
|
||||
const string alphabet = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
return string.Create(length, alphabet, static (span, chars) =>
|
||||
{
|
||||
for (var index = 0; index < span.Length; index++) span[index] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
|
||||
});
|
||||
}
|
||||
|
||||
private static string OperationName(string path) => path switch
|
||||
{
|
||||
"/v7/get_all_list" => "获取酷狗用户歌单",
|
||||
"/everyday_song_recommend" => "获取酷狗每日推荐",
|
||||
"/v2/special_recommend" => "获取酷狗推荐歌单",
|
||||
"/v4/get_list_all_file" => "获取酷狗歌单歌曲",
|
||||
"/pubsongs/v2/get_other_list_file_nofilt" => "获取酷狗歌单歌曲",
|
||||
"/v5/url" => "解析酷狗播放地址",
|
||||
"/cloudlist.service/v6/add_song" => "收藏歌曲",
|
||||
"/v4/delete_songs" => "取消收藏歌曲",
|
||||
"/cloudlist.service/v5/add_list" => "收藏歌单",
|
||||
_ => "酷狗请求"
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,8 @@ public sealed record MusicSong(
|
||||
bool Playable = true)
|
||||
{
|
||||
public string? MvId { get; init; }
|
||||
|
||||
internal object? ProviderData { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MusicPlaylist(
|
||||
@@ -50,7 +52,12 @@ public sealed record MusicPlaylist(
|
||||
string CoverUrl,
|
||||
int TrackCount,
|
||||
string Creator,
|
||||
bool Subscribed = false);
|
||||
bool Subscribed = false)
|
||||
{
|
||||
public bool CanSubscribe { get; init; }
|
||||
|
||||
internal object? ProviderData { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MusicLoginState(
|
||||
bool LoggedIn,
|
||||
@@ -173,9 +180,11 @@ public interface IMusicProvider
|
||||
|
||||
Task<MusicMv?> GetMvAsync(string songId, string? mvId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetFavoriteAsync(string songId, bool favorite, CancellationToken cancellationToken = default);
|
||||
Task<bool?> GetFavoriteStateAsync(MusicSong song, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetPlaylistSubscribedAsync(string playlistId, bool subscribed, CancellationToken cancellationToken = default);
|
||||
Task SetFavoriteAsync(MusicSong song, bool favorite, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetPlaylistSubscribedAsync(MusicPlaylist playlist, bool subscribed, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IMusicPlaybackService
|
||||
|
||||
@@ -421,24 +421,27 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
uri is null ? "提供方未返回可播放的 MV 地址。" : null);
|
||||
}
|
||||
|
||||
public async Task SetFavoriteAsync(string songId, bool favorite, CancellationToken cancellationToken = default)
|
||||
public Task<bool?> GetFavoriteStateAsync(MusicSong song, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<bool?>(null);
|
||||
|
||||
public async Task SetFavoriteAsync(MusicSong song, bool favorite, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureLoggedIn();
|
||||
var result = await PostApiAsync("/api/song/like", new Dictionary<string, string>
|
||||
{
|
||||
["trackId"] = songId,
|
||||
["trackId"] = song.Id,
|
||||
["like"] = favorite ? "true" : "false",
|
||||
["csrf_token"] = CsrfToken()
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
EnsureSuccess(result, "收藏歌曲");
|
||||
}
|
||||
|
||||
public async Task SetPlaylistSubscribedAsync(string playlistId, bool subscribed, CancellationToken cancellationToken = default)
|
||||
public async Task SetPlaylistSubscribedAsync(MusicPlaylist playlist, bool subscribed, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureLoggedIn();
|
||||
var result = await PostApiAsync(subscribed ? "/api/playlist/subscribe" : "/api/playlist/unsubscribe", new Dictionary<string, string>
|
||||
{
|
||||
["id"] = playlistId,
|
||||
["id"] = playlist.Id,
|
||||
["csrf_token"] = CsrfToken()
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
EnsureSuccess(result, "收藏歌单");
|
||||
|
||||
Reference in New Issue
Block a user