Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user