完善酷狗权益识别并修复音乐列表空行
兼容本地酷狗 API 的多种权益响应结构,严格区分会员等级、有效期与音质解锁条件。统一过滤无效及重复歌曲,修复搜索、队列、历史记录和页面中的空行显示。
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace YMhut.Box.Core.Music;
|
||||
|
||||
internal sealed record KugouEntitlementLoadResult(
|
||||
bool Succeeded,
|
||||
IReadOnlyList<MusicEntitlementGrant> Grants);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<MusicEntitlementGrant> Parse(JsonObject response)
|
||||
{
|
||||
var grants = new Dictionary<string, MusicEntitlementGrant>(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<string, MusicEntitlementGrant> 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<string> 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<string>(out var text)) return text;
|
||||
if (value.TryGetValue<bool>(out var boolean)) return boolean ? "1" : "0";
|
||||
if (value.TryGetValue<decimal>(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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<List<MusicHistoryEntry>>(
|
||||
var entries = JsonSerializer.Deserialize<List<MusicHistoryEntry>>(
|
||||
await File.ReadAllTextAsync(HistoryPath, cancellationToken).ConfigureAwait(false),
|
||||
JsonOptions)
|
||||
?? [];
|
||||
return entries.Where(entry => MusicSongRules.IsDisplayable(entry.Song)).ToArray();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
|
||||
@@ -302,7 +302,7 @@ public sealed class MusicQueue
|
||||
public void Replace(IEnumerable<MusicSong> 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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace YMhut.Box.Core.Music;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<MusicSong> FilterDisplayable(IEnumerable<MusicSong>? songs)
|
||||
{
|
||||
if (songs is null) return [];
|
||||
|
||||
var result = new List<MusicSong>();
|
||||
var seen = new HashSet<string>(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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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<MusicSong> 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<MusicPlaylist> 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user