Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public class AppleMusicDiscoveryProvider : IMusicDiscoveryProvider
|
||||
{
|
||||
static readonly HttpClient httpClient = MusicHttp.CreateClient("applemusic");
|
||||
static readonly TimeSpan cacheTtl = TimeSpan.FromHours(6);
|
||||
|
||||
public const string ProviderId = "applemusiccharts";
|
||||
const string defaultCountry = "us";
|
||||
const string userAgent = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)";
|
||||
|
||||
public string Id => ProviderId;
|
||||
public string Name => "Apple Music Charts";
|
||||
public bool Enabled => true;
|
||||
|
||||
static AppleMusicDiscoveryProvider()
|
||||
{
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(20);
|
||||
}
|
||||
|
||||
public async Task<List<MusicBrowseSection>> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var feed = await GetTopAlbumsAsync(cancellationToken);
|
||||
if (feed.Count == 0)
|
||||
return new List<MusicBrowseSection>();
|
||||
|
||||
var sections = new List<MusicBrowseSection>
|
||||
{
|
||||
new MusicBrowseSection
|
||||
{
|
||||
id = "browse:top_albums",
|
||||
title = "Популярные альбомы",
|
||||
type = "album",
|
||||
source_provider = Id,
|
||||
has_more = feed.Count > limit,
|
||||
albums = feed.Take(limit).ToList()
|
||||
}
|
||||
};
|
||||
|
||||
var fresh = feed
|
||||
.Where(i => TryParseDate(i.date, out var releaseDate) && releaseDate >= DateTime.UtcNow.Date.AddDays(-180))
|
||||
.OrderByDescending(i => i.date)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
if (fresh.Count > 0)
|
||||
{
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = "browse:fresh_releases",
|
||||
title = "Свежие релизы",
|
||||
type = "album",
|
||||
source_provider = Id,
|
||||
has_more = fresh.Count >= limit,
|
||||
albums = fresh
|
||||
});
|
||||
}
|
||||
|
||||
sections.AddRange(BuildGenreSections(feed, limit));
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
public async Task<MusicBrowseSection> GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var feed = await GetTopAlbumsAsync(cancellationToken);
|
||||
if (feed.Count == 0)
|
||||
return null;
|
||||
|
||||
return sectionId switch
|
||||
{
|
||||
"browse:top_albums" => new MusicBrowseSection
|
||||
{
|
||||
id = sectionId,
|
||||
title = "Популярные альбомы",
|
||||
type = "album",
|
||||
source_provider = Id,
|
||||
has_more = false,
|
||||
albums = feed.Take(Math.Max(limit, 1)).ToList()
|
||||
},
|
||||
"browse:fresh_releases" => new MusicBrowseSection
|
||||
{
|
||||
id = sectionId,
|
||||
title = "Свежие релизы",
|
||||
type = "album",
|
||||
source_provider = Id,
|
||||
has_more = false,
|
||||
albums = feed
|
||||
.Where(i => TryParseDate(i.date, out var releaseDate) && releaseDate >= DateTime.UtcNow.Date.AddDays(-180))
|
||||
.OrderByDescending(i => i.date)
|
||||
.Take(Math.Max(limit, 1))
|
||||
.ToList()
|
||||
},
|
||||
_ when TryParseGenreSection(feed, sectionId, out var genre) => BuildGenreSection(feed, genre, Math.Max(limit, 1), hasMore: false),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsChartAlbum(string provider, string id)
|
||||
=> string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase)
|
||||
|| (id ?? string.Empty).StartsWith("applecharts:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public Task<MusicAlbum> GetAlbumAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string albumId = (id ?? string.Empty).StartsWith("applecharts:", StringComparison.OrdinalIgnoreCase)
|
||||
? id["applecharts:".Length..]
|
||||
: id;
|
||||
|
||||
return Regex.IsMatch(albumId ?? string.Empty, "^[0-9]+$")
|
||||
? AppleMusicSupport.GetCatalogAlbumAsync(CurrentCountry, albumId, cancellationToken)
|
||||
: Task.FromResult<MusicAlbum>(null);
|
||||
}
|
||||
|
||||
async Task<List<MusicAlbum>> GetTopAlbumsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string country = GetCountry();
|
||||
var albums = await MusicMetadataCacheService.GetOrCreateAsync(
|
||||
ProviderId,
|
||||
"browse",
|
||||
$"{country}:top-albums",
|
||||
cacheTtl,
|
||||
() => LoadTopAlbumsAsync(country, cancellationToken),
|
||||
cancellationToken
|
||||
) ?? new List<MusicAlbum>();
|
||||
|
||||
string resolver = GetAlbumResolver();
|
||||
return albums.Select(album => CopyAlbumForResponse(album, resolver)).ToList();
|
||||
}
|
||||
|
||||
static MusicAlbum CopyAlbumForResponse(MusicAlbum album, string resolver)
|
||||
{
|
||||
return new MusicAlbum
|
||||
{
|
||||
id = album.id,
|
||||
title = album.title,
|
||||
artist_id = album.artist_id,
|
||||
artist_name = album.artist_name,
|
||||
lookup_query = album.lookup_query,
|
||||
lookup_provider = resolver,
|
||||
year = album.year,
|
||||
date = album.date,
|
||||
type = album.type,
|
||||
release_id = album.release_id,
|
||||
description = album.description,
|
||||
search_score = album.search_score,
|
||||
secondary_types = album.secondary_types?.ToList() ?? new List<string>(),
|
||||
images = album.images?.ToList() ?? new List<MusicImage>(),
|
||||
provider_refs = album.provider_refs?.ToList() ?? new List<MusicProviderRef>(),
|
||||
tracks = album.tracks?.ToList() ?? new List<MusicTrack>()
|
||||
};
|
||||
}
|
||||
|
||||
async Task<List<MusicAlbum>> LoadTopAlbumsAsync(string country, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://rss.marketingtools.apple.com/api/v2/{country}/music/most-played/100/albums.json");
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", userAgent);
|
||||
request.Headers.TryAddWithoutValidation("Accept", "application/json");
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new List<MusicAlbum>();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var root = JsonNode.Parse(json) as JsonObject;
|
||||
var results = root?["feed"]?["results"] as JsonArray;
|
||||
|
||||
if (results == null)
|
||||
return new List<MusicAlbum>();
|
||||
|
||||
return results
|
||||
.Select(node => ParseAlbum(node as JsonObject))
|
||||
.Where(i => i != null)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
static string GetCountry()
|
||||
{
|
||||
string country = ModInit.conf?.applemusic_country;
|
||||
if (string.IsNullOrWhiteSpace(country))
|
||||
return defaultCountry;
|
||||
|
||||
country = country.Trim().ToLowerInvariant();
|
||||
return Regex.IsMatch(country, "^[a-z]{2}$") ? country : defaultCountry;
|
||||
}
|
||||
|
||||
public static string CurrentCountry => GetCountry();
|
||||
|
||||
static string GetAlbumResolver()
|
||||
{
|
||||
string resolver = ModInit.conf?.applemusic_album_resolver?.Trim().ToLowerInvariant();
|
||||
return resolver is "applemusic" or "spotify" or "soundcloud" or "musicbrainz" ? resolver : "auto";
|
||||
}
|
||||
|
||||
static MusicAlbum ParseAlbum(JsonObject item)
|
||||
{
|
||||
if (item == null)
|
||||
return null;
|
||||
|
||||
string title = item["name"]?.GetValue<string>()?.Trim();
|
||||
string artistName = item["artistName"]?.GetValue<string>()?.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artistName))
|
||||
return null;
|
||||
|
||||
string lookupQuery = $"{artistName} {title}";
|
||||
string artwork = UpgradeArtwork(item["artworkUrl100"]?.GetValue<string>());
|
||||
string date = item["releaseDate"]?.GetValue<string>();
|
||||
string appleId = item["id"]?.GetValue<string>();
|
||||
string externalId = string.IsNullOrWhiteSpace(appleId)
|
||||
? StableId(lookupQuery)
|
||||
: appleId;
|
||||
|
||||
return new MusicAlbum
|
||||
{
|
||||
id = $"applecharts:{externalId}",
|
||||
title = title,
|
||||
artist_name = artistName,
|
||||
lookup_query = lookupQuery,
|
||||
lookup_provider = GetAlbumResolver(),
|
||||
date = date,
|
||||
year = ParseYear(date),
|
||||
type = "Album",
|
||||
description = JoinGenres(item["genres"] as JsonArray),
|
||||
images = string.IsNullOrWhiteSpace(artwork)
|
||||
? new List<MusicImage>()
|
||||
: new List<MusicImage> { new() { url = artwork, width = 600, height = 600 } },
|
||||
provider_refs = new List<MusicProviderRef>
|
||||
{
|
||||
new() { provider = ProviderId, external_id = externalId }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static string JoinGenres(JsonArray items)
|
||||
{
|
||||
if (items == null)
|
||||
return null;
|
||||
|
||||
var genres = items
|
||||
.Select(node => node?["name"]?.GetValue<string>())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return genres.Count == 0 ? null : string.Join(" · ", genres);
|
||||
}
|
||||
|
||||
static List<MusicBrowseSection> BuildGenreSections(List<MusicAlbum> feed, int limit)
|
||||
{
|
||||
if (feed == null || feed.Count == 0)
|
||||
return new List<MusicBrowseSection>();
|
||||
|
||||
return feed
|
||||
.Select(item => PrimaryGenre(item))
|
||||
.Where(genre => !string.IsNullOrWhiteSpace(genre))
|
||||
.GroupBy(genre => genre)
|
||||
.Where(group => group.Count() >= Math.Max(6, limit / 2))
|
||||
.OrderByDescending(group => group.Count())
|
||||
.ThenBy(group => group.Key)
|
||||
.Take(3)
|
||||
.Select(group => BuildGenreSection(feed, group.Key, limit, hasMore: group.Count() > limit))
|
||||
.Where(section => section != null && section.albums.Count > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
static MusicBrowseSection BuildGenreSection(List<MusicAlbum> feed, string genre, int limit, bool hasMore)
|
||||
{
|
||||
if (feed == null || feed.Count == 0 || string.IsNullOrWhiteSpace(genre))
|
||||
return null;
|
||||
|
||||
var albums = feed
|
||||
.Where(album => string.Equals(PrimaryGenre(album), genre, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(Math.Max(limit, 1))
|
||||
.ToList();
|
||||
|
||||
if (albums.Count == 0)
|
||||
return null;
|
||||
|
||||
return new MusicBrowseSection
|
||||
{
|
||||
id = $"browse:genre:{GenreSlug(genre)}",
|
||||
title = $"Популярное: {LocalizeGenre(genre)}",
|
||||
type = "album",
|
||||
source_provider = ProviderId,
|
||||
has_more = hasMore,
|
||||
albums = albums
|
||||
};
|
||||
}
|
||||
|
||||
static string PrimaryGenre(MusicAlbum album)
|
||||
{
|
||||
if (album == null || string.IsNullOrWhiteSpace(album.description))
|
||||
return null;
|
||||
|
||||
return album.description
|
||||
.Split(" · ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.FirstOrDefault(genre => !string.Equals(genre, "Music", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
static string GenreSlug(string genre)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(genre))
|
||||
return string.Empty;
|
||||
|
||||
var slug = System.Text.RegularExpressions.Regex.Replace(genre.ToLowerInvariant(), @"[^a-z0-9]+", "-").Trim('-');
|
||||
return string.IsNullOrWhiteSpace(slug) ? StableId(genre) : slug;
|
||||
}
|
||||
|
||||
static bool TryParseGenreSection(List<MusicAlbum> feed, string sectionId, out string genre)
|
||||
{
|
||||
genre = null;
|
||||
if (string.IsNullOrWhiteSpace(sectionId) || !sectionId.StartsWith("browse:genre:", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var slug = sectionId.Substring("browse:genre:".Length);
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
return false;
|
||||
|
||||
var feedGenres = feed?
|
||||
.Select(PrimaryGenre)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList() ?? new List<string>();
|
||||
|
||||
genre = feedGenres.FirstOrDefault(item => string.Equals(GenreSlug(item), slug, StringComparison.OrdinalIgnoreCase));
|
||||
return !string.IsNullOrWhiteSpace(genre);
|
||||
}
|
||||
|
||||
static string LocalizeGenre(string genre)
|
||||
{
|
||||
return genre switch
|
||||
{
|
||||
"Hip-Hop/Rap" => "Хип-хоп и рэп",
|
||||
"Pop" => "Поп",
|
||||
"Rock" => "Рок",
|
||||
"Country" => "Кантри",
|
||||
"Alternative" => "Альтернатива",
|
||||
"R&B/Soul" => "R&B и соул",
|
||||
"Dance" => "Танцевальная",
|
||||
"Singer/Songwriter" => "Авторская песня",
|
||||
"Latin" => "Латино",
|
||||
"Christian" => "Христианская музыка",
|
||||
"Jazz" => "Джаз",
|
||||
"Electronic" => "Электроника",
|
||||
"K-Pop" => "K-Pop",
|
||||
"Indie Rock" => "Инди-рок",
|
||||
"Hard Rock" => "Хард-рок",
|
||||
"Soundtrack" => "Саундтреки",
|
||||
_ => genre
|
||||
};
|
||||
}
|
||||
|
||||
static string UpgradeArtwork(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return null;
|
||||
|
||||
return System.Text.RegularExpressions.Regex.Replace(
|
||||
url,
|
||||
"/\\d+x\\d+bb\\.",
|
||||
"/600x600bb.",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase
|
||||
);
|
||||
}
|
||||
|
||||
static int? ParseYear(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length < 4)
|
||||
return null;
|
||||
|
||||
return int.TryParse(value.Substring(0, 4), out var year) ? year : null;
|
||||
}
|
||||
|
||||
static bool TryParseDate(string value, out DateTime date)
|
||||
{
|
||||
return DateTime.TryParse(value, out date);
|
||||
}
|
||||
|
||||
static string StableId(string value)
|
||||
{
|
||||
var bytes = MD5.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace Music;
|
||||
|
||||
public class SoundCloudDiscoveryProvider : IMusicDiscoveryProvider
|
||||
{
|
||||
public string Id => SoundCloudSupport.DiscoveryProviderId;
|
||||
public string Name => "SoundCloud Charts";
|
||||
public bool Enabled => SoundCloudSupport.IsDiscoveryEnabled;
|
||||
|
||||
public Task<List<MusicBrowseSection>> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetHomeSectionsInternalAsync(limit, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MusicBrowseSection> GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetSectionInternalAsync(sectionId, limit, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MusicAlbum> GetAlbumAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SoundCloudSupport.GetChartAlbumAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
async Task<List<MusicBrowseSection>> GetHomeSectionsInternalAsync(int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var albums = await SoundCloudSupport.GetChartAlbumsAsync(cancellationToken);
|
||||
if (albums.Count == 0)
|
||||
return new List<MusicBrowseSection>();
|
||||
|
||||
return new List<MusicBrowseSection>
|
||||
{
|
||||
new()
|
||||
{
|
||||
id = SoundCloudSupport.ChartsSectionId,
|
||||
title = "SoundCloud",
|
||||
type = "album",
|
||||
source_provider = Id,
|
||||
has_more = albums.Count > limit,
|
||||
albums = albums.Take(Math.Max(limit, 1)).ToList()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async Task<MusicBrowseSection> GetSectionInternalAsync(string sectionId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.Equals(sectionId, SoundCloudSupport.ChartsSectionId, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var albums = await SoundCloudSupport.GetChartAlbumsAsync(cancellationToken);
|
||||
if (albums.Count == 0)
|
||||
return null;
|
||||
|
||||
return new MusicBrowseSection
|
||||
{
|
||||
id = SoundCloudSupport.ChartsSectionId,
|
||||
title = "SoundCloud",
|
||||
type = "album",
|
||||
source_provider = Id,
|
||||
has_more = false,
|
||||
albums = albums.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public class SpotifyDiscoveryProvider : IMusicDiscoveryProvider
|
||||
{
|
||||
sealed class Playlist
|
||||
{
|
||||
public string title { get; init; }
|
||||
public string playlistId { get; init; }
|
||||
}
|
||||
|
||||
static readonly TimeSpan cacheTtl = TimeSpan.FromHours(1);
|
||||
static readonly IReadOnlyList<Playlist> globalPlaylists = new List<Playlist>
|
||||
{
|
||||
new() { title = "Топ-50 Spotify: мир", playlistId = "37i9dQZEVXbMDoHDwVN2tF" },
|
||||
new() { title = "Today's Top Hits", playlistId = "37i9dQZF1DXcBWIGoYBM5M" },
|
||||
new() { title = "New Music Friday", playlistId = "37i9dQZF1DX4JAvHpjipBk" },
|
||||
new() { title = "RapCaviar", playlistId = "37i9dQZF1DX0XUsuxWHRQd" },
|
||||
new() { title = "Fresh Finds", playlistId = "37i9dQZF1DWWjGdmeTyeJ6" }
|
||||
};
|
||||
|
||||
static readonly IReadOnlyList<Playlist> themedPlaylists = new List<Playlist>
|
||||
{
|
||||
new() { title = "Клубные хиты", playlistId = "37i9dQZF1DX0BcQWzuB7ZO" },
|
||||
new() { title = "Ностальгическая вечеринка", playlistId = "37i9dQZF1DX7F6T2n2fegs" },
|
||||
new() { title = "Хиты 2000-х", playlistId = "37i9dQZF1DX4o1oenSJRJd" },
|
||||
new() { title = "Хиты 2010-х", playlistId = "37i9dQZF1DWWylYLMvjuRG" },
|
||||
new() { title = "Весёлая пятница", playlistId = "37i9dQZF1DX1g0iEXLFycr" }
|
||||
};
|
||||
|
||||
static readonly IReadOnlyDictionary<string, IReadOnlyList<Playlist>> countryThemedPlaylists =
|
||||
new Dictionary<string, IReadOnlyList<Playlist>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GLOBAL"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Хиты 80-х", playlistId = "37i9dQZF1DX4UtSsGT1Sbe" },
|
||||
new() { title = "Хиты 90-х", playlistId = "37i9dQZF1DXbTxeAdrVG2l" },
|
||||
new() { title = "Классика рока", playlistId = "37i9dQZF1DWXRqgorJj26U" },
|
||||
new() { title = "Песни для поездки", playlistId = "37i9dQZF1DWWMOmoXKqHTD" },
|
||||
new() { title = "Спокойные хиты", playlistId = "37i9dQZF1DX4WYpdgoIcn6" }
|
||||
},
|
||||
["UA"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Літні хіти України", playlistId = "37i9dQZF1DX86ZB9BGPT0a" },
|
||||
new() { title = "Українська ностальгія", playlistId = "3FUapgKzH0x7qSygyWS7rk" },
|
||||
new() { title = "Українські хіти 2000-х", playlistId = "3Id8TtStr3uzNv79RhOgmb" },
|
||||
new() { title = "Українські легенди", playlistId = "158jVnGiYCk96G6oJweWVz" },
|
||||
new() { title = "Українська вечірка", playlistId = "3wKPQ5RdRZymbAcEEkVRwT" }
|
||||
},
|
||||
["CA"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Классика канадского рока", playlistId = "37i9dQZF1DX4bKgmgKIPtO" },
|
||||
new() { title = "Канада: хиты 90-х", playlistId = "37i9dQZF1DX9NmM48Aqz3e" },
|
||||
new() { title = "Канадский хип-хоп", playlistId = "37i9dQZF1DX59ogDi1Z2XL" },
|
||||
new() { title = "Классика канадской инди-музыки", playlistId = "37i9dQZF1DX5dx2bmWL9SZ" },
|
||||
new() { title = "Канадское кантри", playlistId = "37i9dQZF1DWYV2Gh2QglGo" }
|
||||
},
|
||||
["US"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Американа: классика", playlistId = "37i9dQZF1DX7tfbjVrTPnV" },
|
||||
new() { title = "R&B-вечеринка", playlistId = "37i9dQZF1DX2hNQN2Fv6Cy" },
|
||||
new() { title = "Хип-хоп 90-х", playlistId = "37i9dQZF1DX186v583rmzp" },
|
||||
new() { title = "Хип-хоп 2000-х", playlistId = "37i9dQZF1DX1lHW2vbQwNN" },
|
||||
new() { title = "Хип-хоп 2010-х", playlistId = "37i9dQZF1DX97h7ftpNSYT" }
|
||||
},
|
||||
["RU"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Русские клубные хиты", playlistId = "0PwJbcDouwFD1oKhynlUR9" },
|
||||
new() { title = "Русская ностальгия", playlistId = "5oeCCfcOxkBLRL5AXf0iVs" },
|
||||
new() { title = "Русские хиты 2000-х", playlistId = "1JNYqFwOv1bIrXUjHTIDAC" },
|
||||
new() { title = "Русские хиты 2010-х", playlistId = "6OZJ2pHVbVto0SgTMMAK0G" },
|
||||
new() { title = "Русская вечеринка", playlistId = "4IYyB7pb7JIoT3ciWBgJJa" }
|
||||
},
|
||||
["PL"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Rap klub", playlistId = "37i9dQZF1DX3HUaZJRcDLd" },
|
||||
new() { title = "Polskie przeboje wszech czasów", playlistId = "37i9dQZF1DX8J2l55TrZk6" },
|
||||
new() { title = "Polski rap 2000.", playlistId = "37i9dQZF1DX4tIiLXPvlZC" },
|
||||
new() { title = "Polskie lata 2010.", playlistId = "37i9dQZF1DX7bSIS915wSM" },
|
||||
new() { title = "Polska impreza", playlistId = "37i9dQZF1DX7P3ukP665LS" }
|
||||
},
|
||||
["FR"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "La French Touch", playlistId = "37i9dQZF1DX9cbNxuNYT3d" },
|
||||
new() { title = "Pop francophone", playlistId = "37i9dQZF1DX9ZKIkjFureA" },
|
||||
new() { title = "Années 2000", playlistId = "37i9dQZF1DWWTe89CFDZnE" },
|
||||
new() { title = "Années 2010", playlistId = "37i9dQZF1DWVrO0SOklesK" },
|
||||
new() { title = "Soirée française", playlistId = "37i9dQZF1EIe3TBxFniBXv" }
|
||||
},
|
||||
["DE"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Deutschpop Klassiker", playlistId = "37i9dQZF1DX2cNqJ4LgCMf" },
|
||||
new() { title = "Schlager Klassiker", playlistId = "37i9dQZF1DX6uHioFvkN7A" },
|
||||
new() { title = "Deutschrap 2000er", playlistId = "37i9dQZF1DX5sbPvjd2Huv" },
|
||||
new() { title = "Deutschrap 2010er", playlistId = "37i9dQZF1DX1q42kBiHxxd" },
|
||||
new() { title = "POPLAND", playlistId = "37i9dQZF1DXbKGrOUA30KN" }
|
||||
}
|
||||
};
|
||||
|
||||
static readonly IReadOnlyDictionary<string, IReadOnlyList<Playlist>> countryPlaylists =
|
||||
new Dictionary<string, IReadOnlyList<Playlist>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["UA"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Топ-50 Spotify: Украина", playlistId = "37i9dQZEVXbKkidEfWYRuD" },
|
||||
new() { title = "Hot Hits Україна", playlistId = "37i9dQZF1DX1V3tM4cuX0v" },
|
||||
new() { title = "TOP POP 2026", playlistId = "37i9dQZF1DX2vTOtsQ5Isl" },
|
||||
new() { title = "EQUAL Україна", playlistId = "37i9dQZF1DWTtjLYc6QFF2" },
|
||||
new() { title = "Music for Ukraine", playlistId = "37i9dQZF1DWY0TJvcbosib" }
|
||||
},
|
||||
["CA"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Топ-50 Spotify: Канада", playlistId = "37i9dQZEVXbKj23U1GF4IR" },
|
||||
new() { title = "Hot Hits Canada", playlistId = "37i9dQZF1DWXT8uSSn6PRy" },
|
||||
new() { title = "New Music Friday Canada", playlistId = "37i9dQZF1DX5DfG8gQdC3F" },
|
||||
new() { title = "Fresh Finds Canada", playlistId = "37i9dQZF1DWVxStm5ni6tl" },
|
||||
new() { title = "Today's Top Hits", playlistId = "37i9dQZF1DXcBWIGoYBM5M" }
|
||||
},
|
||||
["US"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Топ-50 Spotify: США", playlistId = "37i9dQZEVXbLRQDuF5jeBp" },
|
||||
new() { title = "Hot Hits USA", playlistId = "37i9dQZF1DX0kbJZpiYdZl" },
|
||||
new() { title = "Today's Top Hits", playlistId = "37i9dQZF1DXcBWIGoYBM5M" },
|
||||
new() { title = "New Music Friday", playlistId = "37i9dQZF1DX4JAvHpjipBk" },
|
||||
new() { title = "RapCaviar", playlistId = "37i9dQZF1DX0XUsuxWHRQd" }
|
||||
},
|
||||
["RU"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Русский Топ-50", playlistId = "1qkR4WrgRWFg1w3Qik1lTu" },
|
||||
new() { title = "Русские хиты", playlistId = "7xm8bTP6NFGMDudMHsC2qp" },
|
||||
new() { title = "Русский поп", playlistId = "4dL0DgSaYON6tODhwM72gw" },
|
||||
new() { title = "Русский рэп", playlistId = "0Y8djwSTijxFoC3Z0vdinF" },
|
||||
new() { title = "Русский рок", playlistId = "0Fb2Hc0MdcME6knQ0vFiby" }
|
||||
},
|
||||
["PL"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Топ-50 Spotify: Польша", playlistId = "37i9dQZEVXbN6itCcaL3Tt" },
|
||||
new() { title = "Hot Hits Polska", playlistId = "37i9dQZF1DX49bSMRljsho" },
|
||||
new() { title = "New Music Friday Polska", playlistId = "37i9dQZF1DXaMTfp0LE4AZ" },
|
||||
new() { title = "Fresh Finds Polska", playlistId = "37i9dQZF1DWTI0B69TStH2" },
|
||||
new() { title = "RADAR Polska", playlistId = "37i9dQZF1DX1aXwAOtpwvU" }
|
||||
},
|
||||
["FR"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Топ-50 Spotify: Франция", playlistId = "37i9dQZEVXbIPWwFssbupI" },
|
||||
new() { title = "Hits du Moment", playlistId = "37i9dQZF1DWVuV87wUBNwc" },
|
||||
new() { title = "La Hit Liste", playlistId = "37i9dQZF1DX7LjobXS2hzX" },
|
||||
new() { title = "New Music Friday France", playlistId = "37i9dQZF1DX742okrrpwah" },
|
||||
new() { title = "PVNCHLNRS", playlistId = "37i9dQZF1DX1X23oiQRTB5" }
|
||||
},
|
||||
["DE"] = new List<Playlist>
|
||||
{
|
||||
new() { title = "Топ-50 Spotify: Германия", playlistId = "37i9dQZEVXbJiZcmkrIHGU" },
|
||||
new() { title = "Hot Hits Deutschland", playlistId = "37i9dQZF1DX4jP4eebSWR9" },
|
||||
new() { title = "New Music Friday Deutschland", playlistId = "37i9dQZF1DWUW2bvSkjcJ6" },
|
||||
new() { title = "Deutschrap Brandneu", playlistId = "37i9dQZF1DWSTqUqJcxFk6" },
|
||||
new() { title = "Fresh Finds GSA", playlistId = "37i9dQZF1DX2ddCYH6QIK5" }
|
||||
}
|
||||
};
|
||||
|
||||
const string defaultCountry = "US";
|
||||
|
||||
public string Id => SpotifySupport.DiscoveryProviderId;
|
||||
public string Name => "Spotify";
|
||||
public bool Enabled => ModInit.conf?.spotify_discovery_enabled == true;
|
||||
|
||||
public async Task<List<MusicBrowseSection>> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var section = await BuildSectionAsync(limit, cancellationToken);
|
||||
return section == null ? new List<MusicBrowseSection>() : new List<MusicBrowseSection> { section };
|
||||
}
|
||||
|
||||
public async Task<MusicBrowseSection> GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = GetProfile();
|
||||
if (!string.Equals(sectionId, BuildSectionId(profile.country), StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
return await BuildSectionAsync(limit, cancellationToken);
|
||||
}
|
||||
|
||||
public static bool IsPlaylistAlbum(string provider, string id)
|
||||
=> string.Equals(provider, SpotifySupport.DiscoveryProviderId, StringComparison.OrdinalIgnoreCase)
|
||||
&& TryGetPlaylistId(id, out _);
|
||||
|
||||
public async Task<MusicAlbum> GetPlaylistAlbumAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryGetPlaylistId(id, out string playlistId))
|
||||
return null;
|
||||
|
||||
var profile = GetProfile();
|
||||
var playlist = profile.playlists.FirstOrDefault(item =>
|
||||
string.Equals(item.playlistId, playlistId, StringComparison.OrdinalIgnoreCase));
|
||||
if (playlist == null)
|
||||
return null;
|
||||
|
||||
var album = await LoadPlaylistAsync(playlist.playlistId, SpotifySupport.PlaylistTrackLimit, cancellationToken);
|
||||
return album == null ? null : CopyAlbum(album, playlist.title, includeTracks: true);
|
||||
}
|
||||
|
||||
async Task<MusicBrowseSection> BuildSectionAsync(int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = GetProfile();
|
||||
int take = Math.Clamp(limit, 1, profile.playlists.Count);
|
||||
var playlists = profile.playlists.Take(take).ToList();
|
||||
var tasks = playlists.Select(item => LoadPlaylistAsync(item.playlistId, 1, cancellationToken)).ToList();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
var albums = new List<MusicAlbum>();
|
||||
|
||||
for (int i = 0; i < playlists.Count; i++)
|
||||
{
|
||||
if (results[i] != null)
|
||||
albums.Add(CopyAlbum(results[i], playlists[i].title, includeTracks: false));
|
||||
}
|
||||
|
||||
if (albums.Count == 0)
|
||||
return null;
|
||||
|
||||
return new MusicBrowseSection
|
||||
{
|
||||
id = BuildSectionId(profile.country),
|
||||
title = profile.title,
|
||||
type = "album",
|
||||
source_provider = SpotifySupport.DiscoveryProviderId,
|
||||
has_more = profile.playlists.Count > take,
|
||||
albums = albums
|
||||
};
|
||||
}
|
||||
|
||||
static (string country, string title, IReadOnlyList<Playlist> playlists) GetProfile()
|
||||
{
|
||||
string country = NormalizeCountry(ModInit.conf?.spotify_country);
|
||||
if (!countryPlaylists.TryGetValue(country, out var playlists))
|
||||
return ("GLOBAL", "Spotify: мир", AppendThemed("GLOBAL", globalPlaylists));
|
||||
|
||||
string title = country switch
|
||||
{
|
||||
"UA" => "Spotify: Украина",
|
||||
"CA" => "Spotify: Канада",
|
||||
"US" => "Spotify: США",
|
||||
"RU" => "Spotify: Россия",
|
||||
"PL" => "Spotify: Польша",
|
||||
"FR" => "Spotify: Франция",
|
||||
"DE" => "Spotify: Германия",
|
||||
_ => "Spotify"
|
||||
};
|
||||
return (country, title, AppendThemed(country, playlists));
|
||||
}
|
||||
|
||||
static IReadOnlyList<Playlist> AppendThemed(string country, IReadOnlyList<Playlist> playlists)
|
||||
{
|
||||
var result = playlists.ToList();
|
||||
if (countryThemedPlaylists.TryGetValue(country, out var countryPlaylists))
|
||||
result.AddRange(countryPlaylists);
|
||||
|
||||
result.AddRange(themedPlaylists);
|
||||
return result;
|
||||
}
|
||||
|
||||
static string NormalizeCountry(string country)
|
||||
{
|
||||
country = country?.Trim().ToUpperInvariant();
|
||||
return !string.IsNullOrWhiteSpace(country) && Regex.IsMatch(country, "^[A-Z]{2}$")
|
||||
? country
|
||||
: defaultCountry;
|
||||
}
|
||||
|
||||
static Task<MusicAlbum> LoadPlaylistAsync(string playlistId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
int take = Math.Clamp(limit, 1, SpotifySupport.PlaylistTrackLimit);
|
||||
return MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SpotifySupport.DiscoveryProviderId,
|
||||
"playlist",
|
||||
$"{playlistId}:paged-v2:{take}",
|
||||
cacheTtl,
|
||||
() => SpotifySupport.GetPlaylistAlbumAsync(playlistId, take, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
static MusicAlbum CopyAlbum(MusicAlbum album, string title, bool includeTracks)
|
||||
{
|
||||
return new MusicAlbum
|
||||
{
|
||||
id = album.id,
|
||||
title = string.IsNullOrWhiteSpace(title) ? album.title : title,
|
||||
artist_name = album.artist_name,
|
||||
type = album.type,
|
||||
description = album.description,
|
||||
images = album.images?.ToList() ?? new List<MusicImage>(),
|
||||
provider_refs = album.provider_refs?.ToList() ?? new List<MusicProviderRef>(),
|
||||
tracks = includeTracks ? album.tracks?.ToList() ?? new List<MusicTrack>() : new List<MusicTrack>()
|
||||
};
|
||||
}
|
||||
|
||||
static bool TryGetPlaylistId(string id, out string playlistId)
|
||||
{
|
||||
playlistId = null;
|
||||
const string prefix = "spotify:playlist:";
|
||||
if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
string value = id[prefix.Length..];
|
||||
if (!Regex.IsMatch(value, "^[A-Za-z0-9]{22}$"))
|
||||
return false;
|
||||
|
||||
playlistId = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
static string BuildSectionId(string country)
|
||||
=> $"browse:spotify:{country.ToLowerInvariant()}";
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public class VkTopChartDiscoveryProvider : IMusicDiscoveryProvider
|
||||
{
|
||||
static readonly HttpClient httpClient = MusicHttp.CreateClient("vktop200");
|
||||
static readonly TimeSpan cacheTtl = TimeSpan.FromHours(1);
|
||||
|
||||
const string providerId = "vktop200";
|
||||
const string baseUrl = "https://www.top200chart.ru/";
|
||||
const string userAgent = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)";
|
||||
|
||||
public string Id => providerId;
|
||||
public string Name => "VK Top 200";
|
||||
public bool Enabled => true;
|
||||
|
||||
static VkTopChartDiscoveryProvider()
|
||||
{
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(20);
|
||||
}
|
||||
|
||||
public async Task<List<MusicBrowseSection>> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var feed = await GetTracksAsync(cancellationToken);
|
||||
if (feed.Count == 0)
|
||||
return new List<MusicBrowseSection>();
|
||||
|
||||
return new List<MusicBrowseSection>
|
||||
{
|
||||
new()
|
||||
{
|
||||
id = "browse:vk_top_tracks",
|
||||
title = "Топ треки VK",
|
||||
type = "track",
|
||||
source_provider = Id,
|
||||
has_more = feed.Count > limit,
|
||||
tracks = feed.Take(limit).ToList()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<MusicBrowseSection> GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.Equals(sectionId, "browse:vk_top_tracks", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var feed = await GetTracksAsync(cancellationToken);
|
||||
if (feed.Count == 0)
|
||||
return null;
|
||||
|
||||
return new MusicBrowseSection
|
||||
{
|
||||
id = sectionId,
|
||||
title = "Топ треки VK",
|
||||
type = "track",
|
||||
source_provider = Id,
|
||||
has_more = false,
|
||||
tracks = feed.Take(Math.Max(limit, 1)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
async Task<List<MusicTrack>> GetTracksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await MusicMetadataCacheService.GetOrCreateAsync(
|
||||
providerId,
|
||||
"browse",
|
||||
"top200:tracks:v2",
|
||||
cacheTtl,
|
||||
() => LoadTracksAsync(cancellationToken),
|
||||
cancellationToken
|
||||
) ?? new List<MusicTrack>();
|
||||
}
|
||||
|
||||
async Task<List<MusicTrack>> LoadTracksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, baseUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", userAgent);
|
||||
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new List<MusicTrack>();
|
||||
|
||||
var html = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var json = ExtractTracksJsonArray(html);
|
||||
if (json == null)
|
||||
return new List<MusicTrack>();
|
||||
|
||||
return json
|
||||
.OfType<JsonObject>()
|
||||
.Select(ParseTrack)
|
||||
.Where(i => i != null)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
static JsonArray ExtractTracksJsonArray(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return null;
|
||||
|
||||
const string marker = "\\\"tracks\\\":[";
|
||||
int markerIndex = html.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (markerIndex < 0)
|
||||
return null;
|
||||
|
||||
int start = html.IndexOf('[', markerIndex);
|
||||
if (start < 0)
|
||||
return null;
|
||||
|
||||
int depth = 0;
|
||||
|
||||
for (int i = start; i < html.Length; i++)
|
||||
{
|
||||
char c = html[i];
|
||||
|
||||
if (c == '[')
|
||||
{
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == ']')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
{
|
||||
string arrayJson = html.Substring(start, i - start + 1)
|
||||
.Replace("\\\"", "\"")
|
||||
.Replace("\\\\", "\\");
|
||||
return JsonNode.Parse(arrayJson) as JsonArray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static MusicTrack ParseTrack(JsonObject item)
|
||||
{
|
||||
if (item == null)
|
||||
return null;
|
||||
|
||||
string title = item["title"]?.GetValue<string>()?.Trim();
|
||||
string artist = item["artist"]?.GetValue<string>()?.Trim();
|
||||
string sourceId = item["id"]?.GetValue<int?>()?.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
|
||||
return null;
|
||||
|
||||
string trackId = string.IsNullOrWhiteSpace(sourceId)
|
||||
? $"vkchart:{StableId($"{artist}::{title}")}"
|
||||
: $"vkchart:{sourceId}";
|
||||
|
||||
string cover = item["cover_url"]?.GetValue<string>();
|
||||
int? position = item["position"]?.GetValue<int?>();
|
||||
|
||||
return new MusicTrack
|
||||
{
|
||||
id = trackId,
|
||||
title = title,
|
||||
artist_name = artist,
|
||||
album_title = "VK Top 200",
|
||||
track_number = position,
|
||||
images = string.IsNullOrWhiteSpace(cover)
|
||||
? new List<MusicImage>()
|
||||
: new List<MusicImage> { new() { url = cover, width = 640, height = 640 } },
|
||||
provider_refs = new List<MusicProviderRef>
|
||||
{
|
||||
new() { provider = providerId, external_id = sourceId ?? trackId }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static string StableId(string value)
|
||||
{
|
||||
var bytes = MD5.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
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<List<MusicTrack>> SearchTracksByQueryAsync(
|
||||
string query,
|
||||
int limit = 10,
|
||||
CancellationToken cancellationToken = default,
|
||||
string expectedArtist = null,
|
||||
bool requireAuthorMatch = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return new List<MusicTrack>();
|
||||
|
||||
try
|
||||
{
|
||||
using var youtube = new YoutubeClient(youtubeHttp);
|
||||
var videos = new List<VideoSearchResult>();
|
||||
var seen = new HashSet<string>(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<MusicTrack>();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<List<MusicAlbum>> SearchPlaylistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return new List<MusicAlbum>();
|
||||
|
||||
try
|
||||
{
|
||||
using var youtube = new YoutubeClient(youtubeHttp);
|
||||
var playlists = new List<MusicAlbum>();
|
||||
var seen = new HashSet<string>(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<MusicAlbum>();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<List<MusicArtist>> SearchArtistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return new List<MusicArtist>();
|
||||
|
||||
try
|
||||
{
|
||||
using var youtube = new YoutubeClient(youtubeHttp);
|
||||
var artists = new List<MusicArtist>();
|
||||
var seen = new HashSet<string>(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<MusicArtist>();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<MusicAlbum> 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<MusicAlbum> 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<MusicProviderRef>
|
||||
{
|
||||
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<MusicArtist> 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<MusicProviderRef>
|
||||
{
|
||||
new() { provider = ProviderId, external_id = channelId }
|
||||
},
|
||||
albums = new List<MusicAlbum>
|
||||
{
|
||||
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<string> 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<string>() : new List<string> { artist },
|
||||
duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null,
|
||||
images = ExtractImages(video),
|
||||
provider_refs = new List<MusicProviderRef>
|
||||
{
|
||||
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<MusicProviderRef>
|
||||
{
|
||||
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<MusicProviderRef>
|
||||
{
|
||||
new() { provider = ProviderId, external_id = channelId }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static MusicAlbum BuildChannelUploadsAlbum(string channelId, string channelTitle, List<MusicImage> 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<MusicImage>(),
|
||||
provider_refs = new List<MusicProviderRef>
|
||||
{
|
||||
new() { provider = ProviderId, external_id = channelId }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async Task<MusicAlbum> 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<string>() : new List<string> { 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<MusicProviderRef>
|
||||
{
|
||||
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<string>() : new List<string> { artist },
|
||||
album_id = album?.id,
|
||||
album_title = album?.title,
|
||||
duration_ms = GetDurationMs(video),
|
||||
track_number = number,
|
||||
images = ExtractImages(video),
|
||||
provider_refs = new List<MusicProviderRef>
|
||||
{
|
||||
new() { provider = ProviderId, external_id = videoId }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static List<MusicImage> ExtractImages(params object[] sources)
|
||||
{
|
||||
var images = new List<MusicImage>();
|
||||
var seen = new HashSet<string>(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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user