754 lines
28 KiB
C#
754 lines
28 KiB
C#
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);
|
||
}
|
||
}
|