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 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(); var seen = new HashSet(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 { track.artist_name } : track?.artists ?? new List(), album_title = track?.album_title, isrc = MusicIsrc.Normalize(track?.isrc), duration_ms = track?.duration_ms, payload = MusicJson.Serialize(new Dictionary { ["video_id"] = videoId }) }; } public static List RankMatches(MusicTrack query, IEnumerable 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(); } 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 ConvertSearchResults(IEnumerable videos) { var matches = new List(); 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() : new List { author }, album_title = null, duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null, payload = MusicJson.Serialize(new Dictionary { ["video_id"] = id, ["author"] = author ?? string.Empty }) }); } return matches; } public static IReadOnlyList ConvertAudioStreams(IEnumerable 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 { ["Referer"] = "https://www.youtube.com/", ["Origin"] = "https://www.youtube.com" } }) .ToList(); } public static IReadOnlyList ConvertVideoStreams(IEnumerable 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 { ["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 BuildQueryArtists(string primaryArtist, IEnumerable artists) { var result = new List(); var seen = new HashSet(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()) { 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(); if (!string.IsNullOrWhiteSpace(query?.artist_name)) expectedArtists.Add(query.artist_name); foreach (var artist in query?.artists ?? new List()) { 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()) .Select(NormalizeText) .Where(i => !string.IsNullOrWhiteSpace(i)) .ToList(); string rawCandidateText = NormalizeText($"{match?.title} {string.Join(" ", match?.artists ?? new List())}"); 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())}"); 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); } }