using System.Collections; using System.Net.Http; using System.Text.RegularExpressions; using YoutubeExplode; using YoutubeExplode.Channels; using YoutubeExplode.Playlists; using YoutubeExplode.Search; namespace Music; internal static class YouTubeMusicSearchSupport { public const string SearchSectionId = "search:youtubemusic"; public const string TracksSectionId = "search:youtubemusic:tracks"; public const string PlaylistsSectionId = "search:youtubemusic:playlists"; public const string ArtistsSectionId = "search:youtubemusic:artists"; public const string ProviderId = "youtubeaudio"; const string playlistPrefix = "youtube:playlist:"; const string channelPrefix = "youtube:channel:"; const string channelUploadsPrefix = "youtube:channeluploads:"; const int maxSearchQueries = 2; static readonly string[] titleSeparators = { " - ", " – ", " — " }; static readonly HttpClient youtubeHttp = MusicHttp.CreateClient(ProviderId); public static bool IsSearchEnabled => ModInit.conf?.youtube_audio_enabled == true; public static async Task> SearchTracksByQueryAsync( string query, int limit = 10, CancellationToken cancellationToken = default, string expectedArtist = null, bool requireAuthorMatch = false) { if (string.IsNullOrWhiteSpace(query)) return new List(); try { using var youtube = new YoutubeClient(youtubeHttp); var videos = new List(); var seen = new HashSet(StringComparer.Ordinal); foreach (var searchQuery in BuildQueries(query).Take(maxSearchQueries)) { int queryCount = 0; await foreach (var video in youtube.Search.GetVideosAsync(searchQuery, cancellationToken)) { string videoId = video.Id.Value; if (!string.IsNullOrWhiteSpace(videoId) && seen.Add(videoId)) videos.Add(video); queryCount++; if (queryCount >= Math.Max(limit * 2, 12) || videos.Count >= Math.Max(limit * 3, 18)) break; } if (videos.Count >= Math.Max(limit * 3, 18)) break; } return videos .Where(video => !requireAuthorMatch || SameArtist(CleanArtist(video.Author.ChannelTitle), expectedArtist)) .Select(video => MapTrack(video, expectedArtist)) .Where(i => i != null) .Take(Math.Max(1, limit)) .ToList(); } catch { return new List(); } } public static async Task> SearchPlaylistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(query)) return new List(); try { using var youtube = new YoutubeClient(youtubeHttp); var playlists = new List(); var seen = new HashSet(StringComparer.Ordinal); await foreach (var playlist in youtube.Search.GetPlaylistsAsync(query, cancellationToken)) { var mapped = MapPlaylist(playlist); if (mapped != null && seen.Add(mapped.id)) playlists.Add(mapped); if (playlists.Count >= Math.Max(1, limit)) break; } return playlists; } catch { return new List(); } } public static async Task> SearchArtistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(query)) return new List(); try { using var youtube = new YoutubeClient(youtubeHttp); var artists = new List(); var seen = new HashSet(StringComparer.Ordinal); await foreach (var channel in youtube.Search.GetChannelsAsync($"{query} music", cancellationToken)) { var mapped = MapChannel(channel); if (mapped != null && seen.Add(mapped.id)) artists.Add(mapped); if (artists.Count >= Math.Max(1, limit)) break; } return artists; } catch { return new List(); } } public static async Task GetPlaylistAlbumAsync(string id, CancellationToken cancellationToken = default) { string playlistId = ExtractPlaylistId(id); if (!string.IsNullOrWhiteSpace(playlistId)) return await GetPlaylistAlbumInternalAsync(playlistId, cancellationToken); string channelId = ExtractChannelUploadsId(id); if (!string.IsNullOrWhiteSpace(channelId)) return await GetChannelUploadsAlbumAsync(channelId, cancellationToken); return null; } static async Task GetPlaylistAlbumInternalAsync(string playlistId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(playlistId)) return null; try { using var youtube = new YoutubeClient(youtubeHttp); Playlist playlist = null; try { playlist = await youtube.Playlists.GetAsync(playlistId, cancellationToken); } catch { playlist = null; } var album = new MusicAlbum { id = $"{playlistPrefix}{playlistId}", title = playlist?.Title ?? "YouTube Music Playlist", artist_name = CleanArtist(playlist?.Author.ChannelTitle), type = "Playlist", description = "YouTube Music", images = ExtractImages(playlist), provider_refs = new List { new() { provider = ProviderId, external_id = playlistId } } }; int number = 1; await foreach (var video in youtube.Playlists.GetVideosAsync(playlistId, cancellationToken)) { var track = MapPlaylistTrack(video, album, number); if (track != null) { if (album.images.Count == 0 && track.images.Count > 0) album.images = track.images; album.tracks.Add(track); number++; } if (album.tracks.Count >= 50) break; } return album; } catch { return null; } } public static async Task GetChannelArtistAsync(string id, CancellationToken cancellationToken = default) { string channelId = ExtractChannelId(id); if (string.IsNullOrWhiteSpace(channelId)) return null; try { using var youtube = new YoutubeClient(youtubeHttp); Channel channel = null; try { channel = await youtube.Channels.GetAsync(channelId, cancellationToken); } catch { channel = null; } string name = channel?.Title ?? channelId; var images = ExtractImages(channel); return new MusicArtist { id = $"{channelPrefix}{channelId}", name = name, description = "YouTube Music", images = images, provider_refs = new List { new() { provider = ProviderId, external_id = channelId } }, albums = new List { BuildChannelUploadsAlbum(channelId, name, images) } }; } catch { return null; } } public static bool IsPlaylistAlbum(string provider, string id) { return string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase) && (!string.IsNullOrWhiteSpace(ExtractPlaylistId(id)) || !string.IsNullOrWhiteSpace(ExtractChannelUploadsId(id))); } public static bool IsChannelArtist(string provider, string id) { return string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(ExtractChannelId(id)); } static IEnumerable BuildQueries(string query) { query = Regex.Replace(query.Trim(), @"\s+", " "); yield return $"{query} official audio"; yield return $"{query} topic"; yield return query; } static MusicTrack MapTrack(VideoSearchResult video, string expectedArtist = null) { string videoId = video?.Id.Value; string rawTitle = video?.Title?.Trim(); if (string.IsNullOrWhiteSpace(videoId) || string.IsNullOrWhiteSpace(rawTitle)) return null; string artist = CleanArtist(video.Author.ChannelTitle); string title = CleanTitle(rawTitle, artist, out var titleArtist); if (string.IsNullOrWhiteSpace(artist)) artist = titleArtist; // Artist-pool search knows which artist it requested. This resolves // both common upload formats (Artist - Title and Title - Artist) // without guessing in the generic recommendation normalizer. if (!string.IsNullOrWhiteSpace(expectedArtist) && TrySplitTitleSides(rawTitle, out var left, out var right)) { if (SameArtist(left, expectedArtist)) { artist = expectedArtist.Trim(); title = StripTitleNoise(right); } else if (SameArtist(right, expectedArtist)) { artist = expectedArtist.Trim(); title = StripTitleNoise(left); } } return new MusicTrack { id = $"youtube:{videoId}", title = string.IsNullOrWhiteSpace(title) ? rawTitle : title, artist_name = artist, artists = string.IsNullOrWhiteSpace(artist) ? new List() : new List { artist }, duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null, images = ExtractImages(video), provider_refs = new List { new() { provider = ProviderId, external_id = videoId } } }; } static bool TrySplitTitleSides(string rawTitle, out string left, out string right) { left = null; right = null; if (string.IsNullOrWhiteSpace(rawTitle)) return false; foreach (var separator in titleSeparators) { var parts = rawTitle.Split(separator, 2, StringSplitOptions.TrimEntries); if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) continue; left = parts[0]; right = parts[1]; return true; } return false; } static MusicAlbum MapPlaylist(PlaylistSearchResult playlist) { string playlistId = playlist?.Id.Value; string title = playlist?.Title?.Trim(); if (string.IsNullOrWhiteSpace(playlistId) || string.IsNullOrWhiteSpace(title)) return null; return new MusicAlbum { id = $"{playlistPrefix}{playlistId}", title = title, artist_name = CleanArtist(playlist.Author.ChannelTitle), type = "Playlist", description = "YouTube Music", images = ExtractImages(playlist), provider_refs = new List { new() { provider = ProviderId, external_id = playlistId } } }; } static MusicArtist MapChannel(ChannelSearchResult channel) { string channelId = channel?.Id.Value; string title = channel?.Title?.Trim(); if (string.IsNullOrWhiteSpace(channelId) || string.IsNullOrWhiteSpace(title)) return null; return new MusicArtist { id = $"{channelPrefix}{channelId}", name = title, description = "YouTube Music", images = ExtractImages(channel), provider_refs = new List { new() { provider = ProviderId, external_id = channelId } } }; } static MusicAlbum BuildChannelUploadsAlbum(string channelId, string channelTitle, List images = null) { return new MusicAlbum { id = $"{channelUploadsPrefix}{channelId}", title = "Видео канала", artist_id = $"{channelPrefix}{channelId}", artist_name = channelTitle, type = "Playlist", description = "YouTube Music", images = images ?? new List(), provider_refs = new List { new() { provider = ProviderId, external_id = channelId } } }; } static async Task GetChannelUploadsAlbumAsync(string channelId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(channelId)) return null; try { using var youtube = new YoutubeClient(youtubeHttp); Channel channel = null; try { channel = await youtube.Channels.GetAsync(channelId, cancellationToken); } catch { channel = null; } var album = BuildChannelUploadsAlbum(channelId, channel?.Title ?? channelId, ExtractImages(channel)); int number = 1; await foreach (var video in youtube.Channels.GetUploadsAsync(channelId, cancellationToken)) { var track = MapUploadTrack(video, album, number); if (track != null) { if (album.images.Count == 0 && track.images.Count > 0) album.images = track.images; album.tracks.Add(track); number++; } if (album.tracks.Count >= 50) break; } return album; } catch { return null; } } static MusicTrack MapPlaylistTrack(PlaylistVideo video, MusicAlbum album, int number) { string videoId = video?.Id.Value; string rawTitle = video?.Title?.Trim(); if (string.IsNullOrWhiteSpace(videoId) || string.IsNullOrWhiteSpace(rawTitle)) return null; string artist = CleanArtist(video.Author.ChannelTitle); string title = CleanTitle(rawTitle, artist, out var titleArtist); if (string.IsNullOrWhiteSpace(artist)) artist = titleArtist; return new MusicTrack { id = $"youtube:{videoId}", title = string.IsNullOrWhiteSpace(title) ? rawTitle : title, artist_name = artist, artists = string.IsNullOrWhiteSpace(artist) ? new List() : new List { artist }, album_id = album?.id, album_title = album?.title, duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null, track_number = number, images = ExtractImages(video), provider_refs = new List { new() { provider = ProviderId, external_id = videoId } } }; } static MusicTrack MapUploadTrack(object video, MusicAlbum album, int number) { string videoId = GetNestedString(video, "Id", "Value"); string rawTitle = GetPropertyString(video, "Title")?.Trim(); if (string.IsNullOrWhiteSpace(videoId) || string.IsNullOrWhiteSpace(rawTitle)) return null; string artist = CleanArtist(GetNestedString(video, "Author", "ChannelTitle")); string title = CleanTitle(rawTitle, artist, out var titleArtist); if (string.IsNullOrWhiteSpace(artist)) artist = titleArtist; return new MusicTrack { id = $"youtube:{videoId}", title = string.IsNullOrWhiteSpace(title) ? rawTitle : title, artist_name = artist, artists = string.IsNullOrWhiteSpace(artist) ? new List() : new List { artist }, album_id = album?.id, album_title = album?.title, duration_ms = GetDurationMs(video), track_number = number, images = ExtractImages(video), provider_refs = new List { new() { provider = ProviderId, external_id = videoId } } }; } static List ExtractImages(params object[] sources) { var images = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); void Add(string url, int? width = null, int? height = null) { if (string.IsNullOrWhiteSpace(url) || !seen.Add(url)) return; images.Add(new MusicImage { url = url, width = width, height = height }); } foreach (var source in sources) { if (source == null) continue; Add(GetPropertyString(source, "LogoUrl")); if (GetPropertyValue(source, "Thumbnails") is not IEnumerable thumbnails) continue; foreach (var thumbnail in thumbnails) { string url = GetPropertyString(thumbnail, "Url"); int? width = GetInt(GetNestedValue(thumbnail, "Resolution", "Width")) ?? GetInt(GetPropertyValue(thumbnail, "Width")); int? height = GetInt(GetNestedValue(thumbnail, "Resolution", "Height")) ?? GetInt(GetPropertyValue(thumbnail, "Height")); Add(url, width, height); } } return images .OrderBy(i => i.width ?? 0) .ThenBy(i => i.height ?? 0) .ToList(); } static string GetPropertyString(object target, string name) { return GetPropertyValue(target, name)?.ToString(); } static string GetNestedString(object target, params string[] names) { return GetNestedValue(target, names)?.ToString(); } static object GetNestedValue(object target, params string[] names) { object value = target; foreach (var name in names) { value = GetPropertyValue(value, name); if (value == null) return null; } return value; } static object GetPropertyValue(object target, string name) { return target?.GetType().GetProperty(name)?.GetValue(target); } static int? GetDurationMs(object target) { var value = GetPropertyValue(target, "Duration"); return value is TimeSpan duration ? (int)duration.TotalMilliseconds : null; } static int? GetInt(object value) { if (value == null) return null; if (value is int intValue) return intValue; if (value is long longValue) return longValue > int.MaxValue ? int.MaxValue : (int)longValue; return int.TryParse(value.ToString(), out var parsed) ? parsed : null; } static string ExtractPlaylistId(string id) { if (string.IsNullOrWhiteSpace(id)) return null; if (id.StartsWith(playlistPrefix, StringComparison.OrdinalIgnoreCase)) return id.Substring(playlistPrefix.Length); return null; } static string ExtractChannelUploadsId(string id) { if (string.IsNullOrWhiteSpace(id)) return null; if (id.StartsWith(channelUploadsPrefix, StringComparison.OrdinalIgnoreCase)) return id.Substring(channelUploadsPrefix.Length); return null; } static string ExtractChannelId(string id) { if (string.IsNullOrWhiteSpace(id)) return null; if (id.StartsWith(channelPrefix, StringComparison.OrdinalIgnoreCase)) return id.Substring(channelPrefix.Length); return null; } static string CleanArtist(string value) { if (string.IsNullOrWhiteSpace(value)) return null; value = Regex.Replace(value.Trim(), @"\s*-\s*Topic$", string.Empty, RegexOptions.IgnoreCase); value = Regex.Replace(value, @"\s*VEVO$", string.Empty, RegexOptions.IgnoreCase); value = Regex.Replace(value, @"\s+", " ").Trim(); return string.IsNullOrWhiteSpace(value) ? null : value; } static string CleanTitle(string rawTitle, string knownArtist, out string titleArtist) { titleArtist = null; if (string.IsNullOrWhiteSpace(rawTitle)) return rawTitle; string title = Regex.Replace(rawTitle.Trim(), @"\s+", " "); foreach (var separator in titleSeparators) { var parts = title.Split(separator, 2, StringSplitOptions.TrimEntries); if (parts.Length != 2) continue; string left = parts[0]; string right = parts[1]; if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) continue; titleArtist = left; if (string.IsNullOrWhiteSpace(knownArtist) || SameArtist(left, knownArtist)) return StripTitleNoise(right); } return StripTitleNoise(title); } static string StripTitleNoise(string title) { if (string.IsNullOrWhiteSpace(title)) return title; title = Regex.Replace(title, @"\s*\((official\s+)?(audio|video|music\s+video|visualizer|lyrics?)\)\s*$", string.Empty, RegexOptions.IgnoreCase); title = Regex.Replace(title, @"\s*\[(official\s+)?(audio|video|music\s+video|visualizer|lyrics?)\]\s*$", string.Empty, RegexOptions.IgnoreCase); title = Regex.Replace(title, @"\s+", " ").Trim(); return string.IsNullOrWhiteSpace(title) ? null : title; } static bool SameArtist(string left, string right) { string a = Normalize(left); string b = Normalize(right); return !string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && (a == b || a.Contains(b, StringComparison.Ordinal) || b.Contains(a, StringComparison.Ordinal)); } static string Normalize(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; value = Regex.Replace(value.ToLowerInvariant(), @"\s*-\s*topic$", string.Empty); value = Regex.Replace(value, @"\s*vevo$", string.Empty); value = new string(value.Where(ch => char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch)).ToArray()); return Regex.Replace(value, @"\s+", " ").Trim(); } }