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 top_tracks { get; set; } = new(); public List 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 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(); string title = null; string ownerName = null; string description = null; List 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(), provider_refs = new List { 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> SearchTracksAsync(string query, int limit, CancellationToken cancellationToken = default) { query = query?.Trim(); if (string.IsNullOrWhiteSpace(query)) return new List(); 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(); var tracks = new List(); 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(); } } public static async Task 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 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 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 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> SearchArtistsAsync(string query, int limit, CancellationToken cancellationToken = default) { query = query?.Trim(); if (string.IsNullOrWhiteSpace(query)) return new List(); 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(); var result = new List(); 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(); } } // Лёгкий discovery-ответ для персональных миксов: один queryArtist // без загрузки всей дискографии, которую делает GetArtistAsync. public static async Task 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 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() : MapTopTracks(GetProperty(discography.Value, "topTracks"), Math.Clamp(topTrackLimit, 1, 30)), related_artists = related == null ? new List() : 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> SearchAlbumsAsync(string query, int limit, CancellationToken cancellationToken = default) { query = query?.Trim(); if (string.IsNullOrWhiteSpace(query)) return new List(); 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(); var result = new List(); 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(); } } public static Task 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(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(null) }; } // Экран артиста: профиль + дискография (альбомы+синглы). id = spotify:artist:xxx. public static async Task 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 { 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(StringComparer.Ordinal); foreach (var album in (albumsSection?.albums ?? new List()) .Concat(singlesSection?.albums ?? new List()) .Concat(compilationsSection?.albums ?? new List())) { 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 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 { 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 { 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 { 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 { new() { provider = ProviderId, external_id = uri } } }; } static async Task 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 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 MapTopTracks(JsonElement? topTracks, int limit) { var result = new List(); 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 MapDirectAlbumItems(JsonElement? container, string artistName, int limit) { var result = new List(); if (container == null || !container.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) return result; var seen = new HashSet(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 MapDiscographyGroup(JsonElement? groupNode, string artistName, int limit) { var result = new List(); if (groupNode == null || !groupNode.Value.TryGetProperty("items", out var relItems) || relItems.ValueKind != JsonValueKind.Array) return result; var seen = new HashSet(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 MapDiscographySingleTracks(JsonElement? groupNode, string artistName, int limit) { var result = new List(); if (groupNode == null || !groupNode.Value.TryGetProperty("items", out var relItems) || relItems.ValueKind != JsonValueKind.Array) return result; var seen = new HashSet(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() : new List { artistName }, album_id = albumUri, album_title = title, date = date, track_number = 1, images = images, provider_refs = new List { new() { provider = ProviderId, external_id = albumUri } } }; } static List MapRelatedArtists(JsonElement? relatedArtists, int limit) { var result = new List(); if (relatedArtists == null || !relatedArtists.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) return result; var seen = new HashSet(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 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 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() }; } static MusicBrowseSection BuildTrackSection(string id, string title, List 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() }; } static void AddAlbumSection(MusicArtist artist, string id, string title, List 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 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 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 ImportPlaylistByIdAsync(string playlistId, CancellationToken cancellationToken) { var tracks = new List(); 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 ImportAlbumAsync(string albumId, CancellationToken cancellationToken) { var tracks = new List(); string title = null, artistName = null, date = null; List 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 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(), provider_refs = new List { new() { provider = ProviderId, external_id = uri } } }; } static List ExtractArtistNames(JsonElement? artists) { var result = new List(); 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 MapCoverArt(JsonElement? coverArt) { var result = new List(); 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 MapPlaylistImages(JsonElement? images) { var result = new List(); 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(); } }