Files
lampac/mods/Music/Providers/Spotify/SpotifySupport.cs
T

1396 lines
60 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using static Music.MusicPlaylistImportHelpers;
namespace Music;
// Импорт публичных плейлистов/альбомов Spotify БЕЗ ключей и логина:
// гостевой accessToken со страницы embed-плеера + pathfinder GraphQL
// (persisted queries) — тот же путь, что у веб-плеера Spotify.
// Стримов Spotify не отдаёт (DRM): треки приезжают чистыми метаданными,
// аудио резолвится обычным конвейером (YouTube-матчер), как у VK-чарта.
public static class SpotifySupport
{
public sealed class ArtistDiscoveryResult
{
public string artist_id { get; set; }
public string artist_name { get; set; }
public List<MusicTrack> top_tracks { get; set; } = new();
public List<MusicArtist> related_artists { get; set; } = new();
}
public const string ProviderId = "spotify";
public const string DiscoveryProviderId = "spotifycharts";
public const string PlaylistSourceType = "spotify_playlist";
public const string AlbumSourceType = "spotify_album";
public const string TracksSectionId = "search:spotify:tracks";
public const string ArtistsSectionId = "search:spotify:artists";
public const string AlbumsSectionId = "search:spotify:albums";
public const int PlaylistTrackLimit = 2000;
public static bool IsSearchEnabled => ModInit.conf?.spotify_search_fallback_enabled == true;
const string PathfinderUrl = "https://api-partner.spotify.com/pathfinder/v1/query";
const string PathfinderV2Url = "https://api-partner.spotify.com/pathfinder/v2/query";
// persisted-query хэши веб-плеера; Spotify их периодически ротирует.
// Симптом ротации: ошибка PersistedQueryNotFound в ответе pathfinder —
// тогда взять свежие из бандла веб-плеера (или SpotifyScraper/api/pathfinder.py).
const string FetchPlaylistHash = "a65e12194ed5fc443a1cdebed5fabe33ca5b07b987185d63c72483867ad13cb4";
const string GetAlbumHash = "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10";
// pathfinder v2 (POST) хэши веб-плеера, разведаны с живого open.spotify.com
// (2026-07-20). Питают отдельную Spotify-вкладку поиска + её экраны
// артиста/альбома. Ротируются как v1-хэши импорта (симптом PersistedQueryNotFound).
const string FindTopResultsHash = "755858df4daab8d212980b02a81dcf8c9a58447de318b59d07c4651a1d0450b9";
const string QueryArtistHash = "a8226439dffdc02caecf1fc6e693e98be57638c0195f002d7802e4b8e5802760";
const string QueryAlbumHash = "ce390dbf7ca6b61a23aec210619e1094fe9d23d7f101ff773ce1146f84d4dd10";
const string QueryArtistDiscographyHash = "5e07d323febb57b4a56a42abbf781490e58764aa45feb6e3dc0591564fc56599";
const string QueryArtistAppearsOnHash = "9a4bb7a20d6720fe52d7b47bc001cfa91940ddf5e7113761460b4a288d18a4c1";
// embed-страница для гостевого токена: любой публичный плейлист
const string TokenSeedPlaylistId = "37i9dQZF1DXcBWIGoYBM5M";
const int PlaylistPageLimit = 100;
const int AlbumPageLimit = 50;
const int ArtistOverviewShelfLimit = 20;
const int MaxImportTracks = PlaylistTrackLimit;
const string ArtistDiscographyOrder = "DATE_DESC";
const string BrowserUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
static readonly HttpClient httpClient = MusicHttp.CreateClient("spotify");
static readonly Regex entityUrlRegex = new(@"^https?://open\.spotify\.com/(?:intl-[a-z\-]+/)?(playlist|album)/([A-Za-z0-9]{22})", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex entityUriRegex = new(@"^spotify:(playlist|album):([A-Za-z0-9]{22})$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex accessTokenRegex = new("\"accessToken\"\\s*:\\s*\"([^\"]+)\"", RegexOptions.Compiled);
static readonly Regex accessTokenExpiresRegex = new("\"accessTokenExpirationTimestampMs\"\\s*:\\s*(\\d+)", RegexOptions.Compiled);
static readonly SemaphoreSlim anonymousTokenLock = new(1, 1);
static string anonymousToken;
static DateTime anonymousTokenExpiresAt;
static SpotifySupport()
{
httpClient.Timeout = TimeSpan.FromSeconds(20);
}
public static bool CanHandleUrl(string url) => ParseEntity(url) != null;
public static async Task<MusicAlbum> GetPlaylistAlbumAsync(string playlistId, int limit, CancellationToken cancellationToken = default)
{
playlistId = playlistId?.Trim();
if (string.IsNullOrWhiteSpace(playlistId) || !Regex.IsMatch(playlistId, "^[A-Za-z0-9]{22}$"))
return null;
int take = Math.Clamp(limit, 1, PlaylistTrackLimit);
try
{
var tracks = new List<MusicTrack>();
string title = null;
string ownerName = null;
string description = null;
List<MusicImage> images = null;
int totalCount = -1;
for (int offset = 0; offset < take && (totalCount < 0 || offset < totalCount);)
{
int pageLimit = Math.Min(PlaylistPageLimit, take - offset);
var root = await QueryAsync("fetchPlaylist", FetchPlaylistHash, new
{
uri = $"spotify:playlist:{playlistId}",
offset,
limit = pageLimit,
enableWatchFeedEntrypoint = false
}, "playlist", playlistId, cancellationToken);
var playlist = GetProperty(root, "data", "playlistV2");
if (playlist == null || playlist.Value.ValueKind != JsonValueKind.Object)
return null;
var content = GetProperty(playlist.Value, "content");
if (content == null || !content.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return null;
if (totalCount < 0)
{
totalCount = GetInt(content.Value, "totalCount") ?? items.GetArrayLength();
var owner = GetProperty(playlist.Value, "ownerV2", "data");
title = GetString(playlist.Value, "name")?.Trim();
ownerName = owner != null ? GetString(owner.Value, "name")?.Trim() : null;
description = GetString(playlist.Value, "description")?.Trim();
images = MapPlaylistImages(GetProperty(playlist.Value, "images"));
}
int itemCount = items.GetArrayLength();
if (itemCount == 0)
{
if (offset < Math.Min(totalCount, take))
return null;
break;
}
foreach (var item in items.EnumerateArray())
{
var track = MapPlaylistItem(item);
if (track != null)
tracks.Add(track);
}
offset += pageLimit;
}
tracks = DeduplicateTracks(tracks);
return new MusicAlbum
{
id = $"spotify:playlist:{playlistId}",
title = string.IsNullOrWhiteSpace(title) ? "Spotify Playlist" : title,
artist_name = string.IsNullOrWhiteSpace(ownerName) ? "Spotify" : ownerName,
type = "Playlist",
description = description,
images = images ?? new List<MusicImage>(),
provider_refs = new List<MusicProviderRef>
{
new() { provider = DiscoveryProviderId, external_id = playlistId }
},
tracks = tracks
};
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
// Поиск треков для отдельной Spotify-вкладки в результатах поиска.
// Spotify «по-человечески» понимает ввод («рианна», «маданна», опечатки),
// где MusicBrainz спотыкается. Треки чистые (без стрима) — играются обычным
// конвейером (YouTube-матчер), как импортные и VK-чарт. Пустой список при
// любой неудаче (сеть/ротация/пусто) — вкладка просто не появится.
public static async Task<List<MusicTrack>> SearchTracksAsync(string query, int limit, CancellationToken cancellationToken = default)
{
query = query?.Trim();
if (string.IsNullOrWhiteSpace(query))
return new List<MusicTrack>();
try
{
var root = await QueryV2Async("findTopResults", FindTopResultsHash, new
{
query,
numberOfTopResults = Math.Clamp(limit, 1, 30)
}, cancellationToken);
var tracksV2 = GetProperty(GetProperty(root, "data", "searchV2") ?? default, "tracksV2");
if (tracksV2 == null || !tracksV2.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return new List<MusicTrack>();
var tracks = new List<MusicTrack>();
foreach (var item in items.EnumerateArray())
{
// items[].item.data = Track; альбом/обложка в albumOfTrack
var data = GetProperty(item, "item", "data");
if (data == null || !string.Equals(GetString(data.Value, "__typename"), "Track", StringComparison.OrdinalIgnoreCase))
continue;
var album = GetProperty(data.Value, "albumOfTrack");
string albumTitle = album != null ? GetString(album.Value, "name") : null;
string albumId = album != null ? GetString(album.Value, "uri") : null;
var images = album != null ? MapCoverArt(GetProperty(album.Value, "coverArt")) : null;
var mapped = MapTrackElement(data.Value, albumTitle, images, date: null, durationProperty: "duration", albumId: albumId);
if (mapped != null)
tracks.Add(mapped);
if (tracks.Count >= limit)
break;
}
return DeduplicateTracks(tracks);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new List<MusicTrack>();
}
}
public static async Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(string inputUrl, CancellationToken cancellationToken = default)
{
var entity = ParseEntity(inputUrl);
if (entity == null)
return ImportUnavailable("Вставь ссылку на Spotify плейлист или альбом.");
// прогрев кэша токена + дружелюбная ошибка, если Spotify недоступен
string token = await GetAnonymousTokenAsync(entity.Value.type, entity.Value.id, cancellationToken);
if (string.IsNullOrWhiteSpace(token))
return ImportUnavailable("Не удалось получить Spotify токен.");
try
{
return entity.Value.type == "album"
? await ImportAlbumAsync(entity.Value.id, cancellationToken)
: await ImportPlaylistByIdAsync(entity.Value.id, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
Console.WriteLine($"[Music] spotify import failed: {ex.Message}");
return ImportUnavailable("Spotify не ответил, попробуй ещё раз.");
}
}
public static Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
return Task.FromResult(ImportUnavailable("У плейлиста нет Spotify источника."));
return ImportPlaylistAsync(source.url, cancellationToken);
}
static (string type, string id)? ParseEntity(string value)
{
value = value?.Trim();
if (string.IsNullOrWhiteSpace(value))
return null;
var match = entityUriRegex.Match(value);
if (!match.Success)
match = entityUrlRegex.Match(value);
if (!match.Success)
return null;
return (match.Groups[1].Value.ToLowerInvariant(), match.Groups[2].Value);
}
// Гостевой токен живёт на любой публичной embed-странице (~1 час);
// логина/ключей не требует — это тот же токен, что получает браузер инкогнито.
static async Task<string> GetAnonymousTokenAsync(string entityType, string entityId, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(anonymousToken) && anonymousTokenExpiresAt > DateTime.UtcNow)
return anonymousToken;
await anonymousTokenLock.WaitAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(anonymousToken) && anonymousTokenExpiresAt > DateTime.UtcNow)
return anonymousToken;
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://open.spotify.com/embed/{entityType}/{entityId}");
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
string html = await response.Content.ReadAsStringAsync(cancellationToken);
var tokenMatch = accessTokenRegex.Match(html ?? string.Empty);
if (!tokenMatch.Success)
return null;
var expiresMatch = accessTokenExpiresRegex.Match(html);
DateTime expiresAt = expiresMatch.Success && long.TryParse(expiresMatch.Groups[1].Value, out long expiresMs)
? DateTimeOffset.FromUnixTimeMilliseconds(expiresMs).UtcDateTime.AddMinutes(-2)
: DateTime.UtcNow.AddMinutes(30);
anonymousToken = tokenMatch.Groups[1].Value;
anonymousTokenExpiresAt = expiresAt;
return anonymousToken;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
finally
{
anonymousTokenLock.Release();
}
}
static void InvalidateAnonymousToken()
{
anonymousToken = null;
anonymousTokenExpiresAt = DateTime.MinValue;
}
// сам берёт токен из кэша; на 401 сбрасывает его и повторяет запрос
// один раз со свежим — протухший гостевой токен не роняет импорт/sync
static async Task<JsonElement?> QueryAsync(string operationName, string hash, object variables, string entityType, string entityId, CancellationToken cancellationToken)
{
string url = PathfinderUrl
+ "?operationName=" + Uri.EscapeDataString(operationName)
+ "&variables=" + Uri.EscapeDataString(MusicJson.Serialize(variables))
+ "&extensions=" + Uri.EscapeDataString($"{{\"persistedQuery\":{{\"version\":1,\"sha256Hash\":\"{hash}\"}}}}");
for (int attempt = 0; attempt < 2; attempt++)
{
string token = await GetAnonymousTokenAsync(entityType, entityId, cancellationToken);
if (string.IsNullOrWhiteSpace(token))
return null;
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "application/json");
request.Headers.TryAddWithoutValidation("app-platform", "WebPlayer");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
InvalidateAnonymousToken();
continue;
}
if (!response.IsSuccessStatusCode)
return null;
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
var root = document.RootElement.Clone();
if (root.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Array && errors.GetArrayLength() > 0)
{
string firstError = errors.EnumerateArray().Select(e => GetString(e, "message")).FirstOrDefault(m => !string.IsNullOrWhiteSpace(m));
if (firstError?.Contains("PersistedQueryNotFound", StringComparison.OrdinalIgnoreCase) == true)
Console.WriteLine("[Music] spotify pathfinder hash rotated — module update required");
else
Console.WriteLine($"[Music] spotify pathfinder error: {firstError}");
return null;
}
return root;
}
return null;
}
public static bool IsSpotifyArtist(string provider, string id)
=> string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase)
|| (id ?? string.Empty).StartsWith("spotify:artist:", StringComparison.OrdinalIgnoreCase);
public static bool IsSpotifyAlbum(string provider, string id)
=> (string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase)
&& (id ?? string.Empty).StartsWith("spotify:album:", StringComparison.OrdinalIgnoreCase))
|| (id ?? string.Empty).StartsWith("spotify:album:", StringComparison.OrdinalIgnoreCase);
public static bool IsSpotifyArtistSection(string provider, string id)
=> string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase)
&& ParseSpotifyArtistSectionId(id) != null;
// Артисты для Spotify-вкладки поиска (findTopResults -> searchV2.artists).
public static async Task<List<MusicArtist>> SearchArtistsAsync(string query, int limit, CancellationToken cancellationToken = default)
{
query = query?.Trim();
if (string.IsNullOrWhiteSpace(query))
return new List<MusicArtist>();
try
{
var root = await QueryV2Async("findTopResults", FindTopResultsHash, new { query, numberOfTopResults = Math.Clamp(limit, 1, 30) }, cancellationToken);
var artists = GetProperty(GetProperty(root, "data", "searchV2") ?? default, "artists");
if (artists == null || !artists.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return new List<MusicArtist>();
var result = new List<MusicArtist>();
foreach (var item in items.EnumerateArray())
{
var mapped = MapArtistNode(GetProperty(item, "data"));
if (mapped != null)
result.Add(mapped);
if (result.Count >= limit)
break;
}
return result;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { return new List<MusicArtist>(); }
}
// Лёгкий discovery-ответ для персональных миксов: один queryArtist
// без загрузки всей дискографии, которую делает GetArtistAsync.
public static async Task<ArtistDiscoveryResult> GetArtistDiscoveryByNameAsync(
string query,
int topTrackLimit = 12,
int relatedArtistLimit = 12,
CancellationToken cancellationToken = default)
{
var candidates = await SearchArtistsAsync(query, 5, cancellationToken);
var artist = candidates.FirstOrDefault(candidate => MusicMapSupport.IsAliasOf(candidate?.name, query));
if (artist == null)
return null;
return await GetArtistDiscoveryAsync(artist.id, topTrackLimit, relatedArtistLimit, cancellationToken);
}
public static async Task<ArtistDiscoveryResult> GetArtistDiscoveryAsync(
string artistId,
int topTrackLimit = 12,
int relatedArtistLimit = 12,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(artistId)
|| !artistId.StartsWith("spotify:artist:", StringComparison.OrdinalIgnoreCase))
return null;
try
{
var root = await QueryV2Async("queryArtist", QueryArtistHash, new { uri = artistId }, cancellationToken);
var artist = GetProperty(root, "data", "artistUnion");
if (artist == null || artist.Value.ValueKind != JsonValueKind.Object)
return null;
string name = GetString(GetProperty(artist.Value, "profile") ?? default, "name")?.Trim();
if (string.IsNullOrWhiteSpace(name))
return null;
var discography = GetProperty(artist.Value, "discography");
var related = GetProperty(artist.Value, "relatedContent");
return new ArtistDiscoveryResult
{
artist_id = artistId,
artist_name = name,
top_tracks = discography == null
? new List<MusicTrack>()
: MapTopTracks(GetProperty(discography.Value, "topTracks"), Math.Clamp(topTrackLimit, 1, 30)),
related_artists = related == null
? new List<MusicArtist>()
: MapRelatedArtists(GetProperty(related.Value, "relatedArtists"), Math.Clamp(relatedArtistLimit, 1, 30))
};
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { return null; }
}
// Альбомы для Spotify-вкладки поиска (findTopResults -> searchV2.albumsV2).
public static async Task<List<MusicAlbum>> SearchAlbumsAsync(string query, int limit, CancellationToken cancellationToken = default)
{
query = query?.Trim();
if (string.IsNullOrWhiteSpace(query))
return new List<MusicAlbum>();
try
{
var root = await QueryV2Async("findTopResults", FindTopResultsHash, new { query, numberOfTopResults = Math.Clamp(limit, 1, 30) }, cancellationToken);
var albums = GetProperty(GetProperty(root, "data", "searchV2") ?? default, "albumsV2");
if (albums == null || !albums.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return new List<MusicAlbum>();
var result = new List<MusicAlbum>();
foreach (var item in items.EnumerateArray())
{
var mapped = MapAlbumNode(GetProperty(item, "data"));
if (mapped != null)
result.Add(mapped);
if (result.Count >= limit)
break;
}
return result;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { return new List<MusicAlbum>(); }
}
public static Task<MusicBrowseSection> GetArtistSectionAsync(string sectionId, string page = null, int limit = 20, string artistName = null, CancellationToken cancellationToken = default)
{
var parsed = ParseSpotifyArtistSectionId(sectionId);
if (parsed == null)
return Task.FromResult<MusicBrowseSection>(null);
int offset = 0;
if (!string.IsNullOrWhiteSpace(page) && int.TryParse(page, out int parsedOffset))
offset = Math.Max(0, parsedOffset);
limit = Math.Clamp(limit, 1, 50);
return parsed.Value.sectionKey switch
{
"albums" => GetDiscographySectionAsync(parsed.Value.artistId, "albums", "Альбомы", artistName, offset, limit, cancellationToken),
"singles" => GetDiscographySectionAsync(parsed.Value.artistId, "singles", "Синглы / EP", artistName, offset, limit, cancellationToken),
"compilations" => GetDiscographySectionAsync(parsed.Value.artistId, "compilations", "Сборники", artistName, offset, limit, cancellationToken),
"releases" => GetDiscographySectionAsync(parsed.Value.artistId, "releases", "Релизы", artistName, offset, limit, cancellationToken),
"appears-on" => GetAppearsOnSectionAsync(parsed.Value.artistId, artistName, offset, limit, cancellationToken),
_ => Task.FromResult<MusicBrowseSection>(null)
};
}
// Экран артиста: профиль + дискография (альбомы+синглы). id = spotify:artist:xxx.
public static async Task<MusicArtist> GetArtistAsync(string id, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(id))
return null;
try
{
var root = await QueryV2Async("queryArtist", QueryArtistHash, new { uri = id }, cancellationToken);
var au = GetProperty(root, "data", "artistUnion");
if (au == null || au.Value.ValueKind != JsonValueKind.Object)
return null;
string name = GetString(GetProperty(au.Value, "profile") ?? default, "name")?.Trim();
if (string.IsNullOrWhiteSpace(name))
return null;
var artist = new MusicArtist
{
id = id,
name = name,
images = MapCoverArt(GetProperty(GetProperty(au.Value, "visuals") ?? default, "avatarImage")),
provider_refs = new List<MusicProviderRef> { new() { provider = ProviderId, external_id = id } }
};
var albumsTask = GetDiscographySectionAsync(id, "albums", "Альбомы", name, 0, ArtistOverviewShelfLimit, cancellationToken);
var singlesTask = GetDiscographySectionAsync(id, "singles", "Синглы / EP", name, 0, ArtistOverviewShelfLimit, cancellationToken);
var compilationsTask = GetDiscographySectionAsync(id, "compilations", "Сборники", name, 0, ArtistOverviewShelfLimit, cancellationToken);
var appearsOnTask = GetAppearsOnSectionAsync(id, name, 0, ArtistOverviewShelfLimit, cancellationToken);
var disco = GetProperty(au.Value, "discography");
if (disco != null)
{
var topTracks = MapTopTracks(GetProperty(disco.Value, "topTracks"), limit: 12);
AddTrackSection(artist, $"{id}:top-tracks", "Популярные треки", topTracks);
var popularReleases = MapDirectAlbumItems(GetProperty(disco.Value, "popularReleasesAlbums"), name, limit: 12);
AddAlbumSection(artist, $"{id}:popular-releases", "Популярные релизы", popularReleases);
}
await Task.WhenAll(albumsTask, singlesTask, compilationsTask, appearsOnTask);
var albumsSection = await albumsTask;
var singlesSection = await singlesTask;
var compilationsSection = await compilationsTask;
var appearsOnSection = await appearsOnTask;
AddSectionIfNotEmpty(artist, albumsSection);
AddSectionIfNotEmpty(artist, singlesSection);
AddSectionIfNotEmpty(artist, compilationsSection);
var seenAlbums = new HashSet<string>(StringComparer.Ordinal);
foreach (var album in (albumsSection?.albums ?? new List<MusicAlbum>())
.Concat(singlesSection?.albums ?? new List<MusicAlbum>())
.Concat(compilationsSection?.albums ?? new List<MusicAlbum>()))
{
if (album != null && seenAlbums.Add(album.id))
artist.albums.Add(album);
}
var related = GetProperty(au.Value, "relatedContent");
if (related != null)
{
AddSectionIfNotEmpty(artist, appearsOnSection);
var relatedArtists = MapRelatedArtists(GetProperty(related.Value, "relatedArtists"), limit: 12);
AddArtistSection(artist, $"{id}:related-artists", "Похожие артисты", relatedArtists);
}
return artist;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { return null; }
}
// Экран альбома: мета + треки. id = spotify:album:xxx.
public static async Task<MusicAlbum> GetAlbumAsync(string id, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(id))
return null;
try
{
var root = await QueryV2Async("queryAlbum", QueryAlbumHash, new { uri = id, offset = 0 }, cancellationToken);
var au = GetProperty(root, "data", "albumUnion");
if (au == null || au.Value.ValueKind != JsonValueKind.Object)
return null;
string title = GetString(au.Value, "name")?.Trim();
if (string.IsNullOrWhiteSpace(title))
return null;
string artistName = ExtractArtistNames(GetProperty(au.Value, "artists")).FirstOrDefault();
var images = MapCoverArt(GetProperty(au.Value, "coverArt"));
string date = GetString(GetProperty(au.Value, "date") ?? default, "isoString");
int? year = GetInt(GetProperty(au.Value, "date") ?? default, "year");
var album = new MusicAlbum
{
id = id,
title = title,
artist_name = string.IsNullOrWhiteSpace(artistName) ? "Spotify" : artistName,
year = year,
date = date,
images = images,
provider_refs = new List<MusicProviderRef> { new() { provider = ProviderId, external_id = id } }
};
var tracksV2 = GetProperty(au.Value, "tracksV2");
if (tracksV2 != null && tracksV2.Value.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array)
{
foreach (var item in items.EnumerateArray())
{
var track = GetProperty(item, "track");
if (track == null)
continue;
var mapped = MapTrackElement(track.Value, title, images, date, durationProperty: "duration");
if (mapped != null)
{
mapped.album_id = id;
mapped.album_title = title;
album.tracks.Add(mapped);
}
}
}
return album;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { return null; }
}
static MusicArtist MapArtistNode(JsonElement? node)
{
if (node == null || node.Value.ValueKind != JsonValueKind.Object)
return null;
string uri = GetString(node.Value, "uri");
string name = GetString(GetProperty(node.Value, "profile") ?? default, "name")?.Trim();
if (string.IsNullOrWhiteSpace(uri) || string.IsNullOrWhiteSpace(name))
return null;
return new MusicArtist
{
id = uri,
name = name,
images = MapCoverArt(GetProperty(GetProperty(node.Value, "visuals") ?? default, "avatarImage")),
provider_refs = new List<MusicProviderRef> { new() { provider = ProviderId, external_id = uri } }
};
}
static MusicAlbum MapAlbumNode(JsonElement? node)
{
if (node == null || node.Value.ValueKind != JsonValueKind.Object)
return null;
string uri = GetString(node.Value, "uri");
string title = GetString(node.Value, "name")?.Trim();
if (string.IsNullOrWhiteSpace(uri) || string.IsNullOrWhiteSpace(title))
return null;
return new MusicAlbum
{
id = uri,
title = title,
artist_name = ExtractArtistNames(GetProperty(node.Value, "artists")).FirstOrDefault() ?? "Spotify",
year = GetInt(GetProperty(node.Value, "date") ?? default, "year"),
images = MapCoverArt(GetProperty(node.Value, "coverArt")),
provider_refs = new List<MusicProviderRef> { new() { provider = ProviderId, external_id = uri } }
};
}
static MusicAlbum MapDiscographyRelease(JsonElement release, string artistName)
{
string uri = GetString(release, "uri");
string title = GetString(release, "name")?.Trim();
if (string.IsNullOrWhiteSpace(uri) || string.IsNullOrWhiteSpace(title))
return null;
return new MusicAlbum
{
id = uri,
title = title,
artist_name = string.IsNullOrWhiteSpace(artistName) ? null : artistName,
year = GetInt(GetProperty(release, "date") ?? default, "year"),
type = GetString(release, "type"),
images = MapCoverArt(GetProperty(release, "coverArt")),
provider_refs = new List<MusicProviderRef> { new() { provider = ProviderId, external_id = uri } }
};
}
static async Task<MusicBrowseSection> GetDiscographySectionAsync(string artistId, string sectionKey, string title, string artistName, int offset, int limit, CancellationToken cancellationToken)
{
try
{
string operationName = sectionKey switch
{
"albums" => "queryArtistDiscographyAlbums",
"singles" => "queryArtistDiscographySingles",
"compilations" => "queryArtistDiscographyCompilations",
"releases" => "queryArtistDiscographyAll",
_ => null
};
string responseKey = sectionKey == "releases" ? "all" : sectionKey;
if (string.IsNullOrWhiteSpace(operationName))
return null;
var root = await QueryV2Async(operationName, QueryArtistDiscographyHash, new
{
uri = artistId,
offset,
limit,
order = ArtistDiscographyOrder
}, cancellationToken);
var group = GetProperty(root, "data", "artistUnion", "discography", responseKey);
if (group == null)
return null;
int totalCount = GetInt(group.Value, "totalCount") ?? offset + limit;
if (sectionKey == "singles")
{
var tracks = MapDiscographySingleTracks(group, artistName, limit);
return BuildTrackSection(
BuildSpotifyArtistSectionId(artistId, sectionKey),
title,
tracks,
totalCount > offset + tracks.Count,
NextOffset(offset, tracks.Count, totalCount)
);
}
var albums = MapDiscographyGroup(group, artistName, limit);
return BuildAlbumSection(
BuildSpotifyArtistSectionId(artistId, sectionKey),
title,
albums,
totalCount > offset + albums.Count,
NextOffset(offset, albums.Count, totalCount)
);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { return null; }
}
static async Task<MusicBrowseSection> GetAppearsOnSectionAsync(string artistId, string artistName, int offset, int limit, CancellationToken cancellationToken)
{
try
{
var root = await QueryV2Async("queryArtistAppearsOn", QueryArtistAppearsOnHash, new { uri = artistId }, cancellationToken);
var group = GetProperty(root, "data", "artistUnion", "relatedContent", "appearsOn");
if (group == null)
return null;
var allAlbums = MapDiscographyGroup(group, artistName, 50);
var pageAlbums = allAlbums.Skip(offset).Take(limit).ToList();
int totalCount = allAlbums.Count;
return BuildAlbumSection(
BuildSpotifyArtistSectionId(artistId, "appears-on"),
"Релизы с участием",
pageAlbums,
totalCount > offset + pageAlbums.Count,
NextOffset(offset, pageAlbums.Count, totalCount)
);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { return null; }
}
static List<MusicTrack> MapTopTracks(JsonElement? topTracks, int limit)
{
var result = new List<MusicTrack>();
if (topTracks == null || !topTracks.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return result;
foreach (var item in items.EnumerateArray())
{
var track = GetProperty(item, "track");
if (track == null)
continue;
var album = GetProperty(track.Value, "albumOfTrack");
string albumTitle = album != null ? GetString(album.Value, "name") : null;
string albumId = album != null ? GetString(album.Value, "uri") : null;
var images = album != null ? MapCoverArt(GetProperty(album.Value, "coverArt")) : null;
var mapped = MapTrackElement(track.Value, albumTitle, images, date: null, durationProperty: "duration", albumId: albumId);
if (mapped != null)
result.Add(mapped);
if (result.Count >= limit)
break;
}
return DeduplicateTracks(result);
}
static List<MusicAlbum> MapDirectAlbumItems(JsonElement? container, string artistName, int limit)
{
var result = new List<MusicAlbum>();
if (container == null || !container.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return result;
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in items.EnumerateArray())
{
var album = MapDiscographyRelease(item, artistName);
if (album != null && seen.Add(album.id))
result.Add(album);
if (result.Count >= limit)
break;
}
return result;
}
static List<MusicAlbum> MapDiscographyGroup(JsonElement? groupNode, string artistName, int limit)
{
var result = new List<MusicAlbum>();
if (groupNode == null || !groupNode.Value.TryGetProperty("items", out var relItems) || relItems.ValueKind != JsonValueKind.Array)
return result;
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var relItem in relItems.EnumerateArray())
{
var releases = GetProperty(GetProperty(relItem, "releases") ?? default, "items");
if (releases == null || releases.Value.ValueKind != JsonValueKind.Array || releases.Value.GetArrayLength() == 0)
continue;
var album = MapDiscographyRelease(releases.Value[0], artistName);
if (album != null && seen.Add(album.id))
result.Add(album);
if (result.Count >= limit)
break;
}
return result;
}
static List<MusicTrack> MapDiscographySingleTracks(JsonElement? groupNode, string artistName, int limit)
{
var result = new List<MusicTrack>();
if (groupNode == null || !groupNode.Value.TryGetProperty("items", out var relItems) || relItems.ValueKind != JsonValueKind.Array)
return result;
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var relItem in relItems.EnumerateArray())
{
var releases = GetProperty(GetProperty(relItem, "releases") ?? default, "items");
if (releases == null || releases.Value.ValueKind != JsonValueKind.Array || releases.Value.GetArrayLength() == 0)
continue;
var track = MapDiscographySingleTrack(releases.Value[0], artistName);
string key = track?.album_id ?? track?.id;
if (track != null && !string.IsNullOrWhiteSpace(key) && seen.Add(key))
result.Add(track);
if (result.Count >= limit)
break;
}
return result;
}
static MusicTrack MapDiscographySingleTrack(JsonElement release, string artistName)
{
string albumUri = GetString(release, "uri");
string albumId = GetString(release, "id");
string title = GetString(release, "name")?.Trim();
if (string.IsNullOrWhiteSpace(albumUri) || string.IsNullOrWhiteSpace(title))
return null;
string date = GetString(GetProperty(release, "date") ?? default, "isoString");
var images = MapCoverArt(GetProperty(release, "coverArt"));
string releaseTrackId = !string.IsNullOrWhiteSpace(albumId)
? $"spotify:release:{albumId}"
: albumUri.Replace("spotify:album:", "spotify:release:", StringComparison.OrdinalIgnoreCase);
return new MusicTrack
{
id = releaseTrackId,
title = title,
artist_name = string.IsNullOrWhiteSpace(artistName) ? "Spotify" : artistName,
artists = string.IsNullOrWhiteSpace(artistName) ? new List<string>() : new List<string> { artistName },
album_id = albumUri,
album_title = title,
date = date,
track_number = 1,
images = images,
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = albumUri }
}
};
}
static List<MusicArtist> MapRelatedArtists(JsonElement? relatedArtists, int limit)
{
var result = new List<MusicArtist>();
if (relatedArtists == null || !relatedArtists.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return result;
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in items.EnumerateArray())
{
var artist = MapArtistNode(item);
if (artist != null && seen.Add(artist.id))
result.Add(artist);
if (result.Count >= limit)
break;
}
return result;
}
static void AddSectionIfNotEmpty(MusicArtist artist, MusicBrowseSection section)
{
if (artist == null || section == null)
return;
bool hasEntries = section.tracks?.Count > 0
|| section.albums?.Count > 0
|| section.artists?.Count > 0;
if (hasEntries)
artist.sections.Add(section);
}
static void AddTrackSection(MusicArtist artist, string id, string title, List<MusicTrack> tracks)
{
if (artist == null || tracks == null || tracks.Count == 0)
return;
artist.sections.Add(new MusicBrowseSection
{
id = id,
title = title,
type = "tracks",
source_provider = ProviderId,
has_more = false,
tracks = tracks
});
}
static MusicBrowseSection BuildAlbumSection(string id, string title, List<MusicAlbum> albums, bool hasMore, string nextPage)
{
return new MusicBrowseSection
{
id = id,
title = title,
type = "albums",
source_provider = ProviderId,
has_more = hasMore,
next_page = nextPage,
albums = albums ?? new List<MusicAlbum>()
};
}
static MusicBrowseSection BuildTrackSection(string id, string title, List<MusicTrack> tracks, bool hasMore, string nextPage)
{
return new MusicBrowseSection
{
id = id,
title = title,
type = "tracks",
source_provider = ProviderId,
has_more = hasMore,
next_page = nextPage,
tracks = tracks ?? new List<MusicTrack>()
};
}
static void AddAlbumSection(MusicArtist artist, string id, string title, List<MusicAlbum> albums)
{
if (artist == null || albums == null || albums.Count == 0)
return;
artist.sections.Add(new MusicBrowseSection
{
id = id,
title = title,
type = "albums",
source_provider = ProviderId,
has_more = false,
albums = albums
});
}
static void AddArtistSection(MusicArtist artist, string id, string title, List<MusicArtist> artists)
{
if (artist == null || artists == null || artists.Count == 0)
return;
artist.sections.Add(new MusicBrowseSection
{
id = id,
title = title,
type = "artists",
source_provider = ProviderId,
has_more = false,
artists = artists
});
}
static string BuildSpotifyArtistSectionId(string artistId, string sectionKey)
=> $"{artistId}:{sectionKey}";
static (string artistId, string sectionKey)? ParseSpotifyArtistSectionId(string sectionId)
{
if (string.IsNullOrWhiteSpace(sectionId))
return null;
int separator = sectionId.LastIndexOf(':');
if (separator <= 0 || separator >= sectionId.Length - 1)
return null;
string artistId = sectionId.Substring(0, separator);
string sectionKey = sectionId.Substring(separator + 1);
if (!artistId.StartsWith("spotify:artist:", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(sectionKey))
return null;
return (artistId, sectionKey);
}
static string NextOffset(int offset, int count, int totalCount)
{
int next = offset + Math.Max(0, count);
return count > 0 && totalCount > next ? next.ToString() : null;
}
// pathfinder v2: POST с телом {variables, operationName, extensions}.
// Отдельный транспорт от v1-QueryAsync (тот GET и для импорта). Токен —
// тот же гостевой, с общей embed-страницы; на 401 сброс+повтор.
static async Task<JsonElement?> QueryV2Async(string operationName, string hash, object variables, CancellationToken cancellationToken)
{
string body = MusicJson.Serialize(new
{
variables,
operationName,
extensions = new { persistedQuery = new { version = 1, sha256Hash = hash } }
});
for (int attempt = 0; attempt < 2; attempt++)
{
string token = await GetAnonymousTokenAsync("playlist", TokenSeedPlaylistId, cancellationToken);
if (string.IsNullOrWhiteSpace(token))
return null;
using var request = new HttpRequestMessage(HttpMethod.Post, PathfinderV2Url)
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json")
};
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "application/json");
request.Headers.TryAddWithoutValidation("app-platform", "WebPlayer");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
InvalidateAnonymousToken();
continue;
}
if (!response.IsSuccessStatusCode)
return null;
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
var root = document.RootElement.Clone();
if (root.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Array && errors.GetArrayLength() > 0)
{
string firstError = errors.EnumerateArray().Select(e => GetString(e, "message")).FirstOrDefault(m => !string.IsNullOrWhiteSpace(m));
if (firstError?.Contains("PersistedQueryNotFound", StringComparison.OrdinalIgnoreCase) == true)
Console.WriteLine("[Music] spotify pathfinder v2 hash rotated — module update required");
else
Console.WriteLine($"[Music] spotify pathfinder v2 error: {firstError}");
return null;
}
return root;
}
return null;
}
static async Task<MusicUserPlaylistImportResult> ImportPlaylistByIdAsync(string playlistId, CancellationToken cancellationToken)
{
var tracks = new List<MusicTrack>();
string title = null;
int totalCount = -1;
for (int offset = 0; totalCount < 0 || (offset < totalCount && tracks.Count < MaxImportTracks); offset += PlaylistPageLimit)
{
var root = await QueryAsync("fetchPlaylist", FetchPlaylistHash, new
{
uri = $"spotify:playlist:{playlistId}",
offset,
limit = PlaylistPageLimit,
enableWatchFeedEntrypoint = false
}, "playlist", playlistId, cancellationToken);
var playlist = GetProperty(root, "data", "playlistV2");
if (playlist == null || playlist.Value.ValueKind != JsonValueKind.Object)
{
// страница не отдалась — импорт атомарный, усечённый список не сохраняем
return offset == 0
? ImportUnavailable("Spotify плейлист не найден или приватный.")
: ImportUnavailable("Spotify не отдал плейлист целиком, попробуй ещё раз.");
}
title ??= GetString(playlist.Value, "name");
var content = GetProperty(playlist.Value, "content");
if (content == null)
return ImportUnavailable("Spotify плейлист не удалось прочитать.");
if (totalCount < 0)
totalCount = GetInt(content.Value, "totalCount") ?? 0;
if (!content.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return ImportUnavailable("Spotify не отдал плейлист целиком, попробуй ещё раз.");
if (items.GetArrayLength() == 0 && offset < totalCount)
return ImportUnavailable("Spotify не отдал плейлист целиком, попробуй ещё раз.");
foreach (var item in items.EnumerateArray())
{
var mapped = MapPlaylistItem(item);
if (mapped != null)
tracks.Add(mapped);
}
if (totalCount <= 0)
break;
}
tracks = DeduplicateTracks(tracks);
if (tracks.Count == 0)
return ImportUnavailable("В Spotify плейлисте не найдено треков.");
title = string.IsNullOrWhiteSpace(title) ? "Spotify Playlist" : title.Trim();
return new MusicUserPlaylistImportResult
{
available = true,
title = title,
track_count = tracks.Count,
truncated = totalCount > tracks.Count && tracks.Count >= MaxImportTracks,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = PlaylistSourceType,
url = $"https://open.spotify.com/playlist/{playlistId}",
playlist_id = playlistId,
title = title
}
};
}
static async Task<MusicUserPlaylistImportResult> ImportAlbumAsync(string albumId, CancellationToken cancellationToken)
{
var tracks = new List<MusicTrack>();
string title = null, artistName = null, date = null;
List<MusicImage> albumImages = null;
int totalCount = -1;
for (int offset = 0; totalCount < 0 || (offset < totalCount && tracks.Count < MaxImportTracks); offset += AlbumPageLimit)
{
var root = await QueryAsync("getAlbum", GetAlbumHash, new
{
uri = $"spotify:album:{albumId}",
locale = "",
offset,
limit = AlbumPageLimit
}, "album", albumId, cancellationToken);
var album = GetProperty(root, "data", "albumUnion");
if (album == null || album.Value.ValueKind != JsonValueKind.Object)
{
return offset == 0
? ImportUnavailable("Spotify альбом не найден.")
: ImportUnavailable("Spotify не отдал альбом целиком, попробуй ещё раз.");
}
if (title == null)
{
title = GetString(album.Value, "name");
artistName = ExtractArtistNames(GetProperty(album.Value, "artists")).FirstOrDefault();
date = GetString(GetProperty(album.Value, "date") ?? default, "isoString");
albumImages = MapCoverArt(GetProperty(album.Value, "coverArt"));
}
var tracksV2 = GetProperty(album.Value, "tracksV2");
if (tracksV2 == null)
return ImportUnavailable("Spotify альбом не удалось прочитать.");
if (totalCount < 0)
totalCount = GetInt(tracksV2.Value, "totalCount") ?? 0;
if (!tracksV2.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return ImportUnavailable("Spotify не отдал альбом целиком, попробуй ещё раз.");
if (items.GetArrayLength() == 0 && offset < totalCount)
return ImportUnavailable("Spotify не отдал альбом целиком, попробуй ещё раз.");
foreach (var item in items.EnumerateArray())
{
var track = GetProperty(item, "track");
if (track == null)
continue;
var mapped = MapTrackElement(
track.Value,
title,
albumImages,
date,
durationProperty: "duration",
albumId: $"spotify:album:{albumId}");
if (mapped != null)
tracks.Add(mapped);
}
if (totalCount <= 0)
break;
}
tracks = DeduplicateTracks(tracks);
if (tracks.Count == 0)
return ImportUnavailable("В Spotify альбоме не найдено треков.");
title = string.IsNullOrWhiteSpace(title) ? "Spotify Album" : title.Trim();
string playlistTitle = string.IsNullOrWhiteSpace(artistName) ? title : $"{artistName} — {title}";
return new MusicUserPlaylistImportResult
{
available = true,
title = playlistTitle,
track_count = tracks.Count,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = AlbumSourceType,
url = $"https://open.spotify.com/album/{albumId}",
playlist_id = albumId,
title = playlistTitle
}
};
}
static MusicTrack MapPlaylistItem(JsonElement item)
{
var data = GetProperty(item, "itemV2", "data");
if (data == null || !string.Equals(GetString(data.Value, "__typename"), "Track", StringComparison.OrdinalIgnoreCase))
return null;
var album = GetProperty(data.Value, "albumOfTrack");
string albumTitle = album != null ? GetString(album.Value, "name") : null;
string albumId = album != null ? GetString(album.Value, "uri") : null;
var images = album != null ? MapCoverArt(GetProperty(album.Value, "coverArt")) : null;
return MapTrackElement(data.Value, albumTitle, images, date: null, durationProperty: "trackDuration", albumId: albumId);
}
static MusicTrack MapTrackElement(JsonElement track, string albumTitle, List<MusicImage> images, string date, string durationProperty, string albumId = null)
{
string title = GetString(track, "name")?.Trim();
string uri = GetString(track, "uri");
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(uri))
return null;
var artists = ExtractArtistNames(GetProperty(track, "artists"));
var duration = GetProperty(track, durationProperty);
return new MusicTrack
{
id = uri,
title = title,
artist_name = artists.Count > 0 ? string.Join(", ", artists) : "Spotify",
artists = artists,
album_id = string.IsNullOrWhiteSpace(albumId) ? null : albumId.Trim(),
album_title = string.IsNullOrWhiteSpace(albumTitle) ? null : albumTitle.Trim(),
duration_ms = duration != null ? GetInt(duration.Value, "totalMilliseconds") : null,
track_number = GetInt(track, "trackNumber"),
disc_number = GetInt(track, "discNumber"),
date = date,
images = images?.ToList() ?? new List<MusicImage>(),
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = uri }
}
};
}
static List<string> ExtractArtistNames(JsonElement? artists)
{
var result = new List<string>();
if (artists == null || !artists.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return result;
foreach (var item in items.EnumerateArray())
{
string name = GetString(GetProperty(item, "profile") ?? default, "name")?.Trim();
if (!string.IsNullOrWhiteSpace(name) && !result.Contains(name, StringComparer.OrdinalIgnoreCase))
result.Add(name);
}
return result;
}
static List<MusicImage> MapCoverArt(JsonElement? coverArt)
{
var result = new List<MusicImage>();
if (coverArt == null || !coverArt.Value.TryGetProperty("sources", out var sources) || sources.ValueKind != JsonValueKind.Array)
return result;
foreach (var source in sources.EnumerateArray())
{
string url = GetString(source, "url");
if (string.IsNullOrWhiteSpace(url))
continue;
result.Add(new MusicImage
{
url = url,
width = GetInt(source, "width"),
height = GetInt(source, "height")
});
}
// крупные первыми — как отдаёт SoundCloud-маппер
return result.OrderByDescending(i => i.width ?? 0).ToList();
}
static List<MusicImage> MapPlaylistImages(JsonElement? images)
{
var result = new List<MusicImage>();
if (images == null || !images.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
return result;
foreach (var item in items.EnumerateArray())
{
foreach (var image in MapCoverArt(item))
{
if (!result.Any(i => string.Equals(i.url, image.url, StringComparison.OrdinalIgnoreCase)))
result.Add(image);
}
}
return result.OrderByDescending(i => i.width ?? 0).ToList();
}
}