Исправление: том ./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,284 @@
using Shared.Services.Hybrid;
using System.Globalization;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace Music;
// текст песен через lrclib.net (публичный API без ключей, как в Spotube);
// лирика — volatile metadata-обогащение, кэш в HybridFileCache
public static class MusicLyricsService
{
const string CacheKeyPrefix = "music:lyrics:v12";
const string YouTubeNoSyncedCacheKeyPrefix = "music:lyrics:yt-nosynced:v1";
static readonly TimeSpan SyncedTtl = TimeSpan.FromDays(30);
static readonly TimeSpan ShortTtl = TimeSpan.FromHours(1);
static readonly TimeSpan YouTubeNoSyncedTtl = TimeSpan.FromHours(6);
static readonly HttpClient httpClient = CreateClient();
static HttpClient CreateClient()
{
var client = MusicHttp.CreateClient("lyrics");
client.Timeout = TimeSpan.FromSeconds(12);
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)");
return client;
}
public static async Task<MusicLyricsResponse> GetAsync(string title, string artistName, string albumTitle, int? durationMs, string youtubeId = null, CancellationToken cancellationToken = default)
{
title = title?.Trim();
artistName = artistName?.Trim();
youtubeId = NormalizeYouTubeId(youtubeId);
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artistName))
return new MusicLyricsResponse { available = false, message = "Track metadata is empty." };
string cacheKey = BuildCacheKey(title, artistName, durationMs, youtubeId);
var cached = await HybridCache.Get().ReadCacheAsync<MusicLyricsResponse>(cacheKey, false, null, textJson: true);
if (cached.succes && cached.value != null)
return cached.value;
MusicLyricsResponse result;
try
{
result = await FetchAsync(title, artistName, albumTitle, durationMs, youtubeId, cancellationToken)
?? new MusicLyricsResponse { available = false, message = "Lyrics not found." };
}
catch (Exception ex)
{
Console.WriteLine($"[Music] lyrics fetch failed: {ex.Message}");
// сетевые сбои не кэшируем — следующий запрос попробует снова
return new MusicLyricsResponse { available = false, retry = true, message = "Lyrics provider unavailable." };
}
// Долгий кэш заслуживает только synced. Plain-only мог появиться из-за
// временного сбоя YouTube Music fallback, поэтому partial/retry не кэшируем.
if (!result.retry)
HybridCache.Get().Set(cacheKey, result, result.available && result.synced ? SyncedTtl : ShortTtl, textJson: true);
return result;
}
static async Task<MusicLyricsResponse> FetchAsync(string title, string artistName, string albumTitle, int? durationMs, string youtubeId, CancellationToken cancellationToken)
{
MusicLyricsResponse lrclib = null;
bool lrclibFailed = false;
try
{
lrclib = await FetchLrcLibAsync(title, artistName, albumTitle, durationMs, cancellationToken);
}
catch
{
lrclibFailed = true;
}
if (lrclib != null && lrclib.synced)
return lrclib;
MusicLyricsResponse youtubeMusic = null;
bool youtubeMusicFailed = false;
bool probeSyncedOnly = lrclib != null;
string youTubeNoSyncedKey = probeSyncedOnly ? BuildYouTubeNoSyncedCacheKey(title, artistName, durationMs, youtubeId) : null;
if (probeSyncedOnly)
{
var noSyncedCached = await HybridCache.Get().ReadCacheAsync<bool>(youTubeNoSyncedKey, false, null, textJson: true);
if (noSyncedCached.succes && noSyncedCached.value)
return lrclib;
}
try
{
youtubeMusic = await YouTubeMusicLyricsFallback.GetAsync(title, artistName, durationMs, youtubeId, probeSyncedOnly, cancellationToken);
}
catch (Exception ex)
{
youtubeMusicFailed = true;
Console.WriteLine($"[Music] youtube music lyrics fallback failed: {ex.Message}");
}
if (youtubeMusic != null && youtubeMusic.synced)
return youtubeMusic;
if (probeSyncedOnly && !youtubeMusicFailed)
HybridCache.Get().Set(youTubeNoSyncedKey, true, YouTubeNoSyncedTtl, textJson: true);
if (lrclib != null)
{
if (youtubeMusicFailed)
lrclib.retry = true;
return lrclib;
}
if (youtubeMusic != null)
return youtubeMusic;
// Если хоть один провайдер не успел/упал, не кэшируем «нет текста»:
// следующий запрос может попасть в живой источник.
if (lrclibFailed || youtubeMusicFailed)
throw new TimeoutException("lyrics provider timed out");
return null;
}
static async Task<MusicLyricsResponse> FetchLrcLibAsync(string title, string artistName, string albumTitle, int? durationMs, CancellationToken cancellationToken)
{
string getUrl = "https://lrclib.net/api/get?track_name=" + Uri.EscapeDataString(title)
+ "&artist_name=" + Uri.EscapeDataString(artistName)
+ (string.IsNullOrWhiteSpace(albumTitle) ? "" : "&album_name=" + Uri.EscapeDataString(albumTitle))
+ (durationMs > 0 ? "&duration=" + (durationMs.Value / 1000) : "");
string searchUrl = "https://lrclib.net/api/search?track_name=" + Uri.EscapeDataString(title)
+ "&artist_name=" + Uri.EscapeDataString(artistName);
// lrclib бывает медленным: оба запроса параллельно с общим бюджетом,
// чтобы ответ гарантированно уложился в терпение клиента
using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
budget.CancelAfter(TimeSpan.FromSeconds(9));
var getTask = RequestJsonAsync(getUrl, budget.Token);
var searchTask = RequestJsonAsync(searchUrl, budget.Token);
JsonNode getNode = null, searchNode = null;
bool getFailed = false, searchFailed = false;
try { getNode = await getTask; } catch { getFailed = true; }
try { searchNode = await searchTask; } catch { searchFailed = true; }
var exact = BuildResponse(getNode as JsonObject);
if (exact != null && exact.synced)
return exact;
// приоритет: synced-точный → synced-из-поиска → plain-точный → plain-из-поиска
MusicLyricsResponse searched = null;
if (searchNode is JsonArray items && items.Count > 0)
{
int targetSeconds = durationMs > 0 ? durationMs.Value / 1000 : 0;
JsonObject best = null;
JsonObject bestPlain = null;
foreach (var item in items.OfType<JsonObject>())
{
if (targetSeconds > 0)
{
double itemDuration = item["duration"]?.GetValue<double?>() ?? 0;
if (itemDuration > 0 && Math.Abs(itemDuration - targetSeconds) > 10)
continue;
}
if (!string.IsNullOrWhiteSpace(item["syncedLyrics"]?.GetValue<string>()))
{
best = item;
break;
}
bestPlain ??= string.IsNullOrWhiteSpace(item["plainLyrics"]?.GetValue<string>()) ? null : item;
}
searched = BuildResponse(best ?? bestPlain);
}
if (searched != null && searched.synced)
return searched;
var result = exact ?? searched;
// ничего не нашли, но часть запросов упала по таймауту — это не «нет
// текста», а сбой: бросаем, чтобы результат НЕ закэшировался
if (result == null && (getFailed || searchFailed))
throw new TimeoutException("lyrics provider timed out");
return result;
}
static async Task<JsonNode> RequestJsonAsync(string url, CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
string json = await response.Content.ReadAsStringAsync(cancellationToken);
return string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json);
}
static MusicLyricsResponse BuildResponse(JsonObject item)
{
if (item == null)
return null;
string syncedRaw = item["syncedLyrics"]?.GetValue<string>();
string plain = item["plainLyrics"]?.GetValue<string>();
var lines = ParseSyncedLyrics(syncedRaw);
if (lines.Count == 0 && string.IsNullOrWhiteSpace(plain))
return null;
return new MusicLyricsResponse
{
available = true,
message = "ok",
source = "lrclib",
source_mode = lines.Count > 0 ? "synced" : "plain",
synced = lines.Count > 0,
lines = lines,
plain = plain
};
}
static List<MusicLyricsLine> ParseSyncedLyrics(string lrc)
{
var lines = new List<MusicLyricsLine>();
if (string.IsNullOrWhiteSpace(lrc))
return lines;
foreach (var raw in lrc.Split('\n'))
{
var match = Regex.Match(raw, @"^\s*\[(\d+):(\d+(?:\.\d+)?)\]\s*(.*)$");
if (!match.Success)
continue;
double minutes = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
double seconds = double.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
lines.Add(new MusicLyricsLine
{
time_ms = (int)Math.Round((minutes * 60 + seconds) * 1000),
text = match.Groups[3].Value.Trim()
});
}
return lines.OrderBy(i => i.time_ms).ToList();
}
static string BuildCacheKey(string title, string artistName, int? durationMs, string youtubeId)
{
// длительность — в корзинах по 10s, чтобы близкие рипы попадали в один кэш
int bucket = durationMs > 0 ? durationMs.Value / 10000 : 0;
return string.Join(':', CacheKeyPrefix, artistName.ToLowerInvariant(), title.ToLowerInvariant(), bucket.ToString(), youtubeId ?? "auto");
}
static string BuildYouTubeNoSyncedCacheKey(string title, string artistName, int? durationMs, string youtubeId)
{
int bucket = durationMs > 0 ? durationMs.Value / 10000 : 0;
return string.Join(':', YouTubeNoSyncedCacheKeyPrefix, artistName.ToLowerInvariant(), title.ToLowerInvariant(), bucket.ToString(), youtubeId ?? "auto");
}
static string NormalizeYouTubeId(string youtubeId)
{
if (string.IsNullOrWhiteSpace(youtubeId))
return null;
youtubeId = youtubeId.Trim();
if (youtubeId.StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
youtubeId = youtubeId["youtube:".Length..].Trim();
return Regex.IsMatch(youtubeId, @"^[A-Za-z0-9_-]{6,32}$") ? youtubeId : null;
}
}
@@ -0,0 +1,773 @@
using System.Globalization;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace Music;
internal static class YouTubeMusicLyricsFallback
{
const string HomeUrl = "https://music.youtube.com/";
const string InnertubeBaseUrl = "https://music.youtube.com/youtubei/v1/";
const string SongsSearchParams = "EgWKAQIIAWoMEA4QChADEAQQCRAF";
const int MaxCandidates = 4;
static readonly HttpClient httpClient = CreateClient();
static readonly SemaphoreSlim configLock = new(1, 1);
static YouTubeMusicClientConfig cachedConfig;
static DateTimeOffset configExpiresAt;
static HttpClient CreateClient()
{
var client = MusicHttp.CreateClient("youtubemusic");
client.Timeout = TimeSpan.FromSeconds(12);
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15");
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", HomeUrl.TrimEnd('/'));
client.DefaultRequestHeaders.TryAddWithoutValidation("Referer", HomeUrl);
return client;
}
public static async Task<MusicLyricsResponse> GetAsync(string title, string artistName, int? durationMs, string youtubeId = null, bool syncedOnly = false, CancellationToken cancellationToken = default)
{
if (!YouTubeMusicSearchSupport.IsSearchEnabled || string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artistName))
return null;
using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
budget.CancelAfter(TimeSpan.FromSeconds(12));
var config = await GetConfigAsync(budget.Token);
youtubeId = NormalizeVideoId(youtubeId);
MusicLyricsResponse plainFallback = null;
if (!string.IsNullOrWhiteSpace(youtubeId))
{
// Прямой YouTube id может прийти с грязной карточки, поэтому доверяем
// ему только если он отдаёт настоящие тайминги. Plain ищем через
// канонический поиск по title/artist ниже.
var directLyrics = await FetchLyricsAsync(config, youtubeId, durationMs, budget.Token, syncedOnly: true);
if (directLyrics != null && directLyrics.synced)
return directLyrics;
}
var candidates = await SearchSongCandidatesAsync(config, title, artistName, durationMs, budget.Token);
foreach (var candidate in candidates)
{
var lyrics = await FetchLyricsAsync(config, candidate.VideoId, durationMs, budget.Token, syncedOnly);
if (lyrics != null && lyrics.synced)
return lyrics;
if (!syncedOnly)
plainFallback ??= lyrics;
}
return plainFallback;
}
static MusicLyricsResponse BuildPlainResponse(string plain)
{
if (string.IsNullOrWhiteSpace(plain))
return null;
return new MusicLyricsResponse
{
available = true,
message = "ok",
source = "youtube_music",
source_mode = "plain",
synced = false,
plain = plain.Trim()
};
}
static string NormalizeVideoId(string videoId)
{
if (string.IsNullOrWhiteSpace(videoId))
return null;
videoId = videoId.Trim();
if (videoId.StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
videoId = videoId["youtube:".Length..].Trim();
return Regex.IsMatch(videoId, @"^[A-Za-z0-9_-]{6,32}$") ? videoId : null;
}
static async Task<YouTubeMusicClientConfig> GetConfigAsync(CancellationToken cancellationToken)
{
if (cachedConfig != null && DateTimeOffset.UtcNow < configExpiresAt)
return cachedConfig;
await configLock.WaitAsync(cancellationToken);
try
{
if (cachedConfig != null && DateTimeOffset.UtcNow < configExpiresAt)
return cachedConfig;
string html = await httpClient.GetStringAsync(HomeUrl, cancellationToken);
string apiKey = MatchConfigValue(html, "INNERTUBE_API_KEY");
string clientVersion = MatchConfigValue(html, "INNERTUBE_CLIENT_VERSION");
string visitorData = MatchConfigValue(html, "VISITOR_DATA");
if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(clientVersion))
throw new InvalidOperationException("YouTube Music InnerTube config not found.");
cachedConfig = new YouTubeMusicClientConfig
{
ApiKey = apiKey,
ClientVersion = clientVersion,
VisitorData = visitorData
};
configExpiresAt = DateTimeOffset.UtcNow.AddHours(6);
return cachedConfig;
}
finally
{
configLock.Release();
}
}
static string MatchConfigValue(string html, string key)
{
var match = Regex.Match(html ?? string.Empty, $"\"{Regex.Escape(key)}\"\\s*:\\s*\"([^\"]+)\"");
return match.Success ? Regex.Unescape(match.Groups[1].Value) : null;
}
static async Task<List<YouTubeMusicSongCandidate>> SearchSongCandidatesAsync(YouTubeMusicClientConfig config, string title, string artistName, int? durationMs, CancellationToken cancellationToken)
{
int targetMs = durationMs.GetValueOrDefault();
var result = new List<YouTubeMusicSongCandidate>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var query in BuildCandidateQueries(title, artistName))
{
var root = await PostAsync(config, "search", new JsonObject
{
["context"] = BuildContext(config),
["query"] = query.Query,
["params"] = SongsSearchParams
}, cancellationToken);
var candidates = new List<YouTubeMusicSongCandidate>();
CollectSongCandidates(root, candidates);
foreach (var candidate in candidates.Where(i => IsRelevantCandidate(i, query.Title, query.Artist, targetMs, query.RequireArtist)))
{
if (string.IsNullOrWhiteSpace(candidate.VideoId) || !seen.Add(candidate.VideoId))
continue;
result.Add(candidate);
if (result.Count >= MaxCandidates)
return result;
}
}
return result;
}
static async Task<MusicLyricsResponse> FetchLyricsAsync(YouTubeMusicClientConfig config, string videoId, int? durationMs, CancellationToken cancellationToken, bool syncedOnly = false)
{
if (string.IsNullOrWhiteSpace(videoId))
return null;
var next = await PostAsync(config, "next", new JsonObject
{
["context"] = BuildContext(config),
["videoId"] = videoId,
["playlistId"] = "RDAMVM" + videoId,
["enablePersistentPlaylistPanel"] = true,
["isAudioOnly"] = true,
["tunerSettingValue"] = "AUTOMIX_SETTING_NORMAL",
["watchEndpointMusicSupportedConfigs"] = new JsonObject
{
["watchEndpointMusicConfig"] = new JsonObject
{
["hasPersistentPlaylistPanel"] = true,
["musicVideoType"] = "MUSIC_VIDEO_TYPE_ATV"
}
}
}, cancellationToken);
string browseId = FindBrowseIdByPageType(next, "MUSIC_PAGE_TYPE_TRACK_LYRICS");
if (string.IsNullOrWhiteSpace(browseId))
return null;
var timedBrowse = await PostAsync(config, "browse", new JsonObject
{
["context"] = BuildContext(config, mobile: true),
["browseId"] = browseId
}, cancellationToken);
var timedLines = FindTimedLyricsLines(timedBrowse);
if (timedLines.Count > 0)
{
return new MusicLyricsResponse
{
available = true,
message = "ok",
source = "youtube_music",
source_mode = "synced",
synced = true,
lines = timedLines,
plain = string.Join("\n", timedLines.Select(i => i.text).Where(i => !string.IsNullOrWhiteSpace(i)))
};
}
if (syncedOnly)
return null;
string staticPlain = FindStaticLyricsText(timedBrowse);
if (!string.IsNullOrWhiteSpace(staticPlain))
return BuildPlainResponse(staticPlain);
string mobilePlain = FindDescriptionShelfText(timedBrowse);
if (!string.IsNullOrWhiteSpace(mobilePlain) && !mobilePlain.Contains("Lyrics not available", StringComparison.OrdinalIgnoreCase))
return BuildPlainResponse(mobilePlain);
var browse = await PostAsync(config, "browse", new JsonObject
{
["context"] = BuildContext(config),
["browseId"] = browseId
}, cancellationToken);
string lyrics = FindDescriptionShelfText(browse);
if (string.IsNullOrWhiteSpace(lyrics) || lyrics.Contains("Lyrics not available", StringComparison.OrdinalIgnoreCase))
return null;
return BuildPlainResponse(lyrics);
}
static async Task<JsonNode> PostAsync(YouTubeMusicClientConfig config, string endpoint, JsonObject body, CancellationToken cancellationToken)
{
string url = InnertubeBaseUrl + endpoint + "?key=" + Uri.EscapeDataString(config.ApiKey);
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
using var response = await httpClient.PostAsync(url, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if ((int)response.StatusCode == 401 || (int)response.StatusCode == 403)
InvalidateConfig();
throw new HttpRequestException($"YouTube Music {endpoint} returned {(int)response.StatusCode}");
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
return string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json);
}
static void InvalidateConfig()
{
cachedConfig = null;
configExpiresAt = DateTimeOffset.MinValue;
}
static JsonObject BuildContext(YouTubeMusicClientConfig config, bool mobile = false)
{
var client = new JsonObject
{
["clientName"] = mobile ? "ANDROID_MUSIC" : "WEB_REMIX",
["clientVersion"] = mobile ? "7.21.50" : config.ClientVersion,
["hl"] = "ru",
["gl"] = "US"
};
if (!string.IsNullOrWhiteSpace(config.VisitorData))
client["visitorData"] = config.VisitorData;
return new JsonObject
{
["client"] = client
};
}
static void CollectSongCandidates(JsonNode node, List<YouTubeMusicSongCandidate> candidates)
{
if (node is JsonObject obj)
{
if (obj["musicResponsiveListItemRenderer"] is JsonObject renderer)
{
string videoId = FindWatchVideoId(renderer);
if (!string.IsNullOrWhiteSpace(videoId))
{
var texts = ExtractFlexTexts(renderer);
string title = texts.FirstOrDefault();
string joined = string.Join(" ", texts.Where(i => !string.IsNullOrWhiteSpace(i)));
if (!string.IsNullOrWhiteSpace(title))
{
candidates.Add(new YouTubeMusicSongCandidate
{
VideoId = videoId,
Title = title,
Description = joined,
DurationMs = ParseDurationMs(joined)
});
}
}
}
foreach (var child in obj)
CollectSongCandidates(child.Value, candidates);
}
else if (node is JsonArray array)
{
foreach (var child in array)
CollectSongCandidates(child, candidates);
}
}
static string FindWatchVideoId(JsonNode node)
{
if (node is JsonObject obj)
{
if (obj["watchEndpoint"] is JsonObject endpoint)
{
string videoId = endpoint["videoId"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(videoId))
return videoId;
}
foreach (var child in obj)
{
string result = FindWatchVideoId(child.Value);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
string result = FindWatchVideoId(child);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
return null;
}
static List<string> ExtractFlexTexts(JsonObject renderer)
{
var result = new List<string>();
if (renderer["flexColumns"] is not JsonArray columns)
return result;
foreach (var column in columns.OfType<JsonObject>())
{
var textNode = column["musicResponsiveListItemFlexColumnRenderer"]?["text"];
string text = ExtractText(textNode);
if (!string.IsNullOrWhiteSpace(text))
result.Add(Regex.Replace(text.Trim(), @"\s+", " "));
}
return result;
}
static string ExtractText(JsonNode node)
{
if (node is not JsonObject obj)
return null;
if (obj["simpleText"] != null)
return obj["simpleText"]?.GetValue<string>();
if (obj["runs"] is JsonArray runs)
return string.Concat(runs.OfType<JsonObject>().Select(i => i["text"]?.GetValue<string>() ?? string.Empty));
return null;
}
static int? ParseDurationMs(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var matches = Regex.Matches(text, @"(?<!\d)(\d{1,2}):(\d{2})(?::(\d{2}))?(?!\d)");
if (matches.Count == 0)
return null;
var match = matches[^1];
int first = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
int second = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
int third = match.Groups[3].Success ? int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture) : 0;
int seconds = match.Groups[3].Success
? first * 3600 + second * 60 + third
: first * 60 + second;
return seconds * 1000;
}
static List<YouTubeMusicLyricsQuery> BuildCandidateQueries(string title, string artistName)
{
var result = new List<YouTubeMusicLyricsQuery>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string cleanTitle = CleanSearchTitle(title);
string artist = CleanSearchTitle(artistName);
void add(string query, string queryTitle, string queryArtist, bool requireArtist)
{
query = Regex.Replace(query ?? string.Empty, @"\s+", " ").Trim();
queryTitle = Regex.Replace(queryTitle ?? string.Empty, @"\s+", " ").Trim();
queryArtist = Regex.Replace(queryArtist ?? string.Empty, @"\s+", " ").Trim();
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(queryTitle))
return;
string key = NormalizeText($"{query}|{queryTitle}|{queryArtist}|{requireArtist}");
if (!seen.Add(key))
return;
result.Add(new YouTubeMusicLyricsQuery
{
Query = query,
Title = queryTitle,
Artist = queryArtist,
RequireArtist = requireArtist
});
}
add($"{artistName} {title}", title, artistName, requireArtist: true);
if (!string.Equals(cleanTitle, title, StringComparison.Ordinal))
add($"{artist} {cleanTitle}", cleanTitle, artist, requireArtist: !string.IsNullOrWhiteSpace(artist));
var trailing = ExtractTrailingArtist(cleanTitle);
if (!string.IsNullOrWhiteSpace(trailing.title) && !string.IsNullOrWhiteSpace(trailing.artist))
add($"{trailing.artist} {trailing.title}", trailing.title, trailing.artist, requireArtist: true);
add(cleanTitle, cleanTitle, null, requireArtist: false);
return result;
}
static string CleanSearchTitle(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = Regex.Replace(value, @"#\S+", " ");
value = Regex.Replace(value, @"\b(official|audio|video|lyrics?|visualizer|remaster(?:ed)?|clip)\b", " ", RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"[\[\](){}]", " ");
return Regex.Replace(value, @"\s+", " ").Trim();
}
static (string title, string artist) ExtractTrailingArtist(string title)
{
if (string.IsNullOrWhiteSpace(title))
return (null, null);
var parts = title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
return (null, null);
string last = parts[^1].Trim();
string normalizedLast = NormalizeText(last);
if (normalizedLast.Length < 3)
return (null, null);
if (!Regex.IsMatch(last, @"^\p{L}[\p{L}\p{Nd}._-]*$", RegexOptions.IgnoreCase))
return (null, null);
if (Regex.IsMatch(normalizedLast, @"^(remix|mix|edit|audio|video|lyrics|music|official|cover|live)$", RegexOptions.IgnoreCase))
return (null, null);
string baseTitle = string.Join(' ', parts.Take(parts.Length - 1)).Trim();
return string.IsNullOrWhiteSpace(baseTitle) ? (null, null) : (baseTitle, last);
}
static bool IsRelevantCandidate(YouTubeMusicSongCandidate candidate, string title, string artistName, int targetMs, bool requireArtist = true)
{
string wantedTitle = NormalizeTitle(title);
string candidateTitle = NormalizeTitle(candidate.Title);
string wantedArtist = NormalizeText(artistName);
string candidateDescription = NormalizeText(candidate.Description);
if (string.IsNullOrWhiteSpace(wantedTitle) || string.IsNullOrWhiteSpace(candidateTitle))
return false;
bool titleMatches = candidateTitle == wantedTitle
|| candidateTitle.Contains(wantedTitle, StringComparison.Ordinal)
|| wantedTitle.Contains(candidateTitle, StringComparison.Ordinal)
|| TitleTokenCoverage(wantedTitle, candidateTitle) >= 0.75;
if (!titleMatches)
return false;
bool artistMatches = string.IsNullOrWhiteSpace(wantedArtist) || candidateDescription.Contains(wantedArtist, StringComparison.Ordinal);
if (!artistMatches)
{
var artistTokens = wantedArtist.Split(' ', StringSplitOptions.RemoveEmptyEntries);
artistMatches = artistTokens.Any(i => i.Length > 2 && candidateDescription.Contains(i, StringComparison.Ordinal));
}
if (requireArtist && !artistMatches)
return false;
if (targetMs > 0 && candidate.DurationMs > 0 && Math.Abs(candidate.DurationMs.Value - targetMs) > 25000)
return false;
return true;
}
static string NormalizeTitle(string value)
{
value = Regex.Replace(value ?? string.Empty, @"\b(feat\.?|ft\.?|featuring)\b.*$", " ", RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"\((official|audio|video|lyrics?|visualizer|remaster(ed)?|clip)[^)]*\)", " ", RegexOptions.IgnoreCase);
return NormalizeText(value);
}
static double TitleTokenCoverage(string wantedTitle, string candidateTitle)
{
var wantedTokens = wantedTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (wantedTokens.Length == 0)
return 0;
var candidateTokens = candidateTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.ToArray();
int matched = wantedTokens.Count(wanted => candidateTokens.Any(candidate => TokensClose(wanted, candidate)));
return matched / (double)wantedTokens.Length;
}
static bool TokensClose(string left, string right)
{
if (left == right)
return true;
int min = Math.Min(left.Length, right.Length);
if (min < 4)
return false;
return left.AsSpan(0, 4).SequenceEqual(right.AsSpan(0, 4));
}
static string NormalizeText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
string decomposed = value.ToLowerInvariant().Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(decomposed.Length);
foreach (char c in decomposed)
{
var category = CharUnicodeInfo.GetUnicodeCategory(c);
if (category != UnicodeCategory.NonSpacingMark)
builder.Append(c);
}
return Regex.Replace(builder.ToString().Normalize(NormalizationForm.FormC), @"[^\p{L}\p{Nd}]+", " ").Trim();
}
static string FindBrowseIdByPageType(JsonNode node, string pageType)
{
if (node is JsonObject obj)
{
if (obj["browseEndpoint"] is JsonObject endpoint)
{
string browseId = endpoint["browseId"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(browseId) && ContainsPageType(endpoint, pageType))
return browseId;
}
foreach (var child in obj)
{
string result = FindBrowseIdByPageType(child.Value, pageType);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
string result = FindBrowseIdByPageType(child, pageType);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
return null;
}
static bool ContainsPageType(JsonNode node, string pageType)
{
if (node is JsonObject obj)
{
foreach (var item in obj)
{
if (item.Key == "pageType" && item.Value?.GetValue<string>() == pageType)
return true;
if (ContainsPageType(item.Value, pageType))
return true;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
if (ContainsPageType(child, pageType))
return true;
}
}
return false;
}
static string FindDescriptionShelfText(JsonNode node)
{
if (node is JsonObject obj)
{
if (obj["musicDescriptionShelfRenderer"] is JsonObject shelf)
{
string text = ExtractText(shelf["description"]);
if (!string.IsNullOrWhiteSpace(text))
return text;
}
foreach (var child in obj)
{
string result = FindDescriptionShelfText(child.Value);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
string result = FindDescriptionShelfText(child);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
return null;
}
static List<MusicLyricsLine> FindTimedLyricsLines(JsonNode node)
{
if (node is JsonObject obj)
{
if (obj["timedLyricsData"] is JsonArray timed)
{
var lines = timed
.OfType<JsonObject>()
.Select(ParseTimedLyricsLine)
.Where(i => i != null)
.OrderBy(i => i.time_ms)
.ToList();
if (lines.Count > 0)
return lines;
}
foreach (var child in obj)
{
var result = FindTimedLyricsLines(child.Value);
if (result.Count > 0)
return result;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
var result = FindTimedLyricsLines(child);
if (result.Count > 0)
return result;
}
}
return new List<MusicLyricsLine>();
}
static string FindStaticLyricsText(JsonNode node)
{
if (node is JsonObject obj)
{
if (obj["timedLyricsData"] is JsonArray timed)
{
var lines = timed
.OfType<JsonObject>()
.Select(i => i["lyricLine"]?.GetValue<string>()?.Trim())
.Where(i => !string.IsNullOrWhiteSpace(i) && !string.Equals(i, "♪", StringComparison.Ordinal))
.ToList();
if (lines.Count > 0)
return string.Join("\n", lines);
}
foreach (var child in obj)
{
string result = FindStaticLyricsText(child.Value);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
string result = FindStaticLyricsText(child);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
return null;
}
static MusicLyricsLine ParseTimedLyricsLine(JsonObject item)
{
string text = item["lyricLine"]?.GetValue<string>()?.Trim();
string start = item["cueRange"]?["startTimeMilliseconds"]?.GetValue<string>();
if (!long.TryParse(start, NumberStyles.Integer, CultureInfo.InvariantCulture, out long timeMs))
return null;
return new MusicLyricsLine
{
time_ms = (int)Math.Max(0, Math.Min(int.MaxValue, timeMs)),
text = string.Equals(text, "♪", StringComparison.Ordinal) ? "" : text
};
}
class YouTubeMusicClientConfig
{
public string ApiKey { get; set; }
public string ClientVersion { get; set; }
public string VisitorData { get; set; }
}
class YouTubeMusicSongCandidate
{
public string VideoId { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public int? DurationMs { get; set; }
}
class YouTubeMusicLyricsQuery
{
public string Query { get; set; }
public string Title { get; set; }
public string Artist { get; set; }
public bool RequireArtist { get; set; }
}
}