Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -0,0 +1,753 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user