using System.Text.RegularExpressions; namespace Music; public class SpotifyDiscoveryProvider : IMusicDiscoveryProvider { sealed class Playlist { public string title { get; init; } public string playlistId { get; init; } } static readonly TimeSpan cacheTtl = TimeSpan.FromHours(1); static readonly IReadOnlyList globalPlaylists = new List { new() { title = "Топ-50 Spotify: мир", playlistId = "37i9dQZEVXbMDoHDwVN2tF" }, new() { title = "Today's Top Hits", playlistId = "37i9dQZF1DXcBWIGoYBM5M" }, new() { title = "New Music Friday", playlistId = "37i9dQZF1DX4JAvHpjipBk" }, new() { title = "RapCaviar", playlistId = "37i9dQZF1DX0XUsuxWHRQd" }, new() { title = "Fresh Finds", playlistId = "37i9dQZF1DWWjGdmeTyeJ6" } }; static readonly IReadOnlyList themedPlaylists = new List { new() { title = "Клубные хиты", playlistId = "37i9dQZF1DX0BcQWzuB7ZO" }, new() { title = "Ностальгическая вечеринка", playlistId = "37i9dQZF1DX7F6T2n2fegs" }, new() { title = "Хиты 2000-х", playlistId = "37i9dQZF1DX4o1oenSJRJd" }, new() { title = "Хиты 2010-х", playlistId = "37i9dQZF1DWWylYLMvjuRG" }, new() { title = "Весёлая пятница", playlistId = "37i9dQZF1DX1g0iEXLFycr" } }; static readonly IReadOnlyDictionary> countryThemedPlaylists = new Dictionary>(StringComparer.OrdinalIgnoreCase) { ["GLOBAL"] = new List { new() { title = "Хиты 80-х", playlistId = "37i9dQZF1DX4UtSsGT1Sbe" }, new() { title = "Хиты 90-х", playlistId = "37i9dQZF1DXbTxeAdrVG2l" }, new() { title = "Классика рока", playlistId = "37i9dQZF1DWXRqgorJj26U" }, new() { title = "Песни для поездки", playlistId = "37i9dQZF1DWWMOmoXKqHTD" }, new() { title = "Спокойные хиты", playlistId = "37i9dQZF1DX4WYpdgoIcn6" } }, ["UA"] = new List { new() { title = "Літні хіти України", playlistId = "37i9dQZF1DX86ZB9BGPT0a" }, new() { title = "Українська ностальгія", playlistId = "3FUapgKzH0x7qSygyWS7rk" }, new() { title = "Українські хіти 2000-х", playlistId = "3Id8TtStr3uzNv79RhOgmb" }, new() { title = "Українські легенди", playlistId = "158jVnGiYCk96G6oJweWVz" }, new() { title = "Українська вечірка", playlistId = "3wKPQ5RdRZymbAcEEkVRwT" } }, ["CA"] = new List { new() { title = "Классика канадского рока", playlistId = "37i9dQZF1DX4bKgmgKIPtO" }, new() { title = "Канада: хиты 90-х", playlistId = "37i9dQZF1DX9NmM48Aqz3e" }, new() { title = "Канадский хип-хоп", playlistId = "37i9dQZF1DX59ogDi1Z2XL" }, new() { title = "Классика канадской инди-музыки", playlistId = "37i9dQZF1DX5dx2bmWL9SZ" }, new() { title = "Канадское кантри", playlistId = "37i9dQZF1DWYV2Gh2QglGo" } }, ["US"] = new List { new() { title = "Американа: классика", playlistId = "37i9dQZF1DX7tfbjVrTPnV" }, new() { title = "R&B-вечеринка", playlistId = "37i9dQZF1DX2hNQN2Fv6Cy" }, new() { title = "Хип-хоп 90-х", playlistId = "37i9dQZF1DX186v583rmzp" }, new() { title = "Хип-хоп 2000-х", playlistId = "37i9dQZF1DX1lHW2vbQwNN" }, new() { title = "Хип-хоп 2010-х", playlistId = "37i9dQZF1DX97h7ftpNSYT" } }, ["RU"] = new List { new() { title = "Русские клубные хиты", playlistId = "0PwJbcDouwFD1oKhynlUR9" }, new() { title = "Русская ностальгия", playlistId = "5oeCCfcOxkBLRL5AXf0iVs" }, new() { title = "Русские хиты 2000-х", playlistId = "1JNYqFwOv1bIrXUjHTIDAC" }, new() { title = "Русские хиты 2010-х", playlistId = "6OZJ2pHVbVto0SgTMMAK0G" }, new() { title = "Русская вечеринка", playlistId = "4IYyB7pb7JIoT3ciWBgJJa" } }, ["PL"] = new List { new() { title = "Rap klub", playlistId = "37i9dQZF1DX3HUaZJRcDLd" }, new() { title = "Polskie przeboje wszech czasów", playlistId = "37i9dQZF1DX8J2l55TrZk6" }, new() { title = "Polski rap 2000.", playlistId = "37i9dQZF1DX4tIiLXPvlZC" }, new() { title = "Polskie lata 2010.", playlistId = "37i9dQZF1DX7bSIS915wSM" }, new() { title = "Polska impreza", playlistId = "37i9dQZF1DX7P3ukP665LS" } }, ["FR"] = new List { new() { title = "La French Touch", playlistId = "37i9dQZF1DX9cbNxuNYT3d" }, new() { title = "Pop francophone", playlistId = "37i9dQZF1DX9ZKIkjFureA" }, new() { title = "Années 2000", playlistId = "37i9dQZF1DWWTe89CFDZnE" }, new() { title = "Années 2010", playlistId = "37i9dQZF1DWVrO0SOklesK" }, new() { title = "Soirée française", playlistId = "37i9dQZF1EIe3TBxFniBXv" } }, ["DE"] = new List { new() { title = "Deutschpop Klassiker", playlistId = "37i9dQZF1DX2cNqJ4LgCMf" }, new() { title = "Schlager Klassiker", playlistId = "37i9dQZF1DX6uHioFvkN7A" }, new() { title = "Deutschrap 2000er", playlistId = "37i9dQZF1DX5sbPvjd2Huv" }, new() { title = "Deutschrap 2010er", playlistId = "37i9dQZF1DX1q42kBiHxxd" }, new() { title = "POPLAND", playlistId = "37i9dQZF1DXbKGrOUA30KN" } } }; static readonly IReadOnlyDictionary> countryPlaylists = new Dictionary>(StringComparer.OrdinalIgnoreCase) { ["UA"] = new List { new() { title = "Топ-50 Spotify: Украина", playlistId = "37i9dQZEVXbKkidEfWYRuD" }, new() { title = "Hot Hits Україна", playlistId = "37i9dQZF1DX1V3tM4cuX0v" }, new() { title = "TOP POP 2026", playlistId = "37i9dQZF1DX2vTOtsQ5Isl" }, new() { title = "EQUAL Україна", playlistId = "37i9dQZF1DWTtjLYc6QFF2" }, new() { title = "Music for Ukraine", playlistId = "37i9dQZF1DWY0TJvcbosib" } }, ["CA"] = new List { new() { title = "Топ-50 Spotify: Канада", playlistId = "37i9dQZEVXbKj23U1GF4IR" }, new() { title = "Hot Hits Canada", playlistId = "37i9dQZF1DWXT8uSSn6PRy" }, new() { title = "New Music Friday Canada", playlistId = "37i9dQZF1DX5DfG8gQdC3F" }, new() { title = "Fresh Finds Canada", playlistId = "37i9dQZF1DWVxStm5ni6tl" }, new() { title = "Today's Top Hits", playlistId = "37i9dQZF1DXcBWIGoYBM5M" } }, ["US"] = new List { new() { title = "Топ-50 Spotify: США", playlistId = "37i9dQZEVXbLRQDuF5jeBp" }, new() { title = "Hot Hits USA", playlistId = "37i9dQZF1DX0kbJZpiYdZl" }, new() { title = "Today's Top Hits", playlistId = "37i9dQZF1DXcBWIGoYBM5M" }, new() { title = "New Music Friday", playlistId = "37i9dQZF1DX4JAvHpjipBk" }, new() { title = "RapCaviar", playlistId = "37i9dQZF1DX0XUsuxWHRQd" } }, ["RU"] = new List { new() { title = "Русский Топ-50", playlistId = "1qkR4WrgRWFg1w3Qik1lTu" }, new() { title = "Русские хиты", playlistId = "7xm8bTP6NFGMDudMHsC2qp" }, new() { title = "Русский поп", playlistId = "4dL0DgSaYON6tODhwM72gw" }, new() { title = "Русский рэп", playlistId = "0Y8djwSTijxFoC3Z0vdinF" }, new() { title = "Русский рок", playlistId = "0Fb2Hc0MdcME6knQ0vFiby" } }, ["PL"] = new List { new() { title = "Топ-50 Spotify: Польша", playlistId = "37i9dQZEVXbN6itCcaL3Tt" }, new() { title = "Hot Hits Polska", playlistId = "37i9dQZF1DX49bSMRljsho" }, new() { title = "New Music Friday Polska", playlistId = "37i9dQZF1DXaMTfp0LE4AZ" }, new() { title = "Fresh Finds Polska", playlistId = "37i9dQZF1DWTI0B69TStH2" }, new() { title = "RADAR Polska", playlistId = "37i9dQZF1DX1aXwAOtpwvU" } }, ["FR"] = new List { new() { title = "Топ-50 Spotify: Франция", playlistId = "37i9dQZEVXbIPWwFssbupI" }, new() { title = "Hits du Moment", playlistId = "37i9dQZF1DWVuV87wUBNwc" }, new() { title = "La Hit Liste", playlistId = "37i9dQZF1DX7LjobXS2hzX" }, new() { title = "New Music Friday France", playlistId = "37i9dQZF1DX742okrrpwah" }, new() { title = "PVNCHLNRS", playlistId = "37i9dQZF1DX1X23oiQRTB5" } }, ["DE"] = new List { new() { title = "Топ-50 Spotify: Германия", playlistId = "37i9dQZEVXbJiZcmkrIHGU" }, new() { title = "Hot Hits Deutschland", playlistId = "37i9dQZF1DX4jP4eebSWR9" }, new() { title = "New Music Friday Deutschland", playlistId = "37i9dQZF1DWUW2bvSkjcJ6" }, new() { title = "Deutschrap Brandneu", playlistId = "37i9dQZF1DWSTqUqJcxFk6" }, new() { title = "Fresh Finds GSA", playlistId = "37i9dQZF1DX2ddCYH6QIK5" } } }; const string defaultCountry = "US"; public string Id => SpotifySupport.DiscoveryProviderId; public string Name => "Spotify"; public bool Enabled => ModInit.conf?.spotify_discovery_enabled == true; public async Task> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default) { var section = await BuildSectionAsync(limit, cancellationToken); return section == null ? new List() : new List { section }; } public async Task GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default) { var profile = GetProfile(); if (!string.Equals(sectionId, BuildSectionId(profile.country), StringComparison.OrdinalIgnoreCase)) return null; return await BuildSectionAsync(limit, cancellationToken); } public static bool IsPlaylistAlbum(string provider, string id) => string.Equals(provider, SpotifySupport.DiscoveryProviderId, StringComparison.OrdinalIgnoreCase) && TryGetPlaylistId(id, out _); public async Task GetPlaylistAlbumAsync(string id, CancellationToken cancellationToken = default) { if (!TryGetPlaylistId(id, out string playlistId)) return null; var profile = GetProfile(); var playlist = profile.playlists.FirstOrDefault(item => string.Equals(item.playlistId, playlistId, StringComparison.OrdinalIgnoreCase)); if (playlist == null) return null; var album = await LoadPlaylistAsync(playlist.playlistId, SpotifySupport.PlaylistTrackLimit, cancellationToken); return album == null ? null : CopyAlbum(album, playlist.title, includeTracks: true); } async Task BuildSectionAsync(int limit, CancellationToken cancellationToken) { var profile = GetProfile(); int take = Math.Clamp(limit, 1, profile.playlists.Count); var playlists = profile.playlists.Take(take).ToList(); var tasks = playlists.Select(item => LoadPlaylistAsync(item.playlistId, 1, cancellationToken)).ToList(); var results = await Task.WhenAll(tasks); var albums = new List(); for (int i = 0; i < playlists.Count; i++) { if (results[i] != null) albums.Add(CopyAlbum(results[i], playlists[i].title, includeTracks: false)); } if (albums.Count == 0) return null; return new MusicBrowseSection { id = BuildSectionId(profile.country), title = profile.title, type = "album", source_provider = SpotifySupport.DiscoveryProviderId, has_more = profile.playlists.Count > take, albums = albums }; } static (string country, string title, IReadOnlyList playlists) GetProfile() { string country = NormalizeCountry(ModInit.conf?.spotify_country); if (!countryPlaylists.TryGetValue(country, out var playlists)) return ("GLOBAL", "Spotify: мир", AppendThemed("GLOBAL", globalPlaylists)); string title = country switch { "UA" => "Spotify: Украина", "CA" => "Spotify: Канада", "US" => "Spotify: США", "RU" => "Spotify: Россия", "PL" => "Spotify: Польша", "FR" => "Spotify: Франция", "DE" => "Spotify: Германия", _ => "Spotify" }; return (country, title, AppendThemed(country, playlists)); } static IReadOnlyList AppendThemed(string country, IReadOnlyList playlists) { var result = playlists.ToList(); if (countryThemedPlaylists.TryGetValue(country, out var countryPlaylists)) result.AddRange(countryPlaylists); result.AddRange(themedPlaylists); return result; } static string NormalizeCountry(string country) { country = country?.Trim().ToUpperInvariant(); return !string.IsNullOrWhiteSpace(country) && Regex.IsMatch(country, "^[A-Z]{2}$") ? country : defaultCountry; } static Task LoadPlaylistAsync(string playlistId, int limit, CancellationToken cancellationToken) { int take = Math.Clamp(limit, 1, SpotifySupport.PlaylistTrackLimit); return MusicMetadataCacheService.GetOrCreateAsync( SpotifySupport.DiscoveryProviderId, "playlist", $"{playlistId}:paged-v2:{take}", cacheTtl, () => SpotifySupport.GetPlaylistAlbumAsync(playlistId, take, cancellationToken), cancellationToken ); } static MusicAlbum CopyAlbum(MusicAlbum album, string title, bool includeTracks) { return new MusicAlbum { id = album.id, title = string.IsNullOrWhiteSpace(title) ? album.title : title, artist_name = album.artist_name, type = album.type, description = album.description, images = album.images?.ToList() ?? new List(), provider_refs = album.provider_refs?.ToList() ?? new List(), tracks = includeTracks ? album.tracks?.ToList() ?? new List() : new List() }; } static bool TryGetPlaylistId(string id, out string playlistId) { playlistId = null; const string prefix = "spotify:playlist:"; if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false; string value = id[prefix.Length..]; if (!Regex.IsMatch(value, "^[A-Za-z0-9]{22}$")) return false; playlistId = value; return true; } static string BuildSectionId(string country) => $"browse:spotify:{country.ToLowerInvariant()}"; }