feat: complete 2.0.7.12 platform overhaul

This commit is contained in:
2026-08-16 19:33:03 +08:00
parent 73555cd04c
commit c9fa6f7a88
159 changed files with 13243 additions and 2539 deletions
@@ -0,0 +1,522 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace YMhut.Box.Core.Music;
/// <summary>
/// Kugou's public web endpoints are intentionally kept separate from the NetEase client.
/// The adapter accepts only credentials returned by the provider's own sign-in flow and does
/// not synthesize entitlement tokens or media URLs.
/// </summary>
public sealed class KugouMusicProvider : IMusicProvider, IDisposable
{
private const string WebOrigin = "https://www.kugou.com";
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36";
internal static IReadOnlyList<(string Uri, int Priority)> BrowserCookieSources { get; } =
[
("https://loginservice.kugou.com/", 40),
("https://staticssl.kugou.com/", 30),
("https://login-user.kugou.com/", 20),
("https://www.kugou.com/", 10),
("https://kugou.com/", 0)
];
private readonly IMusicCredentialStore _credentials;
private readonly HttpClient _client;
private readonly SemaphoreSlim _initialization = new(1, 1);
private string _cookie = string.Empty;
private bool _initialized;
public KugouMusicProvider(IMusicCredentialStore credentials, HttpMessageHandler? handler = null)
{
_credentials = credentials;
_client = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_client.Timeout = TimeSpan.FromSeconds(12);
_client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
_client.DefaultRequestHeaders.Referrer = new Uri(WebOrigin + "/");
_client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", WebOrigin);
_client.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.7");
}
public string Id => "kugou";
public MusicLoginState LoginState { get; private set; } = new(false);
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
if (_initialized) return;
await _initialization.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_initialized) return;
var cookie = await _credentials.LoadAsync(Id, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(cookie))
{
var validation = await ValidateCookieAsync(cookie, cancellationToken).ConfigureAwait(false);
if (validation.State.LoggedIn)
{
_cookie = validation.Cookie;
LoginState = validation.State;
if (!string.Equals(cookie, validation.Cookie, StringComparison.Ordinal))
{
await _credentials.SaveAsync(Id, validation.Cookie, cancellationToken).ConfigureAwait(false);
}
}
else
{
await _credentials.ClearAsync(Id, cancellationToken).ConfigureAwait(false);
}
}
_initialized = true;
}
finally
{
_initialization.Release();
}
}
public async Task<MusicLoginState> LoginWithCookieAsync(string cookie, CancellationToken cancellationToken = default)
{
var normalized = NormalizeCookie(cookie);
var validation = await ValidateCookieAsync(normalized, cancellationToken).ConfigureAwait(false);
if (!validation.State.LoggedIn) return validation.State;
_cookie = validation.Cookie;
LoginState = validation.State;
_initialized = true;
await _credentials.SaveAsync(Id, validation.Cookie, cancellationToken).ConfigureAwait(false);
return validation.State;
}
public async Task<MusicLoginState> CompleteOfficialWebLoginAsync(
string cookie,
Uri completionUri,
CancellationToken cancellationToken = default)
{
if (!IsOfficialWebLoginCompletionUri(completionUri)) return new MusicLoginState(false);
var normalized = NormalizeCookie(cookie);
var account = ParseAccount(ParseCookie(normalized));
if (account is null) return new MusicLoginState(false);
_cookie = normalized;
LoginState = ToLoginState(account);
_initialized = true;
await _credentials.SaveAsync(Id, normalized, cancellationToken).ConfigureAwait(false);
return LoginState;
}
public Task<MusicQrSession> CreateQrSessionAsync(CancellationToken cancellationToken = default)
=> Task.FromException<MusicQrSession>(new InvalidOperationException("酷狗扫码登录通过内嵌的官方网页登录完成。"));
public Task<MusicQrStatus> CheckQrSessionAsync(MusicQrSession session, CancellationToken cancellationToken = default)
=> Task.FromResult(new MusicQrStatus(805, "酷狗扫码登录由官方网页管理。", false, false, true));
public async Task LogoutAsync(CancellationToken cancellationToken = default)
{
_cookie = string.Empty;
LoginState = new MusicLoginState(false);
await _credentials.ClearAsync(Id, cancellationToken).ConfigureAwait(false);
}
public async Task<MusicSearchResult> SearchAsync(string keywords, MusicSearchKind kind, int limit = 30, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(keywords) || kind != MusicSearchKind.Songs) return new([], [], []);
var uri = "https://songsearch.kugou.com/song_search_v2?keyword=" +
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 GetJsonAsync(uri, _cookie, cancellationToken).ConfigureAwait(false);
var songs = Array(result["data"]?["lists"])
.Select(MapSong)
.Where(song => !string.IsNullOrWhiteSpace(song.Id))
.ToArray();
return new MusicSearchResult(songs, [], []);
}
public Task<IReadOnlyList<MusicPlaylist>> GetUserPlaylistsAsync(CancellationToken cancellationToken = default)
=> Task.FromException<IReadOnlyList<MusicPlaylist>>(new InvalidOperationException("酷狗官方网页当前未提供可验证的用户歌单接口。"));
public async Task<IReadOnlyList<MusicPlaylist>> GetRecommendedPlaylistsAsync(CancellationToken cancellationToken = default)
=> (await GetChartsAsync(cancellationToken).ConfigureAwait(false)).Take(12).ToArray();
public async Task<IReadOnlyList<MusicPlaylist>> GetChartsAsync(CancellationToken cancellationToken = default)
{
var result = await GetJsonAsync("https://m.kugou.com/rank/list&json=true", _cookie, cancellationToken).ConfigureAwait(false);
return Array(result["rank"]?["list"])
.Select(node => new MusicPlaylist(
Id,
"rank:" + (Text(node, "rankid") ?? string.Empty),
Text(node, "rankname") ?? "酷狗榜单",
NormalizeImage(Text(node, "imgurl") ?? Text(node, "img_9") ?? Text(node, "album_img_9") ?? string.Empty),
Integer(node, "songcount"),
"酷狗音乐"))
.Where(playlist => !playlist.Id.EndsWith(':'))
.ToArray();
}
public Task<IReadOnlyList<MusicSong>> GetDailySongsAsync(CancellationToken cancellationToken = default)
=> Task.FromException<IReadOnlyList<MusicSong>>(new InvalidOperationException("酷狗官方网页当前未提供可验证的账户每日推荐接口。"));
public async Task<IReadOnlyList<MusicSong>> GetPlaylistTracksAsync(string playlistId, int offset = 0, int count = 100, CancellationToken cancellationToken = default)
{
if (!playlistId.StartsWith("rank:", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("当前酷狗网页来源仅支持打开官方榜单。");
}
var rankId = playlistId["rank:".Length..];
var skip = Math.Max(0, offset);
var take = Math.Clamp(count, 1, 200);
var collected = new List<MusicSong>(take);
var page = skip / 20 + 1;
var withinPage = skip % 20;
while (collected.Count < take && page <= 10)
{
var result = await GetJsonAsync(
$"https://m.kugou.com/rank/info/?rankid={Uri.EscapeDataString(rankId)}&page={page}&json=true",
_cookie,
cancellationToken).ConfigureAwait(false);
var songs = Array(result["songs"]?["list"]);
if (songs.Count == 0) break;
collected.AddRange(songs.Skip(withinPage).Select(MapSong).Where(song => !string.IsNullOrWhiteSpace(song.Id)));
if (songs.Count < 20) break;
withinPage = 0;
page++;
}
return collected.Take(take).ToArray();
}
public Task<IReadOnlyList<MusicSong>> GetArtistSongsAsync(string artistId, int offset = 0, int count = 50, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<MusicSong>>([]);
public async Task<MusicStreamResult> ResolveStreamAsync(string songId, MusicPlaybackQuality quality, CancellationToken cancellationToken = default)
{
var identity = SplitSongId(songId);
var uri = "https://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=" + Uri.EscapeDataString(identity.Hash);
if (!string.IsNullOrWhiteSpace(identity.AlbumId))
{
uri += "&album_id=" + Uri.EscapeDataString(identity.AlbumId);
}
var result = await GetJsonAsync(uri, _cookie, cancellationToken).ConfigureAwait(false);
var data = result["data"] ?? result;
var address = Text(data, "url") ?? Text(data, "play_url") ?? Text(data, "play_url_128") ?? FirstText(data, "backup_url");
if (!Uri.TryCreate(address, UriKind.Absolute, out var streamUri))
{
return new MusicStreamResult(null, false, false, MusicPlaybackQuality.Standard, 0,
Text(data, "error") ?? Text(result, "error") ?? "提供方未返回播放地址,歌曲可能需要登录、会员或受地区限制。");
}
var bitrate = Integer(data, "bitRate");
if (bitrate <= 0) bitrate = 128;
return new MusicStreamResult(streamUri, true, false, MusicPlaybackQuality.Standard, bitrate * 1000L,
quality == MusicPlaybackQuality.Standard ? null : "当前来源仅返回服务方允许的标准音质。");
}
public async Task<MusicStreamProbe> ProbeStreamAsync(Uri uri, CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Range = new RangeHeaderValue(0, 1);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("audio/*"));
if (!string.IsNullOrWhiteSpace(_cookie)) request.Headers.TryAddWithoutValidation("Cookie", _cookie);
try
{
using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var reachable = response.IsSuccessStatusCode &&
(contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(contentType));
return new MusicStreamProbe(reachable, contentType, (int)response.StatusCode,
reachable ? null : $"音乐文件服务返回 HTTP {(int)response.StatusCode} 或非音频内容。");
}
catch (HttpRequestException exception)
{
return new MusicStreamProbe(false, string.Empty, (int?)exception.StatusCode ?? 0, "无法访问音乐文件。");
}
}
public async Task<TimedLyrics> GetLyricsAsync(string songId, CancellationToken cancellationToken = default)
{
var identity = SplitSongId(songId);
var searchUri = "https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&hash=" + Uri.EscapeDataString(identity.Hash);
var search = await GetJsonAsync(searchUri, _cookie, cancellationToken).ConfigureAwait(false);
var candidate = Array(search["candidates"]).FirstOrDefault();
var lyricId = Text(candidate, "id");
var accessKey = Text(candidate, "accesskey");
if (string.IsNullOrWhiteSpace(lyricId) || string.IsNullOrWhiteSpace(accessKey))
{
return new TimedLyrics([], string.Empty, null, null);
}
var downloadUri = "https://lyrics.kugou.com/download?ver=1&client=pc&fmt=lrc&charset=utf8&id=" +
Uri.EscapeDataString(lyricId) + "&accesskey=" + Uri.EscapeDataString(accessKey);
var download = await GetJsonAsync(downloadUri, _cookie, cancellationToken).ConfigureAwait(false);
var encoded = Text(download, "content");
if (string.IsNullOrWhiteSpace(encoded)) return new TimedLyrics([], string.Empty, null, null);
try
{
return MusicLyricsParser.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(encoded)));
}
catch (FormatException)
{
return MusicLyricsParser.Parse(encoded);
}
}
public async Task<MusicMv?> GetMvAsync(string songId, string? mvId = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(mvId)) return null;
var result = await GetJsonAsync(
"https://m.kugou.com/app/i/mv.php?cmd=100&ismp3=1&ext=mp4&hash=" + Uri.EscapeDataString(mvId),
_cookie,
cancellationToken).ConfigureAwait(false);
var variants = (result["mvdata"] as JsonObject)?
.Select(pair => pair.Value)
.OfType<JsonObject>()
.Select(node => new
{
Bitrate = Integer(node, "bitrate"),
Address = Text(node, "downurl") ?? FirstText(node, "backupdownurl")
})
.OrderByDescending(item => item.Bitrate)
.ToArray() ?? [];
var address = variants.Select(item => item.Address).FirstOrDefault(value =>
Uri.TryCreate(value, UriKind.Absolute, out var candidate) && candidate.Scheme == Uri.UriSchemeHttps);
var mediaUri = Uri.TryCreate(address, UriKind.Absolute, out var parsed) ? parsed : null;
var hasInsecureVariant = variants.Any(item =>
Uri.TryCreate(item.Address, UriKind.Absolute, out var candidate) && candidate.Scheme == Uri.UriSchemeHttp);
return new MusicMv(
Id,
mvId,
Text(result, "songname") ?? "MV",
Text(result, "singer") ?? string.Empty,
NormalizeImage(Text(result, "mvicon") ?? string.Empty),
TimeSpan.FromMilliseconds(Integer(result, "timelength")),
mediaUri,
mediaUri is not null,
mediaUri is null
? hasInsecureVariant
? "服务方当前仅返回非加密 MV 地址,已拒绝加载。"
: Text(result, "error") ?? "提供方未返回可播放的 MV 地址。"
: null);
}
public Task SetFavoriteAsync(string songId, bool favorite, CancellationToken cancellationToken = default)
=> Task.FromException(new InvalidOperationException("酷狗收藏功能需要服务方提供的已授权账户接口。"));
public Task SetPlaylistSubscribedAsync(string playlistId, bool subscribed, CancellationToken cancellationToken = default)
=> Task.FromException(new InvalidOperationException("酷狗歌单订阅功能需要服务方提供的已授权账户接口。"));
public void Dispose()
{
_client.Dispose();
_initialization.Dispose();
}
private async Task<CookieValidationResult> ValidateCookieAsync(string cookie, CancellationToken cancellationToken)
{
var normalized = NormalizeCookie(cookie);
var parts = ParseCookie(normalized);
var account = ParseAccount(parts);
if (account is null) return new CookieValidationResult(new MusicLoginState(false), string.Empty);
var uri = "https://login-user.kugou.com/v1/autologin?a_id=" + Uri.EscapeDataString(account.AppId) +
"&userid=" + Uri.EscapeDataString(account.UserId) +
"&t=" + Uri.EscapeDataString(account.Token) +
"&ct=" + Uri.EscapeDataString(account.ClientTime) +
"&domain=kugou.com" +
"&plat=4" +
"&dfid=" + Uri.EscapeDataString(parts.GetValueOrDefault("kg_dfid") ?? "-");
using var request = CreateRequest(HttpMethod.Get, uri, normalized);
request.Headers.Accept.Clear();
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/javascript"));
using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return new CookieValidationResult(new MusicLoginState(false), string.Empty);
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var codeMatch = Regex.Match(payload, "[\\\"']?error_code[\\\"']?\\s*[:=]\\s*[\\\"']?(?<code>\\d+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
if (!codeMatch.Success || !int.TryParse(codeMatch.Groups["code"].Value, out var errorCode) || errorCode != 0)
{
return new CookieValidationResult(new MusicLoginState(false), string.Empty);
}
var responseCookies = response.Headers.TryGetValues("Set-Cookie", out var values)
? NeteaseMusicProvider.ExtractResponseCookies(values)
: string.Empty;
var refreshedCookie = NormalizeCookie(MergeCookies(normalized, responseCookies));
var refreshedAccount = ParseAccount(ParseCookie(refreshedCookie)) ?? account;
return new CookieValidationResult(
ToLoginState(refreshedAccount),
refreshedCookie);
}
private async Task<JsonObject> GetJsonAsync(string uri, string cookie, CancellationToken cancellationToken)
=> (await GetJsonWithCookiesAsync(uri, cookie, cancellationToken).ConfigureAwait(false)).Json;
private async Task<JsonResponse> GetJsonWithCookiesAsync(string uri, string cookie, CancellationToken cancellationToken)
{
using var request = CreateRequest(HttpMethod.Get, uri, cookie);
using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) throw new HttpRequestException($"酷狗音乐服务返回 HTTP {(int)response.StatusCode}。", null, response.StatusCode);
var json = JsonNode.Parse(text) as JsonObject ?? throw new JsonException("酷狗音乐服务返回了无效 JSON。");
var cookieHeaders = response.Headers.TryGetValues("Set-Cookie", out var values) ? values : [];
return new JsonResponse(json, NeteaseMusicProvider.ExtractResponseCookies(cookieHeaders));
}
private HttpRequestMessage CreateRequest(HttpMethod method, string uri, string cookie)
{
var request = new HttpRequestMessage(method, uri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (!string.IsNullOrWhiteSpace(cookie)) request.Headers.TryAddWithoutValidation("Cookie", cookie);
return request;
}
private static MusicSong MapSong(JsonNode node)
{
var hash = Text(node, "FileHash") ?? Text(node, "hash") ?? string.Empty;
var albumId = Text(node, "AlbumID") ?? Text(node, "album_id") ?? Text(node, "albumid");
var artist = Text(node, "SingerName") ?? Text(node, "singername") ?? Text(node, "h5_author_name") ?? Text(node, "author") ?? string.Empty;
var artistId = Text(node, "SingerId") ?? Text(node, "singerid") ?? Text(Array(node?["authors"]).FirstOrDefault(), "author_id") ?? artist;
var image = Text(node, "Image") ?? Text(node?["trans_param"], "union_cover") ?? Text(node, "imgurl") ?? string.Empty;
image = NormalizeImage(image);
var duration = Integer(node, "Duration");
if (duration <= 0) duration = Integer(node, "duration");
return new MusicSong(
"kugou",
ComposeSongId(hash, albumId),
Text(node, "SongName") ?? Text(node, "songname") ?? Text(node, "FileName") ?? Text(node, "filename") ?? string.Empty,
artist,
string.IsNullOrWhiteSpace(artist) ? [] : [new MusicArtist(artistId, artist)],
Text(node, "AlbumName") ?? Text(node, "album_name") ?? Text(node, "remark") ?? string.Empty,
image.Replace("{size}", "480", StringComparison.Ordinal),
TimeSpan.FromSeconds(Math.Max(0, duration)),
Math.Max(Integer(node, "PayType"), Integer(node, "pay_type")))
{
MvId = Text(node, "MvHash") ?? Text(node, "mvhash") ?? Text(Array(node?["mvdata"]).FirstOrDefault(), "hash")
};
}
private static IReadOnlyList<JsonNode> Array(JsonNode? node)
=> node is JsonArray array ? array.Where(item => item is not null).Cast<JsonNode>().ToArray() : [];
private static string? FirstText(JsonNode? node, string property)
=> Array(node?[property]).Select(item => item.GetValue<string>()).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static string ComposeSongId(string hash, string? albumId)
=> string.IsNullOrWhiteSpace(albumId) ? hash : $"{hash}|{albumId}";
private static string NormalizeImage(string value)
=> value
.Replace("http://imge.kugou.com", "https://imge.kugou.com", StringComparison.OrdinalIgnoreCase)
.Replace("http://imgessl.kugou.com", "https://imgessl.kugou.com", StringComparison.OrdinalIgnoreCase)
.Replace("{size}", "480", StringComparison.Ordinal);
private static (string Hash, string AlbumId) SplitSongId(string songId)
{
var separator = songId.IndexOf('|');
return separator < 0
? (songId, string.Empty)
: (songId[..separator], songId[(separator + 1)..]);
}
private static string? Text(JsonNode? node, string property)
{
var value = node?[property];
if (value is null) return null;
if (value is JsonValue text && text.TryGetValue<string>(out var result)) return result;
if (value is JsonValue number && number.TryGetValue<long>(out var numeric)) return numeric.ToString();
return value.ToJsonString().Trim('"');
}
private static int Integer(JsonNode? node, string property)
=> int.TryParse(Text(node, property), out var value) ? value : 0;
private static Dictionary<string, string> ParseCookie(string cookie)
=> cookie.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => part.Split('=', 2, StringSplitOptions.TrimEntries))
.Where(pair => pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0]))
.GroupBy(pair => pair[0], StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Last()[1], StringComparer.OrdinalIgnoreCase);
private static KugouAccount? ParseAccount(IReadOnlyDictionary<string, string> cookies)
{
var composite = cookies.GetValueOrDefault("KuGoo");
if (string.IsNullOrWhiteSpace(composite)) return null;
var decoded = global::System.Net.WebUtility.UrlDecode(composite);
var fields = decoded
.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => part.Split('=', 2, StringSplitOptions.TrimEntries))
.Where(pair => pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0]))
.GroupBy(pair => pair[0], StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Last()[1], StringComparer.OrdinalIgnoreCase);
var appId = fields.GetValueOrDefault("a_id") ?? cookies.GetValueOrDefault("a_id") ?? "1014";
var userId = fields.GetValueOrDefault("KugooID") ?? cookies.GetValueOrDefault("KugooID") ?? string.Empty;
var token = fields.GetValueOrDefault("t") ?? cookies.GetValueOrDefault("t") ?? string.Empty;
var clientTime = fields.GetValueOrDefault("ct") ?? cookies.GetValueOrDefault("ct") ?? string.Empty;
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(userId) ||
string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(clientTime)) return null;
var nickname = fields.GetValueOrDefault("NickName") ?? fields.GetValueOrDefault("UserName") ?? "酷狗用户";
var avatar = fields.GetValueOrDefault("Pic") ?? string.Empty;
var vip = fields.GetValueOrDefault("VipType") ?? fields.GetValueOrDefault("vip_type") ?? "none";
return new KugouAccount(appId, userId, token, clientTime, nickname, avatar, vip);
}
private static string NormalizeCookie(string cookie)
=> string.Join("; ", ParseCookie(cookie).OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase).Select(pair => $"{pair.Key}={pair.Value}"));
internal static string MergeBrowserCookies(IEnumerable<(string Name, string Value)> cookies)
=> MergeBrowserCookies(cookies.Select(cookie => (cookie.Name, cookie.Value, Priority: 0)));
internal static string MergeBrowserCookies(IEnumerable<(string Name, string Value, int Priority)> cookies)
{
var merged = new Dictionary<string, (string Value, int Priority, bool Complete)>(StringComparer.OrdinalIgnoreCase);
foreach (var (name, value, priority) in cookies)
{
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) continue;
var complete = string.Equals(name, "KuGoo", StringComparison.OrdinalIgnoreCase) &&
ParseAccount(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { [name] = value }) is not null;
if (merged.TryGetValue(name, out var existing) &&
(existing.Priority > priority ||
existing.Priority == priority && existing.Complete && !complete))
{
continue;
}
merged[name] = (value, priority, complete);
}
return string.Join("; ", merged
.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)
.Select(pair => $"{pair.Key}={pair.Value.Value}"));
}
internal static bool HasWebLoginCredential(string cookie)
=> ParseAccount(ParseCookie(cookie)) is not null;
internal static bool IsOfficialWebLoginCompletionUri(Uri? uri)
=> uri is not null &&
uri.Scheme == Uri.UriSchemeHttps &&
string.Equals(uri.Host, "staticssl.kugou.com", StringComparison.OrdinalIgnoreCase) &&
string.Equals(uri.AbsolutePath.TrimEnd('/'), "/common/html/login/regok.html", StringComparison.OrdinalIgnoreCase);
private static MusicLoginState ToLoginState(KugouAccount account)
=> new(true, account.UserId, account.Nickname, account.AvatarUrl, account.VipLevel);
private static string MergeCookies(params string?[] values)
{
var merged = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value)))
{
foreach (var pair in ParseCookie(value!)) merged[pair.Key] = pair.Value;
}
return string.Join("; ", merged.Select(pair => $"{pair.Key}={pair.Value}"));
}
private sealed record JsonResponse(JsonObject Json, string Cookies);
private sealed record CookieValidationResult(MusicLoginState State, string Cookie);
private sealed record KugouAccount(
string AppId,
string UserId,
string Token,
string ClientTime,
string Nickname,
string AvatarUrl,
string VipLevel);
}