709 lines
27 KiB
C#
709 lines
27 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using YoutubeExplode.Search;
|
|
using YoutubeExplode.Videos.Streams;
|
|
|
|
namespace Music;
|
|
|
|
internal static class YouTubeAudioSupport
|
|
{
|
|
static readonly string[] GenericTrackTitles =
|
|
{
|
|
"intro", "outro", "skit", "interlude", "theme", "song", "track"
|
|
};
|
|
|
|
static readonly string[] PenaltyWords =
|
|
{
|
|
"live", "karaoke", "cover", "reaction", "remix", "sped up", "slowed", "nightcore", "8d", "instrumental", "fan made", "tribute", "parody", "concert", "festival", "demo", "teaser", "choreo", "choreography", "dance"
|
|
};
|
|
|
|
static readonly string[] StrongVariantWords =
|
|
{
|
|
"instrumental", "karaoke", "drumless", "acapella", "a cappella"
|
|
};
|
|
|
|
static readonly string[] LiveVariantWords =
|
|
{
|
|
"live", "concert", "festival", "live performance", "performance",
|
|
"live session", "session", "sessions", "acoustic", "unplugged",
|
|
"tiny desk", "live lounge", "kexp", "boiler room", "city hall",
|
|
"royal albert hall", "madison square garden", "wembley"
|
|
};
|
|
|
|
static readonly string[] BonusWords =
|
|
{
|
|
"official audio", "official video", "audio", "topic", "provided to youtube", "auto-generated by youtube", "visualizer", "lyrics"
|
|
};
|
|
|
|
static readonly string[] EarlyExitPenaltyWords =
|
|
{
|
|
"live", "karaoke", "cover", "reaction", "remix", "sped up", "slowed", "nightcore", "8d", "instrumental", "fan made", "tribute", "parody", "concert", "festival", "demo", "teaser", "choreo", "choreography", "dance", "alternate", "version", "drumless", "acoustic", "unplugged", "remaster", "remastered", "edit", "sped", "reverb"
|
|
};
|
|
|
|
public static IReadOnlyList<string> BuildTrackQueries(MusicTrack track, string playbackMode = null)
|
|
{
|
|
string title = StripFeatureSegment(track?.title)?.Trim();
|
|
string album = track?.album_title?.Trim();
|
|
string artist = track?.artist_name?.Trim();
|
|
string normalizedTitle = NormalizeText(title);
|
|
string normalizedAlbum = NormalizeText(album);
|
|
bool preferVideo = MusicPlaybackModeService.IsVideo(playbackMode);
|
|
string primaryHint = preferVideo ? "official music video" : "official audio";
|
|
string secondaryHint = preferVideo ? "music video" : "audio";
|
|
var queryArtists = BuildQueryArtists(artist, track?.artists);
|
|
var queries = new List<string>();
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
void add(string value)
|
|
{
|
|
value = value?.Trim();
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return;
|
|
|
|
var key = NormalizeText(value);
|
|
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
|
|
return;
|
|
|
|
queries.Add(value);
|
|
}
|
|
|
|
add(MusicIsrc.Normalize(track?.isrc));
|
|
|
|
if (queryArtists.Count == 0 || string.IsNullOrWhiteSpace(title))
|
|
return queries;
|
|
|
|
foreach (var queryArtist in queryArtists)
|
|
add($"{queryArtist} \"{title}\" {primaryHint}");
|
|
|
|
foreach (var queryArtist in queryArtists)
|
|
add($"{queryArtist} {title} {primaryHint}");
|
|
|
|
foreach (var queryArtist in queryArtists)
|
|
add($"{queryArtist} \"{title}\"");
|
|
|
|
foreach (var queryArtist in queryArtists)
|
|
add($"{queryArtist} {title}");
|
|
|
|
if (ShouldAppendAlbumHint(title) && !string.IsNullOrWhiteSpace(album) && normalizedAlbum != normalizedTitle)
|
|
add($"{artist} \"{title}\" {album}");
|
|
|
|
foreach (var queryArtist in queryArtists)
|
|
add($"{queryArtist} {title} {secondaryHint}");
|
|
|
|
return queries;
|
|
}
|
|
|
|
public static MusicAudioMatch BuildDirectMatch(MusicTrack track)
|
|
{
|
|
string videoId = ResolveVideoId(track);
|
|
if (string.IsNullOrWhiteSpace(videoId))
|
|
return null;
|
|
|
|
return new MusicAudioMatch
|
|
{
|
|
provider_id = "youtubeaudio",
|
|
id = videoId,
|
|
title = track?.title,
|
|
artists = !string.IsNullOrWhiteSpace(track?.artist_name) ? new List<string> { track.artist_name } : track?.artists ?? new List<string>(),
|
|
album_title = track?.album_title,
|
|
isrc = MusicIsrc.Normalize(track?.isrc),
|
|
duration_ms = track?.duration_ms,
|
|
payload = MusicJson.Serialize(new Dictionary<string, string>
|
|
{
|
|
["video_id"] = videoId
|
|
})
|
|
};
|
|
}
|
|
|
|
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches, string playbackMode = null)
|
|
{
|
|
string queryTitle = NormalizeTitleForMatch(query?.title);
|
|
string queryArtist = NormalizeText(query?.artist_name);
|
|
string queryAlbum = NormalizeText(query?.album_title);
|
|
string queryPenaltyContext = NormalizeText($"{query?.title} {query?.album_title}");
|
|
string queryArtistCompact = CompactText(queryArtist);
|
|
bool preferVideo = MusicPlaybackModeService.IsVideo(playbackMode);
|
|
|
|
return matches
|
|
.Select(match =>
|
|
{
|
|
int score = 0;
|
|
string rawTitle = NormalizeText(match.title);
|
|
string title = NormalizeTitleForMatch(match.title);
|
|
string primaryTitle = NormalizePrimaryTitleForMatch(match.title);
|
|
string album = NormalizeText(match.album_title);
|
|
string fullTitle = NormalizeText($"{StripFeatureSegment(match.title)} {match.album_title}");
|
|
string rawFullTitle = NormalizeText($"{match.title} {match.album_title}");
|
|
var artists = match.artists.Select(NormalizeText).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
|
|
var titleMatch = EvaluateTitleMatch(queryTitle, title, rawFullTitle);
|
|
bool strongVariant = HasUnexpectedStrongVariant(queryPenaltyContext, rawFullTitle);
|
|
bool primaryTitleContainsQuery = !string.IsNullOrWhiteSpace(queryTitle)
|
|
&& !string.IsNullOrWhiteSpace(primaryTitle)
|
|
&& primaryTitle.Contains(queryTitle, StringComparison.Ordinal);
|
|
bool noisyTitleContainsQuery = !primaryTitleContainsQuery
|
|
&& !string.IsNullOrWhiteSpace(queryTitle)
|
|
&& !string.IsNullOrWhiteSpace(title)
|
|
&& title.Contains(queryTitle, StringComparison.Ordinal);
|
|
|
|
if (titleMatch.exact)
|
|
score += 14;
|
|
else if (primaryTitleContainsQuery)
|
|
score += 10;
|
|
else if (titleMatch.allTokens)
|
|
score += 8;
|
|
else if (titleMatch.coverage >= 0.5d)
|
|
score += 3;
|
|
else if (!string.IsNullOrWhiteSpace(queryTitle))
|
|
score -= 20;
|
|
|
|
if (!string.IsNullOrWhiteSpace(queryArtist) && !string.IsNullOrWhiteSpace(queryTitle) && rawFullTitle.Contains(queryArtist) && rawFullTitle.Contains(queryTitle))
|
|
score += 4;
|
|
|
|
// Если совпадение нашлось только в скобках/хвосте, это часто
|
|
// название альбома или подборки, а не самого трека.
|
|
if (noisyTitleContainsQuery)
|
|
score -= 10;
|
|
|
|
if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a == queryArtist))
|
|
score += 5;
|
|
else if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a.Contains(queryArtist) || queryArtist.Contains(a)))
|
|
score += 3;
|
|
|
|
if (!string.IsNullOrWhiteSpace(queryArtistCompact) && artists.Any(a =>
|
|
CompactText(a) == $"{queryArtistCompact}music"
|
|
|| CompactText(a) == $"{queryArtistCompact}vevo"))
|
|
{
|
|
score += 3;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(queryAlbum) && album.Contains(queryAlbum))
|
|
score += 1;
|
|
|
|
if (query.duration_ms.HasValue && match.duration_ms.HasValue)
|
|
{
|
|
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
|
|
if (diff <= 3000)
|
|
score += 4;
|
|
else if (diff <= 10000)
|
|
score += 2;
|
|
else if (diff > 90000)
|
|
score -= 12;
|
|
else if (diff > 45000)
|
|
score -= 6;
|
|
|
|
if (query.duration_ms.Value > 0 && match.duration_ms.Value > query.duration_ms.Value * 2)
|
|
score -= 16;
|
|
}
|
|
else if (match.duration_ms.HasValue && match.duration_ms.Value > 0 && match.duration_ms.Value < 90000 && !LooksLikeShortTrackQuery(queryTitle))
|
|
{
|
|
score -= 8;
|
|
}
|
|
|
|
string lowered = $"{rawTitle} {string.Join(" ", artists)} {album}";
|
|
|
|
if (BonusWords.Any(i => lowered.Contains(i)))
|
|
score += 2;
|
|
|
|
if (artists.Any(a => a.Contains("topic")))
|
|
score += 4;
|
|
|
|
if (preferVideo)
|
|
{
|
|
if (title.Contains("official video") || title.Contains("music video"))
|
|
score += 8;
|
|
|
|
if (lowered.Contains("hd") || lowered.Contains("4k"))
|
|
score += 2;
|
|
|
|
if (title.Contains("official audio") || title.Contains("provided to youtube"))
|
|
score -= 10;
|
|
|
|
if (artists.Any(a => a.Contains("topic")))
|
|
score -= 10;
|
|
|
|
if (lowered.Contains("lyrics") || lowered.Contains("visualizer"))
|
|
score -= 4;
|
|
}
|
|
else
|
|
{
|
|
if (title.Contains("official audio") || title.Contains("provided to youtube"))
|
|
score += 4;
|
|
|
|
if (title.Contains("official video") || title.Contains("music video"))
|
|
score += 1;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(queryArtist) && !string.IsNullOrWhiteSpace(queryTitle) && title.StartsWith($"{queryArtist} {queryTitle}"))
|
|
score += 3;
|
|
|
|
foreach (var word in PenaltyWords)
|
|
{
|
|
string normalizedWord = NormalizeText(word);
|
|
if (!string.IsNullOrWhiteSpace(normalizedWord) && lowered.Contains(normalizedWord) && !queryPenaltyContext.Contains(normalizedWord))
|
|
score -= 3;
|
|
}
|
|
|
|
// Instrumental/karaoke/drumless версии почти всегда являются
|
|
// другим треком для пользователя. Малый общий штраф не хватал:
|
|
// точное title+artist+duration могло перебить нормальную версию.
|
|
if (strongVariant)
|
|
score -= 40;
|
|
|
|
if (!string.IsNullOrWhiteSpace(queryAlbum) && !string.IsNullOrWhiteSpace(album) && album == queryAlbum)
|
|
score += 2;
|
|
|
|
return new { match, score, titleMatch, strongVariant };
|
|
})
|
|
.ToList() is var ranked
|
|
? (ranked.Any(i => i.titleMatch.relevant)
|
|
? ranked
|
|
.Where(i => i.titleMatch.relevant)
|
|
.Where(i => !i.strongVariant)
|
|
.Where(i => i.score > -12)
|
|
.OrderByDescending(i => i.score)
|
|
.ThenBy(i => i.match.title)
|
|
.Take(5)
|
|
.Select(i => i.match)
|
|
.ToList()
|
|
: ranked
|
|
.Where(i => !i.strongVariant)
|
|
.Where(i => i.score > -8)
|
|
.OrderByDescending(i => i.score)
|
|
.ThenBy(i => i.match.title)
|
|
.Take(5)
|
|
.Select(i => i.match)
|
|
.ToList())
|
|
: new List<MusicAudioMatch>();
|
|
}
|
|
|
|
public static bool IsRelevantMatch(MusicTrack query, MusicAudioMatch match)
|
|
{
|
|
if (query == null || match == null)
|
|
return false;
|
|
|
|
string queryContext = NormalizeText($"{query.title} {query.album_title}");
|
|
string candidateContext = NormalizeText($"{match.title} {match.album_title}");
|
|
if (HasUnexpectedStrongVariant(queryContext, candidateContext))
|
|
return false;
|
|
|
|
string queryTitle = NormalizeTitleForMatch(query.title);
|
|
if (string.IsNullOrWhiteSpace(queryTitle))
|
|
return true;
|
|
|
|
string primaryTitle = NormalizePrimaryTitleForMatch(match.title);
|
|
return EvaluateTitleMatch(queryTitle, primaryTitle, primaryTitle).relevant;
|
|
}
|
|
|
|
public static bool IsConfidentEarlyMatch(MusicTrack query, MusicAudioMatch match)
|
|
{
|
|
if (query == null || match == null)
|
|
return false;
|
|
|
|
string queryTitle = NormalizeTitleForMatch(query.title);
|
|
string candidatePrimaryTitle = NormalizePrimaryTitleForMatch(match.title);
|
|
if (string.IsNullOrWhiteSpace(queryTitle) || string.IsNullOrWhiteSpace(candidatePrimaryTitle))
|
|
return false;
|
|
|
|
if (LooksLikeShortTrackQuery(queryTitle))
|
|
return false;
|
|
|
|
if (!ContainsTokenPhrase(candidatePrimaryTitle, queryTitle))
|
|
return false;
|
|
|
|
if (!HasArtistMatch(query, match))
|
|
return false;
|
|
|
|
if (!HasTightDurationMatch(query, match, 5000))
|
|
return false;
|
|
|
|
if (HasUnexpectedEarlyExitNoise(query, match))
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
public static List<MusicAudioMatch> ConvertSearchResults(IEnumerable<VideoSearchResult> videos)
|
|
{
|
|
var matches = new List<MusicAudioMatch>();
|
|
|
|
foreach (var video in videos)
|
|
{
|
|
string id = video.Id.Value;
|
|
string title = video.Title;
|
|
string author = video.Author.ChannelTitle;
|
|
|
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(title))
|
|
continue;
|
|
|
|
matches.Add(new MusicAudioMatch
|
|
{
|
|
provider_id = "youtubeaudio",
|
|
id = id,
|
|
title = title,
|
|
artists = string.IsNullOrWhiteSpace(author) ? new List<string>() : new List<string> { author },
|
|
album_title = null,
|
|
duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null,
|
|
payload = MusicJson.Serialize(new Dictionary<string, string>
|
|
{
|
|
["video_id"] = id,
|
|
["author"] = author ?? string.Empty
|
|
})
|
|
});
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
public static IReadOnlyList<MusicPlaybackSource> ConvertAudioStreams(IEnumerable<IAudioStreamInfo> streams)
|
|
{
|
|
return streams
|
|
.OrderByDescending(i => IsPreferredContainer(i))
|
|
.ThenByDescending(i => i.Bitrate.BitsPerSecond)
|
|
.Take(3)
|
|
.Select(i => new MusicPlaybackSource
|
|
{
|
|
provider_id = "youtubeaudio",
|
|
url = i.Url,
|
|
mime_type = GetMimeType(i),
|
|
bitrate = (int)Math.Max(1L, i.Bitrate.BitsPerSecond / 1000),
|
|
quality = $"{GetContainerName(i).ToUpperInvariant()} {Math.Max(1L, i.Bitrate.BitsPerSecond / 1000)}kbps",
|
|
headers = new Dictionary<string, string>
|
|
{
|
|
["Referer"] = "https://www.youtube.com/",
|
|
["Origin"] = "https://www.youtube.com"
|
|
}
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
public static IReadOnlyList<MusicPlaybackSource> ConvertVideoStreams(IEnumerable<MuxedStreamInfo> streams)
|
|
{
|
|
return streams
|
|
.OrderByDescending(i => IsPreferredVideoContainer(i))
|
|
.ThenByDescending(i => i.VideoQuality.MaxHeight)
|
|
.ThenByDescending(i => i.Bitrate.BitsPerSecond)
|
|
.Take(3)
|
|
.Select(i => new MusicPlaybackSource
|
|
{
|
|
provider_id = "youtubeaudio",
|
|
url = i.Url,
|
|
mime_type = GetVideoMimeType(i),
|
|
bitrate = (int)Math.Max(1L, i.Bitrate.BitsPerSecond / 1000),
|
|
quality = BuildVideoQualityLabel(i),
|
|
headers = new Dictionary<string, string>
|
|
{
|
|
["Referer"] = "https://www.youtube.com/",
|
|
["Origin"] = "https://www.youtube.com"
|
|
}
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
static bool IsPreferredContainer(IAudioStreamInfo stream)
|
|
{
|
|
string name = GetContainerName(stream);
|
|
return name is "m4a" or "mp4";
|
|
}
|
|
|
|
static bool IsPreferredVideoContainer(MuxedStreamInfo stream)
|
|
{
|
|
string name = GetContainerName(stream);
|
|
return name is "mp4" or "m4v";
|
|
}
|
|
|
|
static string GetContainerName(IStreamInfo stream)
|
|
=> stream.Container.Name?.ToLowerInvariant() ?? stream.Container.ToString().ToLowerInvariant();
|
|
|
|
static string GetMimeType(IAudioStreamInfo stream)
|
|
{
|
|
string container = GetContainerName(stream);
|
|
return container is "m4a" or "mp4" ? "audio/mp4" : "audio/webm";
|
|
}
|
|
|
|
static string GetVideoMimeType(MuxedStreamInfo stream)
|
|
{
|
|
string container = GetContainerName(stream);
|
|
return container is "mp4" or "m4v" ? "video/mp4" : "video/webm";
|
|
}
|
|
|
|
static string BuildVideoQualityLabel(MuxedStreamInfo stream)
|
|
{
|
|
string container = GetContainerName(stream).ToUpperInvariant();
|
|
string height = !string.IsNullOrWhiteSpace(stream.VideoQuality.Label) ? stream.VideoQuality.Label : $"{stream.VideoQuality.MaxHeight}p";
|
|
long bitrate = Math.Max(1L, stream.Bitrate.BitsPerSecond / 1000);
|
|
return $"{container} {height} {bitrate}kbps";
|
|
}
|
|
|
|
static string NormalizeText(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return string.Empty;
|
|
|
|
value = Regex.Replace(value, @"[\(\)\[\]\{\}\-_]+", " ");
|
|
value = value.Trim().ToLowerInvariant().Normalize(NormalizationForm.FormD);
|
|
|
|
var builder = new StringBuilder(value.Length);
|
|
foreach (char ch in value)
|
|
{
|
|
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark)
|
|
continue;
|
|
|
|
if (char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch))
|
|
builder.Append(ch);
|
|
}
|
|
|
|
return Regex.Replace(builder.ToString(), @"\s+", " ").Trim();
|
|
}
|
|
|
|
static string NormalizeTitleForMatch(string value) => NormalizeText(StripFeatureSegment(value));
|
|
|
|
static List<string> BuildQueryArtists(string primaryArtist, IEnumerable<string> artists)
|
|
{
|
|
var result = new List<string>();
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
void add(string value)
|
|
{
|
|
value = value?.Trim();
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return;
|
|
|
|
var key = NormalizeText(value);
|
|
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
|
|
return;
|
|
|
|
result.Add(value);
|
|
}
|
|
|
|
add(primaryArtist);
|
|
|
|
foreach (var artist in artists ?? Array.Empty<string>())
|
|
{
|
|
add(artist);
|
|
if (result.Count >= 3)
|
|
break;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
static string NormalizePrimaryTitleForMatch(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return string.Empty;
|
|
|
|
value = StripFeatureSegment(value);
|
|
value = Regex.Replace(value, @"[\(\[\{][^)\]\}]+[\)\]\}]", " ");
|
|
value = Regex.Replace(value, @"\s+[|/]\s+.*$", " ");
|
|
return NormalizeText(value);
|
|
}
|
|
|
|
static string CompactText(string value)
|
|
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Replace(" ", string.Empty);
|
|
|
|
static bool LooksLikeShortTrackQuery(string normalizedTitle)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(normalizedTitle))
|
|
return false;
|
|
|
|
return GenericTrackTitles.Contains(normalizedTitle)
|
|
|| normalizedTitle.Contains("intro", StringComparison.Ordinal)
|
|
|| normalizedTitle.Contains("outro", StringComparison.Ordinal)
|
|
|| normalizedTitle.Contains("skit", StringComparison.Ordinal)
|
|
|| normalizedTitle.Contains("interlude", StringComparison.Ordinal);
|
|
}
|
|
|
|
static bool ContainsTokenPhrase(string value, string phrase)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(phrase))
|
|
return false;
|
|
|
|
if (value == phrase)
|
|
return true;
|
|
|
|
return value.StartsWith($"{phrase} ", StringComparison.Ordinal)
|
|
|| value.EndsWith($" {phrase}", StringComparison.Ordinal)
|
|
|| value.Contains($" {phrase} ", StringComparison.Ordinal);
|
|
}
|
|
|
|
static bool HasArtistMatch(MusicTrack query, MusicAudioMatch match)
|
|
{
|
|
var expectedArtists = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(query?.artist_name))
|
|
expectedArtists.Add(query.artist_name);
|
|
|
|
foreach (var artist in query?.artists ?? new List<string>())
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(artist))
|
|
expectedArtists.Add(artist);
|
|
}
|
|
|
|
var normalizedExpected = expectedArtists
|
|
.Select(NormalizeText)
|
|
.Where(i => !string.IsNullOrWhiteSpace(i))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
if (normalizedExpected.Count == 0)
|
|
return false;
|
|
|
|
var normalizedCandidateArtists = (match?.artists ?? new List<string>())
|
|
.Select(NormalizeText)
|
|
.Where(i => !string.IsNullOrWhiteSpace(i))
|
|
.ToList();
|
|
|
|
string rawCandidateText = NormalizeText($"{match?.title} {string.Join(" ", match?.artists ?? new List<string>())}");
|
|
|
|
foreach (var expected in normalizedExpected)
|
|
{
|
|
string compactExpected = CompactText(expected);
|
|
if (string.IsNullOrWhiteSpace(compactExpected))
|
|
continue;
|
|
|
|
foreach (var candidate in normalizedCandidateArtists)
|
|
{
|
|
string compactCandidate = CompactText(candidate);
|
|
if (candidate == expected
|
|
|| candidate.Contains(expected, StringComparison.Ordinal)
|
|
|| expected.Contains(candidate, StringComparison.Ordinal)
|
|
|| compactCandidate == compactExpected
|
|
|| compactCandidate == $"{compactExpected}vevo"
|
|
|| compactCandidate == $"{compactExpected}music"
|
|
|| compactCandidate == $"{compactExpected}topic")
|
|
return true;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(rawCandidateText) && ContainsTokenPhrase(rawCandidateText, expected))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static bool HasTightDurationMatch(MusicTrack query, MusicAudioMatch match, int toleranceMs)
|
|
{
|
|
if (query?.duration_ms.HasValue != true || match?.duration_ms.HasValue != true)
|
|
return false;
|
|
|
|
return Math.Abs(query.duration_ms.Value - match.duration_ms.Value) <= toleranceMs;
|
|
}
|
|
|
|
static bool HasUnexpectedEarlyExitNoise(MusicTrack query, MusicAudioMatch match)
|
|
{
|
|
string queryContext = NormalizeText($"{query?.title} {query?.album_title}");
|
|
string candidateContext = NormalizeText($"{match?.title} {match?.album_title} {string.Join(" ", match?.artists ?? new List<string>())}");
|
|
if (string.IsNullOrWhiteSpace(candidateContext))
|
|
return false;
|
|
|
|
foreach (var word in EarlyExitPenaltyWords)
|
|
{
|
|
string normalizedWord = NormalizeText(word);
|
|
if (!string.IsNullOrWhiteSpace(normalizedWord)
|
|
&& ContainsTokenPhrase(candidateContext, normalizedWord)
|
|
&& !ContainsTokenPhrase(queryContext, normalizedWord))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static bool HasUnexpectedStrongVariant(string queryContext, string candidateContext)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(candidateContext))
|
|
return false;
|
|
|
|
foreach (var word in StrongVariantWords.Concat(LiveVariantWords))
|
|
{
|
|
string normalizedWord = NormalizeText(word);
|
|
if (!string.IsNullOrWhiteSpace(normalizedWord)
|
|
&& ContainsTokenPhrase(candidateContext, normalizedWord)
|
|
&& !ContainsTokenPhrase(queryContext, normalizedWord))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static string StripFeatureSegment(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return string.Empty;
|
|
|
|
string original = value.Trim();
|
|
string stripped = Regex.Replace(
|
|
original,
|
|
@"\s*[\(\[\{]\s*(?:feat\.?|ft\.?|featuring)\s*[:\-]?\s+[^)\]\}]+[\)\]\}]\s*",
|
|
" ",
|
|
RegexOptions.IgnoreCase);
|
|
|
|
stripped = Regex.Replace(
|
|
stripped,
|
|
@"\s+(?:feat\.?|ft\.?|featuring)\s*[:\-]?\s+.+$",
|
|
" ",
|
|
RegexOptions.IgnoreCase);
|
|
|
|
stripped = Regex.Replace(stripped, @"\s+", " ").Trim();
|
|
return string.IsNullOrWhiteSpace(stripped) ? original : stripped;
|
|
}
|
|
|
|
static bool ShouldAppendAlbumHint(string title)
|
|
{
|
|
var normalized = NormalizeText(title);
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
return false;
|
|
|
|
if (GenericTrackTitles.Contains(normalized))
|
|
return true;
|
|
|
|
return normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 2;
|
|
}
|
|
|
|
static string ResolveVideoId(MusicTrack track)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(track?.id) && track.id.StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
|
|
return track.id["youtube:".Length..].Trim();
|
|
|
|
if (track?.provider_refs == null)
|
|
return null;
|
|
|
|
foreach (var providerRef in track.provider_refs)
|
|
{
|
|
if (!string.Equals(providerRef?.provider, "youtubeaudio", StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
if (!string.IsNullOrWhiteSpace(providerRef.external_id))
|
|
return providerRef.external_id.Trim();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static (bool relevant, bool exact, bool allTokens, double coverage) EvaluateTitleMatch(string queryTitle, string title, string fullTitle)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(queryTitle))
|
|
return (true, false, false, 0d);
|
|
|
|
bool exact = !string.IsNullOrWhiteSpace(title) && title == queryTitle;
|
|
|
|
var queryTokens = queryTitle
|
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
if (queryTokens.Length == 0)
|
|
return (true, exact, false, 0d);
|
|
|
|
int matchedTokens = queryTokens.Count(token =>
|
|
(!string.IsNullOrWhiteSpace(title) && title.Contains(token, StringComparison.Ordinal))
|
|
|| (!string.IsNullOrWhiteSpace(fullTitle) && fullTitle.Contains(token, StringComparison.Ordinal)));
|
|
|
|
double coverage = matchedTokens / (double)queryTokens.Length;
|
|
bool allTokens = matchedTokens == queryTokens.Length;
|
|
bool relevant = exact
|
|
|| allTokens
|
|
|| (queryTokens.Length >= 3 && coverage >= 0.75d);
|
|
return (relevant, exact, allTokens, coverage);
|
|
}
|
|
}
|