Files
lampac/mods/Music/Services/Playback/MusicRadioService.cs
T

754 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<MusicRadioResponse> 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<string>(exclude.Concat(seeds).Select(i => i.id).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
var excludedKeys = new HashSet<string>(exclude.Concat(seeds).Select(BuildTrackDedupeKey).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
var excludedTitles = new HashSet<string>(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<MusicTrack>();
var outputIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var outputKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var outputTitles = new HashSet<string>(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<Task<List<MusicTrack>>>();
try
{
await Task.WhenAll(relatedTasks);
}
catch
{
}
static List<List<MusicTrack>> CollectPools(IEnumerable<Task<List<MusicTrack>>> tasks) => tasks
.Where(task => task.IsCompletedSuccessfully)
.Select(task => task.Result ?? new List<MusicTrack>())
.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<List<MusicTrack>> pools,
List<MusicTrack> output,
int limit,
HashSet<string> seedNoisyWords,
bool requireCyrillic,
HashSet<string> excludedIds,
HashSet<string> excludedKeys,
HashSet<string> excludedTitles,
HashSet<string> outputIds,
HashSet<string> outputKeys,
HashSet<string> outputTitles,
Dictionary<string, int> 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<string, int> 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<string, int> 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<MusicTrack> tracks) => new()
{
available = true,
tracks = tracks ?? new List<MusicTrack>()
};
static MusicRadioResponse Unavailable(string message) => new()
{
available = false,
message = message
};
static List<MusicTrack> NormalizeTrackList(IEnumerable<MusicTrack> tracks)
{
var result = new List<MusicTrack>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var track in tracks ?? Enumerable.Empty<MusicTrack>())
{
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<string> ExtractSeedArtists(IEnumerable<MusicTrack> seeds)
{
var artists = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var track in seeds ?? Enumerable.Empty<MusicTrack>())
{
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<string> EnumerateTrackArtists(MusicTrack track)
{
if (!string.IsNullOrWhiteSpace(track?.artist_name))
yield return track.artist_name;
foreach (var artist in track?.artists ?? new List<string>())
{
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<List<MusicTrack>> 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<MusicTrack>());
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<List<MusicTrack>> LoadArtistPoolAsync(
string artist,
CancellationToken cancellationToken,
bool requireConfirmedArtist = false)
{
var tasks = new List<Task<List<MusicTrack>>>();
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<MusicTrack>();
try
{
await Task.WhenAll(tasks);
}
catch
{
}
var candidates = tasks
.Where(i => i.IsCompletedSuccessfully)
.SelectMany(i => i.Result ?? new List<MusicTrack>())
.Select(track => NormalizeArtistPoolCandidate(track, artist, !requireConfirmedArtist))
.Where(i => i != null)
.Take(maxPoolPerArtist)
.ToList();
if (requireConfirmedArtist && !candidates.Any(IsConfirmedExternalArtistEvidence))
return new List<MusicTrack>();
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<string> { artist };
}
return track;
}
if (allowTitlePrefix
&& !string.IsNullOrWhiteSpace(title)
&& title.StartsWith(requested + " ", StringComparison.OrdinalIgnoreCase))
{
track.artist_name = artist;
track.artists = new List<string> { 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<List<MusicTrack>> LoadCachedTracksAsync(string providerId, string query, Func<CancellationToken, Task<List<MusicTrack>>> 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<MusicTrack>();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new List<MusicTrack>();
}
}
// «шумные» слова в названии кандидата допустимы только если встречались
// в самих сидах: слушаешь 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<string> CollectSeedNoisyWords(IEnumerable<MusicTrack> seeds)
{
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var seed in seeds ?? Enumerable.Empty<MusicTrack>())
{
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<string> seedNoisyWords,
bool requireCyrillic,
HashSet<string> excludedIds,
HashSet<string> excludedKeys,
HashSet<string> excludedTitles,
HashSet<string> outputIds,
HashSet<string> outputKeys,
HashSet<string> 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<string> { 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*(?<artist>.{2,80}?)\s+[-–—]\s+(?<title>.{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);
}
}