using System.Net.Http; using System.Text.Json; using System.Text.RegularExpressions; using static Music.MusicPlaylistImportHelpers; namespace Music; // Импорт публичных плейлистов/альбомов Apple Music БЕЗ ключей и логина: // анонимный developer-JWT из JS-бандла music.apple.com (живёт ~месяц) // + amp-api catalog — тот же путь, что у веб-плеера. Стримов нет (DRM): // треки приезжают метаданными, аудио резолвит обычный конвейер (YouTube-матчер). public static class AppleMusicSupport { public const string ProviderId = "applemusic"; public const string PlaylistSourceType = "applemusic_playlist"; public const string AlbumSourceType = "applemusic_album"; const string ApiBaseUrl = "https://amp-api.music.apple.com"; const int PageLimit = 100; const int MaxImportTracks = 2000; const int MaxPages = 30; 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("applemusic"); // storefront (us/ru/ua/...), тип, id: pl.xxx / pl.u-xxx у плейлистов, цифры у альбомов static readonly Regex entityUrlRegex = new(@"^https?://music\.apple\.com/([a-z]{2})/(playlist|album)/(?:[^/]+/)?(pl\.[A-Za-z0-9\.\-]+|\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); static readonly Regex catalogAlbumIdRegex = new(@"^applemusic:album:([a-z]{2}):(\d+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); static readonly Regex assetBundleRegex = new("src=\"(/assets/index~[^\"]+\\.js)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled); static readonly Regex developerTokenRegex = new("\"(eyJ0[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+)\"", RegexOptions.Compiled); static readonly SemaphoreSlim developerTokenLock = new(1, 1); static string developerToken; static DateTime developerTokenExpiresAt; static AppleMusicSupport() { httpClient.Timeout = TimeSpan.FromSeconds(20); } public static bool CanHandleUrl(string url) => IsAppleMusicUrl(url); public static bool IsCatalogAlbum(string provider, string id) => string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase) || catalogAlbumIdRegex.IsMatch(id ?? string.Empty); public static Task GetCatalogAlbumAsync(string id, CancellationToken cancellationToken = default) { var match = catalogAlbumIdRegex.Match(id ?? string.Empty); return match.Success ? GetCatalogAlbumAsync(match.Groups[1].Value, match.Groups[2].Value, cancellationToken) : Task.FromResult(null); } public static async Task GetCatalogAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken = default) { try { return (await LoadCatalogAlbumAsync(storefront, albumId, cancellationToken)).album; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch { return null; } } public static async Task ImportPlaylistAsync(string inputUrl, CancellationToken cancellationToken = default) { var entity = ParseEntity(inputUrl); if (entity == null) { if (IsProfileUrl(inputUrl)) return ImportUnavailable("Apple Music профиль не импортируется. Вставь ссылку на конкретный публичный плейлист или альбом."); return ImportUnavailable("Вставь ссылку на Apple Music плейлист или альбом."); } // прогрев кэша токена + дружелюбная ошибка, если Apple недоступен string token = await GetDeveloperTokenAsync(cancellationToken); if (string.IsNullOrWhiteSpace(token)) return ImportUnavailable("Не удалось получить Apple Music токен."); try { return entity.Value.type == "album" ? await ImportAlbumAsync(entity.Value.storefront, entity.Value.id, cancellationToken) : await ImportPlaylistByIdAsync(entity.Value.storefront, entity.Value.id, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { Console.WriteLine($"[Music] apple music import failed: {ex.Message}"); return ImportUnavailable("Apple Music не ответил, попробуй ещё раз."); } } public static Task ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default) { if (source == null || string.IsNullOrWhiteSpace(source.url)) return Task.FromResult(ImportUnavailable("У плейлиста нет Apple Music источника.")); return ImportPlaylistAsync(source.url, cancellationToken); } static (string storefront, string type, string id)? ParseEntity(string value) { value = value?.Trim(); if (string.IsNullOrWhiteSpace(value)) return null; var match = entityUrlRegex.Match(value); if (!match.Success) return null; string type = match.Groups[2].Value.ToLowerInvariant(); string id = match.Groups[3].Value; // альбомная ссылка с плейлистным id (и наоборот) — мусор, не берём bool playlistId = id.StartsWith("pl.", StringComparison.OrdinalIgnoreCase); if (type == "playlist" != playlistId) return null; return (match.Groups[1].Value.ToLowerInvariant(), type, id); } static bool IsAppleMusicUrl(string value) { if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri)) return false; return string.Equals(uri.Host, "music.apple.com", StringComparison.OrdinalIgnoreCase); } static bool IsProfileUrl(string value) { if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri)) return false; return string.Equals(uri.Host, "music.apple.com", StringComparison.OrdinalIgnoreCase) && uri.AbsolutePath.StartsWith("/profile/", StringComparison.OrdinalIgnoreCase); } // Анонимный developer-JWT лежит в бандле /assets/index~*.js главной страницы; // Apple выпускает его на ~месяц — кэшируем сутки и сбрасываем на 401. static async Task GetDeveloperTokenAsync(CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(developerToken) && developerTokenExpiresAt > DateTime.UtcNow) return developerToken; await developerTokenLock.WaitAsync(cancellationToken); try { if (!string.IsNullOrWhiteSpace(developerToken) && developerTokenExpiresAt > DateTime.UtcNow) return developerToken; string html = await FetchTextAsync("https://music.apple.com/us/browse", "text/html,application/xhtml+xml", cancellationToken); var bundleMatch = assetBundleRegex.Match(html ?? string.Empty); if (!bundleMatch.Success) return null; string script = await FetchTextAsync("https://music.apple.com" + bundleMatch.Groups[1].Value, "application/javascript,*/*", cancellationToken); var tokenMatch = developerTokenRegex.Match(script ?? string.Empty); if (!tokenMatch.Success) return null; developerToken = tokenMatch.Groups[1].Value; developerTokenExpiresAt = DateTime.UtcNow.AddHours(24); return developerToken; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch { return null; } finally { developerTokenLock.Release(); } } static void InvalidateDeveloperToken() { developerToken = null; developerTokenExpiresAt = DateTime.MinValue; } static async Task FetchTextAsync(string url, string accept, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent); request.Headers.TryAddWithoutValidation("Accept", accept); using var response = await httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) return null; return await response.Content.ReadAsStringAsync(cancellationToken); } // сам берёт токен из кэша; на 401 сбрасывает его и повторяет запрос // один раз со свежим — протухший developer-токен не роняет импорт/sync static async Task ApiAsync(string path, CancellationToken cancellationToken) { for (int attempt = 0; attempt < 2; attempt++) { string token = await GetDeveloperTokenAsync(cancellationToken); if (string.IsNullOrWhiteSpace(token)) return null; using var request = new HttpRequestMessage(HttpMethod.Get, ApiBaseUrl + path); request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); request.Headers.TryAddWithoutValidation("Origin", "https://music.apple.com"); request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent); request.Headers.TryAddWithoutValidation("Accept", "application/json"); using var response = await httpClient.SendAsync(request, cancellationToken); if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { InvalidateDeveloperToken(); Console.WriteLine("[Music] apple music developer token expired"); continue; } if (!response.IsSuccessStatusCode) return null; string json = await response.Content.ReadAsStringAsync(cancellationToken); using var document = JsonDocument.Parse(json); return document.RootElement.Clone(); } return null; } static async Task ImportPlaylistByIdAsync(string storefront, string playlistId, CancellationToken cancellationToken) { // мета плейлиста ради имени; треки из relationships не берём — // единообразно листаем /tracks с нуля (одна лишняя страница, зато один путь) var meta = await ApiAsync($"/v1/catalog/{storefront}/playlists/{Uri.EscapeDataString(playlistId)}", cancellationToken); var playlist = FirstDataElement(meta); if (playlist == null) return ImportUnavailable("Apple Music плейлист не найден или приватный."); string title = GetString(GetProperty(playlist.Value, "attributes") ?? default, "name"); var tracks = new List(); for (int page = 0; page < MaxPages && tracks.Count < MaxImportTracks; page++) { var root = await ApiAsync($"/v1/catalog/{storefront}/playlists/{Uri.EscapeDataString(playlistId)}/tracks?limit={PageLimit}&offset={page * PageLimit}", cancellationToken); if (root == null) { // страница не отдалась — импорт атомарный, усечёнку не сохраняем return page == 0 ? ImportUnavailable("Apple Music плейлист не удалось прочитать.") : ImportUnavailable("Apple Music не отдал плейлист целиком, попробуй ещё раз."); } int pageItems = MapTrackPage(root.Value, tracks, albumImagesFallback: null, albumTitleFallback: null, dateFallback: null); bool hasNext = GetProperty(root.Value, "next") != null; if (pageItems == 0 || !hasNext) break; } tracks = DeduplicateTracks(tracks); if (tracks.Count == 0) return ImportUnavailable("В Apple Music плейлисте не найдено треков."); title = string.IsNullOrWhiteSpace(title) ? "Apple Music Playlist" : title.Trim(); return new MusicUserPlaylistImportResult { available = true, title = title, track_count = tracks.Count, truncated = tracks.Count >= MaxImportTracks, tracks = tracks, source = new MusicUserPlaylistSource { type = PlaylistSourceType, url = $"https://music.apple.com/{storefront}/playlist/_/{playlistId}", playlist_id = playlistId, title = title } }; } static async Task ImportAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken) { var loaded = await LoadCatalogAlbumAsync(storefront, albumId, cancellationToken); var album = loaded.album; if (album == null) return ImportUnavailable(loaded.error ?? "Apple Music альбом не найден."); string playlistTitle = string.IsNullOrWhiteSpace(album.artist_name) ? album.title : $"{album.artist_name} — {album.title}"; return new MusicUserPlaylistImportResult { available = true, title = playlistTitle, track_count = album.tracks.Count, tracks = album.tracks, source = new MusicUserPlaylistSource { type = AlbumSourceType, url = $"https://music.apple.com/{storefront}/album/_/{albumId}", playlist_id = albumId, title = playlistTitle } }; } static async Task<(MusicAlbum album, string error)> LoadCatalogAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken) { storefront = storefront?.Trim().ToLowerInvariant(); albumId = albumId?.Trim(); if (!Regex.IsMatch(storefront ?? string.Empty, "^[a-z]{2}$") || !Regex.IsMatch(albumId ?? string.Empty, "^[0-9]+$")) return (null, "Некорректный Apple Music album ID."); var meta = await ApiAsync($"/v1/catalog/{storefront}/albums/{Uri.EscapeDataString(albumId)}", cancellationToken); var albumElement = FirstDataElement(meta); if (albumElement == null) return (null, "Apple Music альбом не найден."); var attributes = GetProperty(albumElement.Value, "attributes"); string title = attributes != null ? GetString(attributes.Value, "name") : null; string artistName = attributes != null ? GetString(attributes.Value, "artistName") : null; string date = attributes != null ? GetString(attributes.Value, "releaseDate") : null; var albumImages = attributes != null ? MapArtwork(GetProperty(attributes.Value, "artwork")) : null; var tracks = new List(); int fetched = 0; // первая порция треков приезжает в relationships, остальное — пагинацией; // offset считаем по сырому числу элементов (не по замапленным — часть фильтруется) var relationshipTracks = GetProperty(albumElement.Value, "relationships", "tracks"); bool hasNext = false; if (relationshipTracks != null) { fetched += MapTrackPage(relationshipTracks.Value, tracks, albumImages, title, date); hasNext = GetProperty(relationshipTracks.Value, "next") != null; } for (int page = 1; hasNext && page < MaxPages && tracks.Count < MaxImportTracks; page++) { var root = await ApiAsync($"/v1/catalog/{storefront}/albums/{Uri.EscapeDataString(albumId)}/tracks?limit={PageLimit}&offset={fetched}", cancellationToken); if (root == null) return (null, "Apple Music не отдал альбом целиком, попробуй ещё раз."); int pageItems = MapTrackPage(root.Value, tracks, albumImages, title, date); fetched += pageItems; hasNext = GetProperty(root.Value, "next") != null && pageItems > 0; } tracks = DeduplicateTracks(tracks); if (tracks.Count == 0) return (null, "В Apple Music альбоме не найдено треков."); title = string.IsNullOrWhiteSpace(title) ? "Apple Music Album" : title.Trim(); string canonicalId = $"applemusic:album:{storefront}:{albumId}"; int? year = date?.Length >= 4 && int.TryParse(date[..4], out int parsedYear) ? parsedYear : null; foreach (var track in tracks) track.album_id = canonicalId; return (new MusicAlbum { title = title, id = canonicalId, artist_name = string.IsNullOrWhiteSpace(artistName) ? "Apple Music" : artistName.Trim(), date = date, year = year, type = "Album", images = albumImages ?? new List(), tracks = tracks, provider_refs = new List { new() { provider = ProviderId, external_id = albumId } } }, null); } // маппит data[] страницы (плейлист-треки или альбом-треки); возвращает СЫРОЕ // число элементов страницы — по нему считается offset (часть элементов фильтруется) static int MapTrackPage(JsonElement root, List tracks, List albumImagesFallback, string albumTitleFallback, string dateFallback) { if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array) return 0; int rawCount = 0; foreach (var item in data.EnumerateArray()) { rawCount++; // в плейлистах бывают music-videos — берём только песни string kind = GetString(item, "type"); if (!string.IsNullOrWhiteSpace(kind) && !string.Equals(kind, "songs", StringComparison.OrdinalIgnoreCase)) continue; var mapped = MapTrackElement(item, albumImagesFallback, albumTitleFallback, dateFallback); if (mapped != null) tracks.Add(mapped); } return rawCount; } static MusicTrack MapTrackElement(JsonElement item, List albumImagesFallback, string albumTitleFallback, string dateFallback) { var attributes = GetProperty(item, "attributes"); if (attributes == null) return null; string title = GetString(attributes.Value, "name")?.Trim(); string trackId = GetString(item, "id"); if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(trackId)) return null; string artistName = GetString(attributes.Value, "artistName")?.Trim(); var images = MapArtwork(GetProperty(attributes.Value, "artwork")); if (images.Count == 0 && albumImagesFallback != null) images = albumImagesFallback.ToList(); return new MusicTrack { id = $"applemusic:track:{trackId}", title = title, artist_name = string.IsNullOrWhiteSpace(artistName) ? "Apple Music" : artistName, artists = string.IsNullOrWhiteSpace(artistName) ? new List() : new List { artistName }, album_title = GetString(attributes.Value, "albumName")?.Trim() ?? albumTitleFallback, isrc = MusicIsrc.Normalize(GetString(attributes.Value, "isrc")), duration_ms = GetInt(attributes.Value, "durationInMillis"), track_number = GetInt(attributes.Value, "trackNumber"), disc_number = GetInt(attributes.Value, "discNumber"), date = GetString(attributes.Value, "releaseDate") ?? dateFallback, images = images, provider_refs = new List { new() { provider = ProviderId, external_id = trackId } } }; } // artwork.url — шаблон с {w}x{h}; просим два размера, крупный первым static List MapArtwork(JsonElement? artwork) { var result = new List(); string template = artwork != null ? GetString(artwork.Value, "url") : null; if (string.IsNullOrWhiteSpace(template) || !template.Contains("{w}", StringComparison.OrdinalIgnoreCase)) return result; foreach (int size in new[] { 600, 300 }) { result.Add(new MusicImage { url = template.Replace("{w}", size.ToString()).Replace("{h}", size.ToString()), width = size, height = size }); } return result; } static JsonElement? FirstDataElement(JsonElement? root) { if (root == null || !root.Value.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array || data.GetArrayLength() == 0) return null; return data[0]; } }