完善音乐下载、下载管理和开发工具

This commit is contained in:
2026-08-17 01:58:54 +08:00
parent c9fa6f7a88
commit 8b2dd89d2f
27 changed files with 4052 additions and 748 deletions
+593
View File
@@ -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
+12 -3
View File
@@ -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, "收藏歌单");