Files
YMhut-box-C-/src/YMhut.Box.Core/Music/KugouMusicProvider.cs
T
admin_gitea 80093f285e 完善酷狗权益识别并修复音乐列表空行
兼容本地酷狗 API 的多种权益响应结构,严格区分会员等级、有效期与音质解锁条件。统一过滤无效及重复歌曲,修复搜索、队列、历史记录和页面中的空行显示。
2026-08-18 15:53:01 +08:00

1569 lines
75 KiB
C#

using System.Globalization;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace YMhut.Box.Core.Music;
/// <summary>
/// Direct KuGou provider integration. Account requests use the provider's signed HTTPS
/// gateway and never synthesize entitlements or media addresses.
/// </summary>
public sealed class KugouMusicProvider : IMusicProvider, IDisposable
{
private const string DeviceCredentialKey = "kugou-device";
private const int MaximumAccountItems = 500;
private const string WebOrigin = "https://www.kugou.com";
private const string WebUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36";
private static readonly JsonSerializerOptions CredentialJsonOptions = new(JsonSerializerDefaults.Web)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false
};
private static readonly (MusicPlaybackQuality Quality, string Value, int Bitrate)[] QualityOrder =
[
(MusicPlaybackQuality.Master, "viper_tape", 1000),
(MusicPlaybackQuality.HiRes, "high", 900),
(MusicPlaybackQuality.Lossless, "flac", 800),
(MusicPlaybackQuality.High, "320", 320),
(MusicPlaybackQuality.Standard, "128", 128)
];
private static readonly string[] KugouPlaybackQualities =
[
"128", "320", "flac", "high", "multitrack", "viper_atmos",
"viper_tape", "viper_clear", "super"
];
private readonly IMusicCredentialStore _credentials;
private readonly IMusicAccountSessionStore? _sessions;
private readonly HttpClient _publicClient;
private readonly KugouApiClient _api;
private readonly KugouLocalApiProxyHandler? _localApi;
private readonly SemaphoreSlim _initialization = new(1, 1);
private readonly SemaphoreSlim _accountCacheGate = new(1, 1);
private readonly Dictionary<string, KugouPlaylistData> _playlistData = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, KugouSongData> _songData = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, long> _favoriteFiles = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, long> _subscriptionLists = new(StringComparer.OrdinalIgnoreCase);
private KugouAccountCredential? _account;
private long _likedPlaylistListId;
private bool _deviceInitialized;
private bool _accountCacheLoaded;
private bool _favoriteCacheLoaded;
private bool _initialized;
private readonly object _refreshSync = new();
private Task<MusicLoginState>? _refreshTask;
private DateTimeOffset? _lastRefreshFailure;
public KugouMusicProvider(IMusicCredentialStore credentials, HttpMessageHandler? handler = null)
: this(credentials, handler, null, null)
{
}
public KugouMusicProvider(
IMusicCredentialStore credentials,
HttpMessageHandler handler,
IMusicAccountSessionStore sessions)
: this(credentials, handler, null, sessions)
{
}
internal KugouMusicProvider(
IMusicCredentialStore credentials,
HttpMessageHandler? handler,
Func<string>? playlistKeyFactory,
IMusicAccountSessionStore? sessions = null)
{
_credentials = credentials;
_sessions = sessions;
_api = new KugouApiClient(handler, playlistKeyFactory);
_localApi = handler as KugouLocalApiProxyHandler;
_publicClient = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_publicClient.Timeout = TimeSpan.FromSeconds(12);
_publicClient.DefaultRequestHeaders.UserAgent.ParseAdd(WebUserAgent);
_publicClient.DefaultRequestHeaders.Referrer = new Uri(WebOrigin + "/");
_publicClient.DefaultRequestHeaders.TryAddWithoutValidation("Origin", WebOrigin);
_publicClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.7");
}
public string Id => "kugou";
public MusicLoginState LoginState { get; private set; } = new(false);
public IReadOnlyList<MusicQualityOption> QualityOptions => BuildQualityOptions(LoginState);
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
if (_initialized) return;
await _initialization.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_initialized) return;
await EnsureDeviceCoreAsync(cancellationToken).ConfigureAwait(false);
var secret = await _credentials.LoadAsync(Id, cancellationToken).ConfigureAwait(false);
if (_sessions is not null)
{
var session = await _sessions.LoadAsync(Id, cancellationToken).ConfigureAwait(false);
var cachedAccount = session is null ? null : ParseStoredAccount(session.Credential, out _);
if (session is not null && cachedAccount is not null && session.LoginState.LoggedIn)
{
_account = cachedAccount;
var refreshedAt = session.LoginState.RefreshedAt ?? session.SavedAt;
LoginState = session.LoginState with
{
RefreshedAt = refreshedAt,
IsCached = true,
IsStale = session.LoginState.IsStale || DateTimeOffset.UtcNow - refreshedAt >= TimeSpan.FromHours(12)
};
_initialized = true;
return;
}
}
if (!string.IsNullOrWhiteSpace(secret))
{
var account = ParseStoredAccount(secret, out var migrated);
if (account is null)
{
await _credentials.ClearAsync(Id, cancellationToken).ConfigureAwait(false);
}
else
{
try
{
await ValidateAccountAsync(account, cancellationToken).ConfigureAwait(false);
await CommitAccountAsync(account, migrated, cancellationToken).ConfigureAwait(false);
}
catch (KugouApiException exception) when (exception.AuthenticationFailure)
{
await ClearAccountAsync(cancellationToken).ConfigureAwait(false);
}
}
}
_initialized = true;
}
finally
{
_initialization.Release();
}
}
public Task<MusicLoginState> RefreshLoginStateAsync(bool force = false, CancellationToken cancellationToken = default)
{
lock (_refreshSync)
{
if (!force && LoginState.LoggedIn && !LoginState.IsStale && LoginState.Entitlements.Count > 0)
{
return Task.FromResult(LoginState);
}
if (!force && _lastRefreshFailure is { } failedAt && DateTimeOffset.UtcNow - failedAt < TimeSpan.FromMinutes(2))
{
return Task.FromResult(LoginState with { IsCached = true, IsStale = true });
}
return _refreshTask ??= RefreshLoginStateCoreAsync(cancellationToken);
}
}
private async Task<MusicLoginState> RefreshLoginStateCoreAsync(CancellationToken cancellationToken)
{
try
{
await InitializeAsync(cancellationToken).ConfigureAwait(false);
if (_account is null) return LoginState;
await ValidateAccountAsync(_account, cancellationToken).ConfigureAwait(false);
var refreshedState = EnrichLoginState(ToLoginState(_account));
var entitlementResult = await LoadEntitlementsAsync(_account, cancellationToken).ConfigureAwait(false);
if (_localApi is not null && !entitlementResult.Succeeded)
{
_lastRefreshFailure = DateTimeOffset.UtcNow;
LoginState = LoginState with { IsCached = true, IsStale = true };
return LoginState;
}
LoginState = _localApi is null
? refreshedState
: refreshedState with { Entitlements = entitlementResult.Grants };
_lastRefreshFailure = null;
await SaveSessionAsync(_account, LoginState, cancellationToken).ConfigureAwait(false);
return LoginState;
}
catch (KugouApiException exception) when (exception.AuthenticationFailure)
{
await ClearAccountAsync(cancellationToken).ConfigureAwait(false);
return LoginState;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
_lastRefreshFailure = DateTimeOffset.UtcNow;
LoginState = LoginState with { IsCached = true, IsStale = true };
return LoginState;
}
finally
{
lock (_refreshSync) _refreshTask = null;
}
}
public async Task<MusicLoginState> LoginWithCookieAsync(string cookie, CancellationToken cancellationToken = default)
{
await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false);
var account = ParseStoredAccount(cookie, out _);
if (account is null) return new MusicLoginState(false);
try
{
await ValidateAccountAsync(account, cancellationToken).ConfigureAwait(false);
}
catch (KugouApiException exception) when (exception.AuthenticationFailure)
{
return new MusicLoginState(false);
}
await CommitAccountAsync(account, save: true, cancellationToken).ConfigureAwait(false);
_initialized = true;
return LoginState;
}
public async Task<MusicQrSession> CreateQrSessionAsync(CancellationToken cancellationToken = default)
{
await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false);
var key = await _api.CreateQrKeyAsync(cancellationToken).ConfigureAwait(false);
return new MusicQrSession(
key,
$"https://h5.kugou.com/apps/loginQRCode/html/index.html?qrcode={Uri.EscapeDataString(key)}",
DateTimeOffset.Now.AddMinutes(3));
}
public async Task<MusicQrStatus> CheckQrSessionAsync(MusicQrSession session, CancellationToken cancellationToken = default)
{
if (DateTimeOffset.Now >= session.ExpiresAt) return new MusicQrStatus(800, "二维码已过期。", false, true, true);
var result = await _api.CheckQrAsync(session.Key, cancellationToken).ConfigureAwait(false);
var data = result.Json["data"] ?? result.Json;
var status = KugouApiClient.Integer(data, "status");
switch (status)
{
case 0:
return new MusicQrStatus(800, "二维码已过期。", false, true, true);
case 1:
return new MusicQrStatus(801, "等待扫码。", false, false);
case 2:
return new MusicQrStatus(802, "已扫码,等待手机确认。", false, false);
case 4:
{
var token = KugouApiClient.Text(data, "token") ?? result.Cookies.GetValueOrDefault("token") ?? string.Empty;
var userId = KugouApiClient.Text(data, "userid") ?? result.Cookies.GetValueOrDefault("userid") ?? string.Empty;
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(userId))
{
return new MusicQrStatus(805, "手机已确认,但酷狗未返回完整登录令牌。请刷新二维码重试。", false, false, true);
}
var account = new KugouAccountCredential(
2,
userId,
token,
KugouApiClient.Text(data, "nickname") ?? KugouApiClient.Text(data, "username") ?? "酷狗用户",
NormalizeImage(KugouApiClient.Text(data, "pic") ?? KugouApiClient.Text(data, "avatar") ?? string.Empty),
KugouApiClient.Text(data, "vip_type") ?? "none",
KugouApiClient.Text(data, "vip_token") ?? string.Empty,
KugouApiClient.Text(data, "t1") ?? string.Empty);
try
{
await ValidateAccountAsync(account, cancellationToken).ConfigureAwait(false);
await CommitAccountAsync(account, save: true, cancellationToken).ConfigureAwait(false);
_initialized = true;
return new MusicQrStatus(803, "登录成功。", true, false, true);
}
catch (KugouApiException exception) when (exception.AuthenticationFailure)
{
return new MusicQrStatus(805, "酷狗拒绝了登录令牌,请刷新二维码重试。", false, false, true);
}
}
default:
return new MusicQrStatus(805,
KugouApiClient.Text(data, "msg") ?? KugouApiClient.Text(result.Json, "msg") ?? "酷狗返回了未知扫码状态。",
false,
false,
true);
}
}
public async Task LogoutAsync(CancellationToken cancellationToken = default)
{
await ClearAccountAsync(cancellationToken).ConfigureAwait(false);
_initialized = true;
}
public async Task<MusicSearchResult> SearchAsync(string keywords, MusicSearchKind kind, int limit = 30, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(keywords) || kind != MusicSearchKind.Songs) return new([], [], []);
var uri = "https://songsearch.kugou.com/song_search_v2?keyword=" +
Uri.EscapeDataString(keywords.Trim()) +
$"&page=1&pagesize={Math.Clamp(limit, 1, 100)}&userid=-1&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0";
var result = await GetPublicJsonAsync(uri, cancellationToken).ConfigureAwait(false);
var songs = MusicSongRules.FilterDisplayable(
KugouApiClient.Array(result["data"]?["lists"]).Select(MapSong));
return new MusicSearchResult(songs, [], []);
}
public async Task<IReadOnlyList<MusicPlaylist>> GetUserPlaylistsAsync(CancellationToken cancellationToken = default)
{
var account = await RequireAccountAsync(cancellationToken).ConfigureAwait(false);
try
{
return await LoadAllUserPlaylistsAsync(account, refresh: true, cancellationToken).ConfigureAwait(false);
}
catch (KugouApiException exception) when (exception.AuthenticationFailure)
{
await ExpireAccountAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException("酷狗登录已失效,请重新扫码登录。", exception);
}
}
public Task<IReadOnlyList<MusicPlaylist>> GetRecommendedPlaylistsAsync(CancellationToken cancellationToken = default)
=> RunAccountOperationAsync(() => GetRecommendedPlaylistsCoreAsync(cancellationToken), cancellationToken);
private async Task<IReadOnlyList<MusicPlaylist>> GetRecommendedPlaylistsCoreAsync(CancellationToken cancellationToken)
{
await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false);
if (_account is not null) await EnsureAccountCacheAsync(cancellationToken).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
var body = new JsonObject
{
["appid"] = KugouApiClient.AppId,
["mid"] = _api.Device.Mid,
["clientver"] = KugouApiClient.ClientVersion,
["platform"] = "android",
["clienttime"] = now,
["userid"] = long.TryParse(_account?.UserId, out var userId) ? userId : 0,
["module_id"] = 1,
["page"] = 1,
["pagesize"] = 30,
["key"] = KugouApiClient.SignParamsKey(now),
["special_recommend"] = new JsonObject
{
["withtag"] = 1,
["withsong"] = 1,
["sort"] = 1,
["ugc"] = 1,
["is_selected"] = 0,
["withrecommend"] = 1,
["area_code"] = 1,
["categoryid"] = 0
},
["req_multi"] = 1,
["retrun_min"] = 5,
["return_special_falg"] = 1
};
var response = await _api.SendAndroidAsync(
HttpMethod.Post,
"/v2/special_recommend",
null,
body,
_account,
"specialrec.service.kugou.com",
cancellationToken).ConfigureAwait(false);
return PlaylistArray(response.Json)
.Select(node => MapPlaylist(node, accountPlaylist: false))
.Where(item => !string.IsNullOrWhiteSpace(item.Id))
.Take(30)
.ToArray();
}
public async Task<IReadOnlyList<MusicPlaylist>> GetChartsAsync(CancellationToken cancellationToken = default)
{
var result = await GetPublicJsonAsync("https://m.kugou.com/rank/list&json=true", cancellationToken).ConfigureAwait(false);
return KugouApiClient.Array(result["rank"]?["list"])
.Select(node => new MusicPlaylist(
Id,
"rank:" + (KugouApiClient.Text(node, "rankid") ?? string.Empty),
KugouApiClient.Text(node, "rankname") ?? "酷狗榜单",
NormalizeImage(KugouApiClient.Text(node, "imgurl") ?? KugouApiClient.Text(node, "img_9") ?? string.Empty),
KugouApiClient.Integer(node, "songcount"),
"酷狗音乐"))
.Where(playlist => !playlist.Id.EndsWith(':'))
.ToArray();
}
public async Task<IReadOnlyList<MusicSong>> GetDailySongsAsync(CancellationToken cancellationToken = default)
{
var account = await RequireAccountAsync(cancellationToken).ConfigureAwait(false);
try
{
var response = await _api.SendAndroidAsync(
HttpMethod.Post,
"/everyday_song_recommend",
null,
new JsonObject
{
["platform"] = "android",
["userid"] = long.TryParse(account.UserId, out var userId) ? userId : 0
},
account,
"everydayrec.service.kugou.com",
cancellationToken).ConfigureAwait(false);
return MusicSongRules.FilterDisplayable(SongArray(response.Json).Select(MapSong)).Take(100).ToArray();
}
catch (KugouApiException exception) when (exception.AuthenticationFailure)
{
await ExpireAccountAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException("酷狗登录已失效,请重新扫码登录。", exception);
}
}
public Task<IReadOnlyList<MusicSong>> GetPlaylistTracksAsync(string playlistId, int offset = 0, int count = 100, CancellationToken cancellationToken = default)
=> RunAccountOperationAsync(() => GetPlaylistTracksCoreAsync(playlistId, offset, count, cancellationToken), cancellationToken);
private async Task<IReadOnlyList<MusicSong>> GetPlaylistTracksCoreAsync(string playlistId, int offset, int count, CancellationToken cancellationToken)
{
var take = Math.Clamp(count, 1, MaximumAccountItems);
if (playlistId.StartsWith("rank:", StringComparison.OrdinalIgnoreCase))
{
return await GetRankTracksAsync(playlistId["rank:".Length..], offset, take, cancellationToken).ConfigureAwait(false);
}
if (playlistId.StartsWith("list:", StringComparison.OrdinalIgnoreCase))
{
var listId = ParseLong(playlistId["list:".Length..], "歌单标识无效。");
return await LoadAccountPlaylistTracksAsync(listId, offset, take, cancellationToken).ConfigureAwait(false);
}
if (playlistId.StartsWith("collection:", StringComparison.OrdinalIgnoreCase))
{
var collectionId = playlistId["collection:".Length..];
return await LoadCollectionTracksAsync(collectionId, offset, take, cancellationToken).ConfigureAwait(false);
}
throw new InvalidOperationException("当前酷狗歌单标识不受支持,请刷新歌单列表后重试。");
}
public Task<IReadOnlyList<MusicSong>> GetArtistSongsAsync(string artistId, int offset = 0, int count = 50, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<MusicSong>>([]);
public Task<MusicStreamResult> ResolveStreamAsync(string songId, MusicPlaybackQuality quality, CancellationToken cancellationToken = default)
=> RunAccountOperationAsync(() => ResolveStreamCoreAsync(songId, quality, cancellationToken), cancellationToken);
private async Task<MusicStreamResult> ResolveStreamCoreAsync(string songId, MusicPlaybackQuality quality, CancellationToken cancellationToken)
{
await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false);
var identity = SplitSongId(songId);
var start = global::System.Array.FindIndex(QualityOrder, item => item.Quality == quality);
if (start < 0) start = QualityOrder.Length - 1;
var metadata = SongDataFor(songId);
var candidates = await GetPlaybackCandidatesAsync(identity, metadata, start, cancellationToken).ConfigureAwait(false);
string? lastReason = null;
foreach (var candidate in candidates)
{
var freePartModes = _account is null ? new[] { true } : new[] { false, true };
foreach (var freePart in freePartModes)
{
try
{
var response = await _api.SendAndroidAsync(
HttpMethod.Get,
"/v5/url",
new Dictionary<string, string>
{
["album_id"] = identity.AlbumId,
["area_code"] = "1",
["hash"] = candidate.Hash.ToLowerInvariant(),
["ssa_flag"] = "is_fromtrack",
["version"] = "11430",
["page_id"] = "151369488",
["quality"] = candidate.Value,
["album_audio_id"] = metadata?.MixSongId.ToString(CultureInfo.InvariantCulture) ?? "0",
["behavior"] = "play",
["pid"] = "2",
["cmd"] = "26",
["pidversion"] = "3001",
["IsFreePart"] = freePart ? "1" : "0",
["ppage_id"] = "463467626,350369493,788954147",
["cdnBackup"] = "1",
["module"] = "",
["clientver"] = "11430"
},
null,
_account,
"trackercdn.kugou.com",
cancellationToken,
addTrackKey: true).ConfigureAwait(false);
foreach (var data in PlaybackResponseNodes(response.Json))
{
var extension = KugouApiClient.Text(data, "extName") ?? KugouApiClient.Text(data, "ext_name");
if (string.Equals(extension, "mp4", StringComparison.OrdinalIgnoreCase))
{
lastReason = "当前音质返回了视频容器,已尝试较低音质。";
continue;
}
if (!TryGetHttpsPlaybackUri(data, out var streamUri)) continue;
var bitrate = FirstInteger(data, "bitRate", "bitrate", "bit_rate");
if (bitrate <= 0) bitrate = candidate.Bitrate;
var fullAudio = FirstInteger(data, "is_full_audio", "isFullAudio") == 1;
var trial = FirstInteger(data, "is_free_part", "isFreePart") == 1 || (freePart && !fullAudio);
return new MusicStreamResult(
streamUri,
true,
trial,
candidate.Quality,
bitrate * 1000L,
trial ? "提供方仅允许试听片段。" : candidate.Quality == quality ? null : $"已回退到 {candidate.Quality} 音质。");
}
lastReason = KugouApiClient.Text(response.Json, "error") ??
KugouApiClient.Text(response.Json, "msg") ??
lastReason ??
"提供方未返回可用的 HTTPS 播放地址。";
}
catch (KugouApiException exception) when (!exception.AuthenticationFailure)
{
lastReason = exception.Message;
}
}
}
return new MusicStreamResult(null, false, false, quality, 0,
lastReason ?? "提供方未返回播放地址,歌曲可能需要登录、会员或受地区限制。");
}
private async Task<IReadOnlyList<KugouPlaybackCandidate>> GetPlaybackCandidatesAsync(
(string Hash, string AlbumId) identity,
KugouSongData? metadata,
int start,
CancellationToken cancellationToken)
{
var fallback = QualityOrder.Skip(start).ToArray();
var variants = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var response = await _api.SendAndroidAsync(
HttpMethod.Post,
"/v2/get_res_privilege/lite",
null,
new JsonObject
{
["appid"] = KugouApiClient.AppId,
["area_code"] = 1,
["behavior"] = "play",
["clientver"] = KugouApiClient.ClientVersion,
["need_hash_offset"] = 1,
["relate"] = 1,
["support_verify"] = 1,
["resource"] = new JsonArray
{
new JsonObject
{
["type"] = "audio",
["page_id"] = 0,
["hash"] = identity.Hash,
["album_id"] = metadata?.AlbumId ?? ParseLong(identity.AlbumId, defaultValue: 0)
}
},
["qualities"] = new JsonArray(KugouPlaybackQualities
.Select(value => (JsonNode?)JsonValue.Create(value))
.ToArray())
},
_account,
"media.store.kugou.com",
cancellationToken).ConfigureAwait(false);
foreach (var item in KugouApiClient.Array(response.Json["data"]))
{
AddPlaybackVariant(item, variants);
foreach (var related in KugouApiClient.Array(item?["relate_goods"]))
{
AddPlaybackVariant(related, variants);
}
}
}
catch (KugouApiException exception) when (!exception.AuthenticationFailure)
{
// The URL endpoint can still resolve free or standard variants when the
// privilege service is temporarily unavailable.
}
var resolved = fallback
.Where(item => variants.ContainsKey(item.Value))
.Select(item => new KugouPlaybackCandidate(item.Quality, item.Value, variants[item.Value], item.Bitrate))
.ToArray();
return resolved.Length > 0
? resolved
: fallback.Select(item => new KugouPlaybackCandidate(item.Quality, item.Value, identity.Hash, item.Bitrate)).ToArray();
}
private static void AddPlaybackVariant(JsonNode? node, IDictionary<string, string> variants)
{
if (node is not JsonObject item) return;
if (item.ContainsKey("level") && KugouApiClient.Integer(item, "level") == 0) return;
var quality = KugouApiClient.Text(item, "quality");
var hash = KugouApiClient.Text(item, "hash");
if (string.IsNullOrWhiteSpace(quality) || string.IsNullOrWhiteSpace(hash)) return;
variants.TryAdd(quality, hash);
}
private static IEnumerable<JsonNode> PlaybackResponseNodes(JsonObject response)
{
yield return response;
if (response["data"] is JsonObject data)
{
yield return data;
}
else if (response["data"] is JsonArray array)
{
foreach (var item in array.Where(item => item is not null)) yield return item!;
}
}
private static bool TryGetHttpsPlaybackUri(JsonNode? node, out Uri? result)
{
foreach (var property in new[] { "url", "play_url", "backupUrl", "backup_url", "backupurl" })
{
foreach (var address in TextValues(node?[property]))
{
if (!Uri.TryCreate(address, UriKind.Absolute, out var uri)) continue;
if (uri.Scheme == Uri.UriSchemeHttps)
{
result = uri;
return true;
}
if (uri.Scheme == Uri.UriSchemeHttp && uri.IsDefaultPort && IsKugouMediaHost(uri.Host))
{
var secure = new UriBuilder(uri) { Scheme = Uri.UriSchemeHttps, Port = -1 }.Uri;
result = secure;
return true;
}
}
}
result = null;
return false;
}
private static bool IsKugouMediaHost(string host)
=> host.EndsWith(".kugou.com", StringComparison.OrdinalIgnoreCase) ||
string.Equals(host, "kugou.com", StringComparison.OrdinalIgnoreCase);
private static IEnumerable<string> TextValues(JsonNode? node)
{
if (node is JsonValue value && value.TryGetValue<string>(out var valueText) && !string.IsNullOrWhiteSpace(valueText))
{
yield return valueText;
yield break;
}
if (node is not JsonArray array) yield break;
foreach (var item in array)
{
foreach (var itemText in TextValues(item)) yield return itemText;
}
}
private static int FirstInteger(JsonNode? node, params string[] properties)
=> properties.Select(property => KugouApiClient.Integer(node, property)).FirstOrDefault(value => value != 0);
public async Task<MusicStreamProbe> ProbeStreamAsync(Uri uri, CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Range = new RangeHeaderValue(0, 1);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("audio/*"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream", 0.8));
try
{
using var response = await _publicClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var reachable = response.IsSuccessStatusCode &&
(contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) ||
string.Equals(contentType, "application/octet-stream", StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrWhiteSpace(contentType));
return new MusicStreamProbe(reachable, contentType, (int)response.StatusCode,
reachable ? null : $"音乐文件服务返回 HTTP {(int)response.StatusCode} 或非音频内容。");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new MusicStreamProbe(false, string.Empty, 0, "音乐文件连接超时。");
}
catch (HttpRequestException exception)
{
return new MusicStreamProbe(false, string.Empty, (int?)exception.StatusCode ?? 0, "无法访问音乐文件。");
}
}
public async Task<TimedLyrics> GetLyricsAsync(string songId, CancellationToken cancellationToken = default)
{
var identity = SplitSongId(songId);
if (_localApi is not null)
{
try
{
var searchResult = await _localApi.SendApiAsync("/search/lyric", new Dictionary<string, string>
{
["hash"] = identity.Hash,
["man"] = "yes"
}, cancellationToken: cancellationToken).ConfigureAwait(false);
var localCandidate = KugouApiClient.Array(searchResult["candidates"] ?? searchResult["data"]?["candidates"]).FirstOrDefault();
var localLyricId = KugouApiClient.Text(localCandidate, "id");
var localAccessKey = KugouApiClient.Text(localCandidate, "accesskey");
if (!string.IsNullOrWhiteSpace(localLyricId) && !string.IsNullOrWhiteSpace(localAccessKey))
{
var krc = await _localApi.SendApiAsync("/lyric", new Dictionary<string, string>
{
["id"] = localLyricId,
["accesskey"] = localAccessKey,
["fmt"] = "krc",
["decode"] = "true"
}, cancellationToken: cancellationToken).ConfigureAwait(false);
var decoded = KugouApiClient.Text(krc, "decodeContent") ?? KugouApiClient.Text(krc["data"], "decodeContent");
if (!string.IsNullOrWhiteSpace(decoded))
{
var parsed = MusicLyricsParser.ParseKrc(decoded);
if (parsed.Lines.Count > 0) return parsed;
}
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Fall through to the plain LRC compatibility path.
}
}
var search = await GetPublicJsonAsync(
"https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&hash=" + Uri.EscapeDataString(identity.Hash),
cancellationToken).ConfigureAwait(false);
var candidate = KugouApiClient.Array(search["candidates"]).FirstOrDefault();
var lyricId = KugouApiClient.Text(candidate, "id");
var accessKey = KugouApiClient.Text(candidate, "accesskey");
if (string.IsNullOrWhiteSpace(lyricId) || string.IsNullOrWhiteSpace(accessKey)) return new TimedLyrics([], string.Empty, null, null);
var download = await GetPublicJsonAsync(
"https://lyrics.kugou.com/download?ver=1&client=pc&fmt=lrc&charset=utf8&id=" +
Uri.EscapeDataString(lyricId) + "&accesskey=" + Uri.EscapeDataString(accessKey),
cancellationToken).ConfigureAwait(false);
var encoded = KugouApiClient.Text(download, "content");
if (string.IsNullOrWhiteSpace(encoded)) return new TimedLyrics([], string.Empty, null, null);
try
{
return MusicLyricsParser.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(encoded)));
}
catch (FormatException)
{
return MusicLyricsParser.Parse(encoded);
}
}
public async Task<MusicMv?> GetMvAsync(string songId, string? mvId = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(mvId)) return null;
var result = await GetPublicJsonAsync(
"https://m.kugou.com/app/i/mv.php?cmd=100&ismp3=1&ext=mp4&hash=" + Uri.EscapeDataString(mvId),
cancellationToken).ConfigureAwait(false);
var variants = (result["mvdata"] as JsonObject)?
.Select(pair => pair.Value)
.OfType<JsonObject>()
.Select(node => new { Bitrate = KugouApiClient.Integer(node, "bitrate"), Address = KugouApiClient.Text(node, "downurl") ?? FirstText(node, "backupdownurl") })
.OrderByDescending(item => item.Bitrate)
.ToArray() ?? [];
var address = variants.Select(item => item.Address).FirstOrDefault(value => Uri.TryCreate(value, UriKind.Absolute, out var candidate) && candidate.Scheme == Uri.UriSchemeHttps);
var mediaUri = Uri.TryCreate(address, UriKind.Absolute, out var parsed) ? parsed : null;
var insecure = variants.Any(item => Uri.TryCreate(item.Address, UriKind.Absolute, out var candidate) && candidate.Scheme == Uri.UriSchemeHttp);
return new MusicMv(
Id,
mvId,
KugouApiClient.Text(result, "songname") ?? "MV",
KugouApiClient.Text(result, "singer") ?? string.Empty,
NormalizeImage(KugouApiClient.Text(result, "mvicon") ?? string.Empty),
TimeSpan.FromMilliseconds(KugouApiClient.Integer(result, "timelength")),
mediaUri,
mediaUri is not null,
mediaUri is null ? insecure ? "服务方当前仅返回非加密 MV 地址,已拒绝加载。" : "提供方未返回可播放的 MV 地址。" : null);
}
public Task<bool?> GetFavoriteStateAsync(MusicSong song, CancellationToken cancellationToken = default)
=> RunAccountOperationAsync(() => GetFavoriteStateCoreAsync(song, cancellationToken), cancellationToken);
private async Task<bool?> GetFavoriteStateCoreAsync(MusicSong song, CancellationToken cancellationToken)
{
await RequireAccountAsync(cancellationToken).ConfigureAwait(false);
await EnsureFavoriteCacheAsync(force: false, cancellationToken).ConfigureAwait(false);
return _favoriteFiles.ContainsKey(SplitSongId(song.Id).Hash);
}
public Task SetFavoriteAsync(MusicSong song, bool favorite, CancellationToken cancellationToken = default)
=> RunAccountOperationAsync(() => SetFavoriteCoreAsync(song, favorite, cancellationToken), cancellationToken);
private async Task SetFavoriteCoreAsync(MusicSong song, bool favorite, CancellationToken cancellationToken)
{
var account = await RequireAccountAsync(cancellationToken).ConfigureAwait(false);
await EnsureFavoriteCacheAsync(force: !favorite, cancellationToken).ConfigureAwait(false);
if (_likedPlaylistListId <= 0) throw new InvalidOperationException("未找到酷狗账户的“我喜欢”歌单,已停止收藏操作。");
var identity = SplitSongId(song.Id);
if (favorite)
{
if (_favoriteFiles.ContainsKey(identity.Hash)) return;
var metadata = SongDataFor(song.Id);
var body = new JsonObject
{
["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"),
["token"] = account.Token,
["listid"] = _likedPlaylistListId,
["list_ver"] = 0,
["type"] = 0,
["slow_upload"] = 1,
["scene"] = "false;null",
["data"] = new JsonArray
{
new JsonObject
{
["number"] = 1,
["name"] = song.Name,
["hash"] = identity.Hash,
["size"] = 0,
["sort"] = 0,
["timelen"] = (long)song.Duration.TotalMilliseconds,
["bitrate"] = 0,
["album_id"] = ParseLong(identity.AlbumId, defaultValue: 0),
["mixsongid"] = metadata?.MixSongId ?? 0
}
}
};
await _api.SendAndroidAsync(
HttpMethod.Post,
"/cloudlist.service/v6/add_song",
new Dictionary<string, string>
{
["last_time"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
["last_area"] = "gztx"
},
body,
account,
null,
cancellationToken).ConfigureAwait(false);
}
else
{
if (!_favoriteFiles.TryGetValue(identity.Hash, out var fileId)) return;
await _api.SendAndroidAsync(
HttpMethod.Post,
"/v4/delete_songs",
null,
new JsonObject
{
["listid"] = _likedPlaylistListId,
["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"),
["data"] = new JsonArray { new JsonObject { ["fileid"] = fileId } },
["type"] = 0,
["token"] = account.Token,
["list_ver"] = 0
},
account,
"cloudlist.service.kugou.com",
cancellationToken).ConfigureAwait(false);
}
await EnsureFavoriteCacheAsync(force: true, cancellationToken).ConfigureAwait(false);
}
public Task SetPlaylistSubscribedAsync(MusicPlaylist playlist, bool subscribed, CancellationToken cancellationToken = default)
=> RunAccountOperationAsync(() => SetPlaylistSubscribedCoreAsync(playlist, subscribed, cancellationToken), cancellationToken);
private async Task SetPlaylistSubscribedCoreAsync(MusicPlaylist playlist, bool subscribed, CancellationToken cancellationToken)
{
var account = await RequireAccountAsync(cancellationToken).ConfigureAwait(false);
if (!playlist.CanSubscribe || playlist.ProviderData is not KugouPlaylistData metadata || metadata.Owned)
{
throw new InvalidOperationException("该歌单不是可收藏的外部酷狗歌单。");
}
await EnsureAccountCacheAsync(cancellationToken).ConfigureAwait(false);
var sourceKey = SubscriptionKey(metadata);
if (subscribed)
{
if (_subscriptionLists.ContainsKey(sourceKey)) return;
var response = await _api.SendAndroidAsync(
HttpMethod.Post,
"/cloudlist.service/v5/add_list",
null,
new JsonObject
{
["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"),
["token"] = account.Token,
["total_ver"] = 0,
["name"] = playlist.Name,
["type"] = 1,
["source"] = 1,
["is_pri"] = 0,
["list_create_userid"] = metadata.OwnerUserId,
["list_create_listid"] = metadata.SourceListId,
["list_create_gid"] = metadata.CollectionId,
["from_shupinmv"] = 0
},
account,
null,
cancellationToken).ConfigureAwait(false);
var createdListId = KugouApiClient.Long(response.Json["data"], "listid");
if (createdListId <= 0) createdListId = KugouApiClient.Long(response.Json, "listid");
if (createdListId <= 0) throw new InvalidOperationException("酷狗已接受收藏请求,但未返回账户歌单标识。请刷新“我的歌单”确认结果。");
try
{
var songs = await GetPlaylistTracksAsync(playlist.Id, 0, MaximumAccountItems, cancellationToken).ConfigureAwait(false);
foreach (var batch in songs.Chunk(50))
{
await AddSongsToAccountPlaylistAsync(account, createdListId, batch, cancellationToken).ConfigureAwait(false);
}
}
catch
{
try { await _api.DeleteCollectedPlaylistAsync(account, createdListId, cancellationToken).ConfigureAwait(false); } catch { }
throw;
}
}
else
{
if (!_subscriptionLists.TryGetValue(sourceKey, out var localListId) || localListId <= 0)
{
throw new InvalidOperationException("找不到该歌单在当前账户中的收藏副本,请刷新“我的歌单”后重试。");
}
if (localListId == _likedPlaylistListId) throw new InvalidOperationException("不能通过取消收藏操作删除“我喜欢”歌单。");
await _api.DeleteCollectedPlaylistAsync(account, localListId, cancellationToken).ConfigureAwait(false);
}
InvalidateAccountCaches();
await EnsureAccountCacheAsync(cancellationToken).ConfigureAwait(false);
}
public void Dispose()
{
_publicClient.Dispose();
_api.Dispose();
_initialization.Dispose();
_accountCacheGate.Dispose();
}
private async Task EnsureDeviceAsync(CancellationToken cancellationToken)
{
if (_deviceInitialized) return;
await _initialization.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!_deviceInitialized) await EnsureDeviceCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_initialization.Release();
}
}
private async Task EnsureDeviceCoreAsync(CancellationToken cancellationToken)
{
if (_deviceInitialized) return;
var stored = await _credentials.LoadAsync(DeviceCredentialKey, cancellationToken).ConfigureAwait(false);
KugouDeviceCredential? device = null;
if (!string.IsNullOrWhiteSpace(stored))
{
try { device = JsonSerializer.Deserialize<KugouDeviceCredential>(stored, CredentialJsonOptions); } catch (JsonException) { }
}
device ??= KugouApiClient.CreateDeviceCredential();
_api.Device = device;
await _credentials.SaveAsync(DeviceCredentialKey, JsonSerializer.Serialize(device, CredentialJsonOptions), cancellationToken).ConfigureAwait(false);
_deviceInitialized = true;
}
private async Task<KugouAccountCredential> RequireAccountAsync(CancellationToken cancellationToken)
{
await InitializeAsync(cancellationToken).ConfigureAwait(false);
if (LoginState.LoggedIn && (LoginState.IsStale || LoginState.Entitlements.Count == 0))
{
await RefreshLoginStateAsync(false, cancellationToken).ConfigureAwait(false);
}
return _account ?? throw new InvalidOperationException("请先登录酷狗音乐账户。");
}
private async Task<T> RunAccountOperationAsync<T>(Func<Task<T>> operation, CancellationToken cancellationToken)
{
try
{
return await operation().ConfigureAwait(false);
}
catch (KugouApiException exception) when (exception.AuthenticationFailure)
{
await ExpireAccountAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException("酷狗登录已失效,请重新扫码登录。", exception);
}
}
private async Task RunAccountOperationAsync(Func<Task> operation, CancellationToken cancellationToken)
{
try
{
await operation().ConfigureAwait(false);
}
catch (KugouApiException exception) when (exception.AuthenticationFailure)
{
await ExpireAccountAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException("酷狗登录已失效,请重新扫码登录。", exception);
}
}
private async Task ValidateAccountAsync(KugouAccountCredential account, CancellationToken cancellationToken)
=> _ = await LoadUserPlaylistPageAsync(account, 1, 1, cancellationToken).ConfigureAwait(false);
private async Task CommitAccountAsync(KugouAccountCredential account, bool save, CancellationToken cancellationToken)
{
_account = account with { Version = 2 };
var refreshedState = EnrichLoginState(ToLoginState(_account));
var entitlementResult = await LoadEntitlementsAsync(_account, cancellationToken).ConfigureAwait(false);
LoginState = _localApi is null
? refreshedState
: entitlementResult.Succeeded
? refreshedState with { Entitlements = entitlementResult.Grants }
: refreshedState with { Entitlements = [], IsCached = true, IsStale = true };
InvalidateAccountCaches();
if (save)
{
await _credentials.SaveAsync(Id, JsonSerializer.Serialize(_account, CredentialJsonOptions), cancellationToken).ConfigureAwait(false);
}
await SaveSessionAsync(_account, LoginState, cancellationToken).ConfigureAwait(false);
}
private async Task ClearAccountAsync(CancellationToken cancellationToken)
{
_account = null;
LoginState = new MusicLoginState(false);
InvalidateAccountCaches();
await _credentials.ClearAsync(Id, cancellationToken).ConfigureAwait(false);
if (_sessions is not null) await _sessions.ClearAsync(Id, cancellationToken).ConfigureAwait(false);
}
private async Task ExpireAccountAsync(CancellationToken cancellationToken)
{
await ClearAccountAsync(cancellationToken).ConfigureAwait(false);
_initialized = true;
}
private void InvalidateAccountCaches()
{
_accountCacheLoaded = false;
_favoriteCacheLoaded = false;
_likedPlaylistListId = 0;
_favoriteFiles.Clear();
_subscriptionLists.Clear();
}
private async Task EnsureAccountCacheAsync(CancellationToken cancellationToken)
{
if (_accountCacheLoaded || _account is null) return;
await _accountCacheGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!_accountCacheLoaded)
{
_ = await LoadAllUserPlaylistsAsync(_account, refresh: true, cancellationToken).ConfigureAwait(false);
}
}
finally
{
_accountCacheGate.Release();
}
}
private async Task EnsureFavoriteCacheAsync(bool force, CancellationToken cancellationToken)
{
if (_favoriteCacheLoaded && !force) return;
await EnsureAccountCacheAsync(cancellationToken).ConfigureAwait(false);
if (_likedPlaylistListId <= 0)
{
_favoriteCacheLoaded = true;
return;
}
_favoriteFiles.Clear();
var songs = await LoadAccountPlaylistTracksAsync(_likedPlaylistListId, 0, MaximumAccountItems, cancellationToken).ConfigureAwait(false);
foreach (var song in songs)
{
if (song.ProviderData is KugouSongData { FileId: > 0 } metadata)
{
_favoriteFiles[metadata.Hash] = metadata.FileId;
}
}
_favoriteCacheLoaded = true;
}
private async Task<IReadOnlyList<MusicPlaylist>> LoadAllUserPlaylistsAsync(
KugouAccountCredential account,
bool refresh,
CancellationToken cancellationToken)
{
if (refresh)
{
_likedPlaylistListId = 0;
_subscriptionLists.Clear();
}
var collected = new List<MusicPlaylist>();
const int pageSize = 100;
for (var page = 1; collected.Count < MaximumAccountItems; page++)
{
var nodes = await LoadUserPlaylistPageAsync(account, page, pageSize, cancellationToken).ConfigureAwait(false);
if (nodes.Count == 0) break;
collected.AddRange(nodes.Select(node => MapPlaylist(node, accountPlaylist: true)));
if (nodes.Count < pageSize) break;
}
_accountCacheLoaded = true;
_favoriteCacheLoaded = false;
return collected.Take(MaximumAccountItems).ToArray();
}
private async Task<IReadOnlyList<JsonNode>> LoadUserPlaylistPageAsync(
KugouAccountCredential account,
int page,
int pageSize,
CancellationToken cancellationToken)
{
var response = await _api.SendAndroidAsync(
HttpMethod.Post,
"/v7/get_all_list",
new Dictionary<string, string> { ["plat"] = "1" },
new JsonObject
{
["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"),
["token"] = account.Token,
["total_ver"] = 979,
["type"] = 2,
["page"] = page,
["pagesize"] = pageSize
},
account,
"cloudlist.service.kugou.com",
cancellationToken).ConfigureAwait(false);
return PlaylistArray(response.Json);
}
private async Task<IReadOnlyList<MusicSong>> LoadAccountPlaylistTracksAsync(
long listId,
int offset,
int count,
CancellationToken cancellationToken)
{
var account = await RequireAccountAsync(cancellationToken).ConfigureAwait(false);
var pageSize = Math.Min(100, Math.Max(20, count));
var skip = Math.Max(0, offset);
var page = skip / pageSize + 1;
var withinPage = skip % pageSize;
var collected = new List<MusicSong>(count);
while (collected.Count < count && collected.Count + skip < MaximumAccountItems)
{
var response = await _api.SendAndroidAsync(
HttpMethod.Post,
"/v4/get_list_all_file",
null,
new JsonObject
{
["listid"] = listId,
["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"),
["area_code"] = 1,
["show_relate_goods"] = 0,
["pagesize"] = pageSize,
["allplatform"] = 1,
["show_cover"] = 1,
["type"] = 0,
["token"] = account.Token,
["page"] = page
},
account,
"cloudlist.service.kugou.com",
cancellationToken).ConfigureAwait(false);
var nodes = SongArray(response.Json);
if (nodes.Count == 0) break;
collected.AddRange(nodes.Skip(withinPage).Select(MapSong));
if (nodes.Count < pageSize) break;
withinPage = 0;
page++;
}
return MusicSongRules.FilterDisplayable(collected).Take(count).ToArray();
}
private async Task<IReadOnlyList<MusicSong>> LoadCollectionTracksAsync(
string collectionId,
int offset,
int count,
CancellationToken cancellationToken)
{
await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false);
var collected = new List<MusicSong>(count);
var begin = Math.Max(0, offset);
while (collected.Count < count && begin < MaximumAccountItems)
{
var pageSize = Math.Min(100, count - collected.Count);
var response = await _api.SendAndroidAsync(
HttpMethod.Get,
"/pubsongs/v2/get_other_list_file_nofilt",
new Dictionary<string, string>
{
["area_code"] = "1",
["begin_idx"] = begin.ToString(CultureInfo.InvariantCulture),
["plat"] = "1",
["type"] = "1",
["mode"] = "1",
["personal_switch"] = "1",
["extend_fields"] = "abtags,hot_cmt,popularization",
["pagesize"] = pageSize.ToString(CultureInfo.InvariantCulture),
["global_collection_id"] = collectionId
},
null,
_account,
null,
cancellationToken).ConfigureAwait(false);
var nodes = SongArray(response.Json);
if (nodes.Count == 0) break;
collected.AddRange(nodes.Select(MapSong));
if (nodes.Count < pageSize) break;
begin += nodes.Count;
}
return MusicSongRules.FilterDisplayable(collected).Take(count).ToArray();
}
private async Task<IReadOnlyList<MusicSong>> GetRankTracksAsync(
string rankId,
int offset,
int count,
CancellationToken cancellationToken)
{
var collected = new List<MusicSong>(count);
var page = Math.Max(0, offset) / 20 + 1;
var withinPage = Math.Max(0, offset) % 20;
while (collected.Count < count && page <= 25)
{
var result = await GetPublicJsonAsync(
$"https://m.kugou.com/rank/info/?rankid={Uri.EscapeDataString(rankId)}&page={page}&json=true",
cancellationToken).ConfigureAwait(false);
var songs = KugouApiClient.Array(result["songs"]?["list"]);
if (songs.Count == 0) break;
collected.AddRange(songs.Skip(withinPage).Select(MapSong));
if (songs.Count < 20) break;
withinPage = 0;
page++;
}
return MusicSongRules.FilterDisplayable(collected).Take(count).ToArray();
}
private async Task AddSongsToAccountPlaylistAsync(
KugouAccountCredential account,
long listId,
IReadOnlyCollection<MusicSong> songs,
CancellationToken cancellationToken)
{
var resources = new JsonArray();
foreach (var song in songs)
{
var identity = SplitSongId(song.Id);
var metadata = SongDataFor(song.Id);
resources.Add(new JsonObject
{
["number"] = 1,
["name"] = song.Name,
["hash"] = identity.Hash,
["size"] = 0,
["sort"] = 0,
["timelen"] = (long)song.Duration.TotalMilliseconds,
["bitrate"] = 0,
["album_id"] = ParseLong(identity.AlbumId, defaultValue: 0),
["mixsongid"] = metadata?.MixSongId ?? 0
});
}
await _api.SendAndroidAsync(
HttpMethod.Post,
"/cloudlist.service/v6/add_song",
new Dictionary<string, string>
{
["last_time"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
["last_area"] = "gztx"
},
new JsonObject
{
["userid"] = ParseLong(account.UserId, "酷狗用户标识无效。"),
["token"] = account.Token,
["listid"] = listId,
["list_ver"] = 0,
["type"] = 0,
["slow_upload"] = 1,
["scene"] = "false;null",
["data"] = resources
},
account,
null,
cancellationToken).ConfigureAwait(false);
}
private MusicPlaylist MapPlaylist(JsonNode node, bool accountPlaylist)
{
var name = KugouApiClient.Text(node, "name") ?? KugouApiClient.Text(node, "specialname") ?? "酷狗歌单";
var localListId = FirstLong(node, "listid", "list_id");
var sourceListId = FirstLong(node, "list_create_listid", "specialid", "source_listid");
var ownerId = FirstLong(node, "list_create_userid", "userid", "author_id");
var collectionId = KugouApiClient.Text(node, "list_create_gid") ?? KugouApiClient.Text(node, "global_collection_id") ?? string.Empty;
var confirmedExternalCopy = accountPlaylist &&
_account is not null &&
ownerId > 0 &&
ownerId.ToString(CultureInfo.InvariantCulture) != _account.UserId &&
(sourceListId > 0 || !string.IsNullOrWhiteSpace(collectionId));
var owned = accountPlaylist && !confirmedExternalCopy;
var subscribed = confirmedExternalCopy;
var id = !string.IsNullOrWhiteSpace(collectionId)
? "collection:" + collectionId
: localListId > 0
? "list:" + localListId.ToString(CultureInfo.InvariantCulture)
: string.Empty;
var metadata = new KugouPlaylistData(collectionId, localListId, sourceListId, ownerId, owned);
if (accountPlaylist)
{
if (IsLikedPlaylist(node, name, owned)) _likedPlaylistListId = localListId;
if (subscribed && localListId > 0) _subscriptionLists[SubscriptionKey(metadata)] = localListId;
}
else if (_subscriptionLists.TryGetValue(SubscriptionKey(metadata), out _))
{
subscribed = true;
}
var playlist = new MusicPlaylist(
Id,
id,
name,
NormalizeImage(KugouApiClient.Text(node, "pic") ?? KugouApiClient.Text(node, "imgurl") ?? KugouApiClient.Text(node, "image") ?? string.Empty),
Math.Max(KugouApiClient.Integer(node, "count"), KugouApiClient.Integer(node, "song_count")),
KugouApiClient.Text(node, "list_create_username") ?? KugouApiClient.Text(node, "author_name") ?? (owned ? LoginState.Nickname : "酷狗音乐"),
subscribed)
{
CanSubscribe = !owned && (localListId > 0 || sourceListId > 0 || !string.IsNullOrWhiteSpace(collectionId)),
ProviderData = metadata
};
if (!string.IsNullOrWhiteSpace(id)) _playlistData[id] = metadata;
return playlist;
}
private MusicSong MapSong(JsonNode node)
{
var hash = KugouApiClient.Text(node, "FileHash") ?? KugouApiClient.Text(node, "hash") ?? KugouApiClient.Text(node, "file_hash") ?? string.Empty;
var albumId = KugouApiClient.Text(node, "AlbumID") ?? KugouApiClient.Text(node, "album_id") ?? KugouApiClient.Text(node, "albumid") ?? "0";
var artist = KugouApiClient.Text(node, "SingerName") ?? KugouApiClient.Text(node, "singername") ?? KugouApiClient.Text(node, "h5_author_name") ?? KugouApiClient.Text(node, "author_name") ?? KugouApiClient.Text(node, "author") ?? string.Empty;
var artistId = KugouApiClient.Text(node, "SingerId") ?? KugouApiClient.Text(node, "singerid") ?? KugouApiClient.Text(KugouApiClient.Array(node["authors"]).FirstOrDefault(), "author_id") ?? artist;
var duration = FirstLong(node, "Duration", "duration", "timelen", "time_length");
var durationValue = duration > 10_000 ? TimeSpan.FromMilliseconds(duration) : TimeSpan.FromSeconds(Math.Max(0, duration));
var metadata = new KugouSongData(
hash,
ParseLong(albumId, defaultValue: 0),
FirstLong(node, "mixsongid", "album_audio_id", "audio_id"),
FirstLong(node, "fileid", "file_id"));
var song = new MusicSong(
Id,
ComposeSongId(hash, albumId),
KugouApiClient.Text(node, "SongName") ?? KugouApiClient.Text(node, "songname") ?? KugouApiClient.Text(node, "FileName") ?? KugouApiClient.Text(node, "filename") ?? KugouApiClient.Text(node, "name") ?? string.Empty,
artist,
string.IsNullOrWhiteSpace(artist) ? [] : [new MusicArtist(artistId, artist)],
KugouApiClient.Text(node, "AlbumName") ?? KugouApiClient.Text(node, "album_name") ?? KugouApiClient.Text(node, "remark") ?? string.Empty,
NormalizeImage(KugouApiClient.Text(node, "Image") ?? KugouApiClient.Text(node?["trans_param"], "union_cover") ?? KugouApiClient.Text(node, "imgurl") ?? KugouApiClient.Text(node, "sizable_cover") ?? string.Empty),
durationValue,
Math.Max(KugouApiClient.Integer(node, "PayType"), KugouApiClient.Integer(node, "pay_type")))
{
MvId = KugouApiClient.Text(node, "MvHash") ?? KugouApiClient.Text(node, "mvhash") ?? KugouApiClient.Text(KugouApiClient.Array(node?["mvdata"]).FirstOrDefault(), "hash"),
ProviderData = metadata
};
if (!string.IsNullOrWhiteSpace(song.Id)) _songData[song.Id] = metadata;
return song;
}
private KugouSongData? SongDataFor(string songId)
=> _songData.TryGetValue(songId, out var value)
? value
: new KugouSongData(SplitSongId(songId).Hash, ParseLong(SplitSongId(songId).AlbumId, defaultValue: 0), 0, 0);
private async Task<JsonObject> GetPublicJsonAsync(string uri, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var response = await _publicClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) throw new HttpRequestException($"酷狗音乐服务返回 HTTP {(int)response.StatusCode}。", null, response.StatusCode);
return JsonNode.Parse(text) as JsonObject ?? throw new JsonException("酷狗音乐服务返回了无效 JSON。");
}
private static KugouAccountCredential? ParseStoredAccount(string secret, out bool migrated)
{
migrated = false;
try
{
var account = JsonSerializer.Deserialize<KugouAccountCredential>(secret, CredentialJsonOptions);
if (account is not null && !string.IsNullOrWhiteSpace(account.UserId) && !string.IsNullOrWhiteSpace(account.Token)) return account;
}
catch (JsonException)
{
}
var cookies = ParseCookie(secret);
var composite = cookies.GetValueOrDefault("KuGoo");
var fields = string.IsNullOrWhiteSpace(composite)
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: ParseAmpersandFields(global::System.Net.WebUtility.UrlDecode(composite));
var userId = fields.GetValueOrDefault("KugooID") ?? cookies.GetValueOrDefault("KugooID") ?? cookies.GetValueOrDefault("userid") ?? string.Empty;
var token = fields.GetValueOrDefault("t") ?? cookies.GetValueOrDefault("t") ?? cookies.GetValueOrDefault("token") ?? string.Empty;
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(token)) return null;
migrated = true;
return new KugouAccountCredential(
2,
userId,
token,
fields.GetValueOrDefault("NickName") ?? fields.GetValueOrDefault("UserName") ?? "酷狗用户",
NormalizeImage(fields.GetValueOrDefault("Pic") ?? string.Empty),
fields.GetValueOrDefault("VipType") ?? fields.GetValueOrDefault("vip_type") ?? "none",
cookies.GetValueOrDefault("vip_token") ?? string.Empty,
cookies.GetValueOrDefault("t1") ?? string.Empty);
}
private static Dictionary<string, string> ParseCookie(string cookie)
=> cookie.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => part.Split('=', 2, StringSplitOptions.TrimEntries))
.Where(pair => pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0]))
.GroupBy(pair => pair[0], StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Last()[1], StringComparer.OrdinalIgnoreCase);
private static Dictionary<string, string> ParseAmpersandFields(string value)
=> value.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => part.Split('=', 2, StringSplitOptions.TrimEntries))
.Where(pair => pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0]))
.GroupBy(pair => pair[0], StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Last()[1], StringComparer.OrdinalIgnoreCase);
private static IReadOnlyList<JsonNode> PlaylistArray(JsonObject root)
=> FirstArray(root["data"]?["info"], root["data"]?["special_list"], root["data"]?["list"], root["info"], root["list"]);
private static IReadOnlyList<JsonNode> SongArray(JsonObject root)
=> FirstArray(root["data"]?["song_list"], root["data"]?["songs"], root["data"]?["info"], root["data"]?["list"], root["songs"]?["list"], root["info"], root["list"]);
private static IReadOnlyList<JsonNode> FirstArray(params JsonNode?[] candidates)
=> candidates.Select(KugouApiClient.Array).FirstOrDefault(items => items.Count > 0) ?? [];
private static string? FirstText(JsonNode? node, string property)
=> KugouApiClient.Array(node?[property]).Select(item => item.GetValue<string>()).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static long FirstLong(JsonNode node, params string[] names)
=> names.Select(name => KugouApiClient.Long(node, name)).FirstOrDefault(value => value != 0);
private static string NormalizeImage(string value)
=> value
.Replace("http://imge.kugou.com", "https://imge.kugou.com", StringComparison.OrdinalIgnoreCase)
.Replace("http://imgessl.kugou.com", "https://imgessl.kugou.com", StringComparison.OrdinalIgnoreCase)
.Replace("{size}", "480", StringComparison.Ordinal);
private static string ComposeSongId(string hash, string? albumId)
=> string.IsNullOrWhiteSpace(albumId) || albumId == "0" ? hash : $"{hash}|{albumId}";
private static (string Hash, string AlbumId) SplitSongId(string songId)
{
var separator = songId.IndexOf('|');
return separator < 0 ? (songId, "0") : (songId[..separator], songId[(separator + 1)..]);
}
private static long ParseLong(string value, string error)
=> long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) && result > 0
? result
: throw new InvalidOperationException(error);
private static long ParseLong(string value, long defaultValue)
=> long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : defaultValue;
private static bool IsLikedPlaylist(JsonNode node, string name, bool owned)
=> owned && (KugouApiClient.Integer(node, "is_like") == 1 ||
KugouApiClient.Integer(node, "is_default") == 1 ||
string.Equals(name.Trim(), "我喜欢", StringComparison.Ordinal));
private static string SubscriptionKey(KugouPlaylistData data)
=> !string.IsNullOrWhiteSpace(data.CollectionId)
? "gid:" + data.CollectionId
: $"source:{data.OwnerUserId}:{data.SourceListId}";
private static MusicLoginState ToLoginState(KugouAccountCredential account)
=> new(true, account.UserId, account.Nickname, account.AvatarUrl, account.VipLevel);
private async Task SaveSessionAsync(KugouAccountCredential account, MusicLoginState state, CancellationToken cancellationToken)
{
if (_sessions is null || !state.LoggedIn) return;
await _sessions.SaveAsync(new MusicAccountSession(
DpapiMusicAccountSessionStore.CurrentVersion,
Id,
JsonSerializer.Serialize(account, CredentialJsonOptions),
state,
DateTimeOffset.UtcNow), cancellationToken).ConfigureAwait(false);
}
private async Task<KugouEntitlementLoadResult> LoadEntitlementsAsync(
KugouAccountCredential account,
CancellationToken cancellationToken)
{
if (_localApi is null) return new(false, []);
try
{
var cookie = $"token={account.Token};userid={account.UserId}";
if (!string.IsNullOrWhiteSpace(account.VipLevel)) cookie += $";vip_type={account.VipLevel}";
if (!string.IsNullOrWhiteSpace(account.VipToken)) cookie += $";vip_token={account.VipToken}";
if (!string.IsNullOrWhiteSpace(account.T1)) cookie += $";t1={account.T1}";
var response = await _localApi.SendApiAsync(
"/user/vip/detail",
method: HttpMethod.Post,
body: new JsonObject { ["cookie"] = cookie },
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!KugouEntitlementParser.IsSuccessfulResponse(response)) return new(false, []);
var grants = KugouEntitlementParser.Parse(response);
return new(true, grants.Count == 0
? [new MusicEntitlementGrant("none", "酷狗会员", "membership", false)]
: grants);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new(false, []);
}
}
private static MusicLoginState EnrichLoginState(MusicLoginState state)
{
if (!state.LoggedIn) return state;
var raw = string.IsNullOrWhiteSpace(state.VipLevel) ? "none" : state.VipLevel.Trim();
var known = raw.ToLowerInvariant() switch
{
"1" or "vip" => new MusicEntitlementGrant(raw, "酷狗 VIP", "membership", true),
"2" or "svip" => new MusicEntitlementGrant(raw, "酷狗 SVIP", "membership", true),
"music" or "musicpack" => new MusicEntitlementGrant(raw, "音乐包", "music-package", true),
"none" or "0" => null,
_ => new MusicEntitlementGrant(raw, raw, "unknown", true)
};
return state with
{
Entitlements = known is null ? [] : [known],
RefreshedAt = DateTimeOffset.UtcNow,
IsCached = false,
IsStale = false
};
}
internal static IReadOnlyList<MusicQualityOption> BuildQualityOptions(MusicLoginState state)
{
var active = state.Entitlements.Where(IsActiveEntitlement).ToArray();
var svip = active.Any(IsSvipEntitlement);
var vip = svip || active.Any(IsVipEntitlement);
return
[
new(MusicPlaybackQuality.Master, "蝰蛇母带", "FLAC", 1000, "酷狗 SVIP", svip ? MusicQualityAvailability.Available : MusicQualityAvailability.RequiresEntitlement, svip ? null : "需要酷狗 SVIP 权益"),
new(MusicPlaybackQuality.HiRes, "Hi-Res", "FLAC", 900, "酷狗 VIP", vip ? MusicQualityAvailability.Available : MusicQualityAvailability.RequiresEntitlement, vip ? null : "需要酷狗 VIP 权益"),
new(MusicPlaybackQuality.Lossless, "无损音质", "FLAC", 800, "酷狗 VIP", vip ? MusicQualityAvailability.Available : MusicQualityAvailability.RequiresEntitlement, vip ? null : "需要酷狗 VIP 权益"),
new(MusicPlaybackQuality.High, "高品音质", "MP3", 320, "", MusicQualityAvailability.Available),
new(MusicPlaybackQuality.Standard, "标准音质", "MP3", 128, "", MusicQualityAvailability.Available)
];
}
private static bool IsActiveEntitlement(MusicEntitlementGrant item)
=> item.Enabled && (item.ExpiresAt is null || item.ExpiresAt > DateTimeOffset.UtcNow);
private static bool IsSvipEntitlement(MusicEntitlementGrant item)
=> item.PlatformCode.Equals("svip", StringComparison.OrdinalIgnoreCase) ||
item.PlatformCode is "2" or "6";
private static bool IsVipEntitlement(MusicEntitlementGrant item)
=> IsSvipEntitlement(item) ||
item.PlatformCode.Equals("vip", StringComparison.OrdinalIgnoreCase) ||
item.PlatformCode.Equals("dvip", StringComparison.OrdinalIgnoreCase) ||
item.PlatformCode.Equals("qvip", StringComparison.OrdinalIgnoreCase) ||
item.PlatformCode == "1";
private sealed record KugouSongData(string Hash, long AlbumId, long MixSongId, long FileId);
private sealed record KugouPlaybackCandidate(MusicPlaybackQuality Quality, string Value, string Hash, int Bitrate);
private sealed record KugouPlaylistData(string CollectionId, long LocalListId, long SourceListId, long OwnerUserId, bool Owned);
}