Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
@@ -0,0 +1,361 @@
using System.Collections.Concurrent;
using YoutubeExplode;
using YoutubeExplode.Search;
using YoutubeExplode.Videos.Streams;
namespace Music;
public class YouTubeAudioProvider : IMusicAudioProvider
{
// спекулятивный манифест: после первой пачки поиска манифест
// промежуточного топ-кандидата тянется ПАРАЛЛЕЛЬНО со второй пачкой —
// победитель ранжирования после неё почти никогда не меняется, и
// GetStreamsAsync забирает готовый результат вместо ещё одного ~1.5s
// запроса. Промах (победитель сменился/спекуляция упала) — обычный путь.
static readonly ConcurrentDictionary<string, (DateTime at, Task<ResolvedManifest> task)> speculativeManifests = new();
static readonly TimeSpan speculativeManifestTtl = TimeSpan.FromMinutes(2);
sealed record ResolvedManifest(StreamManifest Manifest, MusicProxyLease ProxyLease);
public string Id => "youtubeaudio";
public string Name => "YouTube Audio";
public bool Enabled => ModInit.conf?.youtube_audio_enabled == true;
public bool RequiresAuth => false;
public bool CacheMissingMatches => false;
public async Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (track == null)
return Array.Empty<MusicAudioMatch>();
var queries = YouTubeAudioSupport.BuildTrackQueries(track, playbackMode);
var directMatch = YouTubeAudioSupport.BuildDirectMatch(track);
if (queries.Count == 0)
return directMatch != null ? new[] { directMatch } : Array.Empty<MusicAudioMatch>();
try
{
var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api);
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
var results = new List<VideoSearchResult>();
var seen = new HashSet<string>(StringComparer.Ordinal);
// batch-parallel: пачки по 3 запроса параллельно (холодный резолв был
// суммой 3-6 последовательных поисков по 1-2s = главная часть 5-16s
// ожидания /music/play). Merge строго в порядке исходных queries —
// вход ранжирования тот же, что у последовательного цикла; dedupe и
// кап 36 на слиянии; набрали кап — следующая пачка не запускается
bool speculated = false;
for (int batchStart = 0; batchStart < queries.Count && results.Count < 36; batchStart += 3)
{
var batchTasks = queries
.Skip(batchStart)
.Take(3)
.Select(query => FetchQueryResultsAsync(youtube, query, cancellationToken))
.ToList();
await Task.WhenAll(batchTasks);
foreach (var batchTask in batchTasks)
{
foreach (var video in batchTask.Result)
{
var videoId = video.Id.Value;
if (!string.IsNullOrWhiteSpace(videoId) && seen.Add(videoId))
results.Add(video);
if (results.Count >= 36)
break;
}
if (results.Count >= 36)
break;
}
// впереди ещё пачка — после первой пачки уже часто есть
// очевидный победитель. Если он проходит строгий гейт
// уверенности, не жжём вторую пачку YouTube-поисков вообще.
// Манифест проверяем до раннего выхода: если топ-кандидат
// оказался без стримов, сохраняем старую полную fallback-схему.
if (directMatch == null && batchStart == 0 && batchStart + 3 < queries.Count && results.Count < 36)
{
var earlyRanked = YouTubeAudioSupport.RankMatches(track, YouTubeAudioSupport.ConvertSearchResults(results), playbackMode);
var earlyTop = earlyRanked.FirstOrDefault();
if (YouTubeAudioSupport.IsConfidentEarlyMatch(track, earlyTop))
{
try
{
var manifestTask = StartSpeculativeManifest(earlyTop.id);
if (manifestTask != null)
{
await manifestTask.WaitAsync(cancellationToken);
proxyLease.Success();
return earlyRanked;
}
}
catch (OperationCanceledException)
{
throw;
}
catch
{
// stream-less первый кандидат — не меняем поведение,
// продолжаем полный поиск и даём ранжированию шанс.
}
}
}
// впереди ещё пачка — спекулятивно тянем манифест текущего
// фаворита (directMatch, если есть — резолвер пробует его первым)
if (!speculated && batchStart + 3 < queries.Count && results.Count < 36)
{
speculated = true;
var prelim = YouTubeAudioSupport.RankMatches(track, YouTubeAudioSupport.ConvertSearchResults(results), playbackMode);
StartSpeculativeManifest(directMatch?.id ?? prelim.FirstOrDefault()?.id);
}
}
var matches = YouTubeAudioSupport.ConvertSearchResults(results);
var ranked = YouTubeAudioSupport.RankMatches(track, matches, playbackMode);
proxyLease.Success();
if (directMatch == null)
return ranked;
var ordered = new List<MusicAudioMatch> { directMatch };
ordered.AddRange(ranked.Where(i => !string.Equals(i.id, directMatch.id, StringComparison.Ordinal)));
return ordered;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
if (MusicHttp.IsProxyFailure(ex))
MusicProxyService.ReportFailure($"Music:{Id}:api");
if (directMatch != null)
return new[] { directMatch };
throw new MusicAudioTransientException(Id, "match", ex);
}
}
// «Найти вручную» в Источниках: сырой пользовательский запрос — без
// переписывания, ранжирования и гейта релевантности, решает человек
public async Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
{
query = query?.Trim();
if (string.IsNullOrWhiteSpace(query))
return Array.Empty<MusicAudioMatch>();
try
{
var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api);
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
var results = new List<VideoSearchResult>();
await foreach (var video in youtube.Search.GetVideosAsync(query, cancellationToken))
{
results.Add(video);
if (results.Count >= 20)
break;
}
proxyLease.Success();
return YouTubeAudioSupport.ConvertSearchResults(results);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
if (MusicHttp.IsProxyFailure(ex))
MusicProxyService.ReportFailure($"Music:{Id}:api");
throw new MusicAudioTransientException(Id, "search", ex);
}
}
public async Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (match == null || string.IsNullOrWhiteSpace(match.id))
return Array.Empty<MusicPlaybackSource>();
for (int attempt = 0; attempt < 2; attempt++)
{
try
{
var resolved = attempt == 0 ? await TryTakeSpeculativeManifestAsync(match.id) : null;
resolved ??= await ResolveManifestAsync(match.id, cancellationToken);
if (MusicPlaybackModeService.IsVideo(playbackMode))
{
var videoStreams = resolved.Manifest.GetMuxedStreams();
var sources = YouTubeAudioSupport.ConvertVideoStreams(videoStreams);
foreach (var source in sources)
resolved.ProxyLease.ApplyTo(source, overwrite: true);
return sources;
}
var audioStreams = resolved.Manifest.GetAudioOnlyStreams();
var audioSources = YouTubeAudioSupport.ConvertAudioStreams(audioStreams);
foreach (var source in audioSources)
resolved.ProxyLease.ApplyTo(source, overwrite: true);
return audioSources;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
if (attempt == 0 && ShouldRetryManifestFailure(ex))
{
await Task.Delay(250, cancellationToken);
continue;
}
Console.WriteLine($"[Music] youtube stream manifest failed for {match.id}: {ex.GetType().Name}: {ex.Message}");
if (IsPermanentManifestFailure(ex))
return Array.Empty<MusicPlaybackSource>();
throw new MusicAudioTransientException(Id, "stream", ex);
}
}
return Array.Empty<MusicPlaybackSource>();
}
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
return Task.FromResult<MusicPlaybackSource>(null);
}
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
{
return YouTubeAudioSupport.IsRelevantMatch(track, match);
}
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
{
return false;
}
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
{
return Array.Empty<string>();
}
static Task<ResolvedManifest> StartSpeculativeManifest(string videoId)
{
if (string.IsNullOrWhiteSpace(videoId))
return null;
var proxyLease = MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
string cacheKey = BuildManifestCacheKey(videoId, proxyLease);
if (speculativeManifests.ContainsKey(cacheKey))
return speculativeManifests.TryGetValue(cacheKey, out var existing) ? existing.task : null;
// уборка протухших записей (непотреблённые спекуляции — промахи ранжирования)
foreach (var entry in speculativeManifests)
{
if (DateTime.UtcNow - entry.Value.at > speculativeManifestTtl)
speculativeManifests.TryRemove(entry.Key, out _);
}
// CancellationToken.None: спекуляция переживает отмену исходного запроса —
// результат заберёт следующий GetStreams по тому же видео в пределах TTL
var task = ResolveManifestAsync(videoId, CancellationToken.None, proxyLease);
speculativeManifests[cacheKey] = (DateTime.UtcNow, task);
_ = task.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
return task;
}
static async Task<ResolvedManifest> TryTakeSpeculativeManifestAsync(string videoId)
{
if (string.IsNullOrWhiteSpace(videoId))
return null;
var proxyLease = MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
string cacheKey = BuildManifestCacheKey(videoId, proxyLease);
if (!speculativeManifests.TryRemove(cacheKey, out var entry))
return null;
if (DateTime.UtcNow - entry.at > speculativeManifestTtl)
return null;
try
{
return await entry.task;
}
catch
{
// упавшая спекуляция не считается попыткой — обычный путь
// с его retry-семантикой отработает как раньше
return null;
}
}
static async Task<List<VideoSearchResult>> FetchQueryResultsAsync(YoutubeClient youtube, string query, CancellationToken cancellationToken)
{
var list = new List<VideoSearchResult>();
await foreach (var video in youtube.Search.GetVideosAsync(query, cancellationToken))
{
list.Add(video);
if (list.Count >= 16)
break;
}
return list;
}
static async Task<ResolvedManifest> ResolveManifestAsync(string videoId, CancellationToken cancellationToken, MusicProxyLease proxyLease = null)
{
proxyLease ??= MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
try
{
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
var manifest = await youtube.Videos.Streams.GetManifestAsync(videoId, cancellationToken);
proxyLease.Success();
return new ResolvedManifest(manifest, proxyLease);
}
catch (Exception ex)
{
if (proxyLease.Enabled && MusicHttp.IsProxyFailure(ex))
proxyLease.Failure();
throw;
}
}
static string BuildManifestCacheKey(string videoId, MusicProxyLease proxyLease)
{
string route = proxyLease?.Enabled == true
? $"{proxyLease.Scope}|{proxyLease.Data.ip}|{proxyLease.Data.username}"
: "direct";
return $"{videoId}|{route}|cfg:{MusicProxyService.ConfigurationVersion}";
}
static bool ShouldRetryManifestFailure(Exception ex)
{
return ex != null
&& ex.GetType().Name == "YoutubeExplodeException"
&& (ex.Message?.IndexOf("cipher manifest", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0;
}
static bool IsPermanentManifestFailure(Exception ex)
{
for (var current = ex; current != null; current = current.InnerException)
{
if (current.GetType().Name is "VideoUnavailableException" or "VideoUnplayableException")
return true;
}
return false;
}
}
@@ -0,0 +1,708 @@
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);
}
}