feat: complete 2.0.7.12 platform overhaul
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user