完善酷狗权益识别并修复音乐列表空行

兼容本地酷狗 API 的多种权益响应结构,严格区分会员等级、有效期与音质解锁条件。统一过滤无效及重复歌曲,修复搜索、队列、历史记录和页面中的空行显示。
This commit is contained in:
2026-08-18 15:53:01 +08:00
parent 149c28082d
commit 80093f285e
7 changed files with 561 additions and 66 deletions
+63 -50
View File
@@ -115,7 +115,7 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
{
RefreshedAt = refreshedAt,
IsCached = true,
IsStale = DateTimeOffset.UtcNow - refreshedAt >= TimeSpan.FromHours(12)
IsStale = session.LoginState.IsStale || DateTimeOffset.UtcNow - refreshedAt >= TimeSpan.FromHours(12)
};
_initialized = true;
return;
@@ -153,7 +153,10 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
{
lock (_refreshSync)
{
if (!force && LoginState.LoggedIn && !LoginState.IsStale) return Task.FromResult(LoginState);
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 });
@@ -169,9 +172,17 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
await InitializeAsync(cancellationToken).ConfigureAwait(false);
if (_account is null) return LoginState;
await ValidateAccountAsync(_account, cancellationToken).ConfigureAwait(false);
LoginState = EnrichLoginState(ToLoginState(_account));
var entitlements = await LoadEntitlementsAsync(_account, cancellationToken).ConfigureAwait(false);
if (entitlements.Count > 0) LoginState = LoginState with { Entitlements = entitlements };
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;
@@ -290,10 +301,8 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
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 = KugouApiClient.Array(result["data"]?["lists"])
.Select(MapSong)
.Where(song => !string.IsNullOrWhiteSpace(song.Id))
.ToArray();
var songs = MusicSongRules.FilterDisplayable(
KugouApiClient.Array(result["data"]?["lists"]).Select(MapSong));
return new MusicSearchResult(songs, [], []);
}
@@ -393,7 +402,7 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
account,
"everydayrec.service.kugou.com",
cancellationToken).ConfigureAwait(false);
return SongArray(response.Json).Select(MapSong).Where(song => !string.IsNullOrWhiteSpace(song.Id)).Take(100).ToArray();
return MusicSongRules.FilterDisplayable(SongArray(response.Json).Select(MapSong)).Take(100).ToArray();
}
catch (KugouApiException exception) when (exception.AuthenticationFailure)
{
@@ -950,7 +959,10 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
private async Task<KugouAccountCredential> RequireAccountAsync(CancellationToken cancellationToken)
{
await InitializeAsync(cancellationToken).ConfigureAwait(false);
if (LoginState.IsStale) await RefreshLoginStateAsync(false, cancellationToken).ConfigureAwait(false);
if (LoginState.LoggedIn && (LoginState.IsStale || LoginState.Entitlements.Count == 0))
{
await RefreshLoginStateAsync(false, cancellationToken).ConfigureAwait(false);
}
return _account ?? throw new InvalidOperationException("请先登录酷狗音乐账户。");
}
@@ -986,9 +998,13 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
private async Task CommitAccountAsync(KugouAccountCredential account, bool save, CancellationToken cancellationToken)
{
_account = account with { Version = 2 };
LoginState = EnrichLoginState(ToLoginState(_account));
var entitlements = await LoadEntitlementsAsync(_account, cancellationToken).ConfigureAwait(false);
if (entitlements.Count > 0) LoginState = LoginState with { Entitlements = entitlements };
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)
{
@@ -1149,7 +1165,7 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
withinPage = 0;
page++;
}
return collected.Take(count).ToArray();
return MusicSongRules.FilterDisplayable(collected).Take(count).ToArray();
}
private async Task<IReadOnlyList<MusicSong>> LoadCollectionTracksAsync(
@@ -1189,7 +1205,7 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
if (nodes.Count < pageSize) break;
begin += nodes.Count;
}
return collected.Take(count).ToArray();
return MusicSongRules.FilterDisplayable(collected).Take(count).ToArray();
}
private async Task<IReadOnlyList<MusicSong>> GetRankTracksAsync(
@@ -1213,7 +1229,7 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
withinPage = 0;
page++;
}
return collected.Take(count).ToArray();
return MusicSongRules.FilterDisplayable(collected).Take(count).ToArray();
}
private async Task AddSongsToAccountPlaylistAsync(
@@ -1462,14 +1478,15 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
DateTimeOffset.UtcNow), cancellationToken).ConfigureAwait(false);
}
private async Task<IReadOnlyList<MusicEntitlementGrant>> LoadEntitlementsAsync(
private async Task<KugouEntitlementLoadResult> LoadEntitlementsAsync(
KugouAccountCredential account,
CancellationToken cancellationToken)
{
if (_localApi is null) return [];
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(
@@ -1477,30 +1494,11 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
method: HttpMethod.Post,
body: new JsonObject { ["cookie"] = cookie },
cancellationToken: cancellationToken).ConfigureAwait(false);
var grants = new List<MusicEntitlementGrant>();
var index = 0;
foreach (var node in KugouApiClient.Array(response["data"]?["busi_vip"] ?? response["busi_vip"]))
{
var product = KugouApiClient.Text(node, "product_type") ?? KugouApiClient.Text(node, "vip_type") ?? $"group-{index}";
var groupCode = KugouApiClient.Text(node, "busi_type") ?? $"group-{index}";
var groupName = index switch
{
0 => "概念版",
1 => "畅听版",
_ => groupCode
};
var enabled = KugouApiClient.Integer(node, "is_vip") == 1;
var expiryText = KugouApiClient.Text(node, "vip_end_time") ?? KugouApiClient.Text(node, "end_time");
DateTimeOffset? expiresAt = DateTimeOffset.TryParse(expiryText, out var parsedExpiry) ? parsedExpiry : null;
grants.Add(new MusicEntitlementGrant(
product,
$"{groupName} {product}",
groupCode,
enabled,
expiresAt));
index++;
}
return grants;
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)
{
@@ -1508,7 +1506,7 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
}
catch
{
return [];
return new(false, []);
}
}
@@ -1533,20 +1531,35 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
};
}
private static IReadOnlyList<MusicQualityOption> BuildQualityOptions(MusicLoginState state)
internal static IReadOnlyList<MusicQualityOption> BuildQualityOptions(MusicLoginState state)
{
var vip = state.Entitlements.Any(item => item.Enabled &&
(item.Group == "membership" || item.PlatformCode.Contains("vip", StringComparison.OrdinalIgnoreCase)));
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", vip ? MusicQualityAvailability.Available : MusicQualityAvailability.RequiresEntitlement, vip ? null : "需要酷狗会员权益"),
new(MusicPlaybackQuality.HiRes, "Hi-Res", "FLAC", 900, "酷狗 VIP", vip ? MusicQualityAvailability.Available : MusicQualityAvailability.RequiresEntitlement, vip ? null : "需要酷狗会员权益"),
new(MusicPlaybackQuality.Lossless, "无损音质", "FLAC", 800, "酷狗 VIP", vip ? MusicQualityAvailability.Available : MusicQualityAvailability.RequiresEntitlement, vip ? null : "需要酷狗会员权益"),
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);