namespace Music; public static class MusicCatalogService { static readonly TimeSpan searchCacheTtl = TimeSpan.FromHours(6); static readonly TimeSpan artistCacheTtl = TimeSpan.FromDays(7); static readonly TimeSpan albumCacheTtl = TimeSpan.FromDays(7); static readonly TimeSpan trackCacheTtl = TimeSpan.FromDays(7); static readonly TimeSpan discoveryHomeProviderResponseBudget = TimeSpan.FromSeconds(2); static readonly TimeSpan discoveryHomeProviderWarmTimeout = TimeSpan.FromSeconds(10); static readonly TimeSpan discoverySectionProviderTimeout = TimeSpan.FromSeconds(8); static readonly TimeSpan searchSectionProviderTimeout = TimeSpan.FromSeconds(4); static readonly TimeSpan searchMetadataResponseBudget = TimeSpan.FromSeconds(2); static readonly TimeSpan searchMetadataTimeout = TimeSpan.FromSeconds(20); const int recentSectionLimit = 100; const int browseSectionLimit = 20; const int browseSectionFullLimit = 100; const int searchSectionLimit = 8; const int searchSectionFullLimit = 24; // v31: RU->latin fallback metadata-поиска для кириллических запросов // западных артистов ("эминем" -> "eminem"), без fuzzy-расширения. // v34: Spotify artist screen добирает полные discography/appears-on секции // отдельными artistsection-запросами вместо короткого overview. // v35: уточнённые Spotify release labels для singles/EP/appears-on. // v36: Spotify singles/EP отдаются как track-section, не album-section. // v37: track metadata сохраняет ISRC для точного audio matching. // v38: Spotify tracks сохраняют album_id для быстрого нативного // разрешения длительности без ошибочного fallback в MusicBrainz. const string metadataCacheVersion = "v38"; static readonly object homeWarmLock = new(); static readonly Dictionary>> homeWarmTasks = new(StringComparer.OrdinalIgnoreCase); public static async Task GetHomeAsync(string profileId, int dailySalt = 0, CancellationToken cancellationToken = default) { var sections = new List { new() { id = "search", title = "Search", endpoint = "/music/search", type = "search" }, new() { id = "providers", title = "Providers", endpoint = "/music/providers", type = "info" } }; if (MusicProviderRegistry.AuthProviders.Any(i => i.Enabled)) sections.Add(new MusicSection { id = "auth", title = "Auth", endpoint = "/music/auth/state", type = "info" }); var recentlyPlayedTask = MusicPlaybackHistoryService.GetRecentAsync(profileId, recentSectionLimit, cancellationToken); var userPlaylistsTask = MusicUserPlaylistService.ListAsync(profileId, cancellationToken); // сводки миксов недели — чистое чтение истории + детерминированная // раскладка, без внешних HTTP; треки грузятся лениво в /music/daily var dailyMixesTask = MusicDailyMixService.GetSummariesAsync(profileId, dailySalt, cancellationToken); var browseSectionsTask = GetBrowseSectionsAsync(cancellationToken); await Task.WhenAll(recentlyPlayedTask, userPlaylistsTask, dailyMixesTask, browseSectionsTask); var browseSectionsResult = await browseSectionsTask; return new MusicHomeResponse { title = "Music", status = "ok", version = "0.1.0", sections = sections, metadata_providers = MusicProviderRegistry.DescribeMetadata(), audio_providers = MusicProviderRegistry.DescribeAudio(), auth_providers = MusicProviderRegistry.DescribeAuth(), recently_played = await recentlyPlayedTask, user_playlists = await userPlaylistsTask, daily_mixes = await dailyMixesTask, browse_sections = browseSectionsResult.sections, browse_sections_warming = browseSectionsResult.warming }; } public static async Task GetBrowseSectionAsync(string sectionId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(sectionId)) return null; foreach (var provider in MusicProviderRegistry.DiscoveryProviders.Where(i => i.Enabled)) { var section = await SafeGetSectionAsync(provider, sectionId, browseSectionFullLimit, cancellationToken); if (section != null) return section; } return null; } public static async Task SearchAsync(string query, string provider = null, bool expanded = false, CancellationToken cancellationToken = default) { var metadata = MusicProviderRegistry.GetMetadataProvider(provider); var metadataTask = SearchMetadataAsync(metadata, query, expanded, cancellationToken); ObserveBackgroundTask(metadataTask); var searchSectionsTask = GetSearchSectionsAsync(query, expanded, cancellationToken); var metadataBudgetTask = Task.Delay(searchMetadataResponseBudget, cancellationToken); var searchSections = await searchSectionsTask; var hasSearchSections = searchSections.Count > 0; (MusicSearchResult result, bool pending) metadataState; if (hasSearchSections) metadataState = await WaitForSearchMetadataForResponseAsync(metadataTask, metadataBudgetTask, cancellationToken); else metadataState = (await metadataTask, false); var result = metadataState.result; if (result.artists != null && result.artists.Count > 0) await DiscogsArtistImageService.ApplyCachedAsync(result.artists, cancellationToken); bool hasResults = (result.artists?.Count > 0) || (result.albums?.Count > 0) || (result.tracks?.Count > 0) || hasSearchSections; return new MusicSearchResponse { query = query, status = hasResults ? "ok" : "empty", metadata_provider = metadata?.Id, metadata_pending = metadataState.pending, audio_providers = MusicProviderRegistry.AudioProviders.Where(i => i.Enabled).Select(i => i.Id).ToList(), artists = result.artists, albums = result.albums, tracks = result.tracks, search_sections = searchSections }; } static async Task<(MusicSearchResult result, bool pending)> WaitForSearchMetadataForResponseAsync(Task metadataTask, Task budgetTask, CancellationToken cancellationToken) { if (metadataTask.IsCompleted) return (await metadataTask, false); var completed = await Task.WhenAny(metadataTask, budgetTask); if (completed == metadataTask) return (await metadataTask, false); cancellationToken.ThrowIfCancellationRequested(); return (new MusicSearchResult(), true); } static void ObserveBackgroundTask(Task task) { _ = task.ContinueWith(completed => { _ = completed.Exception; }, TaskContinuationOptions.OnlyOnFaulted); } static async Task SearchMetadataAsync(IMusicMetadataProvider metadata, string query, bool expanded, CancellationToken cancellationToken) { if (metadata == null || string.IsNullOrWhiteSpace(query)) return new MusicSearchResult(); try { // MusicBrainz живёт на rate-limit и 20s HttpClient-таймауте: // ответ поиска ждёт metadata только короткое окно, но сама metadata-ветка // может догреться дольше и положить результат в кэш. Таймауты не кэшируются. using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(searchMetadataTimeout); return await MusicMetadataCacheService.GetOrCreateAsync( metadata.Id, expanded ? "search_full" : "search", VersionedKey((expanded ? "full|" : "base|") + NormalizeSearchKey(query)), searchCacheTtl, () => metadata.SearchAsync(query, expanded, timeoutCts.Token), timeoutCts.Token ) ?? new MusicSearchResult(); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch { return new MusicSearchResult(); } } public static Task GetArtistAsync(string id, string provider = null, CancellationToken cancellationToken = default) { if (YouTubeMusicSearchSupport.IsChannelArtist(provider, id)) return YouTubeMusicSearchSupport.GetChannelArtistAsync(id, cancellationToken); if (SoundCloudSupport.IsUserArtist(provider, id)) return SoundCloudSupport.GetUserArtistAsync(id, cancellationToken); if (SpotifySupport.IsSearchEnabled && SpotifySupport.IsSpotifyArtist(provider, id)) return MusicMetadataCacheService.GetOrCreateAsync( SpotifySupport.ProviderId, "artist", VersionedKey(id), artistCacheTtl, () => SpotifySupport.GetArtistAsync(id, cancellationToken), cancellationToken); var metadata = MusicProviderRegistry.GetMetadataProvider(provider); bool isMusicBrainz = metadata is MusicBrainzMetadataProvider; return metadata == null ? Task.FromResult(null) : MusicMetadataCacheService.GetOrCreateAsync( metadata.Id, "artist", // Bypass old week-long empty discographies without invalidating other caches. VersionedKey(isMusicBrainz ? $"artist-discography:v2:{id}" : id), artistCacheTtl, () => metadata.GetArtistAsync(id, cancellationToken), cancellationToken, ttlSelector: artist => isMusicBrainz && (artist.albums?.Count ?? 0) == 0 && (artist.sections?.Count ?? 0) == 0 ? TimeSpan.FromMinutes(20) : artistCacheTtl ); } public static Task GetArtistSectionAsync(string id, string provider = null, string page = null, int limit = 20, string artistName = null, CancellationToken cancellationToken = default) { if (SoundCloudSupport.IsUserSection(provider, id)) return SoundCloudSupport.GetUserSectionAsync(id, page, limit, cancellationToken); if (SpotifySupport.IsSearchEnabled && SpotifySupport.IsSpotifyArtistSection(provider, id)) return MusicMetadataCacheService.GetOrCreateAsync( SpotifySupport.ProviderId, "artist_section", VersionedKey($"{id}|{page}|{limit}|{artistName}"), searchCacheTtl, () => SpotifySupport.GetArtistSectionAsync(id, page, limit, artistName, cancellationToken), cancellationToken ); return Task.FromResult(null); } public static Task GetAlbumAsync(string id, string provider = null, CancellationToken cancellationToken = default) { if (YouTubeMusicSearchSupport.IsPlaylistAlbum(provider, id)) return YouTubeMusicSearchSupport.GetPlaylistAlbumAsync(id, cancellationToken); if (AppleMusicSupport.IsCatalogAlbum(provider, id)) return MusicMetadataCacheService.GetOrCreateAsync( AppleMusicSupport.ProviderId, "album", VersionedKey(id), albumCacheTtl, () => AppleMusicSupport.GetCatalogAlbumAsync(id, cancellationToken), cancellationToken); if (AppleMusicDiscoveryProvider.IsChartAlbum(provider, id)) { var discovery = MusicProviderRegistry.DiscoveryProviders .FirstOrDefault(i => string.Equals(i.Id, AppleMusicDiscoveryProvider.ProviderId, StringComparison.OrdinalIgnoreCase)) as AppleMusicDiscoveryProvider; return discovery?.Enabled == true ? MusicMetadataCacheService.GetOrCreateAsync( AppleMusicDiscoveryProvider.ProviderId, "album", VersionedKey($"{AppleMusicDiscoveryProvider.CurrentCountry}|{id}"), albumCacheTtl, () => discovery.GetAlbumAsync(id, cancellationToken), cancellationToken) : Task.FromResult(null); } if (SpotifyDiscoveryProvider.IsPlaylistAlbum(provider, id)) { var discovery = MusicProviderRegistry.DiscoveryProviders .FirstOrDefault(i => string.Equals(i.Id, provider, StringComparison.OrdinalIgnoreCase)) as SpotifyDiscoveryProvider; return discovery?.Enabled == true ? discovery.GetPlaylistAlbumAsync(id, cancellationToken) : Task.FromResult(null); } if (SoundCloudSupport.IsUserTracksAlbum(provider, id)) return SoundCloudSupport.GetUserTracksAlbumAsync(id, cancellationToken); if (SpotifySupport.IsSearchEnabled && SpotifySupport.IsSpotifyAlbum(provider, id)) return MusicMetadataCacheService.GetOrCreateAsync( SpotifySupport.ProviderId, "album", VersionedKey(id), albumCacheTtl, () => SpotifySupport.GetAlbumAsync(id, cancellationToken), cancellationToken); if (string.Equals(provider, SoundCloudSupport.DiscoveryProviderId, StringComparison.OrdinalIgnoreCase)) { var discovery = MusicProviderRegistry.DiscoveryProviders .FirstOrDefault(i => string.Equals(i.Id, provider, StringComparison.OrdinalIgnoreCase)) as SoundCloudDiscoveryProvider; return discovery?.Enabled == true ? discovery.GetAlbumAsync(id, cancellationToken) : Task.FromResult(null); } var metadata = MusicProviderRegistry.GetMetadataProvider(provider); return metadata == null ? Task.FromResult(null) : MusicMetadataCacheService.GetOrCreateAsync( metadata.Id, "album", VersionedKey(id), albumCacheTtl, () => metadata.GetAlbumAsync(id, cancellationToken), cancellationToken ); } public static Task GetTrackAsync(string id, string provider = null, CancellationToken cancellationToken = default) { if (string.Equals(provider, SoundCloudSupport.DiscoveryProviderId, StringComparison.OrdinalIgnoreCase)) return SoundCloudSupport.GetTrackAsync(id, cancellationToken); if (ShouldSkipMetadataTrackLookup(id, provider)) return Task.FromResult(null); var metadata = MusicProviderRegistry.GetMetadataProvider(provider); return metadata == null ? Task.FromResult(null) : MusicMetadataCacheService.GetOrCreateAsync( metadata.Id, "track", VersionedKey(id), trackCacheTtl, () => metadata.GetTrackAsync(id, cancellationToken), cancellationToken ); } static async Task<(List sections, bool warming)> GetBrowseSectionsAsync(CancellationToken cancellationToken) { var providers = MusicProviderRegistry.DiscoveryProviders.Where(i => i.Enabled).ToList(); if (providers.Count == 0) return (new List(), false); var results = await Task.WhenAll(providers.Select(provider => GetHomeSectionsForResponseAsync(provider, browseSectionLimit, cancellationToken))); var sections = new List(); bool warming = false; foreach (var (providerSections, providerWarming) in results) { warming |= providerWarming; if (providerSections != null && providerSections.Count > 0) sections.AddRange(providerSections); } return (sections, warming); } static async Task> GetSearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(query)) return new List(); var tasks = new List>>(); if (YouTubeMusicSearchSupport.IsSearchEnabled) tasks.Add(SafeGetSearchSectionsAsync(token => BuildYouTubeMusicSearchSectionsAsync(query, expanded, token), cancellationToken)); if (SoundCloudSupport.IsDiscoveryEnabled) tasks.Add(SafeGetSearchSectionsAsync(token => BuildSoundCloudSearchSectionsAsync(query, expanded, token), cancellationToken)); if (MusicProviderRegistry.AudioProviders.Any(i => i.Enabled && string.Equals(i.Id, SefonSupport.ProviderId, StringComparison.OrdinalIgnoreCase))) tasks.Add(SafeGetSearchSectionsAsync(async token => ToSearchSectionList(await BuildSefonSearchSectionAsync(query, expanded, token)), cancellationToken)); if (SpotifySupport.IsSearchEnabled) tasks.Add(SafeGetSearchSectionsAsync(token => BuildSpotifySearchSectionsAsync(query, expanded, token), cancellationToken)); if (tasks.Count == 0) return new List(); var results = await Task.WhenAll(tasks); return results .Where(i => i != null) .SelectMany(i => i) .Where(HasSearchSectionEntries) .ToList(); } static async Task> BuildYouTubeMusicSearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken) { int trackLimit = expanded ? searchSectionFullLimit : searchSectionLimit; int sideLimit = expanded ? 18 : 6; string searchKey = NormalizeSearchKey(query); var tracksTask = MusicMetadataCacheService.GetOrCreateAsync( YouTubeMusicSearchSupport.ProviderId, "search_tracks", VersionedKey($"youtubemusic-tracks|{trackLimit}|{searchKey}"), searchCacheTtl, () => YouTubeMusicSearchSupport.SearchTracksByQueryAsync(query, trackLimit, cancellationToken), cancellationToken ); var playlistsTask = MusicMetadataCacheService.GetOrCreateAsync( YouTubeMusicSearchSupport.ProviderId, "search_playlists", VersionedKey($"youtubemusic-playlists|{sideLimit}|{searchKey}"), searchCacheTtl, () => YouTubeMusicSearchSupport.SearchPlaylistsByQueryAsync(query, sideLimit, cancellationToken), cancellationToken ); var artistsTask = MusicMetadataCacheService.GetOrCreateAsync( YouTubeMusicSearchSupport.ProviderId, "search_artists", VersionedKey($"youtubemusic-artists|{sideLimit}|{searchKey}"), searchCacheTtl, () => YouTubeMusicSearchSupport.SearchArtistsByQueryAsync(query, sideLimit, cancellationToken), cancellationToken ); await Task.WhenAll(tracksTask, playlistsTask, artistsTask); var tracks = await tracksTask ?? new List(); var playlists = await playlistsTask ?? new List(); var artists = await artistsTask ?? new List(); var sections = new List(); if (tracks.Count > 0) { sections.Add(new MusicBrowseSection { id = YouTubeMusicSearchSupport.TracksSectionId, title = "Треки", type = "tracks", source_provider = YouTubeMusicSearchSupport.ProviderId, has_more = !expanded && tracks.Count >= trackLimit, tracks = tracks }); } if (playlists.Count > 0) { sections.Add(new MusicBrowseSection { id = YouTubeMusicSearchSupport.PlaylistsSectionId, title = "Плейлисты / альбомы", type = "albums", source_provider = YouTubeMusicSearchSupport.ProviderId, has_more = !expanded && playlists.Count >= sideLimit, albums = playlists }); } if (artists.Count > 0) { sections.Add(new MusicBrowseSection { id = YouTubeMusicSearchSupport.ArtistsSectionId, title = "Исполнители / каналы", type = "artists", source_provider = YouTubeMusicSearchSupport.ProviderId, has_more = !expanded && artists.Count >= sideLimit, artists = artists }); } return sections; } static async Task> BuildSpotifySearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken) { int trackLimit = expanded ? searchSectionFullLimit : searchSectionLimit; int sideLimit = expanded ? 18 : 6; string searchKey = NormalizeSearchKey(query); var artistsTask = MusicMetadataCacheService.GetOrCreateAsync( SpotifySupport.ProviderId, "search_artists", VersionedKey($"spotify-artists|{sideLimit}|{searchKey}"), searchCacheTtl, () => SpotifySupport.SearchArtistsAsync(query, sideLimit, cancellationToken), cancellationToken); var albumsTask = MusicMetadataCacheService.GetOrCreateAsync( SpotifySupport.ProviderId, "search_albums", VersionedKey($"spotify-albums|{sideLimit}|{searchKey}"), searchCacheTtl, () => SpotifySupport.SearchAlbumsAsync(query, sideLimit, cancellationToken), cancellationToken); var tracksTask = MusicMetadataCacheService.GetOrCreateAsync( SpotifySupport.ProviderId, "search_tracks", VersionedKey($"spotify-tracks|{trackLimit}|{searchKey}"), searchCacheTtl, () => SpotifySupport.SearchTracksAsync(query, trackLimit, cancellationToken), cancellationToken); await Task.WhenAll(artistsTask, albumsTask, tracksTask); var artists = await artistsTask ?? new List(); var albums = await albumsTask ?? new List(); var tracks = await tracksTask ?? new List(); var sections = new List(); if (artists.Count > 0) sections.Add(new MusicBrowseSection { id = SpotifySupport.ArtistsSectionId, title = "Исполнители", type = "artists", source_provider = SpotifySupport.ProviderId, has_more = false, artists = artists }); if (albums.Count > 0) sections.Add(new MusicBrowseSection { id = SpotifySupport.AlbumsSectionId, title = "Альбомы", type = "albums", source_provider = SpotifySupport.ProviderId, has_more = false, albums = albums }); if (tracks.Count > 0) sections.Add(new MusicBrowseSection { id = SpotifySupport.TracksSectionId, title = "Треки", type = "tracks", source_provider = SpotifySupport.ProviderId, has_more = false, tracks = tracks }); return sections; } static bool ShouldSkipMetadataTrackLookup(string id, string provider) { // Spotify здесь является каталогом/источником discovery, но не // IMusicMetadataProvider. Без этого gate GetMetadataProvider("spotify") // откатывается на дефолтный MusicBrainz и до его сетевого таймаута // пытается найти там spotify:track:* перед каждым первым запуском. if (StartsWithAny(id, "inline:", "youtube:", "sefon:", "soundcloud:", "spotify:")) return true; return !string.IsNullOrWhiteSpace(provider) && MusicProviderRegistry.AudioProviders.Any(i => string.Equals(i.Id, provider, StringComparison.OrdinalIgnoreCase)); } static bool StartsWithAny(string value, params string[] prefixes) { if (string.IsNullOrWhiteSpace(value)) return false; return prefixes.Any(prefix => value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); } static async Task<(List sections, bool warming)> GetHomeSectionsForResponseAsync(IMusicDiscoveryProvider provider, int limit, CancellationToken cancellationToken) { var warmTask = GetOrStartHomeWarmTask(provider, limit); if (warmTask.IsCompleted) return (await warmTask, false); var completed = await Task.WhenAny(warmTask, Task.Delay(discoveryHomeProviderResponseBudget, cancellationToken)); if (completed == warmTask) return (await warmTask, false); cancellationToken.ThrowIfCancellationRequested(); // warming=true: провайдер срезан по 2s-бюджету, прогрев продолжается в фоне — // клиент по этому флагу тихо дозапрашивает home return (new List(), true); } static Task> GetOrStartHomeWarmTask(IMusicDiscoveryProvider provider, int limit) { string key = $"{provider.Id}:{limit}"; lock (homeWarmLock) { if (homeWarmTasks.TryGetValue(key, out var running) && !running.IsCompleted) return running; var task = SafeGetHomeSectionsAsync(provider, limit); homeWarmTasks[key] = task; _ = task.ContinueWith(completed => { lock (homeWarmLock) { if (homeWarmTasks.TryGetValue(key, out var current) && ReferenceEquals(current, completed)) homeWarmTasks.Remove(key); } if (completed.IsFaulted) _ = completed.Exception; }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); return task; } } static async Task> SafeGetHomeSectionsAsync(IMusicDiscoveryProvider provider, int limit) { try { using var timeoutCts = new CancellationTokenSource(discoveryHomeProviderWarmTimeout); return await provider.GetHomeSectionsAsync(limit, timeoutCts.Token) ?? new List(); } catch (OperationCanceledException) { return new List(); } catch { return new List(); } } static async Task SafeGetSectionAsync(IMusicDiscoveryProvider provider, string sectionId, int limit, CancellationToken cancellationToken) { try { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(discoverySectionProviderTimeout); return await provider.GetSectionAsync(sectionId, limit, timeoutCts.Token); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return null; } catch { return null; } } static async Task SafeGetSearchSectionAsync(Func> factory, CancellationToken cancellationToken) { try { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(searchSectionProviderTimeout); return await factory(timeoutCts.Token); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return null; } catch { return null; } } static async Task> SafeGetSearchSectionsAsync(Func>> factory, CancellationToken cancellationToken) { try { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(searchSectionProviderTimeout); return await factory(timeoutCts.Token) ?? new List(); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return new List(); } catch { return new List(); } } static List ToSearchSectionList(MusicBrowseSection section) { return section == null ? new List() : new List { section }; } static async Task> BuildSoundCloudSearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken) { int trackLimit = expanded ? searchSectionFullLimit : searchSectionLimit; int sideLimit = expanded ? 18 : 6; string searchKey = NormalizeSearchKey(query); var tracksTask = MusicMetadataCacheService.GetOrCreateAsync( SoundCloudSupport.DiscoveryProviderId, "search_tracks", VersionedKey($"soundcloud-tracks|{trackLimit}|{searchKey}"), searchCacheTtl, () => SoundCloudSupport.SearchTracksByQueryAsync(query, trackLimit, cancellationToken), cancellationToken ); var playlistsTask = MusicMetadataCacheService.GetOrCreateAsync( SoundCloudSupport.DiscoveryProviderId, "search_playlists", VersionedKey($"soundcloud-playlists|{sideLimit}|{searchKey}"), searchCacheTtl, () => SoundCloudSupport.SearchPlaylistsByQueryAsync(query, sideLimit, cancellationToken), cancellationToken ); var albumsTask = MusicMetadataCacheService.GetOrCreateAsync( SoundCloudSupport.DiscoveryProviderId, "search_albums", VersionedKey($"soundcloud-albums|{sideLimit}|{searchKey}"), searchCacheTtl, () => SoundCloudSupport.SearchAlbumsByQueryAsync(query, sideLimit, cancellationToken), cancellationToken ); var artistsTask = MusicMetadataCacheService.GetOrCreateAsync( SoundCloudSupport.DiscoveryProviderId, "search_artists", VersionedKey($"soundcloud-artists|{sideLimit}|{searchKey}"), searchCacheTtl, () => SoundCloudSupport.SearchArtistsByQueryAsync(query, sideLimit, cancellationToken), cancellationToken ); await Task.WhenAll(tracksTask, albumsTask, playlistsTask, artistsTask); var tracks = await tracksTask ?? new List(); var albums = await albumsTask ?? new List(); var playlists = await playlistsTask ?? new List(); var artists = await artistsTask ?? new List(); var sections = new List(); if (tracks.Count > 0) { sections.Add(new MusicBrowseSection { id = SoundCloudSupport.SearchTracksSectionId, title = "Треки", type = "tracks", source_provider = SoundCloudSupport.DiscoveryProviderId, has_more = !expanded && tracks.Count >= trackLimit, tracks = tracks }); } if (albums.Count > 0) { sections.Add(new MusicBrowseSection { id = SoundCloudSupport.SearchAlbumsSectionId, title = "Альбомы", type = "albums", source_provider = SoundCloudSupport.DiscoveryProviderId, has_more = !expanded && albums.Count >= sideLimit, albums = albums }); } if (playlists.Count > 0) { sections.Add(new MusicBrowseSection { id = SoundCloudSupport.SearchPlaylistsSectionId, title = "Плейлисты", type = "albums", source_provider = SoundCloudSupport.DiscoveryProviderId, has_more = !expanded && playlists.Count >= sideLimit, albums = playlists }); } if (artists.Count > 0) { sections.Add(new MusicBrowseSection { id = SoundCloudSupport.SearchArtistsSectionId, title = "Исполнители", type = "artists", source_provider = SoundCloudSupport.DiscoveryProviderId, has_more = !expanded && artists.Count >= sideLimit, artists = artists }); } return sections; } static async Task BuildSefonSearchSectionAsync(string query, bool expanded, CancellationToken cancellationToken) { int limit = expanded ? searchSectionFullLimit : searchSectionLimit; var tracks = await MusicMetadataCacheService.GetOrCreateAsync( SefonSupport.ProviderId, "search_tracks", VersionedKey($"sefon|{limit}|{NormalizeSearchKey(query)}"), searchCacheTtl, () => SefonSupport.SearchTracksByQueryAsync(query, limit, cancellationToken), cancellationToken ) ?? new List(); return tracks.Count == 0 ? null : new MusicBrowseSection { id = "search:sefon", title = "Sefon", type = "tracks", source_provider = SefonSupport.ProviderId, has_more = !expanded && tracks.Count >= limit, tracks = tracks }; } static bool HasSearchSectionEntries(MusicBrowseSection section) { return section != null && ((section.tracks?.Count > 0) || (section.albums?.Count > 0) || (section.artists?.Count > 0)); } static string NormalizeSearchKey(string query) => query.Trim().ToLowerInvariant(); static string VersionedKey(string value) => $"{metadataCacheVersion}|{value}"; }