From 80093f285eb67d00d2ef0dadeea9f8ff5b299409 Mon Sep 17 00:00:00 2001 From: admin_gitea Date: Tue, 18 Aug 2026 15:53:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=85=B7=E7=8B=97=E6=9D=83?= =?UTF-8?q?=E7=9B=8A=E8=AF=86=E5=88=AB=E5=B9=B6=E4=BF=AE=E5=A4=8D=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E5=88=97=E8=A1=A8=E7=A9=BA=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兼容本地酷狗 API 的多种权益响应结构,严格区分会员等级、有效期与音质解锁条件。统一过滤无效及重复歌曲,修复搜索、队列、历史记录和页面中的空行显示。 --- .../Music/KugouEntitlementParser.cs | 303 ++++++++++++++++++ .../Music/KugouMusicProvider.cs | 113 ++++--- src/YMhut.Box.Core/Music/MusicHistoryStore.cs | 11 +- src/YMhut.Box.Core/Music/MusicModels.cs | 3 +- src/YMhut.Box.Core/Music/MusicSongRules.cs | 30 ++ .../KugouMusicProviderTests.cs | 134 +++++++- src/box-winUI/Views/NetworkMusicPage.cs | 33 +- 7 files changed, 561 insertions(+), 66 deletions(-) create mode 100644 src/YMhut.Box.Core/Music/KugouEntitlementParser.cs create mode 100644 src/YMhut.Box.Core/Music/MusicSongRules.cs diff --git a/src/YMhut.Box.Core/Music/KugouEntitlementParser.cs b/src/YMhut.Box.Core/Music/KugouEntitlementParser.cs new file mode 100644 index 0000000..4b31283 --- /dev/null +++ b/src/YMhut.Box.Core/Music/KugouEntitlementParser.cs @@ -0,0 +1,303 @@ +using System.Globalization; +using System.Text.Json.Nodes; + +namespace YMhut.Box.Core.Music; + +internal sealed record KugouEntitlementLoadResult( + bool Succeeded, + IReadOnlyList Grants); + +/// +/// Parses the response of KuGouMusicApi's /user/vip/detail endpoint. The upstream +/// endpoint has returned both a busi_vip array and a single object over time, and +/// can wrap the object in data, vip_info or union_vip. Keep the parser tolerant of +/// those wire shapes while leaving unknown platform codes visible and non-unlocking. +/// +internal static class KugouEntitlementParser +{ + private static readonly string[] ProductFields = + [ + "product_type", "productType", "vip_type", "vipType", "vip_level", "vipLevel", + "product", "product_name", "productName", "membership_type", "membershipType", "type" + ]; + + private static readonly string[] GroupFields = + ["busi_type", "busiType", "business_type", "businessType", "business", "service_type", "serviceType", "category"]; + + private static readonly string[] NameFields = + ["display_name", "displayName", "product_name", "productName", "name", "title"]; + + private static readonly string[] ExpiryFields = + [ + "vip_end_time", "vipEndTime", "end_time", "endTime", "expire_time", "expireTime", + "expires_at", "expiresAt", "valid_to", "validTo" + ]; + + private static readonly string[] StatusFields = + ["is_vip", "isVip", "vip_status", "vipStatus", "is_valid", "isValid", "valid", "enabled", "enable", "state", "status"]; + + private static readonly string[] DirectStatusFields = + ["is_vip", "isVip", "vip_status", "vipStatus", "is_valid", "isValid"]; + + internal static IReadOnlyList Parse(JsonObject response) + { + var grants = new Dictionary(StringComparer.OrdinalIgnoreCase); + Visit(response, string.Empty, grants); + return grants.Values + .OrderByDescending(item => item.Enabled) + .ThenBy(item => item.Group, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.PlatformCode, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + internal static bool IsSuccessfulResponse(JsonObject response) + { + var status = Text(Find(response, "status")); + if (!string.IsNullOrWhiteSpace(status)) + { + return status.Trim().ToLowerInvariant() is "1" or "200" or "true" or "ok" or "success"; + } + + var code = Text(Find(response, "code")); + return string.IsNullOrWhiteSpace(code) || code.Trim() is "0" or "200"; + } + + private static void Visit(JsonNode? node, string context, IDictionary grants) + { + switch (node) + { + case JsonObject obj: + if (HasDirectEntitlementFields(obj)) + { + var grant = ParseGrant(obj, context); + if (grant is not null) + { + var key = $"{grant.Group}\u001f{grant.PlatformCode}"; + if (!grants.TryGetValue(key, out var existing) || Prefer(grant, existing)) + { + grants[key] = grant; + } + } + } + foreach (var pair in obj) + { + if (pair.Value is not null) Visit(pair.Value, pair.Key, grants); + } + break; + case JsonArray array: + foreach (var item in array) + { + if (item is not null) Visit(item, context, grants); + } + break; + } + } + + private static bool HasDirectEntitlementFields(JsonObject obj) + => ProductFields.Concat(GroupFields).Concat(ExpiryFields).Concat(DirectStatusFields) + .Any(name => Find(obj, name) is not null); + + private static MusicEntitlementGrant? ParseGrant(JsonObject obj, string context) + { + var rawProduct = FirstText(obj, ProductFields); + var rawGroup = FirstText(obj, GroupFields); + var rawName = FirstText(obj, NameFields); + var expiry = ParseExpiry(FirstText(obj, ExpiryFields)); + var enabled = ParseEnabled(obj, rawProduct, expiry); + var product = NormalizeProduct(rawProduct); + var group = NormalizeGroup(rawGroup, product, context); + + if (string.IsNullOrWhiteSpace(product) && string.IsNullOrWhiteSpace(group)) return null; + if (product is "none" or "0") return null; + if (expiry is { } expiryValue && expiryValue <= DateTimeOffset.UtcNow) enabled = false; + + var displayProduct = DisplayProduct(product, rawName, rawProduct); + var displayGroup = group switch + { + "concept" => "酷狗概念版", + "youth" => "酷狗青春版", + "music-package" => "音乐包", + "membership" => "酷狗会员", + _ => string.IsNullOrWhiteSpace(rawGroup) ? string.Empty : rawGroup.Trim() + }; + var displayName = string.IsNullOrWhiteSpace(displayProduct) + ? (string.IsNullOrWhiteSpace(displayGroup) ? "酷狗平台权益" : displayGroup) + : string.IsNullOrWhiteSpace(displayGroup) || displayGroup == "酷狗会员" + ? displayProduct + : $"{displayGroup} · {displayProduct}"; + + var platformCode = string.IsNullOrWhiteSpace(product) + ? NormalizeUnknown(rawGroup ?? context) + : product; + if (string.IsNullOrWhiteSpace(platformCode)) return null; + return new MusicEntitlementGrant(platformCode, displayName, group, enabled, expiry); + } + + private static bool Prefer(MusicEntitlementGrant candidate, MusicEntitlementGrant existing) + { + if (candidate.Enabled != existing.Enabled) return candidate.Enabled; + if (candidate.ExpiresAt is not null && existing.ExpiresAt is null) return true; + return candidate.ExpiresAt > existing.ExpiresAt; + } + + private static bool ParseEnabled(JsonObject obj, string? rawProduct, DateTimeOffset? expiry) + { + foreach (var field in StatusFields) + { + var value = Find(obj, field); + if (value is not null && TryBoolean(value, out var enabled)) return enabled; + } + + if (expiry is { } expiryValue) return expiryValue > DateTimeOffset.UtcNow; + var product = NormalizeProduct(rawProduct); + return product is "vip" or "svip" or "dvip" or "qvip"; + } + + private static string DisplayProduct(string product, string? rawName, string? rawProduct) + => product switch + { + "svip" => "酷狗 SVIP", + "vip" => "酷狗 VIP", + "dvip" => "酷狗 DVIP", + "qvip" => "酷狗 QVIP", + "music-package" => "酷狗音乐包", + "concept" => "概念版", + "" => string.IsNullOrWhiteSpace(rawName) ? rawProduct?.Trim() ?? string.Empty : rawName.Trim(), + _ => string.IsNullOrWhiteSpace(rawName) ? rawProduct?.Trim() ?? product : rawName.Trim() + }; + + private static string NormalizeProduct(string? raw) + { + var value = NormalizeUnknown(raw); + return value switch + { + "1" or "vip" or "member" or "membership" or "ordinaryvip" or "普通会员" or "普通vip" => "vip", + "2" or "6" or "svip" or "supervip" or "超级会员" or "超级vip" => "svip", + "dvip" or "digitalvip" => "dvip", + "qvip" or "youthvip" => "qvip", + "music" or "musicpack" or "musicpackage" or "音乐包" or "畅听版" or "畅听" => "music-package", + "concept" or "概念版" => "concept", + "0" or "none" or "normal" or "未开通" => "none", + _ => raw?.Trim() ?? string.Empty + }; + } + + private static string NormalizeGroup(string? raw, string product, string context) + { + var value = NormalizeUnknown(raw); + if (string.IsNullOrWhiteSpace(value)) value = NormalizeUnknown(context); + return value switch + { + "concept" or "概念版" => "concept", + "youth" or "青春版" or "青少年" => "youth", + "music" or "musicpack" or "musicpackage" or "音乐包" or "畅听版" or "畅听" => "music-package", + "vip" or "svip" or "membership" or "member" or "会员" => "membership", + "" or "data" or "busivip" or "vipinfo" or "unionvip" or "items" or "list" or "records" or "grants" + => product is "music-package" ? "music-package" : IsMembershipProduct(product) ? "membership" : "unknown", + _ => value + }; + } + + private static bool IsMembershipProduct(string product) + => product is "vip" or "svip" or "dvip" or "qvip"; + + private static string NormalizeUnknown(string? raw) + => string.IsNullOrWhiteSpace(raw) + ? string.Empty + : raw.Trim().ToLowerInvariant().Replace(" ", string.Empty).Replace("_", string.Empty).Replace("-", string.Empty); + + private static string? FirstText(JsonObject obj, IEnumerable fields) + { + foreach (var field in fields) + { + var value = Find(obj, field); + var text = Text(value); + if (!string.IsNullOrWhiteSpace(text)) return text; + } + return null; + } + + private static JsonNode? Find(JsonObject obj, string field) + => obj.FirstOrDefault(pair => string.Equals(pair.Key, field, StringComparison.OrdinalIgnoreCase)).Value; + + private static string? Text(JsonNode? node) + { + if (node is null) return null; + if (node is JsonValue value) + { + if (value.TryGetValue(out var text)) return text; + if (value.TryGetValue(out var boolean)) return boolean ? "1" : "0"; + if (value.TryGetValue(out var number)) return number.ToString(CultureInfo.InvariantCulture); + } + return node.ToJsonString().Trim('"'); + } + + private static bool TryBoolean(JsonNode node, out bool value) + { + var text = Text(node)?.Trim().ToLowerInvariant(); + if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)) + { + value = number > 0; + return true; + } + switch (text) + { + case "1": + case "true": + case "yes": + case "y": + case "active": + case "valid": + case "normal": + case "open": + case "enabled": + case "vip": + case "svip": + value = true; + return true; + case "0": + case "false": + case "no": + case "n": + case "inactive": + case "invalid": + case "expired": + case "closed": + case "disabled": + case "none": + value = false; + return true; + default: + value = false; + return false; + } + } + + private static DateTimeOffset? ParseExpiry(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var value = raw.Trim(); + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)) + { + if (number <= 0) return null; + try + { + return number >= 100_000_000_000 + ? DateTimeOffset.FromUnixTimeMilliseconds(number) + : DateTimeOffset.FromUnixTimeSeconds(number); + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } + + var formats = new[] + { + "yyyy-MM-dd HH:mm:ss", "yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd", "yyyy/MM/dd", + "yyyy-MM-ddTHH:mm:ss", "yyyy-MM-ddTHH:mm:ss.FFFFFFFK" + }; + if (DateTimeOffset.TryParseExact(value, formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed)) return parsed; + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out parsed) ? parsed : null; + } +} diff --git a/src/YMhut.Box.Core/Music/KugouMusicProvider.cs b/src/YMhut.Box.Core/Music/KugouMusicProvider.cs index 1b5cc74..ad6a767 100644 --- a/src/YMhut.Box.Core/Music/KugouMusicProvider.cs +++ b/src/YMhut.Box.Core/Music/KugouMusicProvider.cs @@ -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 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> 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> 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> LoadEntitlementsAsync( + private async Task 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(); - 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 BuildQualityOptions(MusicLoginState state) + internal static IReadOnlyList 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); diff --git a/src/YMhut.Box.Core/Music/MusicHistoryStore.cs b/src/YMhut.Box.Core/Music/MusicHistoryStore.cs index dd539f7..5e61360 100644 --- a/src/YMhut.Box.Core/Music/MusicHistoryStore.cs +++ b/src/YMhut.Box.Core/Music/MusicHistoryStore.cs @@ -34,7 +34,7 @@ public sealed class MusicHistoryStore(AppPaths paths) : IMusicHistoryStore public async Task RecordPlayedAsync(MusicSong song, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(song.Provider) || string.IsNullOrWhiteSpace(song.Id)) return; + if (!MusicSongRules.IsDisplayable(song)) return; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); var temporary = HistoryPath + $".{Guid.NewGuid():N}.tmp"; try @@ -68,10 +68,11 @@ public sealed class MusicHistoryStore(AppPaths paths) : IMusicHistoryStore try { if (!File.Exists(HistoryPath)) return []; - return JsonSerializer.Deserialize>( - await File.ReadAllTextAsync(HistoryPath, cancellationToken).ConfigureAwait(false), - JsonOptions) - ?? []; + var entries = JsonSerializer.Deserialize>( + await File.ReadAllTextAsync(HistoryPath, cancellationToken).ConfigureAwait(false), + JsonOptions) + ?? []; + return entries.Where(entry => MusicSongRules.IsDisplayable(entry.Song)).ToArray(); } catch (JsonException) { diff --git a/src/YMhut.Box.Core/Music/MusicModels.cs b/src/YMhut.Box.Core/Music/MusicModels.cs index a0c705a..93c7a9d 100644 --- a/src/YMhut.Box.Core/Music/MusicModels.cs +++ b/src/YMhut.Box.Core/Music/MusicModels.cs @@ -302,7 +302,7 @@ public sealed class MusicQueue public void Replace(IEnumerable songs, string? currentId = null, string? currentProvider = null) { _items.Clear(); - _items.AddRange(songs.Where(song => !string.IsNullOrWhiteSpace(song.Id))); + _items.AddRange(MusicSongRules.FilterDisplayable(songs)); CurrentIndex = currentId is null ? (_items.Count > 0 ? 0 : -1) : _items.FindIndex(song => @@ -313,6 +313,7 @@ public sealed class MusicQueue public void Select(MusicSong song) { + if (!MusicSongRules.IsDisplayable(song)) return; var index = _items.FindIndex(item => item.Id == song.Id && item.Provider == song.Provider); if (index < 0) { diff --git a/src/YMhut.Box.Core/Music/MusicSongRules.cs b/src/YMhut.Box.Core/Music/MusicSongRules.cs new file mode 100644 index 0000000..ccbf8f5 --- /dev/null +++ b/src/YMhut.Box.Core/Music/MusicSongRules.cs @@ -0,0 +1,30 @@ +namespace YMhut.Box.Core.Music; + +/// +/// Shared validation for song collections rendered or queued by the music client. +/// A provider can return placeholder records while enriching a page; those records +/// must not become visible rows or displace playable songs in a page-sized result. +/// +public static class MusicSongRules +{ + public static bool IsDisplayable(MusicSong? song) + => song is not null && + !string.IsNullOrWhiteSpace(song.Provider) && + !string.IsNullOrWhiteSpace(song.Id) && + !string.IsNullOrWhiteSpace(song.Name); + + public static IReadOnlyList FilterDisplayable(IEnumerable? songs) + { + if (songs is null) return []; + + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var song in songs) + { + if (!IsDisplayable(song)) continue; + var key = $"{song.Provider}\u001f{song.Id}"; + if (seen.Add(key)) result.Add(song); + } + return result; + } +} diff --git a/src/YMhut.Box.Tests/KugouMusicProviderTests.cs b/src/YMhut.Box.Tests/KugouMusicProviderTests.cs index fbade58..ece5e8d 100644 --- a/src/YMhut.Box.Tests/KugouMusicProviderTests.cs +++ b/src/YMhut.Box.Tests/KugouMusicProviderTests.cs @@ -691,13 +691,144 @@ public sealed class KugouMusicProviderTests StringAssert.Contains(sanitized, "ordinary text"); } + [TestMethod] + public void KugouEntitlementParserHandlesSingleObjectAndNestedVipInfo() + { + var response = JsonNode.Parse(""" + { + "status": 1, + "data": { + "vip_info": { + "busi_type": "concept", + "product_type": "svip", + "is_vip": "1", + "vip_end_time": "2099-12-31 23:59:59" + } + } + } + """)!.AsObject(); + + var grants = KugouEntitlementParser.Parse(response); + + Assert.HasCount(1, grants); + Assert.AreEqual("svip", grants[0].PlatformCode); + Assert.AreEqual("concept", grants[0].Group); + Assert.IsTrue(grants[0].Enabled); + Assert.IsNotNull(grants[0].ExpiresAt); + } + + [TestMethod] + public void KugouEntitlementParserPreservesGroupsAndMarksExpiredRightsDisabled() + { + var response = new JsonObject + { + ["data"] = new JsonObject + { + ["busi_vip"] = new JsonArray + { + new JsonObject + { + ["busi_type"] = "concept", + ["product_type"] = "svip", + ["is_vip"] = 1, + ["vip_end_time"] = DateTimeOffset.UtcNow.AddDays(2).ToUnixTimeMilliseconds() + }, + new JsonObject + { + ["busi_type"] = "standard", + ["product_type"] = "qvip", + ["is_vip"] = "1", + ["vip_end_time"] = DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds() + }, + new JsonObject + { + ["busi_type"] = "partner", + ["product_type"] = "partner_plus", + ["is_vip"] = true + } + } + } + }; + + var grants = KugouEntitlementParser.Parse(response); + + Assert.HasCount(3, grants); + var svip = grants.Single(item => item.PlatformCode == "svip"); + var qvip = grants.Single(item => item.PlatformCode == "qvip"); + var unknown = grants.Single(item => item.PlatformCode == "partner_plus"); + Assert.IsTrue(svip.Enabled); + Assert.IsFalse(qvip.Enabled); + Assert.IsTrue(unknown.Enabled); + Assert.AreEqual("partner", unknown.Group); + } + + [TestMethod] + public void KugouQualityOptionsRequireSvipForMasterAndIgnoreExpiredRights() + { + var vip = new MusicLoginState(true) + { + Entitlements = [new("vip", "酷狗 VIP", "membership", true)] + }; + var svip = new MusicLoginState(true) + { + Entitlements = [new("svip", "酷狗 SVIP", "membership", true)] + }; + var expired = new MusicLoginState(true) + { + Entitlements = [new("svip", "酷狗 SVIP", "membership", true, DateTimeOffset.UtcNow.AddMinutes(-1))] + }; + var unknown = new MusicLoginState(true) + { + Entitlements = [new("partner_plus", "合作方会员", "membership", true)] + }; + + var vipOptions = KugouMusicProvider.BuildQualityOptions(vip); + var svipOptions = KugouMusicProvider.BuildQualityOptions(svip); + var expiredOptions = KugouMusicProvider.BuildQualityOptions(expired); + var unknownOptions = KugouMusicProvider.BuildQualityOptions(unknown); + + Assert.AreEqual(MusicQualityAvailability.RequiresEntitlement, vipOptions.Single(item => item.Quality == MusicPlaybackQuality.Master).Availability); + Assert.AreEqual(MusicQualityAvailability.Available, vipOptions.Single(item => item.Quality == MusicPlaybackQuality.Lossless).Availability); + Assert.AreEqual(MusicQualityAvailability.Available, svipOptions.Single(item => item.Quality == MusicPlaybackQuality.Master).Availability); + Assert.AreEqual(MusicQualityAvailability.RequiresEntitlement, expiredOptions.Single(item => item.Quality == MusicPlaybackQuality.Lossless).Availability); + Assert.AreEqual(MusicQualityAvailability.RequiresEntitlement, unknownOptions.Single(item => item.Quality == MusicPlaybackQuality.Lossless).Availability); + } + + [TestMethod] + public void KugouEntitlementParserIgnoresOrdinaryResponseStatusContainers() + { + var response = JsonNode.Parse("""{"status":1,"data":{"status":1,"message":"ok"}}""")!.AsObject(); + + var grants = KugouEntitlementParser.Parse(response); + + Assert.IsEmpty(grants); + Assert.IsTrue(KugouEntitlementParser.IsSuccessfulResponse(response)); + Assert.IsFalse(KugouEntitlementParser.IsSuccessfulResponse(JsonNode.Parse("""{"status":0,"error_code":20017}""")!.AsObject())); + } + + [TestMethod] + public void MusicSongRulesRemoveBlankAndDuplicateRows() + { + var songs = MusicSongRules.FilterDisplayable([ + Song("A|1", "Song A"), + Song("A|1", "Song A duplicate"), + Song("B|2", ""), + new MusicSong("kugou", "C|3", " ", "Artist", [], "Album", "", TimeSpan.Zero, 0), + Song("D|4", "Song D") + ]); + + Assert.HasCount(2, songs); + Assert.AreEqual("A|1", songs[0].Id); + Assert.AreEqual("D|4", songs[1].Id); + } + [TestMethod] public async Task SearchChartsLyricsAndMvKeepPublicHttpsBehavior() { var handler = new StubHttpHandler((request, _) => { var uri = request.RequestUri!; - if (uri.Host == "songsearch.kugou.com") return Task.FromResult(JsonResponse("{\"data\":{\"lists\":[{\"FileHash\":\"ABC123\",\"AlbumID\":\"456\",\"SongName\":\"Song\",\"SingerName\":\"Artist\",\"AlbumName\":\"Album\",\"Duration\":180,\"Image\":\"https://img.test/{size}.jpg\"}]}}")); + if (uri.Host == "songsearch.kugou.com") return Task.FromResult(JsonResponse("{\"data\":{\"lists\":[{\"FileHash\":\"ABC123\",\"AlbumID\":\"456\",\"SongName\":\"Song\",\"SingerName\":\"Artist\",\"AlbumName\":\"Album\",\"Duration\":180,\"Image\":\"https://img.test/{size}.jpg\"},{\"FileHash\":\"EMPTY\",\"AlbumID\":\"457\",\"SongName\":\"\",\"SingerName\":\"Artist\"}]}}")); if (uri.AbsolutePath.Contains("/rank/list", StringComparison.Ordinal)) return Task.FromResult(JsonResponse("{\"rank\":{\"list\":[{\"rankid\":8888,\"rankname\":\"TOP500\",\"imgurl\":\"http://imge.kugou.com/mcommon/{size}/rank.png\"}]}}")); if (uri.AbsolutePath.Contains("/rank/info/", StringComparison.Ordinal)) return Task.FromResult(JsonResponse("{\"songs\":{\"list\":[{\"hash\":\"HASH1\",\"album_id\":\"77\",\"songname\":\"Song\",\"h5_author_name\":\"Artist\",\"duration\":180,\"mvdata\":[{\"hash\":\"MV1\"}] }]}}")); if (uri.Host == "lyrics.kugou.com" && uri.AbsolutePath.EndsWith("/search", StringComparison.Ordinal)) return Task.FromResult(JsonResponse("{\"candidates\":[{\"id\":\"42\",\"accesskey\":\"key\"}]}")); @@ -713,6 +844,7 @@ public sealed class KugouMusicProviderTests var lyrics = await provider.GetLyricsAsync("ABC123|456"); var mv = await provider.GetMvAsync("HASH1|77", "MV1"); + Assert.HasCount(1, search.Songs); Assert.AreEqual("ABC123|456", search.Songs[0].Id); Assert.AreEqual("rank:8888", charts[0].Id); Assert.IsTrue(charts[0].CoverUrl.StartsWith("https://", StringComparison.Ordinal)); diff --git a/src/box-winUI/Views/NetworkMusicPage.cs b/src/box-winUI/Views/NetworkMusicPage.cs index 60f817b..3e817ff 100644 --- a/src/box-winUI/Views/NetworkMusicPage.cs +++ b/src/box-winUI/Views/NetworkMusicPage.cs @@ -855,13 +855,16 @@ public sealed class NetworkMusicPage : Page private Task LoadRecentHistoryAsync() => RunBusyAsync(async () => { var entries = await _historyStore.LoadAsync(); - _visibleSongs = entries + var displayableEntries = entries + .Where(entry => MusicSongRules.IsDisplayable(entry.Song)) + .ToArray(); + _visibleSongs = displayableEntries .Where(entry => string.Equals(entry.Song.Provider, Provider.Id, StringComparison.OrdinalIgnoreCase)) .Select(entry => entry.Song) .ToArray(); _results.Items.Clear(); var index = 1; - foreach (var entry in entries) _results.Items.Add(HistoryRow(entry, index++)); + foreach (var entry in displayableEntries) _results.Items.Add(HistoryRow(entry, index++)); _sectionTitle.Text = AppLocalizer.T("最近播放", "Recently played"); _sectionMeta.Text = AppLocalizer.T("最多保留 500 首;其他来源需要手动切换", "Up to 500 entries; switch providers to play other sources"); }); @@ -1024,10 +1027,10 @@ public sealed class NetworkMusicPage : Page private void RenderSongs(IReadOnlyList songs) { - _visibleSongs = songs; + _visibleSongs = MusicSongRules.FilterDisplayable(songs); _results.Items.Clear(); var index = 1; - foreach (var song in songs) _results.Items.Add(SongRow(song, index++)); + foreach (var song in _visibleSongs) _results.Items.Add(SongRow(song, index++)); } private void RenderPlaylists(IReadOnlyList playlists) @@ -1054,8 +1057,12 @@ public sealed class NetworkMusicPage : Page grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(58) }); grid.Children.Add(Text(index.ToString("00"), 11, foreground: SecondaryTextBrush, maxLines: 1)); AddColumn(grid, Text(song.Name, 13, FontWeights.SemiBold, maxLines: 1), 1); - AddColumn(grid, Text(song.Artist, 12, foreground: SecondaryTextBrush, maxLines: 1), 2); - AddColumn(grid, Text(song.Album, 12, foreground: SecondaryTextBrush, maxLines: 1), 3); + AddColumn(grid, Text(DisplayArtist(song), 12, foreground: SecondaryTextBrush, maxLines: 1), 2); + AddColumn(grid, Text( + string.IsNullOrWhiteSpace(song.Album) ? AppLocalizer.T("未知专辑", "Unknown album") : song.Album, + 12, + foreground: SecondaryTextBrush, + maxLines: 1), 3); AddColumn(grid, Text(song.Duration.ToString(@"m\:ss"), 11, foreground: SecondaryTextBrush, maxLines: 1), 4); return RowItem(song, grid, () => PlaySongAsync(song)); } @@ -1070,7 +1077,7 @@ public sealed class NetworkMusicPage : Page grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(110) }); grid.Children.Add(Text(locked ? "\uE72E" : index.ToString("00"), 12, foreground: SecondaryTextBrush, maxLines: 1)); AddColumn(grid, Text(entry.Song.Name, 13, FontWeights.SemiBold, maxLines: 1), 1); - AddColumn(grid, Text(entry.Song.Artist, 12, foreground: SecondaryTextBrush, maxLines: 1), 2); + AddColumn(grid, Text(DisplayArtist(entry.Song), 12, foreground: SecondaryTextBrush, maxLines: 1), 2); AddColumn(grid, Text( locked ? AppLocalizer.T("其他来源", "Other source") : AppLocalizer.T("当前来源", "Current source"), 11, @@ -1090,6 +1097,11 @@ public sealed class NetworkMusicPage : Page }); } + private static string DisplayArtist(MusicSong song) + => string.IsNullOrWhiteSpace(song.Artist) + ? AppLocalizer.T("未知歌手", "Unknown artist") + : song.Artist; + private ListViewItem PlaylistRow(MusicPlaylist playlist) { var grid = RowGrid(); @@ -1654,7 +1666,9 @@ public sealed class NetworkMusicPage : Page try { await currentProvider.InitializeAsync(); - if (currentProvider.LoginState.LoggedIn && currentProvider.LoginState.IsStale) + var missingEntitlementSnapshot = currentProvider.LoginState.Entitlements.Count == 0; + if (currentProvider.LoginState.LoggedIn && + (currentProvider.LoginState.IsStale || missingEntitlementSnapshot)) { await currentProvider.RefreshLoginStateAsync(); } @@ -1755,7 +1769,8 @@ public sealed class NetworkMusicPage : Page foreach (var grant in currentProvider.LoginState.Entitlements) { var expiry = grant.ExpiresAt is null ? string.Empty : AppLocalizer.T($" · 至 {grant.ExpiresAt:yyyy-MM-dd}", $" · until {grant.ExpiresAt:yyyy-MM-dd}"); - entitlementPanel.Children.Add(Text($"{grant.DisplayName} · {grant.Group}{expiry}", 11.5, foreground: grant.Enabled ? PrimaryTextBrush : SecondaryTextBrush, maxLines: 2)); + var status = grant.Enabled ? string.Empty : AppLocalizer.T(" · 未生效", " · inactive"); + entitlementPanel.Children.Add(Text($"{grant.DisplayName} · {grant.Group}{expiry}{status}", 11.5, foreground: grant.Enabled ? PrimaryTextBrush : SecondaryTextBrush, maxLines: 2)); } content.Children.Add(entitlementPanel); }