using System.Globalization; using System.Text; using System.Text.RegularExpressions; namespace Music; public static class MusicRadioService { static readonly TimeSpan searchCacheTtl = TimeSpan.FromHours(6); static readonly TimeSpan radioTimeout = TimeSpan.FromSeconds(8); const int maxSeedArtists = 5; const int providerQueryLimit = 12; const int maxPoolPerArtist = 24; const string cacheVersion = "radio-v3-metadata"; public static async Task GetAsync(string profileId, MusicRadioRequest request, int limit = 20, CancellationToken cancellationToken = default) { request ??= new MusicRadioRequest(); limit = Math.Clamp(limit, 5, 30); var seeds = NormalizeTrackList(request.seeds); var exclude = NormalizeTrackList(request.exclude); if (seeds.Count == 0) seeds = exclude.TakeLast(Math.Min(5, exclude.Count)).ToList(); var artists = ExtractSeedArtists(seeds); if (artists.Count == 0) return Unavailable("Недостаточно данных для радио."); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(radioTimeout); // сиды исключаем всегда, даже если клиент прислал пустой exclude — // иначе радио может вернуть сам сид-трек под другим id var excludedIds = new HashSet(exclude.Concat(seeds).Select(i => i.id).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase); var excludedKeys = new HashSet(exclude.Concat(seeds).Select(BuildTrackDedupeKey).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase); var excludedTitles = new HashSet(exclude.Concat(seeds).Select(BuildTrackTitleKey).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase); try { foreach (var recent in await MusicPlaybackHistoryService.GetRecentAsync(profileId, 150, timeoutCts.Token)) { if (recent == null || recent.played_at.ToUniversalTime() < DateTime.UtcNow.AddDays(-1)) continue; if (!string.IsNullOrWhiteSpace(recent.track?.id)) excludedIds.Add(recent.track.id); string key = BuildTrackDedupeKey(recent.track); if (!string.IsNullOrWhiteSpace(key)) excludedKeys.Add(key); string titleKey = BuildTrackTitleKey(recent.track); if (!string.IsNullOrWhiteSpace(titleKey)) excludedTitles.Add(titleKey); } } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { } var output = new List(); var outputIds = new HashSet(StringComparer.OrdinalIgnoreCase); var outputKeys = new HashSet(StringComparer.OrdinalIgnoreCase); var outputTitles = new HashSet(StringComparer.OrdinalIgnoreCase); // «шумные» слова допустимы в кандидате, только если были в самом сиде // (слушаешь remix — получай remix, иначе это мусор) var seedNoisyWords = CollectSeedNoisyWords(seeds); // script-affinity включаем только когда ВЕСЬ сид-контекст кириллический: // при Any смешанная очередь (Eminem + Miyagi) резала бы латинские // кандидаты целиком, включая related-пул латинского сида bool requireCyrillic = seeds.Count > 0 && seeds.All(seed => ContainsCyrillic(seed?.title) || ContainsCyrillic(seed?.artist_name)); // слой 1: рекомендации SoundCloud ПО ТРЕКУ (related/station) — живое // радио вместо «топ треков артиста»; artist-пулы остаются как backfill var relatedTasks = SoundCloudSupport.IsDiscoveryEnabled ? seeds.Take(3).Select(seed => LoadRelatedPoolAsync(seed, timeoutCts.Token)).ToList() : new List>>(); try { await Task.WhenAll(relatedTasks); } catch { } static List> CollectPools(IEnumerable>> tasks) => tasks .Where(task => task.IsCompletedSuccessfully) .Select(task => task.Result ?? new List()) .Where(pool => pool.Count > 0) .ToList(); FillRoundRobin(CollectPools(relatedTasks), output, limit, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles); if (output.Count < limit) { // Backfill is intentionally lazy: if SoundCloud related/station gave // enough tracks, do not spend requests on generic artist search. var artistTasks = artists.Select(artist => LoadArtistPoolAsync(artist, timeoutCts.Token)).ToList(); try { await Task.WhenAll(artistTasks); } catch { } FillRoundRobin(CollectPools(artistTasks), output, limit, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles); } return output.Count >= Math.Min(limit, 8) ? Available(output) : Unavailable("Радио не нашло достаточно похожих треков."); } // round-robin по пулам: 1-й трек каждого пула, потом 2-е и т.д. — иначе // первый пул целиком заполняет лимит и «радио» вырождается в один источник // (internal: переиспользуется MusicDailyMixService для «Миксов недели») internal static void FillRoundRobin( List> pools, List output, int limit, HashSet seedNoisyWords, bool requireCyrillic, HashSet excludedIds, HashSet excludedKeys, HashSet excludedTitles, HashSet outputIds, HashSet outputKeys, HashSet outputTitles, Dictionary outputArtistCounts = null, int maxTracksPerArtist = 0) { for (int position = 0; output.Count < limit; position++) { bool anyLeft = false; foreach (var pool in pools) { if (position >= pool.Count) continue; anyLeft = true; var candidate = NormalizeCandidateMetadata(pool[position]); if (maxTracksPerArtist > 0 && outputArtistCounts != null && !CanAddArtistCandidate(candidate, outputArtistCounts, maxTracksPerArtist)) { continue; } if (!AcceptCandidate(candidate, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles)) continue; candidate.auto_radio = true; output.Add(candidate); AddArtistCandidate(candidate, outputArtistCounts); if (output.Count >= limit) break; } if (!anyLeft) break; } } static bool CanAddArtistCandidate(MusicTrack track, Dictionary counts, int maxTracksPerArtist) { string key = BuildArtistBucketKey(track); if (string.IsNullOrWhiteSpace(key)) return true; return !counts.TryGetValue(key, out int count) || count < maxTracksPerArtist; } static void AddArtistCandidate(MusicTrack track, Dictionary counts) { if (counts == null) return; string key = BuildArtistBucketKey(track); if (string.IsNullOrWhiteSpace(key)) return; counts[key] = counts.TryGetValue(key, out int count) ? count + 1 : 1; } static string BuildArtistBucketKey(MusicTrack track) { // Структурированный список хранит primary artist первым. Иначе // "Artist, Guest" и "Artist" попадают в разные bucket'ы и обходят artist-cap. string artist = CleanupArtistName(track?.artists?.FirstOrDefault()); if (string.IsNullOrWhiteSpace(artist)) artist = CleanupArtistName(track?.artist_name); return string.IsNullOrWhiteSpace(artist) ? string.Empty : MusicMapSupport.NormalizeName(artist); } static MusicRadioResponse Available(List tracks) => new() { available = true, tracks = tracks ?? new List() }; static MusicRadioResponse Unavailable(string message) => new() { available = false, message = message }; static List NormalizeTrackList(IEnumerable tracks) { var result = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var track in tracks ?? Enumerable.Empty()) { if (track == null) continue; string id = (track.id ?? string.Empty).Trim(); string key = !string.IsNullOrWhiteSpace(id) ? id : BuildTrackDedupeKey(track); if (string.IsNullOrWhiteSpace(key) || !seen.Add(key)) continue; if (!string.IsNullOrWhiteSpace(id)) track.id = id; result.Add(track); } return result; } static List ExtractSeedArtists(IEnumerable seeds) { var artists = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var track in seeds ?? Enumerable.Empty()) { foreach (var artist in EnumerateTrackArtists(track)) { string value = CleanupArtistName(artist); if (string.IsNullOrWhiteSpace(value) || !seen.Add(NormalizeText(value))) continue; artists.Add(value); if (artists.Count >= maxSeedArtists) return artists; } } return artists; } static IEnumerable EnumerateTrackArtists(MusicTrack track) { if (!string.IsNullOrWhiteSpace(track?.artist_name)) yield return track.artist_name; foreach (var artist in track?.artists ?? new List()) { if (!string.IsNullOrWhiteSpace(artist)) yield return artist; } } internal static string CleanupArtistName(string value) { value = (value ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(value)) return string.Empty; value = Regex.Replace(value, @"\s+(feat\.?|ft\.?|featuring|with|vs\.?)\s+.*$", "", RegexOptions.IgnoreCase); value = Regex.Replace(value, @"\s+", " ").Trim(); return value; } internal static Task> LoadRelatedPoolAsync(MusicTrack seed, CancellationToken cancellationToken) { string artist = CleanupArtistName(seed?.artist_name); string title = (seed?.title ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(artist) || string.IsNullOrWhiteSpace(title)) return Task.FromResult(new List()); string query = $"{artist} {title}"; return LoadCachedTracksAsync( SoundCloudSupport.DiscoveryProviderId, query, token => SoundCloudSupport.FindRelatedTracksByQueryAsync(query, artist, title, maxPoolPerArtist, token), cancellationToken, cacheType: "radio_related", cacheLimit: maxPoolPerArtist); } internal static async Task> LoadArtistPoolAsync( string artist, CancellationToken cancellationToken, bool requireConfirmedArtist = false) { var tasks = new List>>(); string cacheType = requireConfirmedArtist ? "radio_tracks_confirmed" : "radio_tracks"; if (YouTubeMusicSearchSupport.IsSearchEnabled) tasks.Add(LoadCachedTracksAsync( YouTubeMusicSearchSupport.ProviderId, artist, token => YouTubeMusicSearchSupport.SearchTracksByQueryAsync( artist, providerQueryLimit, token, artist, requireConfirmedArtist), cancellationToken, cacheType)); if (SoundCloudSupport.IsDiscoveryEnabled) tasks.Add(LoadCachedTracksAsync( SoundCloudSupport.DiscoveryProviderId, artist, token => SoundCloudSupport.SearchTracksByQueryAsync(artist, providerQueryLimit, token), cancellationToken, cacheType)); if (tasks.Count == 0) return new List(); try { await Task.WhenAll(tasks); } catch { } var candidates = tasks .Where(i => i.IsCompletedSuccessfully) .SelectMany(i => i.Result ?? new List()) .Select(track => NormalizeArtistPoolCandidate(track, artist, !requireConfirmedArtist)) .Where(i => i != null) .Take(maxPoolPerArtist) .ToList(); if (requireConfirmedArtist && !candidates.Any(IsConfirmedExternalArtistEvidence)) return new List(); return candidates; } static bool IsConfirmedExternalArtistEvidence(MusicTrack track) { if ((track?.id ?? string.Empty).StartsWith("youtube:", StringComparison.OrdinalIgnoreCase)) return true; return (track?.id ?? string.Empty).StartsWith("soundcloud:", StringComparison.OrdinalIgnoreCase) && MusicIsrc.Normalize(track.isrc) != null; } static MusicTrack NormalizeArtistPoolCandidate(MusicTrack track, string requestedArtist, bool allowTitlePrefix) { track = NormalizeCandidateMetadata(track); string artist = CleanupArtistName(requestedArtist); if (track == null || string.IsNullOrWhiteSpace(artist)) return track; string requested = NormalizeText(artist); string actual = NormalizeText(track.artist_name); string title = NormalizeText(track.title); if (string.IsNullOrWhiteSpace(requested)) return track; if (!string.IsNullOrWhiteSpace(actual) && (actual == requested || actual.Contains(requested, StringComparison.OrdinalIgnoreCase) || (actual.Length >= 4 && requested.Contains(actual, StringComparison.OrdinalIgnoreCase)))) { // SoundCloud/YouTube sometimes expose channel names like // "032 stas mikhaylov"; if the target artist is clearly embedded, // keep the candidate but display the canonical artist name. if (actual != requested) { track.artist_name = artist; track.artists = new List { artist }; } return track; } if (allowTitlePrefix && !string.IsNullOrWhiteSpace(title) && title.StartsWith(requested + " ", StringComparison.OrdinalIgnoreCase)) { track.artist_name = artist; track.artists = new List { artist }; track.title = StripLeadingArtistFromTitle(track.title, artist); return track; } return null; } static string StripLeadingArtistFromTitle(string title, string artist) { if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist)) return title; string pattern = @"^\s*" + Regex.Escape(artist.Trim()) + @"\s*(?:[-–—:]|&)?\s*"; string stripped = Regex.Replace(title, pattern, "", RegexOptions.IgnoreCase).Trim(); return string.IsNullOrWhiteSpace(stripped) ? title : stripped; } static async Task> LoadCachedTracksAsync(string providerId, string query, Func>> factory, CancellationToken cancellationToken, string cacheType = "radio_tracks", int cacheLimit = providerQueryLimit) { try { string key = $"{cacheVersion}|{providerId}|{cacheLimit}|{NormalizeText(query)}"; return await MusicMetadataCacheService.GetOrCreateAsync( providerId, cacheType, key, searchCacheTtl, () => factory(cancellationToken), cancellationToken ) ?? new List(); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch { return new List(); } } // «шумные» слова в названии кандидата допустимы только если встречались // в самих сидах: слушаешь remix/nightcore — радио вправе их предлагать static readonly string[] conditionalNoisyWords = { "cover", "karaoke", "sped up", "slowed", "nightcore", "remix", "8d audio", "bass boosted", "reverb", "edit", "mashup", "type beat", "freestyle" }; internal static HashSet CollectSeedNoisyWords(IEnumerable seeds) { var allowed = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var seed in seeds ?? Enumerable.Empty()) { string padded = $" {NormalizeNoiseText(seed?.title)} "; foreach (var word in conditionalNoisyWords) { if (padded.Contains($" {word} ", StringComparison.OrdinalIgnoreCase)) allowed.Add(word); } } return allowed; } static bool AcceptCandidate( MusicTrack track, HashSet seedNoisyWords, bool requireCyrillic, HashSet excludedIds, HashSet excludedKeys, HashSet excludedTitles, HashSet outputIds, HashSet outputKeys, HashSet outputTitles) { if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(track.title)) return false; string id = track.id.Trim(); string key = BuildTrackDedupeKey(track); string titleKey = BuildTrackTitleKey(track); if (!LooksLikeStandaloneSong(track, titleKey, seedNoisyWords, requireCyrillic)) return false; if (excludedIds.Contains(id) || outputIds.Contains(id)) return false; if (!string.IsNullOrWhiteSpace(key) && (excludedKeys.Contains(key) || outputKeys.Contains(key))) return false; if (!string.IsNullOrWhiteSpace(titleKey) && (excludedTitles.Contains(titleKey) || outputTitles.Contains(titleKey))) return false; outputIds.Add(id); if (!string.IsNullOrWhiteSpace(key)) outputKeys.Add(key); if (!string.IsNullOrWhiteSpace(titleKey)) outputTitles.Add(titleKey); return true; } internal static MusicTrack NormalizeCandidateMetadata(MusicTrack track) { if (track == null) return track; // Provider/cache objects can be reused by radio and weekly mixes. Never // rewrite those shared instances while preparing one response. var result = CloneTrack(track); result.title = CleanupCandidateTitle(result.title); if (string.IsNullOrWhiteSpace(result.title) || !TrySplitArtistTitle(result.title, out var artist, out var title)) { return result; } string currentArtist = CleanupArtistName(result.artist_name); if (!string.IsNullOrWhiteSpace(currentArtist) && !IsGenericUploaderName(currentArtist) && !ArtistNamesMatch(currentArtist, artist)) { // Structured provider metadata is more trustworthy than an // unverified multi-dash title. Keeping it intact also preserves an // exact provider id for playback instead of inventing a new pair. return result; } result.artist_name = artist; result.artists = new List { artist }; result.title = CleanupCandidateTitle(title); return result; } static bool TrySplitArtistTitle(string rawTitle, out string artist, out string title) { artist = null; title = null; var match = Regex.Match( rawTitle ?? string.Empty, @"^\s*(?.{2,80}?)\s+[-–—]\s+(?.{2,180})\s*$"); if (!match.Success) return false; string artistPart = match.Groups["artist"].Value; string titlePart = match.Groups["title"].Value; artistPart = Regex.Replace(artistPart, @"^\s*[\d#._-]+\s*", "").Trim(); titlePart = Regex.Replace(titlePart, @"^\s*[\d#._-]+\s*", "").Trim(); if (string.IsNullOrWhiteSpace(artistPart) || string.IsNullOrWhiteSpace(titlePart)) return false; if (NormalizeText(artistPart).Length < 2 || NormalizeText(titlePart).Length < 2) return false; if (artistPart.Length > 80 || titlePart.Length > 120) return false; artist = artistPart; title = titlePart; return true; } static bool ArtistNamesMatch(string left, string right) { string a = NormalizeText(CleanupArtistName(left)); string b = NormalizeText(CleanupArtistName(right)); return !string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && (a == b || (a.Length >= 4 && b.Contains(a, StringComparison.Ordinal)) || (b.Length >= 4 && a.Contains(b, StringComparison.Ordinal))); } static bool IsGenericUploaderName(string value) { string normalized = NormalizeText(value); return normalized is "soundcloud" or "youtube" or "unknown" or "various artists"; } static string CleanupCandidateTitle(string value) { value = Regex.Replace(value ?? string.Empty, @"\s+", " ").Trim(); if (string.IsNullOrWhiteSpace(value)) return value; bool filename = Regex.IsMatch(value, @"\.(aac|flac|m4a|mp3|ogg|opus|wav|webm)\s*$", RegexOptions.IgnoreCase); if (!filename) return value; value = Regex.Replace(value, @"\.(aac|flac|m4a|mp3|ogg|opus|wav|webm)\s*$", string.Empty, RegexOptions.IgnoreCase); value = Regex.Replace(value, @"_+", " "); value = Regex.Replace(value, @"^\s*[-–—.]+\s*|\s*[-–—.]+\s*$", string.Empty); return Regex.Replace(value, @"\s+", " ").Trim(); } static MusicTrack CloneTrack(MusicTrack track) { return new MusicTrack { id = track.id, title = track.title, artist_id = track.artist_id, artist_name = track.artist_name, artists = track.artists?.ToList() ?? new List<string>(), album_id = track.album_id, album_title = track.album_title, isrc = track.isrc, duration_ms = track.duration_ms, track_number = track.track_number, disc_number = track.disc_number, date = track.date, search_score = track.search_score, images = track.images?.ToList() ?? new List<MusicImage>(), provider_refs = track.provider_refs?.ToList() ?? new List<MusicProviderRef>(), auto_radio = track.auto_radio }; } static bool LooksLikeStandaloneSong(MusicTrack track, string titleKey, HashSet<string> seedNoisyWords, bool requireCyrillic) { int durationMs = track.duration_ms ?? 0; if (durationMs > 0 && (durationMs < 45_000 || durationMs > 12 * 60_000)) return false; if (string.IsNullOrWhiteSpace(titleKey)) return false; string padded = $" {titleKey} "; string noisePadded = $" {NormalizeNoiseText(track?.title)} "; if (requireCyrillic && !ContainsCyrillic(track?.title) && !ContainsCyrillic(track?.artist_name)) return false; string[] noisy = { " nonstop ", " megamix ", " full album ", " playlist ", " reaction ", " podcast ", " interview ", " instrumental beat ", " dj set ", " live set ", " preview ", " prod " }; if (noisy.Any(word => padded.Contains(word, StringComparison.OrdinalIgnoreCase) || noisePadded.Contains(word, StringComparison.OrdinalIgnoreCase))) return false; foreach (var word in conditionalNoisyWords) { if (seedNoisyWords != null && seedNoisyWords.Contains(word)) continue; if (noisePadded.Contains($" {word} ", StringComparison.OrdinalIgnoreCase)) return false; } return true; } internal static bool ContainsCyrillic(string value) => !string.IsNullOrWhiteSpace(value) && value.Any(c => c is >= '\u0400' and <= '\u04FF'); static string BuildTrackDedupeKey(MusicTrack track) { if (track == null) return string.Empty; string artist = NormalizeText(track.artist_name); string title = BuildTrackTitleKey(track); return string.IsNullOrWhiteSpace(title) ? string.Empty : $"{artist}|{title}"; } static string BuildTrackTitleKey(MusicTrack track) { string rawTitle = track?.title ?? string.Empty; rawTitle = Regex.Replace(rawTitle, @"^\s*.{1,80}\s+[-–—]\s+", "", RegexOptions.IgnoreCase); string title = NormalizeText(rawTitle); string artist = NormalizeText(track?.artist_name); if (!string.IsNullOrWhiteSpace(artist) && title.StartsWith(artist + " ", StringComparison.OrdinalIgnoreCase)) title = title.Substring(artist.Length).Trim(); foreach (var artistName in track?.artists ?? new List<string>()) { string normalizedArtist = NormalizeText(artistName); if (!string.IsNullOrWhiteSpace(normalizedArtist) && title.StartsWith(normalizedArtist + " ", StringComparison.OrdinalIgnoreCase)) title = title.Substring(normalizedArtist.Length).Trim(); } return title; } internal static string NormalizeText(string value) { value = RemoveDiacritics((value ?? string.Empty).ToLowerInvariant()); value = Regex.Replace(value, @"\([^)]*\)|\[[^\]]*\]", " "); value = Regex.Replace(value, @"\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b", " ", RegexOptions.IgnoreCase); value = Regex.Replace(value, @"[^a-z0-9а-яёіїєґ]+", " "); return Regex.Replace(value, @"\s+", " ").Trim(); } static string NormalizeNoiseText(string value) { value = RemoveDiacritics((value ?? string.Empty).ToLowerInvariant()); value = Regex.Replace(value, @"\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b", " ", RegexOptions.IgnoreCase); value = Regex.Replace(value, @"[^a-z0-9а-яёіїєґ]+", " "); return Regex.Replace(value, @"\s+", " ").Trim(); } static string RemoveDiacritics(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; var normalized = value.Normalize(NormalizationForm.FormD); var builder = new StringBuilder(normalized.Length); foreach (var c in normalized) { if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) builder.Append(c); } return builder.ToString().Normalize(NormalizationForm.FormC); } }