完善开发环境工具、音乐服务和界面性能

This commit is contained in:
2026-08-17 03:09:59 +08:00
parent 8b2dd89d2f
commit 92e5c330f2
22 changed files with 1185 additions and 156 deletions
+151 -12
View File
@@ -24,12 +24,17 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
};
private static readonly (MusicPlaybackQuality Quality, string Value, int Bitrate)[] QualityOrder =
[
(MusicPlaybackQuality.Master, "super", 1000),
(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 HttpClient _publicClient;
@@ -348,10 +353,11 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
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;
for (var index = start; index < QualityOrder.Length; index++)
foreach (var candidate in candidates)
{
var candidate = QualityOrder[index];
try
{
var response = await _api.SendAndroidAsync(
@@ -361,12 +367,12 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
{
["album_id"] = identity.AlbumId,
["area_code"] = "1",
["hash"] = identity.Hash.ToLowerInvariant(),
["hash"] = candidate.Hash.ToLowerInvariant(),
["ssa_flag"] = "is_fromtrack",
["version"] = "11430",
["page_id"] = "151369488",
["quality"] = candidate.Value,
["album_audio_id"] = SongDataFor(songId)?.MixSongId.ToString(CultureInfo.InvariantCulture) ?? "0",
["album_audio_id"] = metadata?.MixSongId.ToString(CultureInfo.InvariantCulture) ?? "0",
["behavior"] = "play",
["pid"] = "2",
["cmd"] = "26",
@@ -382,14 +388,20 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
"trackercdn.kugou.com",
cancellationToken,
addTrackKey: true).ConfigureAwait(false);
var data = response.Json["data"] ?? response.Json;
if (data is JsonArray array) data = array.FirstOrDefault();
var address = KugouApiClient.Text(data, "url") ?? KugouApiClient.Text(data, "play_url") ?? FirstText(data, "backup_url");
if (Uri.TryCreate(address, UriKind.Absolute, out var streamUri) && streamUri.Scheme == Uri.UriSchemeHttps)
foreach (var data in PlaybackResponseNodes(response.Json))
{
var bitrate = KugouApiClient.Integer(data, "bitRate");
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 trial = KugouApiClient.Integer(data, "is_free_part") == 1;
var trial = FirstInteger(data, "is_free_part", "isFreePart") == 1;
return new MusicStreamResult(
streamUri,
true,
@@ -398,7 +410,10 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
bitrate * 1000L,
trial ? "提供方仅允许试听片段。" : candidate.Quality == quality ? null : $"已回退到 {candidate.Quality} 音质。");
}
lastReason = KugouApiClient.Text(data, "error") ?? KugouApiClient.Text(response.Json, "error") ?? "提供方未返回可播放地址。";
lastReason = KugouApiClient.Text(response.Json, "error") ??
KugouApiClient.Text(response.Json, "msg") ??
lastReason ??
"提供方未返回可用的 HTTPS 播放地址。";
}
catch (KugouApiException exception) when (!exception.AuthenticationFailure)
{
@@ -409,6 +424,128 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
lastReason ?? "提供方未返回播放地址,歌曲可能需要登录、会员或受地区限制。");
}
private async Task<IReadOnlyList<KugouPlaybackCandidate>> GetPlaybackCandidatesAsync(
(string Hash, string AlbumId) identity,
KugouSongData? metadata,
int start,
CancellationToken cancellationToken)
{
var fallback = QualityOrder.Skip(start).ToArray();
var variants = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var response = await _api.SendAndroidAsync(
HttpMethod.Post,
"/v2/get_res_privilege/lite",
null,
new JsonObject
{
["appid"] = KugouApiClient.AppId,
["area_code"] = 1,
["behavior"] = "play",
["clientver"] = KugouApiClient.ClientVersion,
["need_hash_offset"] = 1,
["relate"] = 1,
["support_verify"] = 1,
["resource"] = new JsonArray
{
new JsonObject
{
["type"] = "audio",
["page_id"] = 0,
["hash"] = identity.Hash,
["album_id"] = metadata?.AlbumId ?? ParseLong(identity.AlbumId, defaultValue: 0)
}
},
["qualities"] = new JsonArray(KugouPlaybackQualities
.Select(value => (JsonNode?)JsonValue.Create(value))
.ToArray())
},
_account,
"media.store.kugou.com",
cancellationToken).ConfigureAwait(false);
foreach (var item in KugouApiClient.Array(response.Json["data"]))
{
AddPlaybackVariant(item, variants);
foreach (var related in KugouApiClient.Array(item?["relate_goods"]))
{
AddPlaybackVariant(related, variants);
}
}
}
catch (KugouApiException exception) when (!exception.AuthenticationFailure)
{
// The URL endpoint can still resolve free or standard variants when the
// privilege service is temporarily unavailable.
}
var resolved = fallback
.Where(item => variants.ContainsKey(item.Value))
.Select(item => new KugouPlaybackCandidate(item.Quality, item.Value, variants[item.Value], item.Bitrate))
.ToArray();
return resolved.Length > 0
? resolved
: fallback.Select(item => new KugouPlaybackCandidate(item.Quality, item.Value, identity.Hash, item.Bitrate)).ToArray();
}
private static void AddPlaybackVariant(JsonNode? node, IDictionary<string, string> variants)
{
if (node is not JsonObject item) return;
if (item.ContainsKey("level") && KugouApiClient.Integer(item, "level") == 0) return;
var quality = KugouApiClient.Text(item, "quality");
var hash = KugouApiClient.Text(item, "hash");
if (string.IsNullOrWhiteSpace(quality) || string.IsNullOrWhiteSpace(hash)) return;
variants.TryAdd(quality, hash);
}
private static IEnumerable<JsonNode> PlaybackResponseNodes(JsonObject response)
{
yield return response;
if (response["data"] is JsonObject data)
{
yield return data;
}
else if (response["data"] is JsonArray array)
{
foreach (var item in array.Where(item => item is not null)) yield return item!;
}
}
private static bool TryGetHttpsPlaybackUri(JsonNode? node, out Uri? result)
{
foreach (var property in new[] { "url", "play_url", "backup_url", "backupurl" })
{
foreach (var address in TextValues(node?[property]))
{
if (Uri.TryCreate(address, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps)
{
result = uri;
return true;
}
}
}
result = null;
return false;
}
private static IEnumerable<string> TextValues(JsonNode? node)
{
if (node is JsonValue value && value.TryGetValue<string>(out var valueText) && !string.IsNullOrWhiteSpace(valueText))
{
yield return valueText;
yield break;
}
if (node is not JsonArray array) yield break;
foreach (var item in array)
{
foreach (var itemText in TextValues(item)) yield return itemText;
}
}
private static int FirstInteger(JsonNode? node, params string[] properties)
=> properties.Select(property => KugouApiClient.Integer(node, property)).FirstOrDefault(value => value != 0);
public async Task<MusicStreamProbe> ProbeStreamAsync(Uri uri, CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
@@ -1189,5 +1326,7 @@ public sealed class KugouMusicProvider : IMusicProvider, IDisposable
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);
}