Исправление: том ./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,62 @@
namespace Music;
public sealed class MusicAudioTransientException : Exception
{
public string ProviderId { get; }
public MusicAudioTransientException(string providerId, string operation, Exception innerException)
: base($"{providerId} {operation} temporarily failed.", innerException)
{
ProviderId = providerId;
}
}
public interface IMusicMetadataProvider
{
string Id { get; }
string Name { get; }
bool Enabled { get; }
Task<MusicSearchResult> SearchAsync(string query, bool expanded = false, CancellationToken cancellationToken = default);
Task<MusicArtist> GetArtistAsync(string id, CancellationToken cancellationToken = default);
Task<MusicAlbum> GetAlbumAsync(string id, CancellationToken cancellationToken = default);
Task<MusicTrack> GetTrackAsync(string id, CancellationToken cancellationToken = default);
}
public interface IMusicDiscoveryProvider
{
string Id { get; }
string Name { get; }
bool Enabled { get; }
Task<List<MusicBrowseSection>> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default);
Task<MusicBrowseSection> GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default);
}
public interface IMusicAudioProvider
{
string Id { get; }
string Name { get; }
bool Enabled { get; }
bool RequiresAuth { get; }
bool CacheMissingMatches { get; }
Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default);
Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default);
bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match);
bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match);
IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track);
}
public interface IMusicAuthProvider
{
string Id { get; }
string Name { get; }
bool Enabled { get; }
Task<MusicAuthState> GetStateAsync(string profileId = null, CancellationToken cancellationToken = default);
Task<bool> SaveAsync(string payload, string profileId = null, CancellationToken cancellationToken = default);
Task LogoutAsync(string profileId = null, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,481 @@
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using static Music.MusicPlaylistImportHelpers;
namespace Music;
// Импорт публичных плейлистов/альбомов Apple Music БЕЗ ключей и логина:
// анонимный developer-JWT из JS-бандла music.apple.com (живёт ~месяц)
// + amp-api catalog — тот же путь, что у веб-плеера. Стримов нет (DRM):
// треки приезжают метаданными, аудио резолвит обычный конвейер (YouTube-матчер).
public static class AppleMusicSupport
{
public const string ProviderId = "applemusic";
public const string PlaylistSourceType = "applemusic_playlist";
public const string AlbumSourceType = "applemusic_album";
const string ApiBaseUrl = "https://amp-api.music.apple.com";
const int PageLimit = 100;
const int MaxImportTracks = 2000;
const int MaxPages = 30;
const string BrowserUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
static readonly HttpClient httpClient = MusicHttp.CreateClient("applemusic");
// storefront (us/ru/ua/...), тип, id: pl.xxx / pl.u-xxx у плейлистов, цифры у альбомов
static readonly Regex entityUrlRegex = new(@"^https?://music\.apple\.com/([a-z]{2})/(playlist|album)/(?:[^/]+/)?(pl\.[A-Za-z0-9\.\-]+|\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex catalogAlbumIdRegex = new(@"^applemusic:album:([a-z]{2}):(\d+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex assetBundleRegex = new("src=\"(/assets/index~[^\"]+\\.js)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex developerTokenRegex = new("\"(eyJ0[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+)\"", RegexOptions.Compiled);
static readonly SemaphoreSlim developerTokenLock = new(1, 1);
static string developerToken;
static DateTime developerTokenExpiresAt;
static AppleMusicSupport()
{
httpClient.Timeout = TimeSpan.FromSeconds(20);
}
public static bool CanHandleUrl(string url) => IsAppleMusicUrl(url);
public static bool IsCatalogAlbum(string provider, string id)
=> string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase)
|| catalogAlbumIdRegex.IsMatch(id ?? string.Empty);
public static Task<MusicAlbum> GetCatalogAlbumAsync(string id, CancellationToken cancellationToken = default)
{
var match = catalogAlbumIdRegex.Match(id ?? string.Empty);
return match.Success
? GetCatalogAlbumAsync(match.Groups[1].Value, match.Groups[2].Value, cancellationToken)
: Task.FromResult<MusicAlbum>(null);
}
public static async Task<MusicAlbum> GetCatalogAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken = default)
{
try
{
return (await LoadCatalogAlbumAsync(storefront, albumId, cancellationToken)).album;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
public static async Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(string inputUrl, CancellationToken cancellationToken = default)
{
var entity = ParseEntity(inputUrl);
if (entity == null)
{
if (IsProfileUrl(inputUrl))
return ImportUnavailable("Apple Music профиль не импортируется. Вставь ссылку на конкретный публичный плейлист или альбом.");
return ImportUnavailable("Вставь ссылку на Apple Music плейлист или альбом.");
}
// прогрев кэша токена + дружелюбная ошибка, если Apple недоступен
string token = await GetDeveloperTokenAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(token))
return ImportUnavailable("Не удалось получить Apple Music токен.");
try
{
return entity.Value.type == "album"
? await ImportAlbumAsync(entity.Value.storefront, entity.Value.id, cancellationToken)
: await ImportPlaylistByIdAsync(entity.Value.storefront, entity.Value.id, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
Console.WriteLine($"[Music] apple music import failed: {ex.Message}");
return ImportUnavailable("Apple Music не ответил, попробуй ещё раз.");
}
}
public static Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
return Task.FromResult(ImportUnavailable("У плейлиста нет Apple Music источника."));
return ImportPlaylistAsync(source.url, cancellationToken);
}
static (string storefront, string type, string id)? ParseEntity(string value)
{
value = value?.Trim();
if (string.IsNullOrWhiteSpace(value))
return null;
var match = entityUrlRegex.Match(value);
if (!match.Success)
return null;
string type = match.Groups[2].Value.ToLowerInvariant();
string id = match.Groups[3].Value;
// альбомная ссылка с плейлистным id (и наоборот) — мусор, не берём
bool playlistId = id.StartsWith("pl.", StringComparison.OrdinalIgnoreCase);
if (type == "playlist" != playlistId)
return null;
return (match.Groups[1].Value.ToLowerInvariant(), type, id);
}
static bool IsAppleMusicUrl(string value)
{
if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri))
return false;
return string.Equals(uri.Host, "music.apple.com", StringComparison.OrdinalIgnoreCase);
}
static bool IsProfileUrl(string value)
{
if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri))
return false;
return string.Equals(uri.Host, "music.apple.com", StringComparison.OrdinalIgnoreCase)
&& uri.AbsolutePath.StartsWith("/profile/", StringComparison.OrdinalIgnoreCase);
}
// Анонимный developer-JWT лежит в бандле /assets/index~*.js главной страницы;
// Apple выпускает его на ~месяц — кэшируем сутки и сбрасываем на 401.
static async Task<string> GetDeveloperTokenAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(developerToken) && developerTokenExpiresAt > DateTime.UtcNow)
return developerToken;
await developerTokenLock.WaitAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(developerToken) && developerTokenExpiresAt > DateTime.UtcNow)
return developerToken;
string html = await FetchTextAsync("https://music.apple.com/us/browse", "text/html,application/xhtml+xml", cancellationToken);
var bundleMatch = assetBundleRegex.Match(html ?? string.Empty);
if (!bundleMatch.Success)
return null;
string script = await FetchTextAsync("https://music.apple.com" + bundleMatch.Groups[1].Value, "application/javascript,*/*", cancellationToken);
var tokenMatch = developerTokenRegex.Match(script ?? string.Empty);
if (!tokenMatch.Success)
return null;
developerToken = tokenMatch.Groups[1].Value;
developerTokenExpiresAt = DateTime.UtcNow.AddHours(24);
return developerToken;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
finally
{
developerTokenLock.Release();
}
}
static void InvalidateDeveloperToken()
{
developerToken = null;
developerTokenExpiresAt = DateTime.MinValue;
}
static async Task<string> FetchTextAsync(string url, string accept, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", accept);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
return await response.Content.ReadAsStringAsync(cancellationToken);
}
// сам берёт токен из кэша; на 401 сбрасывает его и повторяет запрос
// один раз со свежим — протухший developer-токен не роняет импорт/sync
static async Task<JsonElement?> ApiAsync(string path, CancellationToken cancellationToken)
{
for (int attempt = 0; attempt < 2; attempt++)
{
string token = await GetDeveloperTokenAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(token))
return null;
using var request = new HttpRequestMessage(HttpMethod.Get, ApiBaseUrl + path);
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
request.Headers.TryAddWithoutValidation("Origin", "https://music.apple.com");
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "application/json");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
InvalidateDeveloperToken();
Console.WriteLine("[Music] apple music developer token expired");
continue;
}
if (!response.IsSuccessStatusCode)
return null;
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
return null;
}
static async Task<MusicUserPlaylistImportResult> ImportPlaylistByIdAsync(string storefront, string playlistId, CancellationToken cancellationToken)
{
// мета плейлиста ради имени; треки из relationships не берём —
// единообразно листаем /tracks с нуля (одна лишняя страница, зато один путь)
var meta = await ApiAsync($"/v1/catalog/{storefront}/playlists/{Uri.EscapeDataString(playlistId)}", cancellationToken);
var playlist = FirstDataElement(meta);
if (playlist == null)
return ImportUnavailable("Apple Music плейлист не найден или приватный.");
string title = GetString(GetProperty(playlist.Value, "attributes") ?? default, "name");
var tracks = new List<MusicTrack>();
for (int page = 0; page < MaxPages && tracks.Count < MaxImportTracks; page++)
{
var root = await ApiAsync($"/v1/catalog/{storefront}/playlists/{Uri.EscapeDataString(playlistId)}/tracks?limit={PageLimit}&offset={page * PageLimit}", cancellationToken);
if (root == null)
{
// страница не отдалась — импорт атомарный, усечёнку не сохраняем
return page == 0
? ImportUnavailable("Apple Music плейлист не удалось прочитать.")
: ImportUnavailable("Apple Music не отдал плейлист целиком, попробуй ещё раз.");
}
int pageItems = MapTrackPage(root.Value, tracks, albumImagesFallback: null, albumTitleFallback: null, dateFallback: null);
bool hasNext = GetProperty(root.Value, "next") != null;
if (pageItems == 0 || !hasNext)
break;
}
tracks = DeduplicateTracks(tracks);
if (tracks.Count == 0)
return ImportUnavailable("В Apple Music плейлисте не найдено треков.");
title = string.IsNullOrWhiteSpace(title) ? "Apple Music Playlist" : title.Trim();
return new MusicUserPlaylistImportResult
{
available = true,
title = title,
track_count = tracks.Count,
truncated = tracks.Count >= MaxImportTracks,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = PlaylistSourceType,
url = $"https://music.apple.com/{storefront}/playlist/_/{playlistId}",
playlist_id = playlistId,
title = title
}
};
}
static async Task<MusicUserPlaylistImportResult> ImportAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken)
{
var loaded = await LoadCatalogAlbumAsync(storefront, albumId, cancellationToken);
var album = loaded.album;
if (album == null)
return ImportUnavailable(loaded.error ?? "Apple Music альбом не найден.");
string playlistTitle = string.IsNullOrWhiteSpace(album.artist_name) ? album.title : $"{album.artist_name} — {album.title}";
return new MusicUserPlaylistImportResult
{
available = true,
title = playlistTitle,
track_count = album.tracks.Count,
tracks = album.tracks,
source = new MusicUserPlaylistSource
{
type = AlbumSourceType,
url = $"https://music.apple.com/{storefront}/album/_/{albumId}",
playlist_id = albumId,
title = playlistTitle
}
};
}
static async Task<(MusicAlbum album, string error)> LoadCatalogAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken)
{
storefront = storefront?.Trim().ToLowerInvariant();
albumId = albumId?.Trim();
if (!Regex.IsMatch(storefront ?? string.Empty, "^[a-z]{2}$") || !Regex.IsMatch(albumId ?? string.Empty, "^[0-9]+$"))
return (null, "Некорректный Apple Music album ID.");
var meta = await ApiAsync($"/v1/catalog/{storefront}/albums/{Uri.EscapeDataString(albumId)}", cancellationToken);
var albumElement = FirstDataElement(meta);
if (albumElement == null)
return (null, "Apple Music альбом не найден.");
var attributes = GetProperty(albumElement.Value, "attributes");
string title = attributes != null ? GetString(attributes.Value, "name") : null;
string artistName = attributes != null ? GetString(attributes.Value, "artistName") : null;
string date = attributes != null ? GetString(attributes.Value, "releaseDate") : null;
var albumImages = attributes != null ? MapArtwork(GetProperty(attributes.Value, "artwork")) : null;
var tracks = new List<MusicTrack>();
int fetched = 0;
// первая порция треков приезжает в relationships, остальное — пагинацией;
// offset считаем по сырому числу элементов (не по замапленным — часть фильтруется)
var relationshipTracks = GetProperty(albumElement.Value, "relationships", "tracks");
bool hasNext = false;
if (relationshipTracks != null)
{
fetched += MapTrackPage(relationshipTracks.Value, tracks, albumImages, title, date);
hasNext = GetProperty(relationshipTracks.Value, "next") != null;
}
for (int page = 1; hasNext && page < MaxPages && tracks.Count < MaxImportTracks; page++)
{
var root = await ApiAsync($"/v1/catalog/{storefront}/albums/{Uri.EscapeDataString(albumId)}/tracks?limit={PageLimit}&offset={fetched}", cancellationToken);
if (root == null)
return (null, "Apple Music не отдал альбом целиком, попробуй ещё раз.");
int pageItems = MapTrackPage(root.Value, tracks, albumImages, title, date);
fetched += pageItems;
hasNext = GetProperty(root.Value, "next") != null && pageItems > 0;
}
tracks = DeduplicateTracks(tracks);
if (tracks.Count == 0)
return (null, "В Apple Music альбоме не найдено треков.");
title = string.IsNullOrWhiteSpace(title) ? "Apple Music Album" : title.Trim();
string canonicalId = $"applemusic:album:{storefront}:{albumId}";
int? year = date?.Length >= 4 && int.TryParse(date[..4], out int parsedYear) ? parsedYear : null;
foreach (var track in tracks)
track.album_id = canonicalId;
return (new MusicAlbum
{
title = title,
id = canonicalId,
artist_name = string.IsNullOrWhiteSpace(artistName) ? "Apple Music" : artistName.Trim(),
date = date,
year = year,
type = "Album",
images = albumImages ?? new List<MusicImage>(),
tracks = tracks,
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = albumId }
}
}, null);
}
// маппит data[] страницы (плейлист-треки или альбом-треки); возвращает СЫРОЕ
// число элементов страницы — по нему считается offset (часть элементов фильтруется)
static int MapTrackPage(JsonElement root, List<MusicTrack> tracks, List<MusicImage> albumImagesFallback, string albumTitleFallback, string dateFallback)
{
if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
return 0;
int rawCount = 0;
foreach (var item in data.EnumerateArray())
{
rawCount++;
// в плейлистах бывают music-videos — берём только песни
string kind = GetString(item, "type");
if (!string.IsNullOrWhiteSpace(kind) && !string.Equals(kind, "songs", StringComparison.OrdinalIgnoreCase))
continue;
var mapped = MapTrackElement(item, albumImagesFallback, albumTitleFallback, dateFallback);
if (mapped != null)
tracks.Add(mapped);
}
return rawCount;
}
static MusicTrack MapTrackElement(JsonElement item, List<MusicImage> albumImagesFallback, string albumTitleFallback, string dateFallback)
{
var attributes = GetProperty(item, "attributes");
if (attributes == null)
return null;
string title = GetString(attributes.Value, "name")?.Trim();
string trackId = GetString(item, "id");
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(trackId))
return null;
string artistName = GetString(attributes.Value, "artistName")?.Trim();
var images = MapArtwork(GetProperty(attributes.Value, "artwork"));
if (images.Count == 0 && albumImagesFallback != null)
images = albumImagesFallback.ToList();
return new MusicTrack
{
id = $"applemusic:track:{trackId}",
title = title,
artist_name = string.IsNullOrWhiteSpace(artistName) ? "Apple Music" : artistName,
artists = string.IsNullOrWhiteSpace(artistName) ? new List<string>() : new List<string> { artistName },
album_title = GetString(attributes.Value, "albumName")?.Trim() ?? albumTitleFallback,
isrc = MusicIsrc.Normalize(GetString(attributes.Value, "isrc")),
duration_ms = GetInt(attributes.Value, "durationInMillis"),
track_number = GetInt(attributes.Value, "trackNumber"),
disc_number = GetInt(attributes.Value, "discNumber"),
date = GetString(attributes.Value, "releaseDate") ?? dateFallback,
images = images,
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = trackId }
}
};
}
// artwork.url — шаблон с {w}x{h}; просим два размера, крупный первым
static List<MusicImage> MapArtwork(JsonElement? artwork)
{
var result = new List<MusicImage>();
string template = artwork != null ? GetString(artwork.Value, "url") : null;
if (string.IsNullOrWhiteSpace(template) || !template.Contains("{w}", StringComparison.OrdinalIgnoreCase))
return result;
foreach (int size in new[] { 600, 300 })
{
result.Add(new MusicImage
{
url = template.Replace("{w}", size.ToString()).Replace("{h}", size.ToString()),
width = size,
height = size
});
}
return result;
}
static JsonElement? FirstDataElement(JsonElement? root)
{
if (root == null || !root.Value.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array || data.GetArrayLength() == 0)
return null;
return data[0];
}
}
@@ -0,0 +1,62 @@
namespace Music;
public class SefonAudioProvider : IMusicAudioProvider
{
public string Id => SefonSupport.ProviderId;
public string Name => "Sefon";
public bool Enabled => ModInit.conf?.sefon_audio_enabled == true;
public bool RequiresAuth => false;
public bool CacheMissingMatches => true;
public async Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (track == null || MusicPlaybackModeService.IsVideo(playbackMode))
return Array.Empty<MusicAudioMatch>();
try
{
return await SefonSupport.SearchAsync(track, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new MusicAudioTransientException(Id, "match", ex);
}
}
public Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (match == null || MusicPlaybackModeService.IsVideo(playbackMode))
return Task.FromResult<IReadOnlyList<MusicPlaybackSource>>(Array.Empty<MusicPlaybackSource>());
return Task.FromResult(SefonSupport.BuildSources(match));
}
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
return Task.FromResult<MusicPlaybackSource>(null);
}
public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
}
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
{
return SefonSupport.IsRelevantMatch(track, match);
}
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
{
return false;
}
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
{
return Array.Empty<string>();
}
}
@@ -0,0 +1,672 @@
using System.Net;
using System.Net.Http;
using System.Text.RegularExpressions;
using Shared.Services.HTML;
namespace Music;
internal sealed class SefonMatchPayload
{
public string stream_url { get; set; }
public string stream_direct_url { get; set; }
public string track_url { get; set; }
public string referer { get; set; }
}
internal static class SefonSupport
{
public const string ProviderId = "sefonaudio";
public const string BaseUrl = "https://sefon.org";
const int MaxSearchQueries = 3;
static readonly HttpClient httpClient = MusicHttp.CreateClient("sefon");
static readonly string[] SoftPenaltyWords =
{
"nightcore", "remix", "cover", "karaoke", "instrumental", "sped up", "slowed", "speed up",
"bass boosted", "deep", "car and bass", "edit", "mashup", "bootleg", "live", "demo"
};
static readonly string[] HardPenaltyWords =
{
"рингтон", "ringtone", "припев", "pripev", "припiв", "snippet", "тизер", "teaser",
"минус", "acapella", "акапелла"
};
static readonly string[] AlbumNoiseWords =
{
"www.", ".ru", ".com", "muzmix", "mfm", "freshmp3music", "not entered tracks"
};
static SefonSupport()
{
httpClient.Timeout = TimeSpan.FromSeconds(3);
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7");
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Referer", $"{BaseUrl}/");
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Origin", BaseUrl);
}
public static string BuildTrackQuery(MusicTrack track)
{
return BuildTrackQueries(track).FirstOrDefault() ?? string.Empty;
}
public static async Task<List<MusicAudioMatch>> SearchAsync(MusicTrack track, CancellationToken cancellationToken = default)
{
var queries = BuildTrackQueries(track).Take(MaxSearchQueries).ToList();
if (queries.Count == 0)
return new List<MusicAudioMatch>();
var merged = new Dictionary<string, MusicAudioMatch>(StringComparer.OrdinalIgnoreCase);
var tasks = queries
.Select(query => SearchQueryResultAsync(query, cancellationToken))
.ToList();
var batches = await Task.WhenAll(tasks);
foreach (var batch in batches)
{
foreach (var match in batch.matches)
{
if (match == null || string.IsNullOrWhiteSpace(match.id))
continue;
merged[match.id] = match;
}
}
var ranked = RankMatches(track, merged.Values)
.Where(match => IsRelevantMatch(track, match))
.Take(5)
.ToList();
if (ranked.Count == 0)
{
var failure = batches.FirstOrDefault(i => i.error != null).error;
if (failure != null)
throw failure;
}
return ranked;
}
public static async Task<List<MusicTrack>> SearchTracksByQueryAsync(string query, int limit = 10, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
return new List<MusicTrack>();
var matches = await SearchQuerySafeAsync(query.Trim(), cancellationToken);
if (matches.Count == 0)
return new List<MusicTrack>();
return matches
.Select(match => new { match, score = ScoreLooseQueryMatch(query, match) })
.Where(item => item.score > 0)
.OrderByDescending(item => item.score)
.ThenBy(item => item.match.title)
.Take(Math.Max(1, limit))
.Select(item => MapTrack(item.match))
.Where(track => track != null)
.ToList();
}
public static bool IsRelevantMatch(MusicTrack query, MusicAudioMatch match)
{
if (query == null || match == null)
return false;
if (ScoreMatch(query, match) < 10)
return false;
if (!HasArtistOverlap(query, match))
return false;
if (query.duration_ms.HasValue && match.duration_ms.HasValue)
{
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
if (diff > 90000)
return false;
}
return true;
}
static async Task<List<MusicAudioMatch>> SearchQueryAsync(string query, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(query))
return new List<MusicAudioMatch>();
string url = $"{BaseUrl}/song/{Uri.EscapeDataString(query)}";
using var response = await httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (MusicHttp.IsTransientFailureStatus(response.StatusCode))
throw new HttpRequestException($"Sefon returned {(int)response.StatusCode}.", null, response.StatusCode);
return new List<MusicAudioMatch>();
}
string html = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(html))
return new List<MusicAudioMatch>();
var nodes = HtmlParse.Nodes(html, "//div[contains(concat(' ', normalize-space(@class), ' '), ' muzmo-track ') and contains(concat(' ', normalize-space(@class), ' '), ' track-item ')]");
if (nodes.Count == 0)
return new List<MusicAudioMatch>();
var matches = new List<MusicAudioMatch>();
foreach (var node in nodes)
{
string streamPath = node.SelectText(string.Empty, "data-file");
string title = WebUtility.HtmlDecode(node.SelectText(string.Empty, "data-track-title") ?? string.Empty).Trim();
string artist = WebUtility.HtmlDecode(node.SelectText(string.Empty, "data-artist") ?? string.Empty).Trim();
string trackId = node.SelectText(string.Empty, "data-tid");
string trackUrl = node.SelectText(".//a[contains(concat(' ', normalize-space(@class), ' '), ' muzmo-track__title ')]", "href");
string albumTitle = CleanAlbumTitle(node.SelectText(".//span[contains(concat(' ', normalize-space(@class), ' '), ' short-track__info-album ')]"));
int? durationMs = ParseDurationMs(node.SelectText(".//span[contains(concat(' ', normalize-space(@class), ' '), ' short-track__time ')]"));
if (string.IsNullOrWhiteSpace(streamPath) || string.IsNullOrWhiteSpace(title))
continue;
string streamUrl = BuildAbsoluteUrl(streamPath);
if (string.IsNullOrWhiteSpace(streamUrl))
continue;
string directStreamUrl = TryDecodeDirectStreamUrl(streamUrl);
matches.Add(new MusicAudioMatch
{
provider_id = ProviderId,
id = !string.IsNullOrWhiteSpace(trackId) ? trackId : streamUrl,
title = NormalizeMatchTitle(title, artist),
artists = string.IsNullOrWhiteSpace(artist) ? new List<string>() : new List<string> { artist },
album_title = albumTitle,
duration_ms = durationMs,
payload = MusicJson.Serialize(new SefonMatchPayload
{
stream_url = streamUrl,
stream_direct_url = directStreamUrl,
track_url = BuildAbsoluteUrl(trackUrl),
referer = $"{BaseUrl}/"
})
});
}
return matches;
}
static async Task<List<MusicAudioMatch>> SearchQuerySafeAsync(string query, CancellationToken cancellationToken)
{
try
{
return await SearchQueryAsync(query, cancellationToken);
}
catch
{
return new List<MusicAudioMatch>();
}
}
static async Task<(List<MusicAudioMatch> matches, Exception error)> SearchQueryResultAsync(string query, CancellationToken cancellationToken)
{
try
{
return (await SearchQueryAsync(query, cancellationToken), null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return (new List<MusicAudioMatch>(), ex);
}
}
public static IReadOnlyList<MusicPlaybackSource> BuildSources(MusicAudioMatch match)
{
var payload = string.IsNullOrWhiteSpace(match?.payload)
? null
: MusicJson.Deserialize<SefonMatchPayload>(match.payload);
string sourceUrl = !string.IsNullOrWhiteSpace(payload?.stream_direct_url)
? payload.stream_direct_url
: payload?.stream_url;
if (string.IsNullOrWhiteSpace(sourceUrl))
return Array.Empty<MusicPlaybackSource>();
var headers = new Dictionary<string, string>();
if (string.IsNullOrWhiteSpace(payload?.stream_direct_url))
{
headers["Referer"] = string.IsNullOrWhiteSpace(payload.referer) ? $"{BaseUrl}/" : payload.referer;
headers["Origin"] = BaseUrl;
}
return new List<MusicPlaybackSource>
{
new()
{
provider_id = ProviderId,
url = sourceUrl,
mime_type = "audio/mpeg",
quality = "MP3",
headers = headers
}
};
}
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches)
{
return matches
.Select(match => new { match, score = ScoreMatch(query, match) })
.Where(i => i.score > -18)
.OrderByDescending(i => i.score)
.ThenBy(i => i.match.title)
.Select(i => i.match)
.ToList();
}
static int ScoreMatch(MusicTrack query, MusicAudioMatch match)
{
string title = NormalizeText(match?.title);
string album = NormalizeText(match?.album_title);
string artist = NormalizeText(match?.artists.FirstOrDefault());
string lowered = $"{title} {artist} {album}";
string queryAlbum = NormalizeText(query?.album_title);
var queryTitles = BuildNormalizedTitleVariants(query?.title);
var queryArtists = NormalizeArtistFragments(query?.artist_name);
string queryPrimaryArtist = queryArtists.FirstOrDefault() ?? string.Empty;
var matchArtists = NormalizeArtistFragments(match?.artists.FirstOrDefault());
int score = 0;
if (queryTitles.Count > 0)
{
if (queryTitles.Any(i => title == i))
score += 16;
else if (queryTitles.Any(i => !string.IsNullOrWhiteSpace(i) && title.Contains(i)))
score += 10;
else if (queryTitles.Any(i => AllTokensPresent(i, title)))
score += 5;
else
score -= 18;
}
if (!string.IsNullOrWhiteSpace(queryPrimaryArtist))
{
if (matchArtists.Contains(queryPrimaryArtist) || artist == queryPrimaryArtist)
score += 12;
else if (artist.Contains(queryPrimaryArtist))
score += 7;
else
score -= 12;
if (matchArtists.FirstOrDefault() == queryPrimaryArtist)
score += 4;
}
foreach (var queryArtist in queryArtists.Skip(1).Take(3))
{
if (matchArtists.Contains(queryArtist) || artist.Contains(queryArtist))
score += 3;
}
if (!string.IsNullOrWhiteSpace(queryAlbum) && !string.IsNullOrWhiteSpace(album))
{
if (album == queryAlbum)
score += 3;
else if (album.Contains(queryAlbum) || queryAlbum.Contains(album))
score += 1;
}
if (query?.duration_ms.HasValue == true && query.duration_ms.Value > 0 && match?.duration_ms.HasValue == true)
{
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
if (diff <= 3000)
score += 8;
else if (diff <= 10000)
score += 5;
else if (diff <= 20000)
score += 2;
else if (diff > 120000)
score -= 20;
else if (diff > 60000)
score -= 12;
else if (diff > 30000)
score -= 6;
}
else if (match?.duration_ms.HasValue == true && match.duration_ms.Value > 0 && !IsShortTrackQuery(queryTitles))
{
if (match.duration_ms.Value < 90000)
score -= 14;
else if (match.duration_ms.Value < 120000)
score -= 8;
}
if (HardPenaltyWords.Any(i => lowered.Contains(i)))
score -= 18;
if (SoftPenaltyWords.Any(i => lowered.Contains(i)))
score -= 6;
if (AlbumNoiseWords.Any(i => album.Contains(i)))
score -= 4;
if (artist.Contains("www") || artist.Contains(".ru") || artist.Contains(".com"))
score -= 4;
if (title.Length > 140)
score -= 3;
return score;
}
static int ScoreLooseQueryMatch(string query, MusicAudioMatch match)
{
string expected = NormalizeText(query);
if (string.IsNullOrWhiteSpace(expected) || match == null)
return 0;
string title = NormalizeText(match.title);
string artist = NormalizeText(match.artists.FirstOrDefault());
string album = NormalizeText(match.album_title);
string joined = $"{artist} {title} {album}".Trim();
int score = 0;
if (joined == expected)
score += 80;
else if (!string.IsNullOrWhiteSpace(title) && title == expected)
score += 70;
else if (!string.IsNullOrWhiteSpace(joined) && joined.Contains(expected))
score += 50;
else if (AllTokensPresent(expected, joined))
score += 35;
if (!string.IsNullOrWhiteSpace(title) && title.Contains(expected))
score += 10;
if (HardPenaltyWords.Any(i => title.Contains(i)) || HardPenaltyWords.Any(i => album.Contains(i)))
score -= 40;
if (SoftPenaltyWords.Any(i => title.Contains(i)) || SoftPenaltyWords.Any(i => album.Contains(i)))
score -= 18;
return score;
}
static MusicTrack MapTrack(MusicAudioMatch match)
{
if (match == null || string.IsNullOrWhiteSpace(match.title))
return null;
string artistName = match.artists?.FirstOrDefault() ?? string.Empty;
return new MusicTrack
{
id = $"sefon:search:{match.id}",
title = match.title,
artist_name = artistName,
artists = string.IsNullOrWhiteSpace(artistName) ? new List<string>() : new List<string> { artistName },
album_title = match.album_title,
duration_ms = match.duration_ms
};
}
static bool HasArtistOverlap(MusicTrack query, MusicAudioMatch match)
{
var queryArtists = NormalizeArtistFragments(query?.artist_name);
if (queryArtists.Count == 0)
return true;
var matchArtists = NormalizeArtistFragments(match?.artists.FirstOrDefault());
string normalizedArtist = NormalizeText(match?.artists.FirstOrDefault());
foreach (var queryArtist in queryArtists)
{
if (string.IsNullOrWhiteSpace(queryArtist))
continue;
if (matchArtists.Contains(queryArtist) || normalizedArtist.Contains(queryArtist))
return true;
}
return false;
}
static string BuildAbsoluteUrl(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
value = WebUtility.HtmlDecode(value.Trim());
if (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return value;
if (!value.StartsWith("/"))
value = "/" + value;
return BaseUrl + value;
}
static string TryDecodeDirectStreamUrl(string streamUrl)
{
if (string.IsNullOrWhiteSpace(streamUrl))
return null;
const string marker = "/stream/mym/";
int markerIndex = streamUrl.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0)
return null;
string encoded = streamUrl[(markerIndex + marker.Length)..].Trim();
if (string.IsNullOrWhiteSpace(encoded))
return null;
try
{
string padded = encoded.Replace('-', '+').Replace('_', '/');
int mod = padded.Length % 4;
if (mod > 0)
padded = padded.PadRight(padded.Length + (4 - mod), '=');
string decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(padded));
if (Uri.TryCreate(decoded, UriKind.Absolute, out var uri) &&
(string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
{
return uri.ToString();
}
}
catch
{
}
return null;
}
static string CleanAlbumTitle(string value)
{
value = WebUtility.HtmlDecode(value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(value))
return null;
if (string.Equals(value, "Not entered tracks", StringComparison.OrdinalIgnoreCase))
return null;
return value.Trim().Trim('/');
}
static string NormalizeMatchTitle(string title, string artist)
{
title = WebUtility.HtmlDecode(title ?? string.Empty).Trim();
artist = WebUtility.HtmlDecode(artist ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(title))
return title;
if (string.IsNullOrWhiteSpace(artist))
return title;
string normalizedTitle = NormalizeText(title);
string normalizedArtist = NormalizeText(artist);
if (string.IsNullOrWhiteSpace(normalizedTitle) || string.IsNullOrWhiteSpace(normalizedArtist))
return title;
if (normalizedTitle == normalizedArtist)
return title;
var prefixPattern = $"^{Regex.Escape(artist)}\\s*[-–—:]\\s*";
return Regex.Replace(title, prefixPattern, string.Empty, RegexOptions.IgnoreCase).Trim();
}
static int? ParseDurationMs(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var parts = value.Trim().Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 2 &&
int.TryParse(parts[0], out int minutes) &&
int.TryParse(parts[1], out int seconds))
{
return ((minutes * 60) + seconds) * 1000;
}
if (parts.Length == 3 &&
int.TryParse(parts[0], out int hours) &&
int.TryParse(parts[1], out minutes) &&
int.TryParse(parts[2], out seconds))
{
return ((hours * 3600) + (minutes * 60) + seconds) * 1000;
}
return null;
}
static bool AllTokensPresent(string query, string value)
{
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(value))
return false;
var tokens = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return tokens.Length > 0 && tokens.All(value.Contains);
}
static List<string> BuildTrackQueries(MusicTrack track)
{
var queries = new List<string>();
string title = CleanQueryValue(track?.title);
string titlePrimary = CleanQueryValue(StripTitleDecorations(track?.title));
string artistPrimary = CleanQueryValue(ExtractPrimaryArtist(track?.artist_name));
string artistFull = CleanQueryValue(track?.artist_name);
void AddQuery(string artist, string trackTitle)
{
var parts = new[] { artist, trackTitle }
.Where(i => !string.IsNullOrWhiteSpace(i))
.ToArray();
if (parts.Length == 0)
return;
string value = string.Join(" ", parts).Trim();
if (!string.IsNullOrWhiteSpace(value) && queries.All(i => !string.Equals(i, value, StringComparison.OrdinalIgnoreCase)))
queries.Add(value);
}
AddQuery(artistPrimary, titlePrimary);
AddQuery(artistFull, titlePrimary);
if (!string.Equals(titlePrimary, title, StringComparison.OrdinalIgnoreCase))
{
AddQuery(artistPrimary, title);
AddQuery(artistFull, title);
}
AddQuery(string.Empty, titlePrimary);
return queries;
}
static List<string> BuildNormalizedTitleVariants(string value)
{
var variants = new List<string>();
void Add(string next)
{
next = NormalizeText(next);
if (!string.IsNullOrWhiteSpace(next) && variants.All(i => i != next))
variants.Add(next);
}
Add(value);
Add(StripTitleDecorations(value));
return variants;
}
static bool IsShortTrackQuery(List<string> normalizedTitles)
{
return normalizedTitles.Any(i =>
i.Contains("intro")
|| i.Contains("outro")
|| i.Contains("interlude")
|| i.Contains("skit")
|| i.Contains("snippet"));
}
static List<string> NormalizeArtistFragments(string value)
{
if (string.IsNullOrWhiteSpace(value))
return new List<string>();
string normalized = NormalizeText(value);
normalized = Regex.Replace(normalized, @"\b(feat|ft|featuring|prod|with|and|x|vs|и)\b", "|");
normalized = Regex.Replace(normalized, @"\s+-\s+", "|");
normalized = normalized.Replace("&", "|").Replace(",", "|");
return normalized
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(CleanQueryValue)
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
static string ExtractPrimaryArtist(string value)
{
return NormalizeArtistFragments(value).FirstOrDefault() ?? value;
}
static string StripTitleDecorations(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = WebUtility.HtmlDecode(value).Trim();
value = Regex.Replace(value, @"\(([^)]*(feat|ft|live|remix|version|edit|explicit|clean|radio)[^)]*)\)", string.Empty, RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"\[([^\]]*(feat|ft|live|remix|version|edit|explicit|clean|radio)[^\]]*)\]", string.Empty, RegexOptions.IgnoreCase);
return Regex.Replace(value, @"\s+", " ").Trim();
}
static string CleanQueryValue(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = WebUtility.HtmlDecode(value).Trim();
value = Regex.Replace(value, @"\s+", " ");
return value.Trim(' ', '-', '–', '—', ',', ';', ':');
}
static string NormalizeText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = WebUtility.HtmlDecode(value).ToLowerInvariant();
value = Regex.Replace(value, @"[\(\)\[\]\{\}\-_]+", " ");
value = Regex.Replace(value, @"[^0-9a-zа-яёіїєґ\s]+", " ");
return Regex.Replace(value, @"\s+", " ").Trim();
}
}
@@ -0,0 +1,87 @@
namespace Music;
public class SoundCloudAudioProvider : IMusicAudioProvider
{
public string Id => SoundCloudSupport.AudioProviderId;
public string Name => "SoundCloud";
public bool Enabled => SoundCloudSupport.IsAudioEnabled;
public bool RequiresAuth => false;
public bool CacheMissingMatches => false;
public async Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (track == null || MusicPlaybackModeService.IsVideo(playbackMode))
return Array.Empty<MusicAudioMatch>();
try
{
return await SoundCloudSupport.SearchAudioAsync(track, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new MusicAudioTransientException(Id, "match", ex);
}
}
public async Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (match == null || MusicPlaybackModeService.IsVideo(playbackMode))
return Array.Empty<MusicPlaybackSource>();
try
{
return await SoundCloudSupport.BuildAudioSourcesAsync(match, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new MusicAudioTransientException(Id, "stream", ex);
}
}
public async Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (match == null || MusicPlaybackModeService.IsVideo(playbackMode))
return null;
try
{
return await SoundCloudSupport.BuildPreferredAudioSourceAsync(match, cancellationToken);
}
catch
{
return null;
}
}
public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
}
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
{
return SoundCloudSupport.IsRelevantAudioMatch(track, match);
}
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
{
return SoundCloudSupport.HasExactTrackId(track);
}
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
{
return new[]
{
SefonSupport.ProviderId,
YouTubeMusicSearchSupport.ProviderId
};
}
}
@@ -0,0 +1,361 @@
using System.Collections.Concurrent;
using YoutubeExplode;
using YoutubeExplode.Search;
using YoutubeExplode.Videos.Streams;
namespace Music;
public class YouTubeAudioProvider : IMusicAudioProvider
{
// спекулятивный манифест: после первой пачки поиска манифест
// промежуточного топ-кандидата тянется ПАРАЛЛЕЛЬНО со второй пачкой —
// победитель ранжирования после неё почти никогда не меняется, и
// GetStreamsAsync забирает готовый результат вместо ещё одного ~1.5s
// запроса. Промах (победитель сменился/спекуляция упала) — обычный путь.
static readonly ConcurrentDictionary<string, (DateTime at, Task<ResolvedManifest> task)> speculativeManifests = new();
static readonly TimeSpan speculativeManifestTtl = TimeSpan.FromMinutes(2);
sealed record ResolvedManifest(StreamManifest Manifest, MusicProxyLease ProxyLease);
public string Id => "youtubeaudio";
public string Name => "YouTube Audio";
public bool Enabled => ModInit.conf?.youtube_audio_enabled == true;
public bool RequiresAuth => false;
public bool CacheMissingMatches => false;
public async Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (track == null)
return Array.Empty<MusicAudioMatch>();
var queries = YouTubeAudioSupport.BuildTrackQueries(track, playbackMode);
var directMatch = YouTubeAudioSupport.BuildDirectMatch(track);
if (queries.Count == 0)
return directMatch != null ? new[] { directMatch } : Array.Empty<MusicAudioMatch>();
try
{
var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api);
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
var results = new List<VideoSearchResult>();
var seen = new HashSet<string>(StringComparer.Ordinal);
// batch-parallel: пачки по 3 запроса параллельно (холодный резолв был
// суммой 3-6 последовательных поисков по 1-2s = главная часть 5-16s
// ожидания /music/play). Merge строго в порядке исходных queries —
// вход ранжирования тот же, что у последовательного цикла; dedupe и
// кап 36 на слиянии; набрали кап — следующая пачка не запускается
bool speculated = false;
for (int batchStart = 0; batchStart < queries.Count && results.Count < 36; batchStart += 3)
{
var batchTasks = queries
.Skip(batchStart)
.Take(3)
.Select(query => FetchQueryResultsAsync(youtube, query, cancellationToken))
.ToList();
await Task.WhenAll(batchTasks);
foreach (var batchTask in batchTasks)
{
foreach (var video in batchTask.Result)
{
var videoId = video.Id.Value;
if (!string.IsNullOrWhiteSpace(videoId) && seen.Add(videoId))
results.Add(video);
if (results.Count >= 36)
break;
}
if (results.Count >= 36)
break;
}
// впереди ещё пачка — после первой пачки уже часто есть
// очевидный победитель. Если он проходит строгий гейт
// уверенности, не жжём вторую пачку YouTube-поисков вообще.
// Манифест проверяем до раннего выхода: если топ-кандидат
// оказался без стримов, сохраняем старую полную fallback-схему.
if (directMatch == null && batchStart == 0 && batchStart + 3 < queries.Count && results.Count < 36)
{
var earlyRanked = YouTubeAudioSupport.RankMatches(track, YouTubeAudioSupport.ConvertSearchResults(results), playbackMode);
var earlyTop = earlyRanked.FirstOrDefault();
if (YouTubeAudioSupport.IsConfidentEarlyMatch(track, earlyTop))
{
try
{
var manifestTask = StartSpeculativeManifest(earlyTop.id);
if (manifestTask != null)
{
await manifestTask.WaitAsync(cancellationToken);
proxyLease.Success();
return earlyRanked;
}
}
catch (OperationCanceledException)
{
throw;
}
catch
{
// stream-less первый кандидат — не меняем поведение,
// продолжаем полный поиск и даём ранжированию шанс.
}
}
}
// впереди ещё пачка — спекулятивно тянем манифест текущего
// фаворита (directMatch, если есть — резолвер пробует его первым)
if (!speculated && batchStart + 3 < queries.Count && results.Count < 36)
{
speculated = true;
var prelim = YouTubeAudioSupport.RankMatches(track, YouTubeAudioSupport.ConvertSearchResults(results), playbackMode);
StartSpeculativeManifest(directMatch?.id ?? prelim.FirstOrDefault()?.id);
}
}
var matches = YouTubeAudioSupport.ConvertSearchResults(results);
var ranked = YouTubeAudioSupport.RankMatches(track, matches, playbackMode);
proxyLease.Success();
if (directMatch == null)
return ranked;
var ordered = new List<MusicAudioMatch> { directMatch };
ordered.AddRange(ranked.Where(i => !string.Equals(i.id, directMatch.id, StringComparison.Ordinal)));
return ordered;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
if (MusicHttp.IsProxyFailure(ex))
MusicProxyService.ReportFailure($"Music:{Id}:api");
if (directMatch != null)
return new[] { directMatch };
throw new MusicAudioTransientException(Id, "match", ex);
}
}
// «Найти вручную» в Источниках: сырой пользовательский запрос — без
// переписывания, ранжирования и гейта релевантности, решает человек
public async Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
{
query = query?.Trim();
if (string.IsNullOrWhiteSpace(query))
return Array.Empty<MusicAudioMatch>();
try
{
var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api);
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
var results = new List<VideoSearchResult>();
await foreach (var video in youtube.Search.GetVideosAsync(query, cancellationToken))
{
results.Add(video);
if (results.Count >= 20)
break;
}
proxyLease.Success();
return YouTubeAudioSupport.ConvertSearchResults(results);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
if (MusicHttp.IsProxyFailure(ex))
MusicProxyService.ReportFailure($"Music:{Id}:api");
throw new MusicAudioTransientException(Id, "search", ex);
}
}
public async Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (match == null || string.IsNullOrWhiteSpace(match.id))
return Array.Empty<MusicPlaybackSource>();
for (int attempt = 0; attempt < 2; attempt++)
{
try
{
var resolved = attempt == 0 ? await TryTakeSpeculativeManifestAsync(match.id) : null;
resolved ??= await ResolveManifestAsync(match.id, cancellationToken);
if (MusicPlaybackModeService.IsVideo(playbackMode))
{
var videoStreams = resolved.Manifest.GetMuxedStreams();
var sources = YouTubeAudioSupport.ConvertVideoStreams(videoStreams);
foreach (var source in sources)
resolved.ProxyLease.ApplyTo(source, overwrite: true);
return sources;
}
var audioStreams = resolved.Manifest.GetAudioOnlyStreams();
var audioSources = YouTubeAudioSupport.ConvertAudioStreams(audioStreams);
foreach (var source in audioSources)
resolved.ProxyLease.ApplyTo(source, overwrite: true);
return audioSources;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
if (attempt == 0 && ShouldRetryManifestFailure(ex))
{
await Task.Delay(250, cancellationToken);
continue;
}
Console.WriteLine($"[Music] youtube stream manifest failed for {match.id}: {ex.GetType().Name}: {ex.Message}");
if (IsPermanentManifestFailure(ex))
return Array.Empty<MusicPlaybackSource>();
throw new MusicAudioTransientException(Id, "stream", ex);
}
}
return Array.Empty<MusicPlaybackSource>();
}
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
return Task.FromResult<MusicPlaybackSource>(null);
}
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
{
return YouTubeAudioSupport.IsRelevantMatch(track, match);
}
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
{
return false;
}
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
{
return Array.Empty<string>();
}
static Task<ResolvedManifest> StartSpeculativeManifest(string videoId)
{
if (string.IsNullOrWhiteSpace(videoId))
return null;
var proxyLease = MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
string cacheKey = BuildManifestCacheKey(videoId, proxyLease);
if (speculativeManifests.ContainsKey(cacheKey))
return speculativeManifests.TryGetValue(cacheKey, out var existing) ? existing.task : null;
// уборка протухших записей (непотреблённые спекуляции — промахи ранжирования)
foreach (var entry in speculativeManifests)
{
if (DateTime.UtcNow - entry.Value.at > speculativeManifestTtl)
speculativeManifests.TryRemove(entry.Key, out _);
}
// CancellationToken.None: спекуляция переживает отмену исходного запроса —
// результат заберёт следующий GetStreams по тому же видео в пределах TTL
var task = ResolveManifestAsync(videoId, CancellationToken.None, proxyLease);
speculativeManifests[cacheKey] = (DateTime.UtcNow, task);
_ = task.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
return task;
}
static async Task<ResolvedManifest> TryTakeSpeculativeManifestAsync(string videoId)
{
if (string.IsNullOrWhiteSpace(videoId))
return null;
var proxyLease = MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
string cacheKey = BuildManifestCacheKey(videoId, proxyLease);
if (!speculativeManifests.TryRemove(cacheKey, out var entry))
return null;
if (DateTime.UtcNow - entry.at > speculativeManifestTtl)
return null;
try
{
return await entry.task;
}
catch
{
// упавшая спекуляция не считается попыткой — обычный путь
// с его retry-семантикой отработает как раньше
return null;
}
}
static async Task<List<VideoSearchResult>> FetchQueryResultsAsync(YoutubeClient youtube, string query, CancellationToken cancellationToken)
{
var list = new List<VideoSearchResult>();
await foreach (var video in youtube.Search.GetVideosAsync(query, cancellationToken))
{
list.Add(video);
if (list.Count >= 16)
break;
}
return list;
}
static async Task<ResolvedManifest> ResolveManifestAsync(string videoId, CancellationToken cancellationToken, MusicProxyLease proxyLease = null)
{
proxyLease ??= MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
try
{
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
var manifest = await youtube.Videos.Streams.GetManifestAsync(videoId, cancellationToken);
proxyLease.Success();
return new ResolvedManifest(manifest, proxyLease);
}
catch (Exception ex)
{
if (proxyLease.Enabled && MusicHttp.IsProxyFailure(ex))
proxyLease.Failure();
throw;
}
}
static string BuildManifestCacheKey(string videoId, MusicProxyLease proxyLease)
{
string route = proxyLease?.Enabled == true
? $"{proxyLease.Scope}|{proxyLease.Data.ip}|{proxyLease.Data.username}"
: "direct";
return $"{videoId}|{route}|cfg:{MusicProxyService.ConfigurationVersion}";
}
static bool ShouldRetryManifestFailure(Exception ex)
{
return ex != null
&& ex.GetType().Name == "YoutubeExplodeException"
&& (ex.Message?.IndexOf("cipher manifest", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0;
}
static bool IsPermanentManifestFailure(Exception ex)
{
for (var current = ex; current != null; current = current.InnerException)
{
if (current.GetType().Name is "VideoUnavailableException" or "VideoUnplayableException")
return true;
}
return false;
}
}
@@ -0,0 +1,708 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using YoutubeExplode.Search;
using YoutubeExplode.Videos.Streams;
namespace Music;
internal static class YouTubeAudioSupport
{
static readonly string[] GenericTrackTitles =
{
"intro", "outro", "skit", "interlude", "theme", "song", "track"
};
static readonly string[] PenaltyWords =
{
"live", "karaoke", "cover", "reaction", "remix", "sped up", "slowed", "nightcore", "8d", "instrumental", "fan made", "tribute", "parody", "concert", "festival", "demo", "teaser", "choreo", "choreography", "dance"
};
static readonly string[] StrongVariantWords =
{
"instrumental", "karaoke", "drumless", "acapella", "a cappella"
};
static readonly string[] LiveVariantWords =
{
"live", "concert", "festival", "live performance", "performance",
"live session", "session", "sessions", "acoustic", "unplugged",
"tiny desk", "live lounge", "kexp", "boiler room", "city hall",
"royal albert hall", "madison square garden", "wembley"
};
static readonly string[] BonusWords =
{
"official audio", "official video", "audio", "topic", "provided to youtube", "auto-generated by youtube", "visualizer", "lyrics"
};
static readonly string[] EarlyExitPenaltyWords =
{
"live", "karaoke", "cover", "reaction", "remix", "sped up", "slowed", "nightcore", "8d", "instrumental", "fan made", "tribute", "parody", "concert", "festival", "demo", "teaser", "choreo", "choreography", "dance", "alternate", "version", "drumless", "acoustic", "unplugged", "remaster", "remastered", "edit", "sped", "reverb"
};
public static IReadOnlyList<string> BuildTrackQueries(MusicTrack track, string playbackMode = null)
{
string title = StripFeatureSegment(track?.title)?.Trim();
string album = track?.album_title?.Trim();
string artist = track?.artist_name?.Trim();
string normalizedTitle = NormalizeText(title);
string normalizedAlbum = NormalizeText(album);
bool preferVideo = MusicPlaybackModeService.IsVideo(playbackMode);
string primaryHint = preferVideo ? "official music video" : "official audio";
string secondaryHint = preferVideo ? "music video" : "audio";
var queryArtists = BuildQueryArtists(artist, track?.artists);
var queries = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
void add(string value)
{
value = value?.Trim();
if (string.IsNullOrWhiteSpace(value))
return;
var key = NormalizeText(value);
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
return;
queries.Add(value);
}
add(MusicIsrc.Normalize(track?.isrc));
if (queryArtists.Count == 0 || string.IsNullOrWhiteSpace(title))
return queries;
foreach (var queryArtist in queryArtists)
add($"{queryArtist} \"{title}\" {primaryHint}");
foreach (var queryArtist in queryArtists)
add($"{queryArtist} {title} {primaryHint}");
foreach (var queryArtist in queryArtists)
add($"{queryArtist} \"{title}\"");
foreach (var queryArtist in queryArtists)
add($"{queryArtist} {title}");
if (ShouldAppendAlbumHint(title) && !string.IsNullOrWhiteSpace(album) && normalizedAlbum != normalizedTitle)
add($"{artist} \"{title}\" {album}");
foreach (var queryArtist in queryArtists)
add($"{queryArtist} {title} {secondaryHint}");
return queries;
}
public static MusicAudioMatch BuildDirectMatch(MusicTrack track)
{
string videoId = ResolveVideoId(track);
if (string.IsNullOrWhiteSpace(videoId))
return null;
return new MusicAudioMatch
{
provider_id = "youtubeaudio",
id = videoId,
title = track?.title,
artists = !string.IsNullOrWhiteSpace(track?.artist_name) ? new List<string> { track.artist_name } : track?.artists ?? new List<string>(),
album_title = track?.album_title,
isrc = MusicIsrc.Normalize(track?.isrc),
duration_ms = track?.duration_ms,
payload = MusicJson.Serialize(new Dictionary<string, string>
{
["video_id"] = videoId
})
};
}
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches, string playbackMode = null)
{
string queryTitle = NormalizeTitleForMatch(query?.title);
string queryArtist = NormalizeText(query?.artist_name);
string queryAlbum = NormalizeText(query?.album_title);
string queryPenaltyContext = NormalizeText($"{query?.title} {query?.album_title}");
string queryArtistCompact = CompactText(queryArtist);
bool preferVideo = MusicPlaybackModeService.IsVideo(playbackMode);
return matches
.Select(match =>
{
int score = 0;
string rawTitle = NormalizeText(match.title);
string title = NormalizeTitleForMatch(match.title);
string primaryTitle = NormalizePrimaryTitleForMatch(match.title);
string album = NormalizeText(match.album_title);
string fullTitle = NormalizeText($"{StripFeatureSegment(match.title)} {match.album_title}");
string rawFullTitle = NormalizeText($"{match.title} {match.album_title}");
var artists = match.artists.Select(NormalizeText).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var titleMatch = EvaluateTitleMatch(queryTitle, title, rawFullTitle);
bool strongVariant = HasUnexpectedStrongVariant(queryPenaltyContext, rawFullTitle);
bool primaryTitleContainsQuery = !string.IsNullOrWhiteSpace(queryTitle)
&& !string.IsNullOrWhiteSpace(primaryTitle)
&& primaryTitle.Contains(queryTitle, StringComparison.Ordinal);
bool noisyTitleContainsQuery = !primaryTitleContainsQuery
&& !string.IsNullOrWhiteSpace(queryTitle)
&& !string.IsNullOrWhiteSpace(title)
&& title.Contains(queryTitle, StringComparison.Ordinal);
if (titleMatch.exact)
score += 14;
else if (primaryTitleContainsQuery)
score += 10;
else if (titleMatch.allTokens)
score += 8;
else if (titleMatch.coverage >= 0.5d)
score += 3;
else if (!string.IsNullOrWhiteSpace(queryTitle))
score -= 20;
if (!string.IsNullOrWhiteSpace(queryArtist) && !string.IsNullOrWhiteSpace(queryTitle) && rawFullTitle.Contains(queryArtist) && rawFullTitle.Contains(queryTitle))
score += 4;
// Если совпадение нашлось только в скобках/хвосте, это часто
// название альбома или подборки, а не самого трека.
if (noisyTitleContainsQuery)
score -= 10;
if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a == queryArtist))
score += 5;
else if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a.Contains(queryArtist) || queryArtist.Contains(a)))
score += 3;
if (!string.IsNullOrWhiteSpace(queryArtistCompact) && artists.Any(a =>
CompactText(a) == $"{queryArtistCompact}music"
|| CompactText(a) == $"{queryArtistCompact}vevo"))
{
score += 3;
}
if (!string.IsNullOrWhiteSpace(queryAlbum) && album.Contains(queryAlbum))
score += 1;
if (query.duration_ms.HasValue && match.duration_ms.HasValue)
{
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
if (diff <= 3000)
score += 4;
else if (diff <= 10000)
score += 2;
else if (diff > 90000)
score -= 12;
else if (diff > 45000)
score -= 6;
if (query.duration_ms.Value > 0 && match.duration_ms.Value > query.duration_ms.Value * 2)
score -= 16;
}
else if (match.duration_ms.HasValue && match.duration_ms.Value > 0 && match.duration_ms.Value < 90000 && !LooksLikeShortTrackQuery(queryTitle))
{
score -= 8;
}
string lowered = $"{rawTitle} {string.Join(" ", artists)} {album}";
if (BonusWords.Any(i => lowered.Contains(i)))
score += 2;
if (artists.Any(a => a.Contains("topic")))
score += 4;
if (preferVideo)
{
if (title.Contains("official video") || title.Contains("music video"))
score += 8;
if (lowered.Contains("hd") || lowered.Contains("4k"))
score += 2;
if (title.Contains("official audio") || title.Contains("provided to youtube"))
score -= 10;
if (artists.Any(a => a.Contains("topic")))
score -= 10;
if (lowered.Contains("lyrics") || lowered.Contains("visualizer"))
score -= 4;
}
else
{
if (title.Contains("official audio") || title.Contains("provided to youtube"))
score += 4;
if (title.Contains("official video") || title.Contains("music video"))
score += 1;
}
if (!string.IsNullOrWhiteSpace(queryArtist) && !string.IsNullOrWhiteSpace(queryTitle) && title.StartsWith($"{queryArtist} {queryTitle}"))
score += 3;
foreach (var word in PenaltyWords)
{
string normalizedWord = NormalizeText(word);
if (!string.IsNullOrWhiteSpace(normalizedWord) && lowered.Contains(normalizedWord) && !queryPenaltyContext.Contains(normalizedWord))
score -= 3;
}
// Instrumental/karaoke/drumless версии почти всегда являются
// другим треком для пользователя. Малый общий штраф не хватал:
// точное title+artist+duration могло перебить нормальную версию.
if (strongVariant)
score -= 40;
if (!string.IsNullOrWhiteSpace(queryAlbum) && !string.IsNullOrWhiteSpace(album) && album == queryAlbum)
score += 2;
return new { match, score, titleMatch, strongVariant };
})
.ToList() is var ranked
? (ranked.Any(i => i.titleMatch.relevant)
? ranked
.Where(i => i.titleMatch.relevant)
.Where(i => !i.strongVariant)
.Where(i => i.score > -12)
.OrderByDescending(i => i.score)
.ThenBy(i => i.match.title)
.Take(5)
.Select(i => i.match)
.ToList()
: ranked
.Where(i => !i.strongVariant)
.Where(i => i.score > -8)
.OrderByDescending(i => i.score)
.ThenBy(i => i.match.title)
.Take(5)
.Select(i => i.match)
.ToList())
: new List<MusicAudioMatch>();
}
public static bool IsRelevantMatch(MusicTrack query, MusicAudioMatch match)
{
if (query == null || match == null)
return false;
string queryContext = NormalizeText($"{query.title} {query.album_title}");
string candidateContext = NormalizeText($"{match.title} {match.album_title}");
if (HasUnexpectedStrongVariant(queryContext, candidateContext))
return false;
string queryTitle = NormalizeTitleForMatch(query.title);
if (string.IsNullOrWhiteSpace(queryTitle))
return true;
string primaryTitle = NormalizePrimaryTitleForMatch(match.title);
return EvaluateTitleMatch(queryTitle, primaryTitle, primaryTitle).relevant;
}
public static bool IsConfidentEarlyMatch(MusicTrack query, MusicAudioMatch match)
{
if (query == null || match == null)
return false;
string queryTitle = NormalizeTitleForMatch(query.title);
string candidatePrimaryTitle = NormalizePrimaryTitleForMatch(match.title);
if (string.IsNullOrWhiteSpace(queryTitle) || string.IsNullOrWhiteSpace(candidatePrimaryTitle))
return false;
if (LooksLikeShortTrackQuery(queryTitle))
return false;
if (!ContainsTokenPhrase(candidatePrimaryTitle, queryTitle))
return false;
if (!HasArtistMatch(query, match))
return false;
if (!HasTightDurationMatch(query, match, 5000))
return false;
if (HasUnexpectedEarlyExitNoise(query, match))
return false;
return true;
}
public static List<MusicAudioMatch> ConvertSearchResults(IEnumerable<VideoSearchResult> videos)
{
var matches = new List<MusicAudioMatch>();
foreach (var video in videos)
{
string id = video.Id.Value;
string title = video.Title;
string author = video.Author.ChannelTitle;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(title))
continue;
matches.Add(new MusicAudioMatch
{
provider_id = "youtubeaudio",
id = id,
title = title,
artists = string.IsNullOrWhiteSpace(author) ? new List<string>() : new List<string> { author },
album_title = null,
duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null,
payload = MusicJson.Serialize(new Dictionary<string, string>
{
["video_id"] = id,
["author"] = author ?? string.Empty
})
});
}
return matches;
}
public static IReadOnlyList<MusicPlaybackSource> ConvertAudioStreams(IEnumerable<IAudioStreamInfo> streams)
{
return streams
.OrderByDescending(i => IsPreferredContainer(i))
.ThenByDescending(i => i.Bitrate.BitsPerSecond)
.Take(3)
.Select(i => new MusicPlaybackSource
{
provider_id = "youtubeaudio",
url = i.Url,
mime_type = GetMimeType(i),
bitrate = (int)Math.Max(1L, i.Bitrate.BitsPerSecond / 1000),
quality = $"{GetContainerName(i).ToUpperInvariant()} {Math.Max(1L, i.Bitrate.BitsPerSecond / 1000)}kbps",
headers = new Dictionary<string, string>
{
["Referer"] = "https://www.youtube.com/",
["Origin"] = "https://www.youtube.com"
}
})
.ToList();
}
public static IReadOnlyList<MusicPlaybackSource> ConvertVideoStreams(IEnumerable<MuxedStreamInfo> streams)
{
return streams
.OrderByDescending(i => IsPreferredVideoContainer(i))
.ThenByDescending(i => i.VideoQuality.MaxHeight)
.ThenByDescending(i => i.Bitrate.BitsPerSecond)
.Take(3)
.Select(i => new MusicPlaybackSource
{
provider_id = "youtubeaudio",
url = i.Url,
mime_type = GetVideoMimeType(i),
bitrate = (int)Math.Max(1L, i.Bitrate.BitsPerSecond / 1000),
quality = BuildVideoQualityLabel(i),
headers = new Dictionary<string, string>
{
["Referer"] = "https://www.youtube.com/",
["Origin"] = "https://www.youtube.com"
}
})
.ToList();
}
static bool IsPreferredContainer(IAudioStreamInfo stream)
{
string name = GetContainerName(stream);
return name is "m4a" or "mp4";
}
static bool IsPreferredVideoContainer(MuxedStreamInfo stream)
{
string name = GetContainerName(stream);
return name is "mp4" or "m4v";
}
static string GetContainerName(IStreamInfo stream)
=> stream.Container.Name?.ToLowerInvariant() ?? stream.Container.ToString().ToLowerInvariant();
static string GetMimeType(IAudioStreamInfo stream)
{
string container = GetContainerName(stream);
return container is "m4a" or "mp4" ? "audio/mp4" : "audio/webm";
}
static string GetVideoMimeType(MuxedStreamInfo stream)
{
string container = GetContainerName(stream);
return container is "mp4" or "m4v" ? "video/mp4" : "video/webm";
}
static string BuildVideoQualityLabel(MuxedStreamInfo stream)
{
string container = GetContainerName(stream).ToUpperInvariant();
string height = !string.IsNullOrWhiteSpace(stream.VideoQuality.Label) ? stream.VideoQuality.Label : $"{stream.VideoQuality.MaxHeight}p";
long bitrate = Math.Max(1L, stream.Bitrate.BitsPerSecond / 1000);
return $"{container} {height} {bitrate}kbps";
}
static string NormalizeText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = Regex.Replace(value, @"[\(\)\[\]\{\}\-_]+", " ");
value = value.Trim().ToLowerInvariant().Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(value.Length);
foreach (char ch in value)
{
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark)
continue;
if (char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch))
builder.Append(ch);
}
return Regex.Replace(builder.ToString(), @"\s+", " ").Trim();
}
static string NormalizeTitleForMatch(string value) => NormalizeText(StripFeatureSegment(value));
static List<string> BuildQueryArtists(string primaryArtist, IEnumerable<string> artists)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
void add(string value)
{
value = value?.Trim();
if (string.IsNullOrWhiteSpace(value))
return;
var key = NormalizeText(value);
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
return;
result.Add(value);
}
add(primaryArtist);
foreach (var artist in artists ?? Array.Empty<string>())
{
add(artist);
if (result.Count >= 3)
break;
}
return result;
}
static string NormalizePrimaryTitleForMatch(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = StripFeatureSegment(value);
value = Regex.Replace(value, @"[\(\[\{][^)\]\}]+[\)\]\}]", " ");
value = Regex.Replace(value, @"\s+[|/]\s+.*$", " ");
return NormalizeText(value);
}
static string CompactText(string value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Replace(" ", string.Empty);
static bool LooksLikeShortTrackQuery(string normalizedTitle)
{
if (string.IsNullOrWhiteSpace(normalizedTitle))
return false;
return GenericTrackTitles.Contains(normalizedTitle)
|| normalizedTitle.Contains("intro", StringComparison.Ordinal)
|| normalizedTitle.Contains("outro", StringComparison.Ordinal)
|| normalizedTitle.Contains("skit", StringComparison.Ordinal)
|| normalizedTitle.Contains("interlude", StringComparison.Ordinal);
}
static bool ContainsTokenPhrase(string value, string phrase)
{
if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(phrase))
return false;
if (value == phrase)
return true;
return value.StartsWith($"{phrase} ", StringComparison.Ordinal)
|| value.EndsWith($" {phrase}", StringComparison.Ordinal)
|| value.Contains($" {phrase} ", StringComparison.Ordinal);
}
static bool HasArtistMatch(MusicTrack query, MusicAudioMatch match)
{
var expectedArtists = new List<string>();
if (!string.IsNullOrWhiteSpace(query?.artist_name))
expectedArtists.Add(query.artist_name);
foreach (var artist in query?.artists ?? new List<string>())
{
if (!string.IsNullOrWhiteSpace(artist))
expectedArtists.Add(artist);
}
var normalizedExpected = expectedArtists
.Select(NormalizeText)
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.Ordinal)
.ToList();
if (normalizedExpected.Count == 0)
return false;
var normalizedCandidateArtists = (match?.artists ?? new List<string>())
.Select(NormalizeText)
.Where(i => !string.IsNullOrWhiteSpace(i))
.ToList();
string rawCandidateText = NormalizeText($"{match?.title} {string.Join(" ", match?.artists ?? new List<string>())}");
foreach (var expected in normalizedExpected)
{
string compactExpected = CompactText(expected);
if (string.IsNullOrWhiteSpace(compactExpected))
continue;
foreach (var candidate in normalizedCandidateArtists)
{
string compactCandidate = CompactText(candidate);
if (candidate == expected
|| candidate.Contains(expected, StringComparison.Ordinal)
|| expected.Contains(candidate, StringComparison.Ordinal)
|| compactCandidate == compactExpected
|| compactCandidate == $"{compactExpected}vevo"
|| compactCandidate == $"{compactExpected}music"
|| compactCandidate == $"{compactExpected}topic")
return true;
}
if (!string.IsNullOrWhiteSpace(rawCandidateText) && ContainsTokenPhrase(rawCandidateText, expected))
return true;
}
return false;
}
static bool HasTightDurationMatch(MusicTrack query, MusicAudioMatch match, int toleranceMs)
{
if (query?.duration_ms.HasValue != true || match?.duration_ms.HasValue != true)
return false;
return Math.Abs(query.duration_ms.Value - match.duration_ms.Value) <= toleranceMs;
}
static bool HasUnexpectedEarlyExitNoise(MusicTrack query, MusicAudioMatch match)
{
string queryContext = NormalizeText($"{query?.title} {query?.album_title}");
string candidateContext = NormalizeText($"{match?.title} {match?.album_title} {string.Join(" ", match?.artists ?? new List<string>())}");
if (string.IsNullOrWhiteSpace(candidateContext))
return false;
foreach (var word in EarlyExitPenaltyWords)
{
string normalizedWord = NormalizeText(word);
if (!string.IsNullOrWhiteSpace(normalizedWord)
&& ContainsTokenPhrase(candidateContext, normalizedWord)
&& !ContainsTokenPhrase(queryContext, normalizedWord))
return true;
}
return false;
}
static bool HasUnexpectedStrongVariant(string queryContext, string candidateContext)
{
if (string.IsNullOrWhiteSpace(candidateContext))
return false;
foreach (var word in StrongVariantWords.Concat(LiveVariantWords))
{
string normalizedWord = NormalizeText(word);
if (!string.IsNullOrWhiteSpace(normalizedWord)
&& ContainsTokenPhrase(candidateContext, normalizedWord)
&& !ContainsTokenPhrase(queryContext, normalizedWord))
return true;
}
return false;
}
static string StripFeatureSegment(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
string original = value.Trim();
string stripped = Regex.Replace(
original,
@"\s*[\(\[\{]\s*(?:feat\.?|ft\.?|featuring)\s*[:\-]?\s+[^)\]\}]+[\)\]\}]\s*",
" ",
RegexOptions.IgnoreCase);
stripped = Regex.Replace(
stripped,
@"\s+(?:feat\.?|ft\.?|featuring)\s*[:\-]?\s+.+$",
" ",
RegexOptions.IgnoreCase);
stripped = Regex.Replace(stripped, @"\s+", " ").Trim();
return string.IsNullOrWhiteSpace(stripped) ? original : stripped;
}
static bool ShouldAppendAlbumHint(string title)
{
var normalized = NormalizeText(title);
if (string.IsNullOrWhiteSpace(normalized))
return false;
if (GenericTrackTitles.Contains(normalized))
return true;
return normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 2;
}
static string ResolveVideoId(MusicTrack track)
{
if (!string.IsNullOrWhiteSpace(track?.id) && track.id.StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
return track.id["youtube:".Length..].Trim();
if (track?.provider_refs == null)
return null;
foreach (var providerRef in track.provider_refs)
{
if (!string.Equals(providerRef?.provider, "youtubeaudio", StringComparison.OrdinalIgnoreCase))
continue;
if (!string.IsNullOrWhiteSpace(providerRef.external_id))
return providerRef.external_id.Trim();
}
return null;
}
static (bool relevant, bool exact, bool allTokens, double coverage) EvaluateTitleMatch(string queryTitle, string title, string fullTitle)
{
if (string.IsNullOrWhiteSpace(queryTitle))
return (true, false, false, 0d);
bool exact = !string.IsNullOrWhiteSpace(title) && title == queryTitle;
var queryTokens = queryTitle
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (queryTokens.Length == 0)
return (true, exact, false, 0d);
int matchedTokens = queryTokens.Count(token =>
(!string.IsNullOrWhiteSpace(title) && title.Contains(token, StringComparison.Ordinal))
|| (!string.IsNullOrWhiteSpace(fullTitle) && fullTitle.Contains(token, StringComparison.Ordinal)));
double coverage = matchedTokens / (double)queryTokens.Length;
bool allTokens = matchedTokens == queryTokens.Length;
bool relevant = exact
|| allTokens
|| (queryTokens.Length >= 3 && coverage >= 0.75d);
return (relevant, exact, allTokens, coverage);
}
}
@@ -0,0 +1,51 @@
namespace Music;
public class Z3FmAudioProvider : IMusicAudioProvider
{
public string Id => Z3FmSupport.AudioProviderId;
public string Name => "Z3.FM";
public bool Enabled => Z3FmSupport.IsAudioEnabled;
public bool RequiresAuth => false;
public bool CacheMissingMatches => false;
public Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (!Enabled || track == null)
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
return Z3FmSupport.SearchAsync(track, cancellationToken);
}
public Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (!Enabled || match == null)
return Task.FromResult<IReadOnlyList<MusicPlaybackSource>>(Array.Empty<MusicPlaybackSource>());
return Z3FmSupport.BuildSourcesAsync(match, cancellationToken);
}
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
return Task.FromResult<MusicPlaybackSource>(null);
}
public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
}
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
{
return Z3FmSupport.IsRelevantMatch(track, match);
}
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
{
return false;
}
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
{
return Array.Empty<string>();
}
}
@@ -0,0 +1,626 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Playwright;
using Shared.Models.Base;
using Shared.PlaywrightCore;
using Shared.Services.Utilities;
using System.Net;
using System.Text.RegularExpressions;
namespace Music;
public static class Z3FmSupport
{
public const string AudioProviderId = "z3fmaudio";
public const string BaseUrl = "https://z3.fm";
public const string SearchPath = "/mp3/search";
static readonly MemoryCache cookieCache = new(new MemoryCacheOptions());
static readonly TimeSpan cookieLifetime = TimeSpan.FromMinutes(20);
static readonly Regex resultBlockRegex = new(
"<div class=\"song song-wrap[^\"]*\">(?<block>.*?)<span[^>]*data-time=\"(?<seconds>\\d+)\"[^>]*data-sid=\"(?<sid>\\d+)\"[^>]*data-url=\"(?<download>/download/\\d+)\"[^>]*data-title=\"(?<fulltitle>[^\"]+)\"[^>]*class=\"song-play[^\"]*\"",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
);
static readonly Regex artistRegex = new(
"<div class=\"song-artist\">.*?<a[^>]*href=\"(?<href>/artist/\\d+)\"[^>]*>(?<inner>.*?)</a>",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
);
static readonly Regex titleRegex = new(
"<div class=\"song-name\">.*?<a[^>]*href=\"(?<href>/song/\\d+)\"[^>]*>(?<inner>.*?)</a>",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
);
static readonly Regex songPageDownloadRegex = new(
"data-sid=\"(?<sid>\\d+)\"\\s+data-url=\"(?<download>/download/\\d+)\"",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
);
static readonly string[] noisyTerms =
{
"remix", "mix", "instrumental", "karaoke", "spedup", "slowed", "nightcore", "8d", "cover", "live", "edit", "extended"
};
public static bool IsModuleEnabled => ModInit.conf?.z3fm_enabled == true;
public static bool IsAudioEnabled => IsModuleEnabled && ModInit.conf?.z3fm_audio_enabled == true;
public static bool IsProxyEnabled => IsModuleEnabled && ModInit.conf?.z3fm_proxy_enabled == true;
public static string BuildSearchUrl(string query)
{
query = string.IsNullOrWhiteSpace(query) ? string.Empty : query.Trim();
return $"{BaseUrl.TrimEnd('/')}{SearchPath}?keywords={Uri.EscapeDataString(query)}";
}
public static Dictionary<string, string> CreateBrowserHeaders()
{
return new Dictionary<string, string>
{
["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
["Accept-Language"] = "ru,en-US;q=0.9,en;q=0.8",
["Cache-Control"] = "no-cache",
["Pragma"] = "no-cache",
["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7; rv:139.0) Gecko/20100101 Firefox/139.0"
};
}
public static async Task<IReadOnlyList<MusicAudioMatch>> SearchAsync(MusicTrack track, CancellationToken cancellationToken = default)
{
if (track == null || string.IsNullOrWhiteSpace(track.title))
return Array.Empty<MusicAudioMatch>();
var matches = new Dictionary<string, MusicAudioMatch>(StringComparer.Ordinal);
foreach (var query in BuildQueryVariants(track))
{
cancellationToken.ThrowIfCancellationRequested();
var page = await FetchPageAsync(BuildSearchUrl(query), cancellationToken);
if (page == null || string.IsNullOrWhiteSpace(page.Html))
continue;
foreach (var item in ParseSearchResults(page.Html))
{
cancellationToken.ThrowIfCancellationRequested();
if (!string.IsNullOrWhiteSpace(page.CookieHeader))
{
cookieCache.Set(BuildCookieCacheKey(item.sid), new Z3FmCookieContext
{
CookieHeader = page.CookieHeader,
Referer = string.IsNullOrWhiteSpace(item.trackUrl) ? page.Url : item.trackUrl
}, cookieLifetime);
}
if (matches.ContainsKey(item.sid))
continue;
matches[item.sid] = new MusicAudioMatch
{
provider_id = AudioProviderId,
id = item.sid,
title = item.title,
artists = string.IsNullOrWhiteSpace(item.artist) ? new List<string>() : new List<string> { item.artist },
duration_ms = item.durationMs,
payload = MusicJson.Serialize(new Z3FmMatchPayload
{
sid = item.sid,
download_url = item.downloadUrl,
track_url = item.trackUrl
})
};
}
if (matches.Count > 0)
break;
}
return RankMatches(track, matches.Values).Take(20).ToList();
}
public static async Task<IReadOnlyList<MusicPlaybackSource>> BuildSourcesAsync(MusicAudioMatch match, CancellationToken cancellationToken = default)
{
var payload = string.IsNullOrWhiteSpace(match?.payload)
? null
: MusicJson.Deserialize<Z3FmMatchPayload>(match.payload);
if (payload == null || string.IsNullOrWhiteSpace(payload.sid))
return Array.Empty<MusicPlaybackSource>();
var context = await EnsureCookieContextAsync(payload, cancellationToken);
string url = BuildAbsoluteUrl(payload.download_url);
if (string.IsNullOrWhiteSpace(url))
return Array.Empty<MusicPlaybackSource>();
var headers = new Dictionary<string, string>
{
["Referer"] = context?.Referer ?? BuildAbsoluteUrl(payload.track_url) ?? $"{BaseUrl}/",
["Origin"] = BaseUrl,
["User-Agent"] = CreateBrowserHeaders()["User-Agent"]
};
if (!string.IsNullOrWhiteSpace(context?.CookieHeader))
headers["Cookie"] = context.CookieHeader;
var source = new MusicPlaybackSource
{
provider_id = AudioProviderId,
url = url,
mime_type = "audio/mpeg",
quality = "MP3",
headers = headers
};
ApplyConfiguredProxy(source);
return new List<MusicPlaybackSource> { source };
}
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches)
{
return matches
.Select(match => new { match, score = ScoreMatch(query, match) })
.Where(i => i.score >= 4)
.OrderByDescending(i => i.score)
.ThenBy(i => i.match.title)
.Select(i => i.match)
.ToList();
}
public static bool IsRelevantMatch(MusicTrack query, MusicAudioMatch match)
{
return ScoreMatch(query, match) >= 6;
}
public static void ApplyConfiguredProxy(MusicPlaybackSource source)
{
if (source == null)
return;
var configured = ParseConfiguredProxy();
if (configured == null)
return;
source.proxy_url = configured.Server;
source.proxy_username = configured.Username;
source.proxy_password = configured.Password;
}
public static WebProxy GetHttpProxy()
{
var configured = ParseConfiguredProxy();
if (configured == null)
return null;
var credentials = string.IsNullOrWhiteSpace(configured.Username)
? null
: new NetworkCredential(configured.Username, configured.Password ?? string.Empty);
return new WebProxy(configured.Server, false, null, credentials);
}
public static (string ip, string username, string password) GetBrowserProxyData()
{
var configured = ParseConfiguredProxy();
if (configured == null)
return default;
return (configured.Server, configured.Username, configured.Password);
}
public static bool HasConfiguredProxy() => ParseConfiguredProxy() != null;
static async Task<Z3FmCookieContext> EnsureCookieContextAsync(Z3FmMatchPayload payload, CancellationToken cancellationToken)
{
if (payload == null || string.IsNullOrWhiteSpace(payload.sid))
return null;
if (cookieCache.TryGetValue(BuildCookieCacheKey(payload.sid), out Z3FmCookieContext cached) && cached != null)
return cached;
string trackUrl = BuildAbsoluteUrl(payload.track_url);
if (string.IsNullOrWhiteSpace(trackUrl))
return null;
var page = await FetchPageAsync(trackUrl, cancellationToken);
if (page == null)
return null;
if (string.IsNullOrWhiteSpace(payload.download_url))
{
var songPage = ParseSongPage(page.Html);
if (songPage != null && songPage.sid == payload.sid && !string.IsNullOrWhiteSpace(songPage.downloadUrl))
payload.download_url = songPage.downloadUrl;
}
if (string.IsNullOrWhiteSpace(page.CookieHeader))
return null;
var context = new Z3FmCookieContext
{
CookieHeader = page.CookieHeader,
Referer = trackUrl
};
cookieCache.Set(BuildCookieCacheKey(payload.sid), context, cookieLifetime);
return context;
}
static IEnumerable<string> BuildQueryVariants(MusicTrack track)
{
var variants = new List<string>();
void add(string value)
{
value = value?.Trim();
if (string.IsNullOrWhiteSpace(value))
return;
if (variants.All(existing => !string.Equals(existing, value, StringComparison.OrdinalIgnoreCase)))
variants.Add(value);
}
add($"{track.artist_name} {track.title}");
if (track.artists != null && track.artists.Count > 0)
{
string credit = string.Join(' ', track.artists.Where(i => !string.IsNullOrWhiteSpace(i)));
add($"{credit} {track.title}");
}
add(track.title);
return variants;
}
static async Task<Z3FmBrowserPage> FetchPageAsync(string url, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var headers = CreateBrowserHeaders();
var proxyData = GetBrowserProxyData();
MusicProxyLease proxyLease = null;
if (string.IsNullOrWhiteSpace(proxyData.ip))
{
proxyLease = MusicProxyService.Acquire(AudioProviderId, MusicProxyPurpose.Api);
proxyData = proxyLease.Data;
}
var init = new BaseSettings
{
plugin = "music.z3fm",
priorityBrowser = "firefox"
};
try
{
using var browser = new PlaywrightBrowser(init.priorityBrowser);
var page = await browser.NewPageAsync(init.plugin, headers, proxyData, keepopen: true, imitationHuman: false, deferredDispose: false).ConfigureAwait(false);
if (page == null)
return null;
var response = await page.GotoAsync(url, new PageGotoOptions
{
Timeout = 15_000,
WaitUntil = WaitUntilState.DOMContentLoaded
}).ConfigureAwait(false);
string html = null;
if (response != null)
html = await response.TextAsync().ConfigureAwait(false);
html ??= await page.ContentAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(html) || IsBlockedHtml(html))
{
proxyLease?.Failure();
return null;
}
var cookies = await page.Context.CookiesAsync().ConfigureAwait(false);
proxyLease?.Success();
return new Z3FmBrowserPage
{
Url = url,
Html = html,
CookieHeader = BuildCookieHeader(cookies)
};
}
catch
{
proxyLease?.Failure();
return null;
}
}
static IEnumerable<Z3FmParsedEntry> ParseSearchResults(string html)
{
if (string.IsNullOrWhiteSpace(html))
yield break;
foreach (Match match in resultBlockRegex.Matches(html))
{
string block = match.Groups["block"].Value;
string sid = match.Groups["sid"].Value;
string downloadUrl = match.Groups["download"].Value;
string fullTitle = HtmlDecode(match.Groups["fulltitle"].Value);
if (string.IsNullOrWhiteSpace(sid) || string.IsNullOrWhiteSpace(downloadUrl))
continue;
string title = ExtractTitle(block);
string artist = ExtractArtist(block);
string trackUrl = ExtractTrackUrl(block);
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
(artist, title) = SplitFullTitle(fullTitle);
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
continue;
int? durationMs = null;
if (int.TryParse(match.Groups["seconds"].Value, out int seconds) && seconds > 0)
durationMs = seconds * 1000;
yield return new Z3FmParsedEntry
{
sid = sid,
artist = artist,
title = title,
downloadUrl = BuildAbsoluteUrl(downloadUrl),
trackUrl = BuildAbsoluteUrl(trackUrl),
durationMs = durationMs
};
}
}
static Z3FmParsedEntry ParseSongPage(string html)
{
if (string.IsNullOrWhiteSpace(html))
return null;
var match = songPageDownloadRegex.Match(html);
if (!match.Success)
return null;
return new Z3FmParsedEntry
{
sid = match.Groups["sid"].Value,
downloadUrl = BuildAbsoluteUrl(match.Groups["download"].Value)
};
}
static string ExtractArtist(string block)
{
var match = artistRegex.Match(block ?? string.Empty);
if (!match.Success)
return null;
return CleanInlineText(match.Groups["inner"].Value);
}
static string ExtractTitle(string block)
{
var match = titleRegex.Match(block ?? string.Empty);
if (!match.Success)
return null;
return CleanInlineText(match.Groups["inner"].Value);
}
static string ExtractTrackUrl(string block)
{
var match = titleRegex.Match(block ?? string.Empty);
return match.Success ? match.Groups["href"].Value : null;
}
static (string artist, string title) SplitFullTitle(string fullTitle)
{
fullTitle = CleanInlineText(fullTitle);
if (string.IsNullOrWhiteSpace(fullTitle))
return (null, null);
int delimiter = fullTitle.IndexOf(" - ", StringComparison.Ordinal);
if (delimiter <= 0 || delimiter >= fullTitle.Length - 3)
return (null, fullTitle);
return (fullTitle[..delimiter].Trim(), fullTitle[(delimiter + 3)..].Trim());
}
static int ScoreMatch(MusicTrack query, MusicAudioMatch match)
{
string queryTitle = SearchNameTo.Convert(query?.title, string.Empty);
string matchTitle = SearchNameTo.Convert(match?.title, string.Empty);
string fullMatchTitle = SearchNameTo.Convert($"{match?.artists?.FirstOrDefault()} {match?.title}", string.Empty);
string queryPrimaryArtist = BuildArtistFragments(query?.artist_name).FirstOrDefault() ?? string.Empty;
var matchArtists = BuildArtistFragments(match?.artists?.FirstOrDefault());
int score = 0;
if (!string.IsNullOrWhiteSpace(queryTitle))
{
if (string.Equals(matchTitle, queryTitle, StringComparison.Ordinal))
score += 18;
else if (!string.IsNullOrWhiteSpace(matchTitle) && matchTitle.Contains(queryTitle, StringComparison.Ordinal))
score += 10;
else if (AllTokensPresent(queryTitle, matchTitle))
score += 5;
else
score -= 14;
}
if (!string.IsNullOrWhiteSpace(queryPrimaryArtist))
{
if (matchArtists.Contains(queryPrimaryArtist) || string.Equals(SearchNameTo.Convert(match?.artists?.FirstOrDefault(), string.Empty), queryPrimaryArtist, StringComparison.Ordinal))
score += 12;
else if (matchArtists.Any(a => a.Contains(queryPrimaryArtist, StringComparison.Ordinal)))
score += 6;
else
score -= 8;
}
if (query?.duration_ms.HasValue == true && match?.duration_ms.HasValue == true)
{
int delta = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
if (delta <= 2_000) score += 8;
else if (delta <= 5_000) score += 6;
else if (delta <= 10_000) score += 3;
else if (delta <= 20_000) score += 1;
else score -= 2;
}
string queryNoiseBase = $"{SearchNameTo.Convert(query?.title, string.Empty)} {SearchNameTo.Convert(query?.artist_name, string.Empty)}";
foreach (var term in noisyTerms)
{
if (!queryNoiseBase.Contains(term, StringComparison.Ordinal) && fullMatchTitle.Contains(term, StringComparison.Ordinal))
score -= 6;
}
return score;
}
static List<string> BuildArtistFragments(string artist)
{
artist = artist?.Trim();
if (string.IsNullOrWhiteSpace(artist))
return new List<string>();
return artist
.Split(new[] { ",", "&", " x ", " feat. ", " feat ", " ft. ", " ft ", " and " }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(i => SearchNameTo.Convert(i, string.Empty))
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.Ordinal)
.ToList();
}
static bool AllTokensPresent(string query, string target)
{
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(target))
return false;
var tokens = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return tokens.Length > 0 && tokens.All(token => target.Contains(token, StringComparison.Ordinal));
}
static string CleanInlineText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
value = Regex.Replace(value, "<.*?>", string.Empty, RegexOptions.Singleline);
value = HtmlDecode(value);
value = value.Replace("\r", " ").Replace("\n", " ").Trim();
return Regex.Replace(value, "\\s{2,}", " ").Trim();
}
static string HtmlDecode(string value) => WebUtility.HtmlDecode(value ?? string.Empty);
static string BuildAbsoluteUrl(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
value = value.Trim();
if (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return value;
if (!value.StartsWith('/'))
value = "/" + value;
return BaseUrl + value;
}
static string BuildCookieHeader(IReadOnlyList<BrowserContextCookiesResult> cookies)
{
if (cookies == null || cookies.Count == 0)
return null;
var parts = cookies
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Value != null)
.Select(c => $"{c.Name}={c.Value}")
.ToArray();
return parts.Length == 0 ? null : string.Join("; ", parts);
}
static bool IsBlockedHtml(string html)
{
if (string.IsNullOrWhiteSpace(html))
return true;
return html.Contains("Just a moment", StringComparison.OrdinalIgnoreCase)
|| html.Contains("cf-challenge", StringComparison.OrdinalIgnoreCase)
|| html.Contains("challenge-platform", StringComparison.OrdinalIgnoreCase)
|| html.Contains("Attention Required", StringComparison.OrdinalIgnoreCase)
|| html.Contains("Access denied", StringComparison.OrdinalIgnoreCase);
}
static string BuildCookieCacheKey(string sid) => $"z3fm:cookie:{sid}";
sealed class ProxyConfiguration
{
public string Server { get; init; }
public string Username { get; init; }
public string Password { get; init; }
}
sealed class Z3FmBrowserPage
{
public string Url { get; init; }
public string Html { get; init; }
public string CookieHeader { get; init; }
}
sealed class Z3FmCookieContext
{
public string CookieHeader { get; init; }
public string Referer { get; init; }
}
sealed class Z3FmParsedEntry
{
public string sid { get; init; }
public string artist { get; init; }
public string title { get; init; }
public string trackUrl { get; set; }
public string downloadUrl { get; set; }
public int? durationMs { get; init; }
}
sealed class Z3FmMatchPayload
{
public string sid { get; set; }
public string track_url { get; set; }
public string download_url { get; set; }
}
static ProxyConfiguration ParseConfiguredProxy()
{
if (!IsProxyEnabled)
return null;
string raw = ModInit.conf?.z3fm_proxy_url?.Trim();
if (string.IsNullOrWhiteSpace(raw))
return null;
if (!raw.Contains("://", StringComparison.Ordinal))
raw = "http://" + raw;
if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri) || string.IsNullOrWhiteSpace(uri.Host))
return null;
string username = ModInit.conf?.z3fm_proxy_username?.Trim();
string password = ModInit.conf?.z3fm_proxy_password ?? string.Empty;
if (string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(uri.UserInfo))
{
var parts = uri.UserInfo.Split(':', 2, StringSplitOptions.None);
username = Uri.UnescapeDataString(parts[0]);
password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
}
var builder = new UriBuilder(uri)
{
UserName = string.Empty,
Password = string.Empty
};
return new ProxyConfiguration
{
Server = builder.Uri.GetLeftPart(UriPartial.Authority),
Username = string.IsNullOrWhiteSpace(username) ? null : username,
Password = string.IsNullOrWhiteSpace(username) ? null : password
};
}
}
@@ -0,0 +1,56 @@
namespace Music;
public class SoundCloudAuthProvider : IMusicAuthProvider
{
public string Id => SoundCloudSupport.AuthProviderId;
public string Name => "SoundCloud";
public bool Enabled => SoundCloudSupport.IsAuthEnabled;
public async Task<MusicAuthState> GetStateAsync(string profileId = null, CancellationToken cancellationToken = default)
{
var credentials = await MusicAuthStorageService.GetAsync<SoundCloudCredentials>(profileId, Id, cancellationToken);
credentials = SoundCloudSupport.NormalizeCredentials(credentials);
bool hasToken = SoundCloudSupport.HasAccessToken(credentials);
bool expired = SoundCloudSupport.IsTokenExpired(credentials);
return new MusicAuthState
{
provider_id = Id,
provider_name = Name,
authenticated = hasToken && !expired,
requires_auth = true,
token_ready = hasToken,
web_ready = hasToken,
mode = "oauth",
message = SoundCloudSupport.BuildStateMessage(credentials)
};
}
public async Task<bool> SaveAsync(string payload, string profileId = null, CancellationToken cancellationToken = default)
{
var parsed = SoundCloudSupport.ParseSavePayload(payload);
if (!parsed.TryGetValue("access_token", out var accessToken) || string.IsNullOrWhiteSpace(accessToken))
return false;
var credentials = await MusicAuthStorageService.GetAsync<SoundCloudCredentials>(profileId, Id, cancellationToken) ?? new SoundCloudCredentials();
credentials.access_token = accessToken;
credentials.refresh_token = parsed.TryGetValue("refresh_token", out var refreshToken) ? refreshToken : credentials.refresh_token;
credentials.token_type = parsed.TryGetValue("token_type", out var tokenType) ? tokenType : credentials.token_type;
credentials.scope = parsed.TryGetValue("scope", out var scope) ? scope : credentials.scope;
credentials.username = parsed.TryGetValue("username", out var username) ? username : credentials.username;
credentials.user_urn = parsed.TryGetValue("user_urn", out var userUrn) ? userUrn : credentials.user_urn;
if (parsed.TryGetValue("expires_in", out var expiresInRaw) && long.TryParse(expiresInRaw, out var expiresInSeconds) && expiresInSeconds > 0)
credentials.expires_at_unix = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + expiresInSeconds;
await MusicAuthStorageService.SaveAsync(profileId, Id, SoundCloudSupport.NormalizeCredentials(credentials), cancellationToken);
return true;
}
public Task LogoutAsync(string profileId = null, CancellationToken cancellationToken = default)
{
return MusicAuthStorageService.DeleteAsync(profileId, Id, cancellationToken);
}
}
@@ -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();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,298 @@
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
namespace Music;
// Похожие артисты с music-map.com (проект Gnod/Gnoosic): один GET на артиста,
// парс якорей #gnodMap, отсортированных по музыкальной близости (краудсорсная
// коллаборативная фильтрация). Используется ТОЛЬКО как additive-слой «специи»
// в «Миксах недели»: не источник в UI, не участник радио, любой сбой — пустой
// список и микс собирается без внешнего слоя. Живой прототип 2026-07-16
// подтвердил стабильность парсинга и RU/транслит-покрытие (10/10 сидов по 48
// артистов, включая кириллические запросы и выдачу).
public static class MusicMapSupport
{
const string BrowserUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
const string cacheVersion = "mm-v1";
// похожесть артистов почти статична; 14 дней — быстро самовосстановится,
// если сайт/парсер временно отдал мусор (совет Кодекса)
static readonly TimeSpan similarCacheTtl = TimeSpan.FromDays(14);
static readonly HttpClient httpClient = MusicHttp.CreateClient("musicmap");
static readonly Regex mapBlockRegex = new(@"<div id=gnodMap>(.*?)</div>", RegexOptions.Singleline | RegexOptions.Compiled);
static readonly Regex anchorRegex = new("<a href=\"[^\"]*\" class=S id=s(\\d+)>([^<]*)</a>", RegexOptions.Compiled);
static MusicMapSupport()
{
httpClient.Timeout = TimeSpan.FromSeconds(10);
}
public static async Task<List<string>> GetSimilarArtistsAsync(string artist, CancellationToken cancellationToken = default)
{
string normalized = NormalizeName(artist);
if (string.IsNullOrWhiteSpace(normalized))
return new List<string>();
try
{
// пустой ответ кэшируется коротким emptyTtl (20 мин) — разовый
// сбой сайта не залипает на 14 дней
return await MusicMetadataCacheService.GetOrCreateAsync(
"musicmap",
"similar_artists",
$"{cacheVersion}|{normalized}",
similarCacheTtl,
() => FetchSimilarArtistsAsync(artist, cancellationToken),
cancellationToken
) ?? new List<string>();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new List<string>();
}
}
static async Task<List<string>> FetchSimilarArtistsAsync(string artist, CancellationToken cancellationToken)
{
var result = new List<string>();
try
{
// формат пути music-map: имя в lower, пробелы -> '+', остальное
// percent-encoded (кириллица и '&' включительно)
string path = Uri.EscapeDataString(artist.Trim().ToLowerInvariant()).Replace("%20", "+");
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://www.music-map.com/{path}");
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return result;
string html = await response.Content.ReadAsStringAsync(cancellationToken);
var block = mapBlockRegex.Match(html ?? string.Empty);
if (!block.Success)
return result;
foreach (Match anchor in anchorRegex.Matches(block.Groups[1].Value))
{
// s0 — сам запрошенный артист, не кандидат
if (anchor.Groups[1].Value == "0")
continue;
string name = System.Net.WebUtility.HtmlDecode(anchor.Groups[2].Value).Trim();
if (!string.IsNullOrWhiteSpace(name))
result.Add(name);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
}
return result;
}
/// <summary>
/// Фильтр кандидатов карты: выбрасывает алиасы/проекты сид-артистов
/// (containment + fuzzy + потокенно — ловит «Myagi», «Mijagi»,
/// «Gruppa Skryptonite»), артистов истории (кросс-скрипт через транслит:
/// «Скриптонит» == «Scriptonite») и дубли по нормализованному имени.
/// Порядок карты (по близости) сохраняется.
/// </summary>
public static List<string> FilterCandidates(IEnumerable<string> candidates, IEnumerable<string> aliasReferences, IEnumerable<string> excludeNames)
{
var references = (aliasReferences ?? Enumerable.Empty<string>()).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var excludedRaw = (excludeNames ?? Enumerable.Empty<string>()).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var excluded = excludedRaw.Select(NormalizeName).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var candidate in candidates ?? Enumerable.Empty<string>())
{
string normalized = NormalizeName(candidate);
if (string.IsNullOrWhiteSpace(normalized) || !seen.Add(normalized))
continue;
if (references.Any(reference => IsAliasOf(candidate, reference)))
continue;
if (excluded.Any(name => name == normalized || FuzzyClose(name, normalized))
|| excludedRaw.Any(name => IsAliasOf(candidate, name)))
continue;
result.Add(candidate);
}
return result;
}
internal static bool IsAliasOf(string candidate, string reference)
{
string c = NormalizeName(candidate);
string r = NormalizeName(reference);
if (string.IsNullOrWhiteSpace(c) || string.IsNullOrWhiteSpace(r))
return string.Equals(candidate?.Trim(), reference?.Trim(), StringComparison.OrdinalIgnoreCase);
if (c.Contains(r, StringComparison.Ordinal) || r.Contains(c, StringComparison.Ordinal))
return true;
// опечаточные алиасы (Myagi/Mijagi) и транслит-варианты
if (FuzzyClose(c, r))
return true;
// потокенно: алиас внутри многословного имени («Gruppa Skryptonite»
// для сида «Скриптонит»)
return TokensOf(candidate).Any(token => FuzzyClose(token, r))
|| TokensOf(reference).Any(token => FuzzyClose(c, token));
}
internal static bool FuzzyClose(string a, string b)
=> Levenshtein(a, b) <= Math.Max(1, Math.Min(a.Length, b.Length) / 4);
static IEnumerable<string> TokensOf(string name)
=> Regex.Split(name ?? string.Empty, @"[^\p{L}\p{Nd}]+")
.Select(NormalizeName)
.Where(token => !string.IsNullOrWhiteSpace(token));
// RU->latin транслит + только [a-z0-9]: кросс-скрипт сравнение имён
// («Скриптонит» и «Scriptonite» сводятся к сравнимым строкам)
internal static string NormalizeName(string name)
{
var builder = new StringBuilder((name ?? string.Empty).Length);
foreach (char raw in (name ?? string.Empty).ToLowerInvariant())
{
if (translit.TryGetValue(raw, out string mapped))
builder.Append(mapped);
else if (raw is >= 'a' and <= 'z' or >= '0' and <= '9')
builder.Append(raw);
}
return builder.ToString();
}
internal static bool ContainsCyrillic(string value)
=> (value ?? string.Empty).Any(ch => ch is >= 'а' and <= 'я' or 'ё' or >= 'А' and <= 'Я' or 'Ё'
or >= 'і' and <= 'ї' or 'є' or 'ґ' or >= 'І' and <= 'Ї' or 'Є' or 'Ґ');
// Для поискового fallback нельзя использовать NormalizeName: он склеивает
// слова. MusicBrainz лучше понимает "maikl dzhekson", чем
// "maikldzhekson", поэтому сохраняем пробелы/разделители.
internal static string TransliterateCyrillicToLatinForSearch(string value)
{
var builder = new StringBuilder((value ?? string.Empty).Length);
bool lastWasSpace = true;
foreach (char raw in (value ?? string.Empty).ToLowerInvariant())
{
if (translit.TryGetValue(raw, out string mapped))
{
if (!string.IsNullOrEmpty(mapped))
{
builder.Append(mapped);
lastWasSpace = false;
}
continue;
}
if (raw is >= 'a' and <= 'z' or >= '0' and <= '9')
{
builder.Append(raw);
lastWasSpace = false;
continue;
}
if (!lastWasSpace)
{
builder.Append(' ');
lastWasSpace = true;
}
}
return builder.ToString().Trim();
}
static readonly Dictionary<char, string> translit = new()
{
['а'] = "a",
['б'] = "b",
['в'] = "v",
['г'] = "g",
['д'] = "d",
['е'] = "e",
['ё'] = "e",
['ж'] = "zh",
['з'] = "z",
['и'] = "i",
['й'] = "i",
['к'] = "k",
['л'] = "l",
['м'] = "m",
['н'] = "n",
['о'] = "o",
['п'] = "p",
['р'] = "r",
['с'] = "s",
['т'] = "t",
['у'] = "u",
['ф'] = "f",
['х'] = "h",
['ц'] = "c",
['ч'] = "ch",
['ш'] = "sh",
['щ'] = "sch",
['ъ'] = "",
['ы'] = "y",
['ь'] = "",
['э'] = "e",
['ю'] = "yu",
['я'] = "ya",
['і'] = "i",
['ї'] = "i",
['є'] = "e",
['ґ'] = "g"
};
static int Levenshtein(string a, string b)
{
a ??= string.Empty;
b ??= string.Empty;
if (a.Length < b.Length)
(a, b) = (b, a);
var previous = new int[b.Length + 1];
var current = new int[b.Length + 1];
for (int j = 0; j <= b.Length; j++)
previous[j] = j;
for (int i = 1; i <= a.Length; i++)
{
current[0] = i;
for (int j = 1; j <= b.Length; j++)
{
int substitution = previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
current[j] = Math.Min(Math.Min(previous[j] + 1, current[j - 1] + 1), substitution);
}
(previous, current) = (current, previous);
}
return previous[b.Length];
}
}
@@ -0,0 +1,108 @@
namespace Music;
public class SoundCloudCredentials
{
public string access_token { get; set; }
public string refresh_token { get; set; }
public string token_type { get; set; }
public string scope { get; set; }
public string username { get; set; }
public string user_urn { get; set; }
public long? expires_at_unix { get; set; }
}
public class SoundCloudTokenResponse
{
public string access_token { get; set; }
public string refresh_token { get; set; }
public string token_type { get; set; }
public string scope { get; set; }
public int? expires_in { get; set; }
}
public class SoundCloudCollectionDto<T>
{
public List<T> collection { get; set; } = new();
public string next_href { get; set; }
public string query_urn { get; set; }
}
public class SoundCloudNestedCollectionDto<T>
{
public List<T> collection { get; set; } = new();
}
public class SoundCloudChartSelectionDto
{
public string urn { get; set; }
public string title { get; set; }
public SoundCloudNestedCollectionDto<SoundCloudPlaylistDto> items { get; set; }
}
public class SoundCloudUserDto
{
public string urn { get; set; }
public string username { get; set; }
public string avatar_url { get; set; }
public string permalink_url { get; set; }
}
public class SoundCloudTrackDto
{
public string urn { get; set; }
public string title { get; set; }
public string artwork_url { get; set; }
public string permalink_url { get; set; }
public string stream_url { get; set; }
public string access { get; set; }
public int? duration { get; set; }
public int? full_duration { get; set; }
public string published_at { get; set; }
public SoundCloudUserDto user { get; set; }
public SoundCloudMediaDto media { get; set; }
}
public class SoundCloudPlaylistDto
{
public string urn { get; set; }
public string title { get; set; }
public string artwork_url { get; set; }
public string permalink_url { get; set; }
public int? track_count { get; set; }
public string published_at { get; set; }
public string display_date { get; set; }
public int? duration { get; set; }
public string genre { get; set; }
public SoundCloudUserDto user { get; set; }
public List<SoundCloudTrackDto> tracks { get; set; } = new();
}
public class SoundCloudMediaDto
{
public List<SoundCloudTranscodingDto> transcodings { get; set; } = new();
}
public class SoundCloudTranscodingDto
{
public string url { get; set; }
public string preset { get; set; }
public bool? snipped { get; set; }
public SoundCloudFormatDto format { get; set; }
}
public class SoundCloudFormatDto
{
public string protocol { get; set; }
public string mime_type { get; set; }
}
public class SoundCloudMatchPayload
{
public string track_id { get; set; }
public string urn { get; set; }
public string permalink_url { get; set; }
public string title { get; set; }
public string artist_name { get; set; }
public int? duration_ms { get; set; }
public string track_authorization { get; set; }
}
@@ -0,0 +1,748 @@
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Music;
// Audio capability: поиск и ранжирование матчей под трек, скоринг,
// выбор transcoding и построение stream-источников.
public static partial class SoundCloudSupport
{
public static async Task<IReadOnlyList<MusicAudioMatch>> SearchAudioAsync(MusicTrack track, CancellationToken cancellationToken = default)
{
if (track == null)
return Array.Empty<MusicAudioMatch>();
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return Array.Empty<MusicAudioMatch>();
var results = new List<JsonElement>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string exactTrackId = ParseTrackApiId(track.id);
if (!string.IsNullOrWhiteSpace(exactTrackId))
{
var exact = await LoadTrackElementAsync(exactTrackId, clientId, cancellationToken);
if (exact.HasValue && IsPlayableAudioTrack(exact.Value))
{
var exactMatch = MapAudioMatch(exact.Value);
if (exactMatch != null)
return new[] { exactMatch };
}
return Array.Empty<MusicAudioMatch>();
}
foreach (string query in BuildAudioQueries(track))
{
var elements = await SearchTrackElementsAsync(query, clientId, 10, cancellationToken);
if (elements == null)
continue;
foreach (var item in elements)
{
string id = GetString(item, "id");
if (string.IsNullOrWhiteSpace(id) || !seen.Add(id) || !IsPlayableAudioTrack(item))
continue;
results.Add(item);
if (results.Count >= 16)
break;
}
if (results.Count >= 16)
break;
}
return RankAudioMatches(track, results);
}
public static async Task<IReadOnlyList<MusicPlaybackSource>> BuildAudioSourcesAsync(MusicAudioMatch match, CancellationToken cancellationToken = default)
{
if (match == null)
return Array.Empty<MusicPlaybackSource>();
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return Array.Empty<MusicPlaybackSource>();
var payload = ParseMatchPayload(match);
string trackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
if (string.IsNullOrWhiteSpace(trackId))
return Array.Empty<MusicPlaybackSource>();
var track = await LoadTrackElementAsync(trackId, clientId, cancellationToken);
if (!track.HasValue || !track.Value.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object)
return Array.Empty<MusicPlaybackSource>();
if (!media.TryGetProperty("transcodings", out var transcodings) || transcodings.ValueKind != JsonValueKind.Array)
return Array.Empty<MusicPlaybackSource>();
var sources = new List<MusicPlaybackSource>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var transcoding in OrderPlayableTranscodings(transcodings))
{
string streamUrl = await ResolveTranscodingUrlAsync(transcoding, clientId, payload?.track_authorization, cancellationToken);
if (string.IsNullOrWhiteSpace(streamUrl) || IsPreviewStreamUrl(streamUrl) || !seen.Add(streamUrl))
continue;
string mime = GetFormatMime(transcoding);
string protocol = GetFormatProtocol(transcoding);
sources.Add(new MusicPlaybackSource
{
provider_id = AudioProviderId,
url = streamUrl,
mime_type = NormalizeSourceMime(protocol, mime, streamUrl),
quality = BuildQualityLabel(protocol, mime),
bitrate = EstimateBitrate(streamUrl, mime)
});
}
return sources;
}
public static async Task<MusicPlaybackSource> BuildPreferredAudioSourceAsync(MusicAudioMatch match, CancellationToken cancellationToken = default)
{
if (match == null)
return null;
string cacheKey = BuildPreferredSourceCacheKey(match);
if (!string.IsNullOrWhiteSpace(cacheKey))
{
var cached = await MusicMetadataCacheService.GetAsync<MusicPlaybackSource>(AudioProviderId, "preferred_source", cacheKey, cancellationToken);
if (cached != null && !string.IsNullOrWhiteSpace(cached.url))
return cached;
}
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var payload = ParseMatchPayload(match);
string trackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
if (string.IsNullOrWhiteSpace(trackId))
return null;
var track = await LoadTrackElementAsync(trackId, clientId, cancellationToken);
if (!track.HasValue || !track.Value.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object)
return null;
if (!media.TryGetProperty("transcodings", out var transcodings) || transcodings.ValueKind != JsonValueKind.Array)
return null;
foreach (var transcoding in OrderPlayableTranscodings(transcodings))
{
string streamUrl = await ResolveTranscodingUrlAsync(transcoding, clientId, payload?.track_authorization, cancellationToken);
if (string.IsNullOrWhiteSpace(streamUrl) || IsPreviewStreamUrl(streamUrl))
continue;
string mime = GetFormatMime(transcoding);
string protocol = GetFormatProtocol(transcoding);
var source = new MusicPlaybackSource
{
provider_id = AudioProviderId,
url = streamUrl,
mime_type = NormalizeSourceMime(protocol, mime, streamUrl),
quality = BuildQualityLabel(protocol, mime),
bitrate = EstimateBitrate(streamUrl, mime)
};
if (!string.IsNullOrWhiteSpace(cacheKey))
await MusicMetadataCacheService.SaveAsync(AudioProviderId, "preferred_source", cacheKey, source, preferredSourceCacheTtl, cancellationToken);
return source;
}
return null;
}
public static async Task<MusicAudioMatch> GetExactAudioMatchAsync(MusicTrack track, CancellationToken cancellationToken = default)
{
if (track == null)
return null;
string exactTrackId = ParseTrackApiId(track.id);
if (string.IsNullOrWhiteSpace(exactTrackId))
return null;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var exact = await LoadTrackElementAsync(exactTrackId, clientId, cancellationToken);
if (!exact.HasValue || !IsPlayableAudioTrack(exact.Value))
return null;
return MapAudioMatch(exact.Value);
}
static bool IsPreviewStreamUrl(string streamUrl)
{
streamUrl = NormalizeValue(streamUrl);
if (string.IsNullOrWhiteSpace(streamUrl))
return false;
if (streamUrl.Contains("cf-preview-media.sndcdn.com", StringComparison.OrdinalIgnoreCase))
return true;
if (streamUrl.Contains("/preview/", StringComparison.OrdinalIgnoreCase))
return true;
if (streamUrl.Contains("cf-hls-media.sndcdn.com/playlist/0/30/", StringComparison.OrdinalIgnoreCase))
return true;
if (streamUrl.Contains("cf-hls-media.sndcdn.com/media/0/30/", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
public static bool IsRelevantAudioMatch(MusicTrack track, MusicAudioMatch match)
{
if (track == null || match == null)
return false;
var payload = ParseMatchPayload(match);
string expectedTrackId = ParseTrackApiId(track.id);
string actualTrackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
if (!string.IsNullOrWhiteSpace(expectedTrackId))
return string.Equals(expectedTrackId, actualTrackId, StringComparison.OrdinalIgnoreCase);
return ScoreAudioMatch(track, match) >= 40;
}
public static bool HasExactTrackId(MusicTrack track)
=> !string.IsNullOrWhiteSpace(ParseTrackApiId(track?.id));
static List<string> BuildAudioQueries(MusicTrack track)
{
var queries = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(string value)
{
string normalized = NormalizeValue(value);
if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized))
queries.Add(normalized);
}
string title = NormalizeValue(track?.title);
string primaryArtist = NormalizeValue(track?.artist_name);
string allArtists = track?.artists == null ? null : string.Join(", ", track.artists.Where(a => !string.IsNullOrWhiteSpace(a)).Distinct(StringComparer.OrdinalIgnoreCase));
Add($"{primaryArtist} {title}");
Add($"{allArtists} {title}");
Add(title);
return queries;
}
static IReadOnlyList<MusicAudioMatch> RankAudioMatches(MusicTrack expectedTrack, IEnumerable<JsonElement> candidates)
{
var ranked = new List<(MusicAudioMatch match, int score)>();
foreach (var candidate in candidates)
{
var match = MapAudioMatch(candidate);
if (match == null)
continue;
int score = ScoreAudioMatch(expectedTrack, match);
if (score < 40)
continue;
match.duration_ms ??= GetInt(candidate, "full_duration") ?? GetInt(candidate, "duration");
ranked.Add((match, score));
}
return ranked
.OrderByDescending(x => x.score)
.ThenBy(x => Math.Abs((x.match.duration_ms ?? 0) - (expectedTrack?.duration_ms ?? 0)))
.ThenBy(x => x.match.title, StringComparer.OrdinalIgnoreCase)
.Select(x => x.match)
.ToList();
}
static MusicAudioMatch MapAudioMatch(JsonElement track)
{
string trackId = GetString(track, "id");
string rawTitle = NormalizeValue(GetString(track, "title"));
var titleParts = SplitArtistTitleFromSoundCloudTitle(rawTitle);
string title = NormalizeValue(titleParts.title) ?? rawTitle;
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(title))
return null;
string artistName = null;
string permalinkUrl = GetString(track, "permalink_url");
string urn = GetString(track, "urn");
var artists = ExtractCandidateArtists(track);
artistName = artists.FirstOrDefault();
var payload = new SoundCloudMatchPayload
{
track_id = trackId,
urn = urn,
permalink_url = permalinkUrl,
title = title,
artist_name = artistName,
duration_ms = GetInt(track, "full_duration") ?? GetInt(track, "duration"),
track_authorization = NormalizeValue(GetString(track, "track_authorization"))
};
return new MusicAudioMatch
{
provider_id = AudioProviderId,
id = !string.IsNullOrWhiteSpace(urn) ? urn : $"soundcloud:tracks:{trackId}",
title = title,
artists = artists,
isrc = GetTrackIsrc(track),
duration_ms = payload.duration_ms,
payload = MusicJson.Serialize(payload)
};
}
static int ScoreAudioMatch(MusicTrack expectedTrack, MusicAudioMatch candidate)
{
if (expectedTrack == null || candidate == null)
return 0;
string expectedTitle = NormalizeSearchText(expectedTrack.title);
string candidateTitle = NormalizeSearchText(candidate.title);
string expectedArtist = NormalizeSearchText(expectedTrack.artist_name);
var expectedArtists = BuildArtistFragments(new[] { expectedTrack.artist_name }.Concat(expectedTrack.artists ?? Enumerable.Empty<string>()));
var candidateArtists = BuildArtistFragments(candidate.artists);
string candidateArtist = candidateArtists.FirstOrDefault() ?? NormalizeSearchText(candidate.artists.FirstOrDefault());
string expectedIsrc = MusicIsrc.Normalize(expectedTrack.isrc);
string candidateIsrc = MusicIsrc.Normalize(candidate.isrc);
int score = 0;
bool artistMatched = false;
bool durationMatched = false;
if (expectedIsrc != null && candidateIsrc != null)
{
if (!string.Equals(expectedIsrc, candidateIsrc, StringComparison.Ordinal))
return int.MinValue;
score += 120;
}
if (!string.IsNullOrWhiteSpace(expectedTitle) && !string.IsNullOrWhiteSpace(candidateTitle))
{
if (string.Equals(expectedTitle, candidateTitle, StringComparison.Ordinal))
score += 60;
else if (candidateTitle.Contains(expectedTitle, StringComparison.Ordinal) || expectedTitle.Contains(candidateTitle, StringComparison.Ordinal))
score += 35;
else
score += CountSharedTokens(expectedTitle, candidateTitle) * 8;
}
if (expectedArtists.Count > 0 && candidateArtists.Count > 0)
{
if (candidateArtists.Any(a => expectedArtists.Contains(a)))
{
score += 30;
artistMatched = true;
}
else if (candidateArtists.Any(a => expectedArtists.Any(e => a.Contains(e, StringComparison.Ordinal) || e.Contains(a, StringComparison.Ordinal))))
{
score += 16;
artistMatched = true;
}
else
{
int sharedArtistTokens = expectedArtists.Max(e => candidateArtists.Max(a => CountSharedTokens(e, a)));
score += sharedArtistTokens * 6;
artistMatched = sharedArtistTokens > 0;
}
}
else if (!string.IsNullOrWhiteSpace(expectedArtist) && !string.IsNullOrWhiteSpace(candidateArtist))
{
if (string.Equals(expectedArtist, candidateArtist, StringComparison.Ordinal))
{
score += 30;
artistMatched = true;
}
else if (candidateArtist.Contains(expectedArtist, StringComparison.Ordinal) || expectedArtist.Contains(candidateArtist, StringComparison.Ordinal))
{
score += 16;
artistMatched = true;
}
else
{
int sharedArtistTokens = CountSharedTokens(expectedArtist, candidateArtist);
score += sharedArtistTokens * 6;
artistMatched = sharedArtistTokens > 0;
}
}
if (!artistMatched && expectedArtists.Any(a => candidateTitle.Contains(a, StringComparison.Ordinal)))
{
score += 16;
artistMatched = true;
}
if (expectedArtists.Count > 1 && candidateArtists.Count > 0)
{
foreach (string artist in expectedArtists.Skip(1).Take(3))
{
if (candidateArtists.Any(a => a == artist || a.Contains(artist, StringComparison.Ordinal)))
score += 4;
}
}
int expectedDuration = expectedTrack.duration_ms ?? 0;
int candidateDuration = candidate.duration_ms ?? 0;
if (expectedDuration > 0 && candidateDuration > 0)
{
double durationRatio = candidateDuration / (double)expectedDuration;
if (durationRatio >= 2d || durationRatio <= 0.5d)
return int.MinValue;
int delta = Math.Abs(expectedDuration - candidateDuration);
if (delta <= 2_000)
{
score += 18;
durationMatched = true;
}
else if (delta <= 5_000)
{
score += 12;
durationMatched = true;
}
else if (delta <= 10_000)
{
score += 6;
durationMatched = true;
}
else if (durationRatio >= 1.5d || durationRatio <= 0.67d) score -= 54;
else if (durationRatio >= 1.2d || durationRatio <= 0.8d) score -= 24;
else if (delta >= 30_000) score -= 12;
}
if (expectedArtists.Count > 0 && !artistMatched && !durationMatched)
score -= 36;
score -= ComputeDescriptorPenalty(expectedTitle, candidateTitle);
return score;
}
static int ComputeDescriptorPenalty(string expectedText, string candidateText)
{
int penalty = 0;
foreach (var descriptor in hardDescriptorPenalties)
{
bool expectedHas = HasDescriptor(expectedText, descriptor.term);
bool candidateHas = HasDescriptor(candidateText, descriptor.term);
if (!expectedHas && candidateHas)
penalty += descriptor.penalty;
}
return penalty;
}
static readonly (string term, int penalty)[] hardDescriptorPenalties =
[
("fast", 42),
("sped", 42),
("sped up", 42),
("slowed", 42),
("slowed reverb", 54),
("nightcore", 42),
("mp3 wav", 54),
("mp3", 28),
("wav", 28),
("reverb", 18),
("cover", 28),
("karaoke", 28),
("instrumental", 28),
("snippet", 36),
("looped", 28),
("8d", 24),
("bass boosted", 24),
("live", 18),
("remix", 18),
("remastered", 12),
("1 hour", 72),
("hour version", 72),
("long version", 42),
("extended", 12),
("tiktok", 18)
];
static bool HasDescriptor(string text, string descriptor)
{
string normalizedText = NormalizeSearchText(text);
string normalizedDescriptor = NormalizeSearchText(descriptor);
if (string.IsNullOrWhiteSpace(normalizedText) || string.IsNullOrWhiteSpace(normalizedDescriptor))
return false;
if (normalizedDescriptor.Contains(' ', StringComparison.Ordinal))
return normalizedText.Contains(normalizedDescriptor, StringComparison.Ordinal);
var tokens = normalizedText.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return tokens.Contains(normalizedDescriptor, StringComparer.Ordinal);
}
static List<string> ExtractCandidateArtists(JsonElement track)
{
var artists = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void AddMany(string value)
{
foreach (string artist in SplitArtistNames(value))
{
if (!string.IsNullOrWhiteSpace(artist) && seen.Add(artist))
artists.Add(artist);
}
}
if (track.TryGetProperty("publisher_metadata", out var publisher) && publisher.ValueKind == JsonValueKind.Object)
AddMany(GetString(publisher, "artist"));
var titleParts = SplitArtistTitleFromSoundCloudTitle(GetString(track, "title"));
AddMany(titleParts.artist);
if (track.TryGetProperty("user", out var user) && user.ValueKind == JsonValueKind.Object)
AddMany(GetString(user, "username"));
return artists;
}
static List<string> SplitArtistNames(string value)
{
if (string.IsNullOrWhiteSpace(value))
return new List<string>();
return value
.Split(new[] { ",", "&", " x ", " feat. ", " feat ", " ft. ", " ft ", " and " }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(i => NormalizeValue(i))
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
static List<string> BuildArtistFragments(IEnumerable<string> artists)
{
var fragments = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (string artist in artists ?? Enumerable.Empty<string>())
{
foreach (string part in SplitArtistNames(artist))
{
string normalized = NormalizeSearchText(part);
if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized))
fragments.Add(normalized);
}
}
return fragments;
}
static string NormalizeSearchText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var chars = value
.Trim()
.ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) ? c : ' ')
.ToArray();
return string.Join(' ', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
static int CountSharedTokens(string left, string right)
{
if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
return 0;
var leftTokens = left.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var rightSet = right.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.Ordinal);
int shared = 0;
foreach (string token in leftTokens)
{
if (rightSet.Contains(token))
shared++;
}
return shared;
}
static bool IsPlayableAudioTrack(JsonElement track)
{
if (!track.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object)
return false;
if (!media.TryGetProperty("transcodings", out var transcodings) || transcodings.ValueKind != JsonValueKind.Array)
return false;
return OrderPlayableTranscodings(transcodings).Any();
}
static IEnumerable<JsonElement> OrderPlayableTranscodings(JsonElement transcodings)
{
if (transcodings.ValueKind != JsonValueKind.Array)
return Array.Empty<JsonElement>();
return transcodings.EnumerateArray()
.Where(IsPlainPlayableTranscoding)
.Select(t => t.Clone())
.OrderByDescending(GetTranscodingPriority)
.ToList();
}
static bool IsPlainPlayableTranscoding(JsonElement transcoding)
{
string protocol = GetFormatProtocol(transcoding);
string mime = GetFormatMime(transcoding);
if (string.IsNullOrWhiteSpace(protocol) || protocol.Contains("encrypted", StringComparison.OrdinalIgnoreCase))
return false;
return protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase)
|| protocol.Equals("hls", StringComparison.OrdinalIgnoreCase)
|| mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase)
|| mime.Contains("mp4", StringComparison.OrdinalIgnoreCase);
}
static int GetTranscodingPriority(JsonElement transcoding)
{
string protocol = GetFormatProtocol(transcoding);
string mime = GetFormatMime(transcoding);
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return 400;
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase))
return 300;
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return 200;
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase))
return 100;
return 0;
}
static string GetFormatProtocol(JsonElement transcoding)
{
if (transcoding.TryGetProperty("format", out var format) && format.ValueKind == JsonValueKind.Object)
return NormalizeValue(GetString(format, "protocol")) ?? string.Empty;
return string.Empty;
}
static string GetFormatMime(JsonElement transcoding)
{
if (transcoding.TryGetProperty("format", out var format) && format.ValueKind == JsonValueKind.Object)
return NormalizeValue(GetString(format, "mime_type")) ?? "audio/mpeg";
return "audio/mpeg";
}
static async Task<string> ResolveTranscodingUrlAsync(JsonElement transcoding, string clientId, string trackAuthorization, CancellationToken cancellationToken)
{
string transcodingUrl = NormalizeValue(GetString(transcoding, "url"));
if (string.IsNullOrWhiteSpace(transcodingUrl))
return null;
var query = new List<string>
{
$"client_id={Uri.EscapeDataString(clientId)}"
};
if (!string.IsNullOrWhiteSpace(trackAuthorization))
query.Add($"track_authorization={Uri.EscapeDataString(trackAuthorization)}");
string url = transcodingUrl.Contains('?')
? $"{transcodingUrl}&{string.Join("&", query)}"
: $"{transcodingUrl}?{string.Join("&", query)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("licenseAuthToken", out _))
return null;
return NormalizeValue(GetString(document.RootElement, "url"));
}
static SoundCloudMatchPayload ParseMatchPayload(MusicAudioMatch match)
{
if (string.IsNullOrWhiteSpace(match?.payload))
return null;
try
{
return MusicJson.Deserialize<SoundCloudMatchPayload>(match.payload);
}
catch
{
return null;
}
}
static string BuildQualityLabel(string protocol, string mime)
{
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return "MP3";
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return "HLS MP3";
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase) && mime.Contains("mp4", StringComparison.OrdinalIgnoreCase))
return "HLS AAC";
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase))
return "Direct";
return "SoundCloud";
}
static string NormalizeSourceMime(string protocol, string mime, string streamUrl)
{
if (!string.IsNullOrWhiteSpace(protocol) && protocol.Contains("hls", StringComparison.OrdinalIgnoreCase))
return "application/vnd.apple.mpegurl";
if (!string.IsNullOrWhiteSpace(mime))
return mime;
return streamUrl.Contains(".m3u8", StringComparison.OrdinalIgnoreCase) ? "application/vnd.apple.mpegurl" : "audio/mpeg";
}
static int? EstimateBitrate(string streamUrl, string mime)
{
if (!string.IsNullOrWhiteSpace(streamUrl))
{
var match = Regex.Match(streamUrl, @"[._-](\d{2,3})k(?:\.|/)", RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out int bitrate))
return bitrate;
}
return mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase) ? 128 : null;
}
static string BuildPreferredSourceCacheKey(MusicAudioMatch match)
{
var payload = ParseMatchPayload(match);
string trackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
return string.IsNullOrWhiteSpace(trackId) ? null : $"preferred:{trackId}";
}
}
@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Music;
// OAuth credentials: парсинг payload, нормализация и статус токена.
public static partial class SoundCloudSupport
{
public static Dictionary<string, string> ParseSavePayload(string payload)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(payload))
return result;
foreach (var part in payload.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
int sep = part.IndexOf('=');
if (sep <= 0)
continue;
string key = WebUtility.UrlDecode(part.Substring(0, sep));
string value = WebUtility.UrlDecode(part[(sep + 1)..]);
if (!string.IsNullOrWhiteSpace(key))
result[key] = value;
}
return result;
}
public static SoundCloudCredentials NormalizeCredentials(SoundCloudCredentials credentials)
{
if (credentials == null)
return null;
credentials.access_token = NormalizeValue(credentials.access_token);
credentials.refresh_token = NormalizeValue(credentials.refresh_token);
credentials.token_type = NormalizeValue(credentials.token_type);
credentials.scope = NormalizeValue(credentials.scope);
credentials.username = NormalizeValue(credentials.username);
credentials.user_urn = NormalizeValue(credentials.user_urn);
return credentials;
}
public static bool HasAccessToken(SoundCloudCredentials credentials)
=> !string.IsNullOrWhiteSpace(NormalizeCredentials(credentials)?.access_token);
public static bool IsTokenExpired(SoundCloudCredentials credentials)
{
var normalized = NormalizeCredentials(credentials);
if (normalized?.expires_at_unix == null || normalized.expires_at_unix <= 0)
return false;
return normalized.expires_at_unix <= DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
public static string BuildStateMessage(SoundCloudCredentials credentials)
{
if (!HasOAuthConfig())
return "SoundCloud OAuth is not configured.";
if (!HasAccessToken(credentials))
return "SoundCloud is not connected.";
return IsTokenExpired(credentials)
? "SoundCloud token is saved, but expired."
: "SoundCloud is connected.";
}
public static string BuildClientCredentialsPayload()
{
if (!HasClientCredentials())
return string.Empty;
var parts = new List<string>
{
"grant_type=client_credentials",
$"client_id={Uri.EscapeDataString(ModInit.conf.soundcloud_client_id)}",
$"client_secret={Uri.EscapeDataString(ModInit.conf.soundcloud_client_secret)}"
};
return string.Join("&", parts);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,276 @@
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using static Music.MusicPlaylistImportHelpers;
namespace Music;
// Импорт пользовательских плейлистов и лайков по ссылке
// (профиль → лайки, плейлист → треки) для MusicUserPlaylistService.
public static partial class SoundCloudSupport
{
static async Task<MusicUserPlaylistImportResult> ImportResolvedPlaylistAsync(JsonElement playlist, string clientId, CancellationToken cancellationToken)
{
int? trackCount = GetInt(playlist, "track_count");
int loadedTracks = GetArrayLength(playlist, "tracks");
string playlistId = GetString(playlist, "id");
bool knownIncompleteTrackList = trackCount.HasValue && loadedTracks < trackCount.Value;
if (!string.IsNullOrWhiteSpace(playlistId) && (!trackCount.HasValue || loadedTracks < trackCount.Value))
{
var hydrated = await LoadPlaylistByIdAsync(playlistId, clientId, cancellationToken);
if (hydrated.HasValue)
playlist = hydrated.Value;
else if (knownIncompleteTrackList)
return ImportUnavailable("SoundCloud плейлист не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
}
var hydrationIds = CollectTrackIdsForHydration(playlist);
var trackDetails = await LoadTrackDetailsAsync(hydrationIds, clientId, cancellationToken);
if (hydrationIds.Count > 0 && !trackDetails.complete)
return ImportUnavailable("SoundCloud плейлист не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
var album = MapResolvedPlaylist(playlist, trackDetails.tracks);
if (album == null)
return ImportUnavailable("SoundCloud плейлист не удалось прочитать.");
var tracks = DeduplicateTracks(album.tracks);
if (tracks.Count == 0)
return ImportUnavailable("В SoundCloud плейлисте не найдено треков.");
string sourceUrl = album.provider_refs?.FirstOrDefault(i => i?.provider == DiscoveryProviderId)?.external_id
?? GetString(playlist, "permalink_url");
return new MusicUserPlaylistImportResult
{
available = true,
title = string.IsNullOrWhiteSpace(album.title) ? "SoundCloud Playlist" : album.title,
track_count = tracks.Count,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = "soundcloud_playlist",
url = sourceUrl,
playlist_id = playlistId,
title = album.title
}
};
}
static async Task<MusicUserPlaylistImportResult> ImportUserLikesAsync(JsonElement user, string clientId, CancellationToken cancellationToken)
{
const int likesImportLimit = 200;
string userId = GetString(user, "id");
string userUrl = GetString(user, "permalink_url");
string username = NormalizeValue(GetString(user, "username")) ?? "SoundCloud";
if (string.IsNullOrWhiteSpace(userId))
return ImportUnavailable("SoundCloud профиль не удалось прочитать.");
var album = new MusicAlbum
{
id = $"soundcloud:likes:{userId}",
title = "Мои лайки (SoundCloud)",
artist_name = username,
images = BuildImages(GetString(user, "avatar_url"))
};
var likes = await LoadLikedTrackElementsAsync(userId, clientId, likesImportLimit, cancellationToken);
if (!likes.complete)
return ImportUnavailable("SoundCloud лайки не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
string fallbackArtwork = SelectFirstImageUrl(album.images);
int position = 1;
foreach (var item in likes.items)
{
var trackElement = ExtractLikedTrack(item);
if (!trackElement.HasValue)
continue;
var mapped = MapPlaylistTrack(trackElement.Value, album, position++, fallbackArtwork);
if (mapped != null)
album.tracks.Add(mapped);
}
var tracks = DeduplicateTracks(album.tracks);
if (tracks.Count == 0)
return ImportUnavailable("Лайки SoundCloud не найдены или скрыты.");
return new MusicUserPlaylistImportResult
{
available = true,
message = likes.truncated
? $"Импортированы последние {tracks.Count} треков SoundCloud. У профиля есть ещё лайки, они не вошли в лимит {likesImportLimit}."
: null,
title = album.title,
track_count = tracks.Count,
truncated = likes.truncated,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = "soundcloud_likes",
url = userUrl,
user_id = userId,
title = username
}
};
}
static async Task<SoundCloudCollectionPage> LoadLikedTrackElementsAsync(string userId, string clientId, int limit, CancellationToken cancellationToken)
{
SoundCloudCollectionPage incomplete = null;
foreach (var path in new[] { $"users/{userId}/likes", $"users/{userId}/track_likes", $"users/{userId}/likes/tracks" })
{
var page = await SafeLoadCollectionElementsAsync(path, clientId, limit, cancellationToken);
var tracks = page.items.Where(item => ExtractLikedTrack(item).HasValue).ToList();
if (tracks.Count > 0)
{
return new SoundCloudCollectionPage
{
items = tracks,
complete = page.complete,
hasMore = page.hasMore,
nextPage = page.nextPage,
truncated = page.truncated
};
}
if (!page.complete)
incomplete ??= page;
}
return incomplete == null
? SoundCloudCollectionPage.Empty()
: new SoundCloudCollectionPage
{
complete = false,
hasMore = incomplete.hasMore,
nextPage = incomplete.nextPage
};
}
static JsonElement? ExtractLikedTrack(JsonElement item)
{
if (item.ValueKind != JsonValueKind.Object)
return null;
if (item.TryGetProperty("track", out var track) && track.ValueKind == JsonValueKind.Object)
return track;
string kind = NormalizeValue(GetString(item, "kind"));
if (string.Equals(kind, "track", StringComparison.OrdinalIgnoreCase) || item.TryGetProperty("title", out _))
return item;
return null;
}
static bool LooksLikePlaylist(JsonElement element)
=> element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty("tracks", out var tracks)
&& tracks.ValueKind == JsonValueKind.Array
&& !string.IsNullOrWhiteSpace(GetString(element, "permalink_url"));
static bool LooksLikeUser(JsonElement element)
=> element.ValueKind == JsonValueKind.Object
&& !string.IsNullOrWhiteSpace(GetString(element, "username"))
&& !string.IsNullOrWhiteSpace(GetString(element, "permalink_url"));
static string NormalizeSoundCloudUrl(string value)
{
value = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(value))
return null;
if (!value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
value = "https://" + value.TrimStart('/');
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return null;
string host = uri.Host.Trim().ToLowerInvariant();
if (host == "m.soundcloud.com" || host == "www.soundcloud.com")
host = "soundcloud.com";
if (host != "soundcloud.com")
return null;
string path = uri.AbsolutePath.TrimEnd('/');
if (string.IsNullOrWhiteSpace(path) || path == "/")
return null;
return $"https://soundcloud.com{path}";
}
static async Task<string> ExpandSoundCloudShortUrlAsync(string value, CancellationToken cancellationToken)
{
value = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(value))
return null;
if (!value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
value = "https://" + value.TrimStart('/');
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return null;
string host = uri.Host.Trim().ToLowerInvariant();
if (host != "on.soundcloud.com")
return null;
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, uri);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
string finalUrl = response.RequestMessage?.RequestUri?.ToString();
return NormalizeSoundCloudUrl(finalUrl);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
public static async Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(string inputUrl, CancellationToken cancellationToken = default)
{
string soundCloudUrl = NormalizeSoundCloudUrl(inputUrl)
?? await ExpandSoundCloudShortUrlAsync(inputUrl, cancellationToken);
if (string.IsNullOrWhiteSpace(soundCloudUrl))
return ImportUnavailable("Вставь ссылку на SoundCloud профиль или плейлист.");
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return ImportUnavailable("Не удалось получить SoundCloud client_id.");
var resolved = await ResolveUrlElementAsync(soundCloudUrl, clientId, cancellationToken);
if (!resolved.HasValue)
return ImportUnavailable("SoundCloud ссылку не удалось открыть.");
var element = resolved.Value;
string kind = NormalizeValue(GetString(element, "kind"));
if (string.Equals(kind, "user", StringComparison.OrdinalIgnoreCase) || LooksLikeUser(element))
return await ImportUserLikesAsync(element, clientId, cancellationToken);
if (string.Equals(kind, "playlist", StringComparison.OrdinalIgnoreCase) || LooksLikePlaylist(element))
return await ImportResolvedPlaylistAsync(element, clientId, cancellationToken);
return ImportUnavailable("Поддерживаются только SoundCloud профиль и плейлист.");
}
public static Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
return Task.FromResult(ImportUnavailable("У плейлиста нет SoundCloud источника."));
return ImportPlaylistAsync(source.url, cancellationToken);
}
}
@@ -0,0 +1,662 @@
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Music;
// Транспорт и общая инфраструктура SoundCloud: client_id (конфиг или скрап с кэшем),
// построение URL/заголовков, resolve, постраничные коллекции api-v2, JSON-хелперы.
// Остальные части класса: .Catalog.cs (discovery), .Audio.cs (матчинг/стримы),
// .Import.cs (импорт плейлистов/лайков), .Auth.cs (OAuth credentials).
public static partial class SoundCloudSupport
{
public const string AuthProviderId = "soundcloud";
public const string AudioProviderId = "soundcloudaudio";
public const string DiscoveryProviderId = "soundcloudcharts";
public const string ApiBaseUrl = "https://api.soundcloud.com";
public const string ApiV2BaseUrl = "https://api-v2.soundcloud.com";
public const string OAuthTokenUrl = "https://secure.soundcloud.com/oauth/token";
public const string ChartsSectionId = "browse:soundcloud";
public const string SearchSectionId = "search:soundcloud";
public const string SearchTracksSectionId = "search:soundcloud:tracks";
public const string SearchAlbumsSectionId = "search:soundcloud:albums";
public const string SearchPlaylistsSectionId = "search:soundcloud:playlists";
public const string SearchArtistsSectionId = "search:soundcloud:artists";
public const string PlaylistIdPrefix = "soundcloud:playlist:";
public const string UserIdPrefix = "soundcloud:user:";
public const string UserTracksAlbumPrefix = "soundcloud:usertracks:";
public const string UserSectionPrefix = "soundcloud:usersection:";
static readonly HttpClient httpClient = MusicHttp.CreateClient("soundcloud");
static readonly Regex apiClientIdRegex = new("\"apiClient\".*?\"id\":\"([^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
static readonly Regex publicClientIdRegex = new("(?:client_id|clientId)\\s*[:=]\\s*[\"']?([A-Za-z0-9_-]{20,})", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex publicAssetRegex = new("https://a-v2\\.sndcdn\\.com/assets/[^\"'<>\\s]+\\.js", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex titleArtistSeparatorRegex = new(@"^\s*(?<artist>.+?)\s+(?:-|–|—|:)\s+(?<title>.+?)\s*$", RegexOptions.Compiled);
static readonly TimeSpan chartsCacheTtl = TimeSpan.FromHours(1);
static readonly TimeSpan playlistCacheTtl = TimeSpan.FromMinutes(30);
static readonly TimeSpan trackCacheTtl = TimeSpan.FromMinutes(30);
static readonly TimeSpan preferredSourceCacheTtl = TimeSpan.FromMinutes(20);
static readonly TimeSpan publicClientIdCacheTtl = TimeSpan.FromHours(6);
static readonly SemaphoreSlim publicClientIdLock = new(1, 1);
static string publicClientId;
static DateTime publicClientIdExpiresAt;
const string chartsCacheVersion = "v9";
static SoundCloudSupport()
{
httpClient.Timeout = TimeSpan.FromSeconds(20);
}
public static HttpClient HttpClient => httpClient;
public static bool IsModuleEnabled => ModInit.conf?.soundcloud_enabled == true;
public static bool IsDiscoveryEnabled => IsModuleEnabled && ModInit.conf?.soundcloud_discovery_enabled == true;
public static bool IsAudioEnabled => IsModuleEnabled && ModInit.conf?.soundcloud_audio_enabled == true;
public static bool IsAuthEnabled => IsModuleEnabled && ModInit.conf?.soundcloud_auth_enabled == true && HasOAuthConfig();
public static bool HasClientId() => !string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_client_id);
public static bool HasClientCredentials() => HasClientId() && !string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_client_secret);
public static bool HasOAuthConfig() => HasClientCredentials() && !string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_redirect_uri);
public static string Country => NormalizeCountry(ModInit.conf?.soundcloud_country);
public static string NormalizeCountry(string country)
{
country = country?.Trim().ToUpperInvariant();
return country is "GB" or "UK" ? "UK" : "US";
}
public static string BuildApiUrl(string path, IDictionary<string, string> query = null)
{
var baseUrl = $"{ApiBaseUrl.TrimEnd('/')}/{path?.TrimStart('/')}";
if (query == null || query.Count == 0)
return baseUrl;
var items = query
.Where(i => !string.IsNullOrWhiteSpace(i.Key) && !string.IsNullOrWhiteSpace(i.Value))
.Select(i => $"{Uri.EscapeDataString(i.Key)}={Uri.EscapeDataString(i.Value)}")
.ToList();
return items.Count == 0 ? baseUrl : $"{baseUrl}?{string.Join("&", items)}";
}
public static string BuildApiV2Url(string path, IDictionary<string, string> query = null)
{
var baseUrl = $"{ApiV2BaseUrl.TrimEnd('/')}/{path?.TrimStart('/')}";
if (query == null || query.Count == 0)
return baseUrl;
var items = query
.Where(i => !string.IsNullOrWhiteSpace(i.Key) && !string.IsNullOrWhiteSpace(i.Value))
.Select(i => $"{Uri.EscapeDataString(i.Key)}={Uri.EscapeDataString(i.Value)}")
.ToList();
return items.Count == 0 ? baseUrl : $"{baseUrl}?{string.Join("&", items)}";
}
public static string BuildChartsUrl(bool trending, string genre = "all-music")
{
string route = trending ? "new" : "";
string url = $"https://soundcloud.com/charts/{route}".TrimEnd('/');
return $"{url}?country={Uri.EscapeDataString(Country)}&genre={Uri.EscapeDataString(string.IsNullOrWhiteSpace(genre) ? "all-music" : genre)}";
}
public static Dictionary<string, string> CreatePublicHeaders(string accessToken = null)
{
var headers = new Dictionary<string, string>
{
["Accept"] = "application/json; charset=utf-8",
["User-Agent"] = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)"
};
if (!string.IsNullOrWhiteSpace(accessToken))
headers["Authorization"] = $"OAuth {accessToken.Trim()}";
return headers;
}
public static async Task<string> GetClientIdAsync(CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_client_id))
return ModInit.conf.soundcloud_client_id.Trim();
if (!string.IsNullOrWhiteSpace(publicClientId) && publicClientIdExpiresAt > DateTime.UtcNow)
return publicClientId;
await publicClientIdLock.WaitAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(publicClientId) && publicClientIdExpiresAt > DateTime.UtcNow)
return publicClientId;
string clientId = await FetchPublicClientIdAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(clientId))
{
publicClientId = clientId;
publicClientIdExpiresAt = DateTime.UtcNow.Add(publicClientIdCacheTtl);
}
return clientId;
}
finally
{
publicClientIdLock.Release();
}
}
static async Task<string> FetchPublicClientIdAsync(CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "https://soundcloud.com/");
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, cancellationToken);
string html = await response.Content.ReadAsStringAsync(cancellationToken);
string clientId = ExtractPublicClientId(html);
if (!string.IsNullOrWhiteSpace(clientId))
return clientId;
foreach (Match asset in publicAssetRegex.Matches(html ?? string.Empty).Take(12))
{
try
{
using var assetRequest = new HttpRequestMessage(HttpMethod.Get, asset.Value);
assetRequest.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
assetRequest.Headers.TryAddWithoutValidation("Accept", "application/javascript,*/*");
using var assetResponse = await httpClient.SendAsync(assetRequest, cancellationToken);
if (!assetResponse.IsSuccessStatusCode)
continue;
string script = await assetResponse.Content.ReadAsStringAsync(cancellationToken);
clientId = ExtractPublicClientId(script);
if (!string.IsNullOrWhiteSpace(clientId))
return clientId;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
// SoundCloud rotates asset bundles often; try the next one.
}
}
return null;
}
static string ExtractPublicClientId(string content)
{
if (string.IsNullOrWhiteSpace(content))
return null;
var legacy = apiClientIdRegex.Match(content);
if (legacy.Success)
return legacy.Groups[1].Value.Trim();
var current = publicClientIdRegex.Match(content);
return current.Success ? current.Groups[1].Value.Trim() : null;
}
static void InvalidatePublicClientIdOnUnauthorized(HttpStatusCode statusCode, string clientId)
{
if (statusCode != HttpStatusCode.Unauthorized || string.IsNullOrWhiteSpace(clientId))
return;
if (!string.Equals(publicClientId, clientId, StringComparison.Ordinal))
return;
publicClientId = null;
publicClientIdExpiresAt = DateTime.MinValue;
}
static async Task<JsonElement?> ResolveUrlElementAsync(string entityUrl, string clientId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(entityUrl) || string.IsNullOrWhiteSpace(clientId))
return null;
string url = BuildApiV2Url("resolve", new Dictionary<string, string>
{
["url"] = entityUrl,
["client_id"] = clientId
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
static async Task<JsonElement?> LoadPlaylistByIdAsync(string playlistId, string clientId, CancellationToken cancellationToken)
{
string url = BuildApiV2Url($"playlists/{playlistId}", new Dictionary<string, string>
{
["representation"] = "full",
["client_id"] = clientId
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
static async Task<SoundCloudTrackDetails> LoadTrackDetailsAsync(List<string> ids, string clientId, CancellationToken cancellationToken)
{
var result = new SoundCloudTrackDetails();
if (ids == null || ids.Count == 0 || string.IsNullOrWhiteSpace(clientId))
return result;
foreach (var chunk in ids.Distinct(StringComparer.OrdinalIgnoreCase).Chunk(50))
{
string url = BuildApiV2Url("tracks", new Dictionary<string, string>
{
["ids"] = string.Join(",", chunk),
["client_id"] = clientId
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
result.complete = false;
continue;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
result.complete = false;
continue;
}
foreach (var track in document.RootElement.EnumerateArray())
{
string id = GetString(track, "id");
if (!string.IsNullOrWhiteSpace(id))
result.tracks[id] = track.Clone();
}
}
return result;
}
static async Task<JsonElement?> LoadTrackElementAsync(string trackId, string clientId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(clientId))
return null;
string url = BuildApiV2Url($"tracks/{trackId}", new Dictionary<string, string>
{
["client_id"] = clientId
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
sealed class SoundCloudCollectionPage
{
public List<JsonElement> items { get; set; } = new();
public bool complete { get; set; } = true;
public bool hasMore { get; set; }
public string nextPage { get; set; }
public bool truncated { get; set; }
public static SoundCloudCollectionPage Empty() => new();
}
sealed class SoundCloudTrackDetails
{
public Dictionary<string, JsonElement> tracks { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public bool complete { get; set; } = true;
}
static async Task<SoundCloudCollectionPage> SafeLoadCollectionElementsAsync(
string path,
string clientId,
int limit,
CancellationToken cancellationToken,
IDictionary<string, string> query = null,
string pageUrl = null)
{
try
{
return await LoadCollectionElementsAsync(path, clientId, limit, cancellationToken, query, pageUrl);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new SoundCloudCollectionPage { complete = false };
}
}
static async Task<SoundCloudCollectionPage> LoadCollectionElementsAsync(
string path,
string clientId,
int limit,
CancellationToken cancellationToken,
IDictionary<string, string> query = null,
string pageUrl = null)
{
var results = new List<JsonElement>();
var result = new SoundCloudCollectionPage { items = results };
if ((string.IsNullOrWhiteSpace(path) && string.IsNullOrWhiteSpace(pageUrl)) || string.IsNullOrWhiteSpace(clientId))
return result;
int target = Math.Max(1, limit);
var queryParams = query == null
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string>(query, StringComparer.OrdinalIgnoreCase);
string url;
if (!string.IsNullOrWhiteSpace(pageUrl))
{
url = EnsureSoundCloudCollectionUrl(pageUrl, clientId);
}
else
{
queryParams["client_id"] = clientId;
queryParams["limit"] = target.ToString();
queryParams["linked_partitioning"] = "1";
url = BuildApiV2Url(path, queryParams);
}
for (int page = 0; page < 8 && !string.IsNullOrWhiteSpace(url) && results.Count < target; page++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
result.complete = false;
result.hasMore = true;
result.nextPage = CleanSoundCloudPageUrl(url);
return result;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in document.RootElement.EnumerateArray())
{
if (results.Count >= target)
break;
results.Add(item.Clone());
}
result.hasMore = false;
result.nextPage = null;
break;
}
if (!document.RootElement.TryGetProperty("collection", out var collection) || collection.ValueKind != JsonValueKind.Array)
{
result.complete = false;
break;
}
int beforeCount = results.Count;
foreach (var item in collection.EnumerateArray())
{
if (results.Count >= target)
break;
results.Add(item.Clone());
}
string nextUrl = GetString(document.RootElement, "next_href");
result.hasMore = !string.IsNullOrWhiteSpace(nextUrl) && results.Count > beforeCount;
result.nextPage = result.hasMore ? CleanSoundCloudPageUrl(nextUrl) : null;
result.truncated = result.hasMore && results.Count >= target;
url = result.hasMore && results.Count < target
? EnsureSoundCloudCollectionUrl(nextUrl, clientId)
: null;
}
if (!string.IsNullOrWhiteSpace(url) && results.Count < target)
{
result.complete = false;
result.hasMore = true;
result.nextPage = CleanSoundCloudPageUrl(url);
}
if (result.hasMore && results.Count >= target)
result.truncated = true;
return result;
}
static async Task<List<JsonElement>> LoadUserTrackElementsAsync(string userId, string clientId, int limit, CancellationToken cancellationToken)
{
var page = await LoadCollectionElementsAsync($"users/{userId}/tracks", clientId, limit, cancellationToken);
return page.items;
}
static string EnsureSoundCloudCollectionUrl(string url, string clientId)
{
if (string.IsNullOrWhiteSpace(url))
return url;
if (!url.StartsWith(ApiV2BaseUrl, StringComparison.OrdinalIgnoreCase))
return null;
url = AddQueryParameterIfMissing(url, "client_id", clientId);
return AddQueryParameterIfMissing(url, "linked_partitioning", "1");
}
static string CleanSoundCloudPageUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
if (!url.StartsWith(ApiV2BaseUrl, StringComparison.OrdinalIgnoreCase))
return null;
int queryIndex = url.IndexOf('?');
if (queryIndex < 0)
return url;
string path = url.Substring(0, queryIndex);
var query = url.Substring(queryIndex + 1)
.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(part => !part.StartsWith("client_id=", StringComparison.OrdinalIgnoreCase))
.ToList();
return query.Count == 0 ? path : $"{path}?{string.Join("&", query)}";
}
static string AddQueryParameterIfMissing(string url, string key, string value)
{
if (string.IsNullOrWhiteSpace(url) || string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
return url;
if (Regex.IsMatch(url, $@"[?&]{Regex.Escape(key)}=", RegexOptions.IgnoreCase))
return url;
char separator = url.Contains('?') ? '&' : '?';
return $"{url}{separator}{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}";
}
static async Task<List<JsonElement>> SearchTrackElementsAsync(string query, string clientId, int limit, CancellationToken cancellationToken)
{
return await SearchElementsAsync("search/tracks", query, clientId, limit, cancellationToken);
}
static async Task<List<JsonElement>> SearchElementsAsync(string path, string query, string clientId, int limit, CancellationToken cancellationToken)
{
var results = new List<JsonElement>();
if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(clientId))
return results;
try
{
string url = BuildApiV2Url(path, new Dictionary<string, string>
{
["q"] = query.Trim(),
["client_id"] = clientId,
["limit"] = Math.Max(1, limit).ToString(),
["linked_partitioning"] = "1"
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("collection", out var collection) || collection.ValueKind != JsonValueKind.Array)
return null;
foreach (var item in collection.EnumerateArray())
results.Add(item.Clone());
return results;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
static (string artist, string title) SplitArtistTitleFromSoundCloudTitle(string value)
{
string normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
return (null, null);
var match = titleArtistSeparatorRegex.Match(normalized);
if (!match.Success)
return (null, normalized);
string artist = NormalizeValue(match.Groups["artist"].Value);
string title = NormalizeValue(match.Groups["title"].Value);
if (string.IsNullOrWhiteSpace(artist) || string.IsNullOrWhiteSpace(title))
return (null, normalized);
return (artist, title);
}
static string ParseTrackApiId(string id)
{
if (string.IsNullOrWhiteSpace(id))
return null;
var match = Regex.Match(id.Trim(), @"soundcloud:tracks?:(\d+)$", RegexOptions.IgnoreCase);
return match.Success ? match.Groups[1].Value : null;
}
static string GetString(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value))
return null;
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.ToString(),
JsonValueKind.True => bool.TrueString,
JsonValueKind.False => bool.FalseString,
_ => null
};
}
static int? GetInt(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value))
return null;
if (value.ValueKind == JsonValueKind.Number)
{
if (value.TryGetInt32(out var intValue))
return intValue;
if (value.TryGetInt64(out var longValue) && longValue is <= int.MaxValue and >= int.MinValue)
return (int)longValue;
}
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out var parsed))
return parsed;
return null;
}
static string GetTrackIsrc(JsonElement track)
{
if (!track.TryGetProperty("publisher_metadata", out var publisher) || publisher.ValueKind != JsonValueKind.Object)
return null;
return MusicIsrc.Normalize(GetString(publisher, "isrc"));
}
static int GetArrayLength(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.Array)
return 0;
return value.GetArrayLength();
}
static string NormalizeValue(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,266 @@
using System.Text.Json;
namespace Music;
public class YandexMusicAudioProvider : IMusicAudioProvider
{
public string Id => "yandexmusic";
public string Name => "Yandex Music";
public bool Enabled => true;
public bool RequiresAuth => true;
public bool CacheMissingMatches => false;
public async Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (track == null)
return Array.Empty<MusicAudioMatch>();
var credentials = await MusicAuthStorageService.GetAsync<YandexMusicCredentials>(profileId, Id, cancellationToken);
if (!YandexMusicSupport.HasApiToken(credentials))
return Array.Empty<MusicAudioMatch>();
string query = YandexMusicSupport.BuildTrackQuery(track);
if (string.IsNullOrWhiteSpace(query))
return Array.Empty<MusicAudioMatch>();
try
{
using var http = MusicHttp.CreateClient("yandexmusic");
YandexMusicSupport.ApplyHeaders(http, YandexMusicSupport.CreateApiHeaders(credentials));
string url = $"https://api.music.yandex.net/search?text={HttpUtility.UrlEncode(query)}&type=track&page=0&page-size=12&nocorrect=false";
using var response = await http.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
return Array.Empty<MusicAudioMatch>();
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("result", out var result) ||
!result.TryGetProperty("tracks", out var tracks) ||
!tracks.TryGetProperty("results", out var items) ||
items.ValueKind != JsonValueKind.Array)
{
return Array.Empty<MusicAudioMatch>();
}
var matches = new List<MusicAudioMatch>();
foreach (var item in items.EnumerateArray())
{
string trackId = item.TryGetProperty("id", out var idProp) ? idProp.ToString() : null;
string title = item.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null;
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(title))
continue;
var artists = new List<string>();
if (item.TryGetProperty("artists", out var artistsProp) && artistsProp.ValueKind == JsonValueKind.Array)
{
foreach (var artist in artistsProp.EnumerateArray())
{
if (artist.TryGetProperty("name", out var nameProp))
{
var name = nameProp.GetString();
if (!string.IsNullOrWhiteSpace(name))
artists.Add(name);
}
}
}
string albumTitle = null;
string albumId = null;
if (item.TryGetProperty("albums", out var albumsProp) && albumsProp.ValueKind == JsonValueKind.Array)
{
var album = albumsProp.EnumerateArray().FirstOrDefault();
if (album.ValueKind != JsonValueKind.Undefined)
{
if (album.TryGetProperty("title", out var albumTitleProp))
albumTitle = albumTitleProp.GetString();
if (album.TryGetProperty("id", out var albumIdProp))
albumId = albumIdProp.ToString();
}
}
int? duration = null;
if (item.TryGetProperty("durationMs", out var durationProp))
{
if (durationProp.ValueKind == JsonValueKind.Number && durationProp.TryGetInt32(out var dur))
duration = dur;
else if (durationProp.ValueKind == JsonValueKind.String && int.TryParse(durationProp.GetString(), out dur))
duration = dur;
}
matches.Add(new MusicAudioMatch
{
provider_id = Id,
id = trackId,
title = title,
artists = artists,
album_title = albumTitle,
duration_ms = duration,
payload = MusicJson.Serialize(new YandexMusicMatchPayload
{
track_id = trackId,
album_id = albumId,
album_title = albumTitle
})
});
}
return YandexMusicSupport.RankMatches(track, matches).Take(5).ToList();
}
catch
{
return Array.Empty<MusicAudioMatch>();
}
}
public async Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (match == null || string.IsNullOrWhiteSpace(match.id))
return Array.Empty<MusicPlaybackSource>();
var credentials = await MusicAuthStorageService.GetAsync<YandexMusicCredentials>(profileId, Id, cancellationToken);
if (!YandexMusicSupport.HasApiToken(credentials))
return Array.Empty<MusicPlaybackSource>();
try
{
using var http = MusicHttp.CreateClient("yandexmusic");
YandexMusicSupport.ApplyHeaders(http, YandexMusicSupport.CreateApiHeaders(credentials));
using var response = await http.GetAsync($"https://api.music.yandex.net/tracks/{match.id}/download-info", cancellationToken);
if (!response.IsSuccessStatusCode)
return Array.Empty<MusicPlaybackSource>();
string json = await response.Content.ReadAsStringAsync(cancellationToken);
var downloads = YandexMusicSupport.ParseDownloadInfos(json);
var selected = downloads.FirstOrDefault();
if (selected == null || string.IsNullOrWhiteSpace(selected.download_info_url))
return Array.Empty<MusicPlaybackSource>();
using var detailsResponse = await http.GetAsync(selected.download_info_url, cancellationToken);
if (!detailsResponse.IsSuccessStatusCode)
return Array.Empty<MusicPlaybackSource>();
string raw = await detailsResponse.Content.ReadAsStringAsync(cancellationToken);
if (!YandexMusicSupport.TryParseDownloadPayload(raw, out var host, out var path, out var ts, out var s))
return Array.Empty<MusicPlaybackSource>();
string streamUrl = YandexMusicSupport.BuildStreamUrl(host, path, ts, s);
return new List<MusicPlaybackSource>
{
new()
{
provider_id = Id,
url = streamUrl,
mime_type = string.Equals(selected.codec, "aac", StringComparison.OrdinalIgnoreCase) ? "audio/aac" : "audio/mpeg",
bitrate = selected.bitrate_kbps,
quality = selected.bitrate_kbps.HasValue ? $"{(selected.codec ?? "audio").ToUpperInvariant()} {selected.bitrate_kbps}kbps" : (selected.codec ?? "audio").ToUpperInvariant(),
headers = new Dictionary<string, string>
{
["Referer"] = "https://music.yandex.ru/"
}
}
};
}
catch
{
return Array.Empty<MusicPlaybackSource>();
}
}
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
return Task.FromResult<MusicPlaybackSource>(null);
}
public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
}
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
{
return track != null && match != null;
}
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
{
return false;
}
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
{
return Array.Empty<string>();
}
}
public class YandexMusicAuthProvider : IMusicAuthProvider
{
public string Id => "yandexmusic";
public string Name => "Yandex Music";
public bool Enabled => true;
public async Task<MusicAuthState> GetStateAsync(string profileId = null, CancellationToken cancellationToken = default)
{
var credentials = await MusicAuthStorageService.GetAsync<YandexMusicCredentials>(profileId, Id, cancellationToken);
credentials = YandexMusicSupport.Normalize(credentials);
bool hasToken = YandexMusicSupport.HasApiToken(credentials);
bool hasWeb = YandexMusicSupport.HasWebAuth(credentials);
return new MusicAuthState
{
provider_id = Id,
provider_name = Name,
authenticated = hasToken || hasWeb,
requires_auth = true,
token_ready = hasToken,
web_ready = hasWeb,
mode = "token+cookies",
message = YandexMusicSupport.BuildStateMessage(hasToken, hasWeb)
};
}
public async Task<bool> SaveAsync(string payload, string profileId = null, CancellationToken cancellationToken = default)
{
var parsed = YandexMusicSupport.ParseSavePayload(payload);
if (parsed.Count == 0)
return false;
var credentials = await MusicAuthStorageService.GetAsync<YandexMusicCredentials>(profileId, Id, cancellationToken) ?? new YandexMusicCredentials();
if (parsed.TryGetValue("ym_token", out var ymToken))
credentials.ym_token = ymToken;
if (parsed.TryGetValue("ym_session_id", out var ymSessionId))
credentials.ym_session_id = ymSessionId;
if (parsed.TryGetValue("ym_sessionid2", out var ymSessionId2))
credentials.ym_sessionid2 = ymSessionId2;
if (parsed.TryGetValue("ym_yandexuid", out var ymYandexUid))
credentials.ym_yandexuid = ymYandexUid;
if (parsed.TryGetValue("ym_yandex_login", out var ymYandexLogin))
credentials.ym_yandex_login = ymYandexLogin;
if (parsed.TryGetValue("ym_l_token", out var ymLToken))
credentials.ym_l_token = ymLToken;
credentials = YandexMusicSupport.Normalize(credentials);
if (YandexMusicSupport.IsEmpty(credentials))
{
await MusicAuthStorageService.DeleteAsync(profileId, Id, cancellationToken);
return true;
}
await MusicAuthStorageService.SaveAsync(profileId, Id, credentials, cancellationToken);
return true;
}
public Task LogoutAsync(string profileId = null, CancellationToken cancellationToken = default)
=> MusicAuthStorageService.DeleteAsync(profileId, Id, cancellationToken);
}
@@ -0,0 +1,378 @@
using System.Net.Http;
using System.Text.Json;
using System.Xml.Linq;
using Shared.Services.Utilities;
namespace Music;
internal sealed class YandexDownloadInfo
{
public string download_info_url { get; set; }
public int? bitrate_kbps { get; set; }
public string codec { get; set; }
}
internal static class YandexMusicSupport
{
public const string ProviderId = "yandexmusic";
public const string Salt = "XGRlBW9FXlekgbPrRHuSiA";
static readonly Dictionary<string, string> KeyMap = new(StringComparer.OrdinalIgnoreCase)
{
["ym_token"] = "ym_token",
["token"] = "ym_token",
["oauth"] = "ym_token",
["ym_session_id"] = "ym_session_id",
["session_id"] = "ym_session_id",
["Session_id"] = "ym_session_id",
["ym_sessionid2"] = "ym_sessionid2",
["sessionid2"] = "ym_sessionid2",
["ym_yandexuid"] = "ym_yandexuid",
["yandexuid"] = "ym_yandexuid",
["ym_yandex_login"] = "ym_yandex_login",
["yandex_login"] = "ym_yandex_login",
["login"] = "ym_yandex_login",
["ym_l_token"] = "ym_l_token",
["l_token"] = "ym_l_token",
["L"] = "ym_l_token"
};
public static bool HasApiToken(YandexMusicCredentials credentials)
=> !string.IsNullOrWhiteSpace(credentials?.ym_token);
public static bool HasWebAuth(YandexMusicCredentials credentials)
{
if (credentials == null)
return false;
bool hasSession = !string.IsNullOrWhiteSpace(credentials.ym_session_id) ||
!string.IsNullOrWhiteSpace(credentials.ym_sessionid2);
return hasSession &&
!string.IsNullOrWhiteSpace(credentials.ym_yandexuid) &&
!string.IsNullOrWhiteSpace(credentials.ym_yandex_login) &&
!string.IsNullOrWhiteSpace(credentials.ym_l_token);
}
public static YandexMusicCredentials Normalize(YandexMusicCredentials credentials)
{
credentials ??= new YandexMusicCredentials();
credentials.ym_token = NormalizeValue(credentials.ym_token);
credentials.ym_session_id = NormalizeValue(credentials.ym_session_id);
credentials.ym_sessionid2 = NormalizeValue(credentials.ym_sessionid2);
credentials.ym_yandexuid = NormalizeValue(credentials.ym_yandexuid);
credentials.ym_yandex_login = NormalizeValue(credentials.ym_yandex_login);
credentials.ym_l_token = NormalizeValue(credentials.ym_l_token);
return credentials;
}
public static bool IsEmpty(YandexMusicCredentials credentials)
{
credentials = Normalize(credentials);
return string.IsNullOrWhiteSpace(credentials.ym_token) &&
string.IsNullOrWhiteSpace(credentials.ym_session_id) &&
string.IsNullOrWhiteSpace(credentials.ym_sessionid2) &&
string.IsNullOrWhiteSpace(credentials.ym_yandexuid) &&
string.IsNullOrWhiteSpace(credentials.ym_yandex_login) &&
string.IsNullOrWhiteSpace(credentials.ym_l_token);
}
public static Dictionary<string, string> CreateApiHeaders(YandexMusicCredentials credentials)
{
credentials = Normalize(credentials);
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Accept"] = "application/json, text/plain, */*",
["Accept-Language"] = "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
["Referer"] = "https://music.yandex.ru/"
};
if (HasApiToken(credentials))
headers["Authorization"] = $"OAuth {credentials.ym_token}";
return headers;
}
public static Dictionary<string, string> CreateWebHeaders(YandexMusicCredentials credentials)
{
credentials = Normalize(credentials);
if (!HasWebAuth(credentials))
return null;
var cookie = new List<string>();
if (!string.IsNullOrWhiteSpace(credentials.ym_session_id))
cookie.Add($"Session_id={credentials.ym_session_id}");
if (!string.IsNullOrWhiteSpace(credentials.ym_sessionid2))
cookie.Add($"sessionid2={credentials.ym_sessionid2}");
cookie.Add($"yandexuid={credentials.ym_yandexuid}");
cookie.Add($"yandex_login={credentials.ym_yandex_login}");
cookie.Add($"L={credentials.ym_l_token}");
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Accept"] = "*/*",
["Accept-Language"] = "ru-RU,ru;q=0.9,en;q=0.8",
["Cache-Control"] = "no-cache",
["Pragma"] = "no-cache",
["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
["Referer"] = "https://music.yandex.ru/",
["Cookie"] = string.Join("; ", cookie)
};
}
public static void ApplyHeaders(HttpClient http, Dictionary<string, string> headers)
{
http.DefaultRequestHeaders.Clear();
foreach (var header in headers)
http.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
}
public static string BuildTrackQuery(MusicTrack track)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(track?.artist_name))
parts.Add(track.artist_name);
if (!string.IsNullOrWhiteSpace(track?.title))
parts.Add(track.title);
if (!string.IsNullOrWhiteSpace(track?.album_title))
parts.Add(track.album_title);
return string.Join(" ", parts.Where(i => !string.IsNullOrWhiteSpace(i))).Trim();
}
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches)
{
string queryTitle = NormalizeText(query?.title);
string queryArtist = NormalizeText(query?.artist_name);
string queryAlbum = NormalizeText(query?.album_title);
return matches
.Select(match =>
{
int score = 0;
string title = NormalizeText(match.title);
string album = NormalizeText(match.album_title);
var artists = match.artists.Select(NormalizeText).ToList();
if (title == queryTitle)
score += 6;
else if (!string.IsNullOrWhiteSpace(queryTitle) && title.Contains(queryTitle))
score += 4;
if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a == queryArtist))
score += 4;
else if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a.Contains(queryArtist) || queryArtist.Contains(a)))
score += 2;
if (!string.IsNullOrWhiteSpace(queryAlbum) && album == queryAlbum)
score += 2;
if (query.duration_ms.HasValue && match.duration_ms.HasValue)
{
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
if (diff <= 3000)
score += 3;
else if (diff <= 10000)
score += 1;
}
return new { match, score };
})
.OrderByDescending(i => i.score)
.ThenBy(i => i.match.title)
.Select(i => i.match)
.ToList();
}
public static IReadOnlyList<YandexDownloadInfo> ParseDownloadInfos(string json)
{
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<YandexDownloadInfo>();
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Array)
return Array.Empty<YandexDownloadInfo>();
var infos = new List<YandexDownloadInfo>();
foreach (var item in result.EnumerateArray())
{
string url = TryGetString(item, "downloadInfoUrl");
if (string.IsNullOrWhiteSpace(url))
continue;
infos.Add(new YandexDownloadInfo
{
download_info_url = url,
bitrate_kbps = TryGetInt(item, "bitrateInKbps"),
codec = TryGetString(item, "codec")
});
}
return infos
.OrderByDescending(i => string.Equals(i.codec, "mp3", StringComparison.OrdinalIgnoreCase))
.ThenByDescending(i => i.bitrate_kbps ?? 0)
.ToList();
}
public static string BuildStreamUrl(string host, string path, string ts, string s)
{
string pathForSign = path?.TrimStart('/') ?? string.Empty;
string sign = CrypTo.md5(Salt + pathForSign + s);
return $"https://{host}/get-mp3/{sign}/{ts}{path}";
}
public static bool TryParseDownloadPayload(string raw, out string host, out string path, out string ts, out string s)
{
host = null;
path = null;
ts = null;
s = null;
if (string.IsNullOrWhiteSpace(raw))
return false;
raw = raw.Trim();
try
{
if (raw.StartsWith("{"))
{
using var doc = JsonDocument.Parse(raw);
host = TryGetString(doc.RootElement, "host");
path = TryGetString(doc.RootElement, "path");
ts = TryGetString(doc.RootElement, "ts");
s = TryGetString(doc.RootElement, "s");
}
else
{
var xml = XDocument.Parse(raw);
host = xml.Root?.Element("host")?.Value;
path = xml.Root?.Element("path")?.Value;
ts = xml.Root?.Element("ts")?.Value;
s = xml.Root?.Element("s")?.Value;
}
}
catch
{
return false;
}
return !string.IsNullOrWhiteSpace(host) &&
!string.IsNullOrWhiteSpace(path) &&
!string.IsNullOrWhiteSpace(ts) &&
!string.IsNullOrWhiteSpace(s);
}
public static Dictionary<string, string> ParseSavePayload(string payload)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(payload))
return result;
payload = payload.Trim();
try
{
if (payload.StartsWith("{"))
{
using var doc = JsonDocument.Parse(payload);
foreach (var property in doc.RootElement.EnumerateObject())
{
if (!KeyMap.TryGetValue(property.Name, out var key))
continue;
result[key] = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString()
: property.Value.ToString();
}
return result;
}
}
catch
{
}
var form = HttpUtility.ParseQueryString(payload
.Replace("\r", "&")
.Replace("\n", "&")
.Replace(";", "&"));
foreach (string rawKey in form.AllKeys)
{
if (string.IsNullOrWhiteSpace(rawKey) || !KeyMap.TryGetValue(rawKey, out var key))
continue;
result[key] = form[rawKey];
}
return result;
}
public static string BuildStateMessage(bool hasToken, bool hasWeb)
{
if (hasToken && hasWeb)
return "Token и web cookies сохранены.";
if (hasToken)
return "Token сохранён. Web cookies для подборок и web-разделов ещё не заданы.";
if (hasWeb)
return "Web cookies сохранены. OAuth token для API и playback ещё не задан.";
return "Token and cookies are required for full functionality.";
}
static string NormalizeValue(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
static string NormalizeText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
return new string(value
.Trim()
.ToLowerInvariant()
.Where(ch => char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch))
.ToArray());
}
static string TryGetString(JsonElement element, string name)
{
if (!element.TryGetProperty(name, out var property))
return null;
return property.ValueKind == JsonValueKind.String ? property.GetString() : property.ToString();
}
static int? TryGetInt(JsonElement element, string name)
{
if (!element.TryGetProperty(name, out var property))
return null;
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var number))
return number;
if (property.ValueKind == JsonValueKind.String && int.TryParse(property.GetString(), out number))
return number;
return null;
}
}