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 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> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) { if (track == null) return Array.Empty(); var queries = YouTubeAudioSupport.BuildTrackQueries(track, playbackMode); var directMatch = YouTubeAudioSupport.BuildDirectMatch(track); if (queries.Count == 0) return directMatch != null ? new[] { directMatch } : Array.Empty(); try { var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api); using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease)); var results = new List(); var seen = new HashSet(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 { 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> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default) { query = query?.Trim(); if (string.IsNullOrWhiteSpace(query)) return Array.Empty(); try { var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api); using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease)); var results = new List(); 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> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) { if (match == null || string.IsNullOrWhiteSpace(match.id)) return Array.Empty(); 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(); throw new MusicAudioTransientException(Id, "stream", ex); } } return Array.Empty(); } public Task TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) { return Task.FromResult(null); } public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match) { return YouTubeAudioSupport.IsRelevantMatch(track, match); } public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match) { return false; } public IReadOnlyList GetFallbackProviderIds(MusicTrack track) { return Array.Empty(); } static Task 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 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> FetchQueryResultsAsync(YoutubeClient youtube, string query, CancellationToken cancellationToken) { var list = new List(); await foreach (var video in youtube.Search.GetVideosAsync(query, cancellationToken)) { list.Add(video); if (list.Count >= 16) break; } return list; } static async Task 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; } }