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

362 lines
16 KiB
C#

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