完善开发环境工具、音乐服务和界面性能
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user