Files
lampac/mods/Music/Providers/Audio/YouTube/YouTubeAudioSupport.cs
T

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);
}
}