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; /// /// Direct KuGou provider integration. Account requests use the provider's signed HTTPS /// gateway and never synthesize entitlements or media addresses. /// 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 _playlistData = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _songData = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _favoriteFiles = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _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? _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? 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 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 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 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 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 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 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 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> 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> GetRecommendedPlaylistsAsync(CancellationToken cancellationToken = default) => RunAccountOperationAsync(() => GetRecommendedPlaylistsCoreAsync(cancellationToken), cancellationToken); private async Task> 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> 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> 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> GetPlaylistTracksAsync(string playlistId, int offset = 0, int count = 100, CancellationToken cancellationToken = default) => RunAccountOperationAsync(() => GetPlaylistTracksCoreAsync(playlistId, offset, count, cancellationToken), cancellationToken); private async Task> 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> GetArtistSongsAsync(string artistId, int offset = 0, int count = 50, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task ResolveStreamAsync(string songId, MusicPlaybackQuality quality, CancellationToken cancellationToken = default) => RunAccountOperationAsync(() => ResolveStreamCoreAsync(songId, quality, cancellationToken), cancellationToken); private async Task 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 { ["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> GetPlaybackCandidatesAsync( (string Hash, string AlbumId) identity, KugouSongData? metadata, int start, CancellationToken cancellationToken) { var fallback = QualityOrder.Skip(start).ToArray(); var variants = new Dictionary(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 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 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 TextValues(JsonNode? node) { if (node is JsonValue value && value.TryGetValue(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 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 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 { ["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 { ["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 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() .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 GetFavoriteStateAsync(MusicSong song, CancellationToken cancellationToken = default) => RunAccountOperationAsync(() => GetFavoriteStateCoreAsync(song, cancellationToken), cancellationToken); private async Task 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 { ["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(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 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 RunAccountOperationAsync(Func> 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 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> LoadAllUserPlaylistsAsync( KugouAccountCredential account, bool refresh, CancellationToken cancellationToken) { if (refresh) { _likedPlaylistListId = 0; _subscriptionLists.Clear(); } var collected = new List(); 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> LoadUserPlaylistPageAsync( KugouAccountCredential account, int page, int pageSize, CancellationToken cancellationToken) { var response = await _api.SendAndroidAsync( HttpMethod.Post, "/v7/get_all_list", new Dictionary { ["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> 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(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> LoadCollectionTracksAsync( string collectionId, int offset, int count, CancellationToken cancellationToken) { await EnsureDeviceAsync(cancellationToken).ConfigureAwait(false); var collected = new List(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 { ["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> GetRankTracksAsync( string rankId, int offset, int count, CancellationToken cancellationToken) { var collected = new List(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 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 { ["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 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(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(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 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 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 PlaylistArray(JsonObject root) => FirstArray(root["data"]?["info"], root["data"]?["special_list"], root["data"]?["list"], root["info"], root["list"]); private static IReadOnlyList 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 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()).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 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 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); }