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);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Text.Json;
|
||||
using YMhut.Box.Core.App;
|
||||
|
||||
namespace YMhut.Box.Core.Music;
|
||||
|
||||
public sealed record MusicHistoryEntry(MusicSong Song, DateTimeOffset PlayedAt);
|
||||
|
||||
public interface IMusicHistoryStore
|
||||
{
|
||||
Task<IReadOnlyList<MusicHistoryEntry>> LoadAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task RecordPlayedAsync(MusicSong song, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class MusicHistoryStore(AppPaths paths) : IMusicHistoryStore
|
||||
{
|
||||
private const int MaximumEntries = 500;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private string HistoryPath => Path.Combine(paths.Data, "Music", "history.json");
|
||||
|
||||
public async Task<IReadOnlyList<MusicHistoryEntry>> LoadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await ReadUnsafeAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecordPlayedAsync(MusicSong song, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(song.Provider) || string.IsNullOrWhiteSpace(song.Id)) return;
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var temporary = HistoryPath + $".{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
var history = (await ReadUnsafeAsync(cancellationToken).ConfigureAwait(false))
|
||||
.Where(entry =>
|
||||
!string.Equals(entry.Song.Provider, song.Provider, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(entry.Song.Id, song.Id, StringComparison.Ordinal))
|
||||
.Prepend(new MusicHistoryEntry(song, DateTimeOffset.Now))
|
||||
.Take(MaximumEntries)
|
||||
.ToArray();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(HistoryPath)!);
|
||||
await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(history, JsonOptions), cancellationToken).ConfigureAwait(false);
|
||||
File.Move(temporary, HistoryPath, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(temporary)) File.Delete(temporary);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<MusicHistoryEntry>> ReadUnsafeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(HistoryPath)) return [];
|
||||
return JsonSerializer.Deserialize<List<MusicHistoryEntry>>(
|
||||
await File.ReadAllTextAsync(HistoryPath, cancellationToken).ConfigureAwait(false),
|
||||
JsonOptions)
|
||||
?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,10 @@ public sealed record MusicSong(
|
||||
string CoverUrl,
|
||||
TimeSpan Duration,
|
||||
int Fee,
|
||||
bool Playable = true);
|
||||
bool Playable = true)
|
||||
{
|
||||
public string? MvId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MusicPlaylist(
|
||||
string Provider,
|
||||
@@ -56,9 +59,16 @@ public sealed record MusicLoginState(
|
||||
string AvatarUrl = "",
|
||||
string VipLevel = "none");
|
||||
|
||||
public sealed record MusicQrSession(string Key, string LoginUrl, DateTimeOffset ExpiresAt);
|
||||
public sealed record MusicQrSession(string Key, string LoginUrl, DateTimeOffset ExpiresAt)
|
||||
{
|
||||
internal string SessionCookie { get; set; } = string.Empty;
|
||||
|
||||
public sealed record MusicQrStatus(int Code, string Message, bool Completed, bool Expired);
|
||||
internal bool AuthorizationConfirmed { get; set; }
|
||||
|
||||
internal bool PendingLogged { get; set; }
|
||||
}
|
||||
|
||||
public sealed record MusicQrStatus(int Code, string Message, bool Completed, bool Expired, bool Terminal = false);
|
||||
|
||||
public sealed record MusicStreamResult(
|
||||
Uri? Uri,
|
||||
@@ -68,6 +78,33 @@ public sealed record MusicStreamResult(
|
||||
long Bitrate,
|
||||
string? ProviderReason = null);
|
||||
|
||||
public sealed record MusicStreamProbe(bool Reachable, string ContentType, int StatusCode, string? Reason = null);
|
||||
|
||||
public sealed record MusicMv(
|
||||
string Provider,
|
||||
string Id,
|
||||
string Title,
|
||||
string Artist,
|
||||
string CoverUrl,
|
||||
TimeSpan Duration,
|
||||
Uri? Uri,
|
||||
bool Playable,
|
||||
string? ProviderReason = null);
|
||||
|
||||
public enum MusicPlaybackFailureStage
|
||||
{
|
||||
Resolve,
|
||||
Network,
|
||||
MediaOpen,
|
||||
Decode
|
||||
}
|
||||
|
||||
public sealed record MusicPlaybackFailure(
|
||||
MusicPlaybackFailureStage Stage,
|
||||
string Code,
|
||||
string Message,
|
||||
bool Retryable);
|
||||
|
||||
public sealed record TimedLyricWord(string Text, TimeSpan Start, TimeSpan Duration, int CharacterStart, int CharacterEnd);
|
||||
|
||||
public sealed record TimedLyricLine(
|
||||
@@ -92,7 +129,11 @@ public sealed record MusicSession(
|
||||
MusicPlaybackQuality Quality,
|
||||
IReadOnlyList<MusicSong> Queue,
|
||||
int CurrentIndex,
|
||||
DateTimeOffset SavedAt);
|
||||
DateTimeOffset SavedAt)
|
||||
{
|
||||
// Kept outside the positional constructor so sessions written by previous versions remain valid.
|
||||
public string? CurrentSongProvider { get; init; }
|
||||
}
|
||||
|
||||
public interface IMusicProvider
|
||||
{
|
||||
@@ -126,8 +167,12 @@ public interface IMusicProvider
|
||||
|
||||
Task<MusicStreamResult> ResolveStreamAsync(string songId, MusicPlaybackQuality quality, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MusicStreamProbe> ProbeStreamAsync(Uri uri, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TimedLyrics> GetLyricsAsync(string songId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MusicMv?> GetMvAsync(string songId, string? mvId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetFavoriteAsync(string songId, bool favorite, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetPlaylistSubscribedAsync(string playlistId, bool subscribed, CancellationToken cancellationToken = default);
|
||||
@@ -137,12 +182,16 @@ public interface IMusicPlaybackService
|
||||
{
|
||||
event EventHandler? StateChanged;
|
||||
|
||||
event EventHandler? CurrentSongChanged;
|
||||
|
||||
MusicQueue Queue { get; }
|
||||
|
||||
MusicSong? CurrentSong { get; }
|
||||
|
||||
bool IsPlaying { get; }
|
||||
|
||||
MusicPlaybackFailure? LastFailure { get; }
|
||||
|
||||
TimeSpan Position { get; }
|
||||
|
||||
TimeSpan Duration { get; }
|
||||
@@ -155,6 +204,8 @@ public interface IMusicPlaybackService
|
||||
|
||||
Task PlayAsync(MusicSong song, IReadOnlyList<MusicSong>? queue = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ChangeQualityAsync(MusicPlaybackQuality quality, CancellationToken cancellationToken = default);
|
||||
|
||||
void PlayPause();
|
||||
|
||||
Task NextAsync(CancellationToken cancellationToken = default);
|
||||
@@ -177,11 +228,15 @@ public sealed class MusicQueue
|
||||
|
||||
public MusicSong? Current => CurrentIndex >= 0 && CurrentIndex < _items.Count ? _items[CurrentIndex] : null;
|
||||
|
||||
public void Replace(IEnumerable<MusicSong> songs, string? currentId = null)
|
||||
public void Replace(IEnumerable<MusicSong> songs, string? currentId = null, string? currentProvider = null)
|
||||
{
|
||||
_items.Clear();
|
||||
_items.AddRange(songs.Where(song => !string.IsNullOrWhiteSpace(song.Id)));
|
||||
CurrentIndex = currentId is null ? (_items.Count > 0 ? 0 : -1) : _items.FindIndex(song => song.Id == currentId);
|
||||
CurrentIndex = currentId is null
|
||||
? (_items.Count > 0 ? 0 : -1)
|
||||
: _items.FindIndex(song =>
|
||||
song.Id == currentId &&
|
||||
(string.IsNullOrWhiteSpace(currentProvider) || string.Equals(song.Provider, currentProvider, StringComparison.OrdinalIgnoreCase)));
|
||||
if (CurrentIndex < 0 && _items.Count > 0) CurrentIndex = 0;
|
||||
}
|
||||
|
||||
@@ -219,6 +274,7 @@ public sealed class MusicQueue
|
||||
public sealed class MusicSessionStore(AppPaths paths)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
private string SessionPath => Path.Combine(paths.Data, "Music", "session.json");
|
||||
|
||||
public async Task<MusicSession?> LoadAsync(CancellationToken cancellationToken = default)
|
||||
@@ -236,9 +292,24 @@ public sealed class MusicSessionStore(AppPaths paths)
|
||||
|
||||
public async Task SaveAsync(MusicSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(SessionPath)!);
|
||||
var temporary = SessionPath + ".tmp";
|
||||
await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(session, JsonOptions), cancellationToken);
|
||||
File.Move(temporary, SessionPath, true);
|
||||
await _writeGate.WaitAsync(cancellationToken);
|
||||
var temporary = SessionPath + $".{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(SessionPath)!);
|
||||
await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(session, JsonOptions), cancellationToken);
|
||||
File.Move(temporary, SessionPath, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(temporary)) File.Delete(temporary);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace YMhut.Box.Core.Music;
|
||||
|
||||
public interface IMusicProviderRegistry
|
||||
{
|
||||
event EventHandler? CurrentProviderChanged;
|
||||
|
||||
IReadOnlyList<IMusicProvider> Providers { get; }
|
||||
|
||||
IMusicProvider Current { get; }
|
||||
|
||||
IMusicProvider GetRequired(string providerId);
|
||||
|
||||
bool TryGet(string providerId, out IMusicProvider provider);
|
||||
|
||||
void Select(string providerId);
|
||||
}
|
||||
|
||||
public sealed class MusicProviderRegistry : IMusicProviderRegistry
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IMusicProvider> _byId;
|
||||
private readonly IReadOnlyList<IMusicProvider> _providers;
|
||||
private IMusicProvider _current;
|
||||
|
||||
public MusicProviderRegistry(IEnumerable<IMusicProvider> providers, string defaultProviderId = "netease")
|
||||
{
|
||||
var materialized = providers
|
||||
.GroupBy(provider => provider.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.ToArray();
|
||||
if (materialized.Length == 0) throw new InvalidOperationException("At least one music provider must be registered.");
|
||||
|
||||
_providers = materialized;
|
||||
_byId = materialized.ToDictionary(provider => provider.Id, StringComparer.OrdinalIgnoreCase);
|
||||
_current = _byId.TryGetValue(defaultProviderId, out var selected) ? selected : materialized[0];
|
||||
}
|
||||
|
||||
public event EventHandler? CurrentProviderChanged;
|
||||
|
||||
public IReadOnlyList<IMusicProvider> Providers => _providers;
|
||||
|
||||
public IMusicProvider Current => _current;
|
||||
|
||||
public IMusicProvider GetRequired(string providerId)
|
||||
=> TryGet(providerId, out var provider)
|
||||
? provider
|
||||
: throw new InvalidOperationException($"Music provider '{providerId}' is not available.");
|
||||
|
||||
public bool TryGet(string providerId, out IMusicProvider provider)
|
||||
=> _byId.TryGetValue(providerId ?? string.Empty, out provider!);
|
||||
|
||||
public void Select(string providerId)
|
||||
{
|
||||
var next = GetRequired(providerId);
|
||||
if (ReferenceEquals(next, _current)) return;
|
||||
_current = next;
|
||||
CurrentProviderChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace YMhut.Box.Core.Music;
|
||||
|
||||
/// <summary>
|
||||
/// Separates a seek preview from the media position so playback updates cannot overwrite user input.
|
||||
/// </summary>
|
||||
public sealed class MusicSeekInteraction
|
||||
{
|
||||
public bool IsPointerActive { get; private set; }
|
||||
|
||||
public bool IsKeyboardActive { get; private set; }
|
||||
|
||||
public bool IsPreviewing { get; private set; }
|
||||
|
||||
public double PreviewSeconds { get; private set; }
|
||||
|
||||
public bool ShouldSynchronize => !IsPreviewing;
|
||||
|
||||
public void BeginPointer(double currentSeconds)
|
||||
{
|
||||
IsPointerActive = true;
|
||||
BeginPreview(currentSeconds);
|
||||
}
|
||||
|
||||
public TimeSpan? EndPointer(TimeSpan duration)
|
||||
{
|
||||
IsPointerActive = false;
|
||||
return Commit(duration);
|
||||
}
|
||||
|
||||
public TimeSpan? PointerCaptureLost(TimeSpan duration)
|
||||
{
|
||||
IsPointerActive = false;
|
||||
return Commit(duration);
|
||||
}
|
||||
|
||||
public void BeginKeyboard(double currentSeconds)
|
||||
{
|
||||
IsKeyboardActive = true;
|
||||
BeginPreview(currentSeconds);
|
||||
}
|
||||
|
||||
public TimeSpan? EndKeyboard(TimeSpan duration)
|
||||
{
|
||||
IsKeyboardActive = false;
|
||||
return Commit(duration);
|
||||
}
|
||||
|
||||
public TimeSpan? ValueChanged(double seconds, TimeSpan duration)
|
||||
{
|
||||
BeginPreview(seconds);
|
||||
return IsPointerActive || IsKeyboardActive ? null : Commit(duration);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
IsPointerActive = false;
|
||||
IsKeyboardActive = false;
|
||||
IsPreviewing = false;
|
||||
PreviewSeconds = 0;
|
||||
}
|
||||
|
||||
private void BeginPreview(double seconds)
|
||||
{
|
||||
PreviewSeconds = double.IsFinite(seconds) ? Math.Max(0, seconds) : 0;
|
||||
IsPreviewing = true;
|
||||
}
|
||||
|
||||
private TimeSpan? Commit(TimeSpan duration)
|
||||
{
|
||||
if (!IsPreviewing) return null;
|
||||
IsPreviewing = false;
|
||||
if (duration <= TimeSpan.Zero) return null;
|
||||
return TimeSpan.FromSeconds(Math.Clamp(PreviewSeconds, 0, duration.TotalSeconds));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@ namespace YMhut.Box.Core.Music;
|
||||
|
||||
public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
{
|
||||
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36";
|
||||
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36";
|
||||
private const string WebOrigin = "https://music.163.com";
|
||||
internal const int QrLoginType = 3;
|
||||
private static readonly TimeSpan LoginValidationDelay = TimeSpan.FromMilliseconds(350);
|
||||
private static readonly (MusicPlaybackQuality Quality, string Level)[] QualityOrder =
|
||||
[
|
||||
(MusicPlaybackQuality.Master, "jymaster"),
|
||||
@@ -33,16 +36,20 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
private readonly ILogService? _logService;
|
||||
private readonly HttpClient _client;
|
||||
private readonly SemaphoreSlim _initialization = new(1, 1);
|
||||
private readonly string _deviceId = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||
private string _cookie = string.Empty;
|
||||
private bool _initialized;
|
||||
|
||||
public NeteaseMusicProvider(IMusicCredentialStore credentials, ILogService? logService = null)
|
||||
public NeteaseMusicProvider(IMusicCredentialStore credentials, ILogService? logService = null, HttpMessageHandler? handler = null)
|
||||
{
|
||||
_credentials = credentials;
|
||||
_logService = logService;
|
||||
_client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
_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("https://music.163.com/");
|
||||
_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 => "netease";
|
||||
@@ -63,7 +70,7 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
_initialized = true;
|
||||
return;
|
||||
}
|
||||
LoginState = await GetLoginStateAsync(_cookie, cancellationToken).ConfigureAwait(false);
|
||||
LoginState = await ValidateLoginCookieAsync(_cookie, attempts: 2, cancellationToken).ConfigureAwait(false);
|
||||
if (!LoginState.LoggedIn && !string.IsNullOrEmpty(_cookie))
|
||||
{
|
||||
_cookie = string.Empty;
|
||||
@@ -81,31 +88,53 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
{
|
||||
var normalized = NormalizeCookie(cookie);
|
||||
if (!ParseCookie(normalized).ContainsKey("MUSIC_U")) return new MusicLoginState(false);
|
||||
var state = await GetLoginStateAsync(normalized, cancellationToken).ConfigureAwait(false);
|
||||
var state = await ValidateLoginCookieAsync(normalized, attempts: 2, cancellationToken).ConfigureAwait(false);
|
||||
if (!state.LoggedIn) return state;
|
||||
_cookie = normalized;
|
||||
LoginState = state;
|
||||
_initialized = true;
|
||||
await _credentials.SaveAsync(Id, normalized, cancellationToken).ConfigureAwait(false);
|
||||
await CommitLoginAsync(normalized, state, cancellationToken).ConfigureAwait(false);
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task<MusicQrSession> CreateQrSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new JsonObject { ["type"] = 1, ["csrf_token"] = "" };
|
||||
var result = await PostEapiAsync("/api/login/qrcode/unikey", payload, captureCookies: false, cancellationToken).ConfigureAwait(false);
|
||||
var payload = CreateQrLoginPayload();
|
||||
var result = await PostEapiAsync("/api/login/qrcode/unikey", payload, captureCookies: true, cancellationToken).ConfigureAwait(false);
|
||||
var key = Text(result.Json, "unikey") ?? Text(result.Json["data"], "unikey");
|
||||
if (string.IsNullOrWhiteSpace(key)) throw new InvalidOperationException("网易云未返回二维码登录密钥。");
|
||||
return new MusicQrSession(key, $"https://music.163.com/login?codekey={Uri.EscapeDataString(key)}", DateTimeOffset.Now.AddMinutes(3));
|
||||
var sessionCookie = MergeCookies(
|
||||
$"deviceId={_deviceId}",
|
||||
Text(result.Json, "cookie"),
|
||||
Text(result.Json["data"], "cookie"),
|
||||
result.Cookies);
|
||||
return new MusicQrSession(key, $"https://music.163.com/login?codekey={Uri.EscapeDataString(key)}", DateTimeOffset.Now.AddMinutes(3))
|
||||
{
|
||||
SessionCookie = sessionCookie
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<MusicQrStatus> CheckQrSessionAsync(MusicQrSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session.AuthorizationConfirmed)
|
||||
{
|
||||
if (DateTimeOffset.Now >= session.ExpiresAt.AddSeconds(30))
|
||||
{
|
||||
return new MusicQrStatus(805, "授权已完成,但账户资料加载超时,请刷新二维码重试。", false, false, true);
|
||||
}
|
||||
|
||||
return await CompleteAuthorizedQrLoginAsync(session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (DateTimeOffset.Now >= session.ExpiresAt) return new MusicQrStatus(800, "二维码已过期", false, true);
|
||||
var payload = new JsonObject { ["key"] = session.Key, ["csrf_token"] = "" };
|
||||
var result = await PostEapiAsync("/api/login/qrcode/client/login", payload, captureCookies: true, cancellationToken).ConfigureAwait(false);
|
||||
var code = Integer(result.Json, "code");
|
||||
var message = Text(result.Json, "message") ?? code switch
|
||||
var payload = CreateQrLoginPayload(session.Key);
|
||||
var result = await PostEapiAsync(
|
||||
"/api/login/qrcode/client/login",
|
||||
payload,
|
||||
captureCookies: true,
|
||||
cancellationToken,
|
||||
session.SessionCookie).ConfigureAwait(false);
|
||||
var data = result.Json["data"];
|
||||
var dataCode = Integer(data, "code");
|
||||
var rootCode = Integer(result.Json, "code");
|
||||
var code = dataCode is >= 800 and <= 805 ? dataCode : rootCode;
|
||||
var message = Text(data, "message") ?? Text(result.Json, "message") ?? code switch
|
||||
{
|
||||
801 => "等待扫码",
|
||||
802 => "已扫码,等待确认",
|
||||
@@ -113,14 +142,63 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
800 => "二维码已过期",
|
||||
_ => "等待登录"
|
||||
};
|
||||
session.SessionCookie = MergeCookies(
|
||||
session.SessionCookie,
|
||||
Text(result.Json, "cookie"),
|
||||
Text(data, "cookie"),
|
||||
result.Cookies);
|
||||
if (code == 803)
|
||||
{
|
||||
var cookie = string.IsNullOrWhiteSpace(result.Cookies) ? Text(result.Json, "cookie") ?? "" : result.Cookies;
|
||||
var state = await LoginWithCookieAsync(cookie, cancellationToken).ConfigureAwait(false);
|
||||
if (!state.LoggedIn) return new MusicQrStatus(code, "登录凭据未通过验证", false, false);
|
||||
return new MusicQrStatus(code, message, true, false);
|
||||
if (!ParseCookie(session.SessionCookie).ContainsKey("MUSIC_U"))
|
||||
{
|
||||
await LogLoginEventAsync("Warning", "QR sign-in did not return a provider token", "providerCode=803", cancellationToken).ConfigureAwait(false);
|
||||
return new MusicQrStatus(805, "手机端已确认,但服务端未返回登录凭据,请刷新二维码重试。", false, false, true);
|
||||
}
|
||||
|
||||
session.AuthorizationConfirmed = true;
|
||||
return await CompleteAuthorizedQrLoginAsync(session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
return new MusicQrStatus(code, message, false, code == 800);
|
||||
var terminal = code >= 400 && code is not 800 and not 801 and not 802;
|
||||
return new MusicQrStatus(code, message, false, code == 800, terminal);
|
||||
}
|
||||
|
||||
private async Task<MusicQrStatus> CompleteAuthorizedQrLoginAsync(MusicQrSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = NormalizeCookie(session.SessionCookie);
|
||||
var state = await ValidateLoginCookieAsync(normalized, attempts: 2, cancellationToken).ConfigureAwait(false);
|
||||
if (!state.LoggedIn)
|
||||
{
|
||||
if (!session.PendingLogged)
|
||||
{
|
||||
session.PendingLogged = true;
|
||||
await LogLoginEventAsync("Info", "QR sign-in authorized; account synchronization is pending", cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
return new MusicQrStatus(804, "手机端已确认,正在加载账户资料…", false, false);
|
||||
}
|
||||
|
||||
await CommitLoginAsync(normalized, state, cancellationToken).ConfigureAwait(false);
|
||||
await LogLoginEventAsync("Info", "QR sign-in completed", cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return new MusicQrStatus(803, "登录成功", true, false);
|
||||
}
|
||||
|
||||
private async Task<MusicLoginState> ValidateLoginCookieAsync(string cookie, int attempts, CancellationToken cancellationToken)
|
||||
{
|
||||
MusicLoginState state = new(false);
|
||||
for (var attempt = 0; attempt < Math.Max(1, attempts); attempt++)
|
||||
{
|
||||
state = await GetLoginStateAsync(cookie, cancellationToken).ConfigureAwait(false);
|
||||
if (state.LoggedIn || attempt + 1 >= attempts) return state;
|
||||
await Task.Delay(LoginValidationDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private async Task CommitLoginAsync(string cookie, MusicLoginState state, CancellationToken cancellationToken)
|
||||
{
|
||||
_cookie = cookie;
|
||||
LoginState = state;
|
||||
_initialized = true;
|
||||
await _credentials.SaveAsync(Id, cookie, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(CancellationToken cancellationToken = default)
|
||||
@@ -136,9 +214,10 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
if (string.IsNullOrWhiteSpace(keywords)) return new([], [], []);
|
||||
var type = kind switch { MusicSearchKind.Artists => 100, MusicSearchKind.Playlists => 1000, _ => 1 };
|
||||
var result = await PostFormAsync(
|
||||
"https://music.163.com/api/cloudsearch/get/web",
|
||||
"https://music.163.com/api/search/get/web",
|
||||
new Dictionary<string, string> { ["s"] = keywords.Trim(), ["type"] = type.ToString(), ["limit"] = Math.Clamp(limit, 1, 100).ToString(), ["offset"] = "0" },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
EnsureSuccess(result, "搜索");
|
||||
var root = result["result"];
|
||||
return kind switch
|
||||
{
|
||||
@@ -249,17 +328,49 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
return trial ?? new MusicStreamResult(null, false, false, quality, 0, "提供方未返回播放地址,歌曲可能需要登录、会员或受地区限制。");
|
||||
}
|
||||
|
||||
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/*"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream", 0.8));
|
||||
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 mediaResponse = contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(contentType, "application/octet-stream", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.IsNullOrWhiteSpace(contentType);
|
||||
var reachable = response.IsSuccessStatusCode && mediaResponse;
|
||||
var reason = reachable
|
||||
? null
|
||||
: !response.IsSuccessStatusCode
|
||||
? $"音乐文件服务返回 HTTP {(int)response.StatusCode}。"
|
||||
: $"播放地址返回了非音频内容({contentType})。";
|
||||
return new MusicStreamProbe(reachable, contentType, (int)response.StatusCode, reason);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new MusicStreamProbe(false, string.Empty, 0, "音乐文件连接超时。");
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
return new MusicStreamProbe(false, string.Empty, (int?)exception.StatusCode ?? 0, $"无法访问音乐文件:{exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TimedLyrics> GetLyricsAsync(string songId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await PostApiAsync("/api/song/lyric/v1", new Dictionary<string, string>
|
||||
{
|
||||
["id"] = songId,
|
||||
["cp"] = "false",
|
||||
["lv"] = "0",
|
||||
["kv"] = "0",
|
||||
["tv"] = "0",
|
||||
["rv"] = "0",
|
||||
["yv"] = "0",
|
||||
["lv"] = "-1",
|
||||
["kv"] = "-1",
|
||||
["tv"] = "-1",
|
||||
["rv"] = "-1",
|
||||
["yv"] = "-1",
|
||||
["csrf_token"] = CsrfToken()
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
var raw = Text(result["lrc"], "lyric") ?? "";
|
||||
@@ -269,6 +380,47 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
return MusicLyricsParser.Parse(raw, translated, romanized, yrc);
|
||||
}
|
||||
|
||||
public async Task<MusicMv?> GetMvAsync(string songId, string? mvId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(songId)) return null;
|
||||
JsonNode? song = null;
|
||||
if (string.IsNullOrWhiteSpace(mvId) || mvId == "0")
|
||||
{
|
||||
var detail = await GetAsync(
|
||||
"https://music.163.com/api/song/detail",
|
||||
new Dictionary<string, string> { ["ids"] = $"[{songId}]" },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
song = Array(detail["songs"]).FirstOrDefault();
|
||||
mvId = Text(song, "mv");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(mvId) || mvId == "0") return null;
|
||||
|
||||
var mv = await GetAsync(
|
||||
"https://music.163.com/api/mv/detail",
|
||||
new Dictionary<string, string> { ["mvid"] = mvId },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var data = mv["data"];
|
||||
var url = (data?["brs"] as JsonObject)?
|
||||
.Select(pair => new
|
||||
{
|
||||
Bitrate = long.TryParse(pair.Key, out var bitrate) ? bitrate : 0,
|
||||
Url = pair.Value is JsonValue value && value.TryGetValue<string>(out var address) ? address : string.Empty
|
||||
})
|
||||
.OrderByDescending(candidate => candidate.Bitrate)
|
||||
.Select(candidate => candidate.Url)
|
||||
.FirstOrDefault(value => Uri.TryCreate(value, UriKind.Absolute, out _));
|
||||
return new MusicMv(
|
||||
Id,
|
||||
mvId,
|
||||
Text(data, "name") ?? Text(song, "name") ?? "MV",
|
||||
Text(data, "artistName") ?? "",
|
||||
Text(data, "cover") ?? "",
|
||||
TimeSpan.FromMilliseconds(Long(data, "duration")),
|
||||
Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null,
|
||||
uri is not null,
|
||||
uri is null ? "提供方未返回可播放的 MV 地址。" : null);
|
||||
}
|
||||
|
||||
public async Task SetFavoriteAsync(string songId, bool favorite, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureLoggedIn();
|
||||
@@ -304,14 +456,15 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
var result = await GetAsync("https://music.163.com/api/w/nuser/account/get", new Dictionary<string, string>(), cancellationToken, cookie).ConfigureAwait(false);
|
||||
var data = result["data"] ?? result;
|
||||
var profile = data?["profile"] ?? result["profile"] ?? data?["account"]?["profile"];
|
||||
var userId = Text(profile, "userId") ?? Text(profile, "id") ?? "";
|
||||
var account = data?["account"] ?? result["account"];
|
||||
var userId = Text(profile, "userId") ?? Text(profile, "id") ?? Text(account, "userId") ?? Text(account, "id") ?? "";
|
||||
if (string.IsNullOrWhiteSpace(userId)) return new MusicLoginState(false);
|
||||
var vip = Integer(profile, "vipType");
|
||||
var vip = Math.Max(Integer(profile, "vipType"), Integer(account, "vipType"));
|
||||
return new MusicLoginState(
|
||||
true,
|
||||
userId,
|
||||
Text(profile, "nickname") ?? "网易云用户",
|
||||
Text(profile, "avatarUrl") ?? "",
|
||||
Text(profile, "nickname") ?? Text(account, "userName") ?? Text(account, "nickname") ?? "网易云用户",
|
||||
Text(profile, "avatarUrl") ?? Text(account, "avatarUrl") ?? "",
|
||||
vip >= 10 ? "svip" : vip >= 1 ? "vip" : "none");
|
||||
}
|
||||
|
||||
@@ -352,23 +505,29 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
return await SendJsonAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<EapiResult> PostEapiAsync(string apiPath, JsonObject payload, bool captureCookies, CancellationToken cancellationToken)
|
||||
private async Task<EapiResult> PostEapiAsync(
|
||||
string apiPath,
|
||||
JsonObject payload,
|
||||
bool captureCookies,
|
||||
CancellationToken cancellationToken,
|
||||
string? requestCookie = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var effectiveCookie = MergeCookies(_cookie, requestCookie);
|
||||
var header = new JsonObject
|
||||
{
|
||||
["__csrf"] = CsrfToken(),
|
||||
["appver"] = "8.0.0",
|
||||
["__csrf"] = ParseCookie(effectiveCookie).GetValueOrDefault("__csrf", ""),
|
||||
["appver"] = "3.1.17.204416",
|
||||
["buildver"] = now / 1000,
|
||||
["channel"] = "",
|
||||
["deviceId"] = "",
|
||||
["channel"] = "netease",
|
||||
["deviceId"] = ParseCookie(effectiveCookie).GetValueOrDefault("deviceId", _deviceId),
|
||||
["mobilename"] = "",
|
||||
["resolution"] = "1920x1080",
|
||||
["os"] = "android",
|
||||
["osver"] = "",
|
||||
["os"] = "pc",
|
||||
["osver"] = "Microsoft-Windows-10-Professional-build-19045-64bit",
|
||||
["requestId"] = $"{now}_{Random.Shared.Next(0, 1000):0000}",
|
||||
["versioncode"] = "140",
|
||||
["MUSIC_U"] = ParseCookie(_cookie).GetValueOrDefault("MUSIC_U", "")
|
||||
["MUSIC_U"] = ParseCookie(effectiveCookie).GetValueOrDefault("MUSIC_U", "")
|
||||
};
|
||||
payload["header"] = header.ToJsonString();
|
||||
var payloadText = payload.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
|
||||
@@ -379,15 +538,23 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
aes.Mode = CipherMode.ECB;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
var encrypted = Convert.ToHexString(aes.EncryptEcb(Encoding.UTF8.GetBytes(plaintext), PaddingMode.PKCS7));
|
||||
var headerCookie = string.Join("; ", header.Select(pair => $"{pair.Key}={CookieValue(pair.Value)}"));
|
||||
var fullCookie = string.IsNullOrWhiteSpace(_cookie) ? headerCookie : headerCookie + "; " + _cookie;
|
||||
var headerCookie = string.Join("; ", header.Select(pair =>
|
||||
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(CookieValue(pair.Value))}"));
|
||||
var fullCookie = MergeCookies(headerCookie, effectiveCookie);
|
||||
using var request = CreateRequest(HttpMethod.Post, "https://interface3.music.163.com/eapi" + apiPath[4..], fullCookie);
|
||||
request.Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["params"] = encrypted });
|
||||
using var response = await SendProviderRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var json = await ParseResponseAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
var cookies = captureCookies && response.Headers.TryGetValues("Set-Cookie", out var values)
|
||||
? string.Join("; ", values.Select(value => value.Split(';', 2)[0]).Where(value => value.Contains('=')))
|
||||
: string.Empty;
|
||||
var cookieHeaders = new List<string>();
|
||||
if (captureCookies && response.Headers.TryGetValues("Set-Cookie", out var responseCookies))
|
||||
{
|
||||
cookieHeaders.AddRange(responseCookies);
|
||||
}
|
||||
if (captureCookies && response.Content.Headers.TryGetValues("Set-Cookie", out var contentCookies))
|
||||
{
|
||||
cookieHeaders.AddRange(contentCookies);
|
||||
}
|
||||
var cookies = ExtractResponseCookies(cookieHeaders);
|
||||
return new EapiResult(json, cookies);
|
||||
}
|
||||
|
||||
@@ -413,7 +580,7 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
}
|
||||
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new HttpRequestException("The music provider did not respond within 10 seconds.", exception);
|
||||
throw new HttpRequestException("The music provider did not respond within 12 seconds.", exception);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
@@ -441,7 +608,10 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
Text(album, "name") ?? "",
|
||||
Text(album, "picUrl") ?? Text(album, "coverUrl") ?? "",
|
||||
TimeSpan.FromMilliseconds(Long(node, "dt") is var duration && duration > 0 ? duration : Long(node, "duration")),
|
||||
Integer(node, "fee"));
|
||||
Integer(node, "fee"))
|
||||
{
|
||||
MvId = Text(node, "mv")
|
||||
};
|
||||
}
|
||||
|
||||
private static MusicArtist MapArtist(JsonNode? node) => new(
|
||||
@@ -515,6 +685,46 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
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 MergeCookies(params string?[] values)
|
||||
{
|
||||
var merged = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
foreach (var pair in ParseCookie(value)) merged[pair.Key] = pair.Value;
|
||||
}
|
||||
return string.Join("; ", merged.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase).Select(pair => $"{pair.Key}={pair.Value}"));
|
||||
}
|
||||
|
||||
internal static JsonObject CreateQrLoginPayload(string? key = null)
|
||||
{
|
||||
var payload = new JsonObject { ["type"] = QrLoginType, ["csrf_token"] = "" };
|
||||
if (!string.IsNullOrWhiteSpace(key)) payload["key"] = key;
|
||||
return payload;
|
||||
}
|
||||
|
||||
internal static string ExtractResponseCookies(IEnumerable<string> headerValues)
|
||||
{
|
||||
var pairs = new List<string>();
|
||||
foreach (var headerValue in headerValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(headerValue)) continue;
|
||||
foreach (var cookie in SetCookieSeparatorRegex().Split(headerValue))
|
||||
{
|
||||
var pair = cookie.Split(';', 2)[0].Trim();
|
||||
if (pair.Contains('=', StringComparison.Ordinal)) pairs.Add(pair);
|
||||
}
|
||||
}
|
||||
return MergeCookies(pairs.ToArray());
|
||||
}
|
||||
|
||||
private Task LogLoginEventAsync(
|
||||
string level,
|
||||
string message,
|
||||
string? detail = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _logService?.WriteAsync(level, "music-login", message, detail, cancellationToken) ?? Task.CompletedTask;
|
||||
|
||||
private static string CookieValue(JsonNode? node)
|
||||
{
|
||||
if (node is JsonValue value && value.TryGetValue<string>(out var text)) return text;
|
||||
@@ -522,6 +732,9 @@ public sealed partial class NeteaseMusicProvider : IMusicProvider, IDisposable
|
||||
}
|
||||
|
||||
private sealed record EapiResult(JsonObject Json, string Cookies);
|
||||
|
||||
[GeneratedRegex(@",(?=\s*[!#$%&'*+\-.^_`|~0-9A-Za-z]+=)", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SetCookieSeparatorRegex();
|
||||
}
|
||||
|
||||
public static partial class MusicLyricsParser
|
||||
@@ -530,6 +743,7 @@ public static partial class MusicLyricsParser
|
||||
{
|
||||
var translations = ParseLrc(translated ?? "").ToDictionary(line => line.Start, line => line.Text);
|
||||
var lines = string.IsNullOrWhiteSpace(yrc) ? ParseLrc(raw) : ParseYrc(yrc);
|
||||
if (lines.Count == 0 && !string.IsNullOrWhiteSpace(raw)) lines = ParseLrc(raw);
|
||||
var ordered = lines.OrderBy(line => line.Start).ToArray();
|
||||
var completed = new List<TimedLyricLine>(ordered.Length);
|
||||
for (var index = 0; index < ordered.Length; index++)
|
||||
|
||||
Reference in New Issue
Block a user