feat: complete 2.0.7.12 platform overhaul

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