remove Music

This commit is contained in:
2026-10-04 17:15:11 +03:00
parent f43ac9b74e
commit 83f44275ad
69 changed files with 0 additions and 40996 deletions
-295
View File
@@ -1,295 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Text;
namespace Music;
public class ApiController : BaseController
{
[HttpGet]
[AllowAnonymous]
[Route("music.js")]
[Route("music/js/{token}")]
public ActionResult MusicJS(string token)
{
string plugin = FileCache.ReadAllText($"{ModInit.modpath}/plugin.js", "music.js", false)
.Replace("{localhost}", host)
.Replace("{client_debug_enabled}", ModInit.conf?.client_debug_enabled == true ? "true" : "false")
.Replace("{stats_clear_enabled}", ModInit.conf?.stats_clear_enabled == true ? "true" : "false")
.Replace("{daily_reset_enabled}", ModInit.conf?.daily_reset_enabled == true ? "true" : "false")
.Replace("{spotify_search_fallback_enabled}", ModInit.conf?.spotify_search_fallback_enabled == true ? "true" : "false");
// ETag считаем от ИТОГОВОГО текста: ответ зависит не только от plugin.js,
// но и от host и флагов конфига выше — хэш исходного файла дал бы ложный
// 304 после смены конфигурации и оставил бы клиенту старые значения.
// no-cache (не no-store): браузер хранит копию, но перед использованием
// перепроверяет её условным запросом — 304 без тела при неизменном файле.
string etag = $"\"{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(plugin)))[..32].ToLowerInvariant()}\"";
Response.Headers["Cache-Control"] = "private, no-cache, must-revalidate";
Response.Headers["ETag"] = etag;
string ifNoneMatch = Request.Headers["If-None-Match"].FirstOrDefault();
if (!string.IsNullOrEmpty(ifNoneMatch))
{
foreach (var rawTag in ifNoneMatch.Split(','))
{
string tag = rawTag.Trim();
if (tag == "*" || tag == etag || tag == $"W/{etag}")
return new StatusCodeResult(304);
}
}
return Content(plugin, "application/javascript; charset=utf-8");
}
[HttpPost]
[AllowAnonymous]
[Route("music/clientlog")]
public ActionResult ClientLog(string event_name, string state, string track_id, string message)
{
static string cut(string value, int max)
{
if (string.IsNullOrWhiteSpace(value))
return "";
value = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
return value.Length <= max ? value : value.Substring(0, max);
}
if (ModInit.conf?.client_debug_enabled != true)
return ContentTo(MusicJson.Serialize(new { ok = true, logged = false }));
Console.WriteLine($"[MusicClient] event={cut(event_name, 64)} track={cut(track_id, 96)} state={cut(state, 6000)} message={cut(message, 400)}");
return ContentTo(MusicJson.Serialize(new { ok = true, logged = true }));
}
[HttpGet]
[Route("music")]
[Route("music/home")]
async public Task<ActionResult> Home(int? daily_salt)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicCatalogService.GetHomeAsync(profileId, daily_salt ?? 0);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/playlists")]
async public Task<ActionResult> Playlists()
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicUserPlaylistService.ListAsync(profileId, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(new { available = true, playlists = result }));
}
[HttpGet]
[Route("music/playlists/tracks")]
async public Task<ActionResult> PlaylistTracks(string id)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var tracks = await MusicUserPlaylistService.GetTracksAsync(profileId, id, HttpContext.RequestAborted);
tracks = MusicImageProxyService.Apply(this, tracks);
return ContentTo(MusicJson.Serialize(new { available = true, playlist_id = id, tracks }));
}
[HttpPost]
[Route("music/playlists/create")]
async public Task<ActionResult> PlaylistCreate(string title)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
string playlistId = await MusicUserPlaylistService.CreateAsync(profileId, title, HttpContext.RequestAborted);
return ContentTo(MusicJson.Serialize(new { created = playlistId != null, playlist_id = playlistId, title }));
}
[HttpPost]
[Route("music/playlists/delete")]
async public Task<ActionResult> PlaylistDelete(string id)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
bool removed = await MusicUserPlaylistService.DeleteAsync(profileId, id, HttpContext.RequestAborted);
return ContentTo(MusicJson.Serialize(new { removed, playlist_id = id }));
}
[HttpPost]
[Route("music/playlists/import")]
[Route("music/playlists/import/soundcloud")] // legacy-роут для старого клиентского JS
async public Task<ActionResult> PlaylistImport(string url)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicUserPlaylistService.ImportAsync(profileId, url, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpPost]
[Route("music/playlists/sync")]
async public Task<ActionResult> PlaylistSync(string id)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicUserPlaylistService.SyncAsync(profileId, id, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpPost]
[Route("music/playlists/track/add")]
async public Task<ActionResult> PlaylistTrackAdd(string id, string track)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
MusicTrack parsed = null;
try
{
parsed = string.IsNullOrWhiteSpace(track) ? null : MusicJson.Deserialize<MusicTrack>(track);
}
catch
{
}
bool saved = await MusicUserPlaylistService.AddTrackAsync(profileId, id, parsed, HttpContext.RequestAborted);
var playlist = saved ? await MusicUserPlaylistService.GetSummaryAsync(profileId, id, HttpContext.RequestAborted) : null;
if (playlist != null)
playlist = MusicImageProxyService.Apply(this, playlist);
return ContentTo(MusicJson.Serialize(new { saved, playlist_id = id, playlist }));
}
[HttpPost]
[Route("music/playlists/track/remove")]
async public Task<ActionResult> PlaylistTrackRemove(string id, string track_id)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
bool removed = await MusicUserPlaylistService.RemoveTrackAsync(profileId, id, track_id, HttpContext.RequestAborted);
var playlist = removed ? await MusicUserPlaylistService.GetSummaryAsync(profileId, id, HttpContext.RequestAborted) : null;
if (playlist != null)
playlist = MusicImageProxyService.Apply(this, playlist);
return ContentTo(MusicJson.Serialize(new { removed, playlist_id = id, track_id, playlist }));
}
[HttpPost]
[Route("music/playlists/track/move")]
async public Task<ActionResult> PlaylistTrackMove(string id, string track_id, int position)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
bool moved = await MusicUserPlaylistService.MoveTrackAsync(profileId, id, track_id, position, HttpContext.RequestAborted);
var playlist = moved ? await MusicUserPlaylistService.GetSummaryAsync(profileId, id, HttpContext.RequestAborted) : null;
if (playlist != null)
playlist = MusicImageProxyService.Apply(this, playlist);
return ContentTo(MusicJson.Serialize(new { moved, playlist_id = id, track_id, position, playlist }));
}
[HttpGet]
[Route("music/daily")]
async public Task<ActionResult> DailyMix(int day, int? salt)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicDailyMixService.GetMixAsync(profileId, day, salt ?? 0, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/stats/top")]
async public Task<ActionResult> StatsTop(int? limit)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicStatsService.GetTopAsync(profileId, limit ?? 30, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpPost]
[Route("music/stats/clear")]
async public Task<ActionResult> StatsClear()
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
int removed = await MusicStatsService.ClearAsync(profileId, HttpContext.RequestAborted);
return ContentTo(MusicJson.Serialize(new { cleared = true, removed }));
}
[HttpPost]
[Route("music/radio")]
async public Task<ActionResult> Radio(string seeds, string exclude, int? limit)
{
// защитные капы: сырой JSON режем ДО десериализации (клиент шлёт
// exclude всей очереди ≤500 компакт-треков ≈ 150KB — 400KB с запасом),
// распарсенные списки — до разумных размеров
const int maxRawJsonLength = 400_000;
const int maxSeeds = 10;
const int maxExclude = 1000;
static List<MusicTrack> parseTracks(string value, int maxItems)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > maxRawJsonLength)
return new List<MusicTrack>();
try
{
var parsed = MusicJson.Deserialize<List<MusicTrack>>(value) ?? new List<MusicTrack>();
return parsed.Count > maxItems ? parsed.Take(maxItems).ToList() : parsed;
}
catch
{
return new List<MusicTrack>();
}
}
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicRadioService.GetAsync(profileId, new MusicRadioRequest
{
seeds = parseTracks(seeds, maxSeeds),
exclude = parseTracks(exclude, maxExclude)
}, limit ?? 20, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/lyrics")]
async public Task<ActionResult> Lyrics(string title, string artist_name, string album_title, int? duration_ms, string youtube_id)
{
var result = await MusicLyricsService.GetAsync(title, artist_name, album_title, duration_ms, youtube_id, HttpContext.RequestAborted);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/providers")]
public ActionResult Providers()
{
var result = new
{
metadata = MusicProviderRegistry.DescribeMetadata(),
audio = MusicProviderRegistry.DescribeAudio(),
auth = MusicProviderRegistry.DescribeAuth()
};
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/section")]
async public Task<ActionResult> Section(string id)
{
var result = await MusicCatalogService.GetBrowseSectionAsync(id);
if (result == null)
return ContentTo(MusicJson.Serialize(new { available = false, message = "Section not found." }));
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
}
-41
View File
@@ -1,41 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Music;
public class AuthController : BaseController
{
[HttpGet]
[Route("music/auth/state")]
async public Task<ActionResult> State()
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicAuthService.GetStatesAsync(profileId);
return ContentTo(MusicJson.Serialize(result));
}
[HttpPost]
[Route("music/auth/save")]
async public Task<ActionResult> Save(string provider, string payload)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
bool result = await MusicAuthService.SaveAsync(provider, payload, profileId);
return ContentTo(MusicJson.Serialize(new
{
provider,
saved = result
}));
}
[HttpPost]
[Route("music/auth/logout")]
async public Task<ActionResult> Logout(string provider)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
await MusicAuthService.LogoutAsync(provider, profileId);
return ContentTo(MusicJson.Serialize(new
{
provider,
logged_out = true
}));
}
}
@@ -1,169 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Music;
public class PlaybackController : BaseController
{
[HttpGet]
[Route("music/matches")]
async public Task<ActionResult> Matches(string id, string provider, string audio_provider, string playback_mode, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc, string query)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
var result = string.IsNullOrWhiteSpace(query)
? await MusicResolver.GetMatchesAsync(track, audio_provider, playback_mode, profileId)
: await MusicResolver.GetMatchesByQueryAsync(track, audio_provider, query, playback_mode);
return ContentTo(MusicJson.Serialize(result));
}
[HttpPost]
[Route("music/match/select")]
async public Task<ActionResult> SelectMatch(string id, string provider, string audio_provider, string playback_mode, string match_id, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc, string query)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
bool saved = await MusicResolver.SelectMatchAsync(track, audio_provider, match_id, playback_mode, profileId, query);
return ContentTo(MusicJson.Serialize(new
{
saved,
track_id = id,
audio_provider,
match_id
}));
}
[HttpPost]
[Route("music/match/reset")]
async public Task<ActionResult> ResetMatch(string id, string provider, string playback_mode, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc)
{
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
bool reset = track != null && await MusicSourceMatchService.DeleteAsync(track.id, playback_mode);
return ContentTo(MusicJson.Serialize(new
{
reset,
track_id = id
}));
}
[HttpGet]
[Route("music/play")]
async public Task<ActionResult> Play(string id, string provider, string audio_provider, string stream_mode, string playback_mode, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
var result = await MusicPlaybackService.ResolveTrackAsync(track, audio_provider, stream_mode, playback_mode, profileId, HttpContext.RequestAborted);
MusicPlaybackService.PrepareStreamSources(host, track, provider, audio_provider, stream_mode, playback_mode, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/stream")]
async public Task<ActionResult> Stream(string ticket, string id, string provider, string audio_provider, string quality, string stream_mode, string playback_mode, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc)
{
if (MusicStreamTicketService.TryGet(ticket, out var ticketSource))
{
// тикет жив, но upstream-ссылка внутри могла умереть (403/404/410/416 —
// googlevideo TTL/IP-привязка): не транслируем смерть клиенту, а
// падаем в пере-резолв по fallback-параметрам ниже
var relayed = await MusicStreamRelayService.RelayAsync(HttpContext, ticketSource, failOnUpstreamError: !string.IsNullOrWhiteSpace(id));
if (relayed != null)
return relayed;
}
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
// upstream-ссылка умерла (403/410/416 в relay) — кэш sources для этого
// трека может держать те же мёртвые URL: пере-резолв идёт с refreshSources
var result = await MusicPlaybackService.ResolveTrackAsync(track, audio_provider, stream_mode, playback_mode, profileId, HttpContext.RequestAborted, refreshSources: true);
if (track == null || result?.available != true || result.sources.Count == 0)
return StatusCode(404);
var source = MusicPlaybackService.SelectSource(result.sources, quality, stream_mode);
if (source == null || string.IsNullOrWhiteSpace(source.url))
return StatusCode(404);
return await MusicStreamRelayService.RelayAsync(HttpContext, source);
}
[HttpPost]
[Route("music/history/mark")]
async public Task<ActionResult> MarkHistory(string id, string provider, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc, bool count_play = false, long? played_ms = null, string image = null)
{
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
if (track != null)
{
image = image?.Trim();
if (image?.StartsWith("//", StringComparison.Ordinal) == true)
image = "https:" + image;
if (track.images?.Any(i => !string.IsNullOrWhiteSpace(i?.url)) != true
&& !string.IsNullOrWhiteSpace(image) && image.Length <= 8192
&& Uri.TryCreate(image, UriKind.Absolute, out var imageUri)
&& (imageUri.Scheme == Uri.UriSchemeHttp || imageUri.Scheme == Uri.UriSchemeHttps))
{
// Client artwork belongs to this history entry, not the shared metadata cache.
track = MusicJson.Deserialize<MusicTrack>(MusicJson.Serialize(track));
track.images = new List<MusicImage> { new() { url = imageUri.AbsoluteUri } };
}
await MusicPlaybackHistoryService.SaveAsync(profileId, track);
// статистику инкрементим только по честному прослушиванию:
// mark дёргается и для обновления payload (смена источника),
// без флага это накручивало бы счётчики
if (count_play || played_ms > 0)
await MusicStatsService.RecordPlayAsync(profileId, track, count_play, played_ms, HttpContext.RequestAborted);
}
return ContentTo(MusicJson.Serialize(new
{
saved = track != null,
track_id = id
}));
}
[HttpPost]
[Route("music/history/remove")]
async public Task<ActionResult> RemoveHistory(string id)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
bool removed = await MusicPlaybackHistoryService.RemoveAsync(profileId, id);
return ContentTo(MusicJson.Serialize(new
{
removed,
track_id = id
}));
}
[HttpGet]
[Route("music/playlist.m3u")]
async public Task<ActionResult> Playlist(string ids, string provider, string audio_provider, string stream_mode, string playback_mode, string playlist_strategy)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
string playlist = await MusicPlaylistService.BuildAsync(new MusicPlaylistRequest
{
ids = ids,
provider = provider,
audio_provider = audio_provider,
stream_mode = stream_mode,
playback_mode = playback_mode,
playlist_strategy = playlist_strategy,
profile_id = profileId,
host = host,
uid = Request.Query["uid"].FirstOrDefault(),
account_email = Request.Query["account_email"].FirstOrDefault(),
cancellation_token = HttpContext.RequestAborted
});
return Content(playlist, "audio/x-mpegurl; charset=utf-8");
}
}
@@ -1,87 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Music;
public class SearchController : BaseController
{
[HttpGet]
[Route("music/search")]
async public Task<ActionResult> Search(string q, string provider, bool full = false)
{
var result = await MusicCatalogService.SearchAsync(q, provider, full);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/artist")]
async public Task<ActionResult> Artist(string id, string provider)
{
var result = await MusicCatalogService.GetArtistAsync(id, provider);
if (result == null)
return ContentTo(MusicJson.Serialize(new { available = false, message = "Artist not found." }));
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/artistsection")]
async public Task<ActionResult> ArtistSection(string id, string provider, string page, string artist_name, int limit = 20)
{
var result = await MusicCatalogService.GetArtistSectionAsync(id, provider, page, limit, artist_name);
if (result == null)
return ContentTo(MusicJson.Serialize(new { available = false, message = "Artist section not found." }));
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/artistimg")]
async public Task<ActionResult> ArtistImage(string id, string name, string country, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(name))
return ContentTo(MusicJson.Serialize(new { available = false, id, images = new List<MusicImage>() }));
var artist = new MusicArtist
{
id = id,
name = name,
country = country
};
artist.images = await DiscogsArtistImageService.ResolveImagesAsync(artist, cancellationToken) ?? new List<MusicImage>();
artist = MusicImageProxyService.Apply(this, artist);
return ContentTo(MusicJson.Serialize(new
{
available = artist.images.Count > 0,
id = id,
images = artist.images
}));
}
[HttpGet]
[Route("music/album")]
async public Task<ActionResult> Album(string id, string provider)
{
var result = await MusicCatalogService.GetAlbumAsync(id, provider);
if (result == null)
return ContentTo(MusicJson.Serialize(new { available = false, message = "Album not found." }));
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/track")]
async public Task<ActionResult> Track(string id, string provider)
{
var result = await MusicCatalogService.GetTrackAsync(id, provider);
if (result == null)
return ContentTo(MusicJson.Serialize(new { available = false, message = "Track not found." }));
return ContentTo(MusicJson.Serialize(result));
}
}
-11
View File
@@ -1,11 +0,0 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
global using System.Web;
global using Shared;
global using Shared.Models.AppConf;
global using Shared.Models.Base;
global using Shared.Services;
-136
View File
@@ -1,136 +0,0 @@
using Shared.Models.AppConf;
using Shared.Models.Events;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using Microsoft.Extensions.DependencyInjection;
namespace Music;
public class ModInit : IModuleLoaded, IModuleConfigure
{
static readonly object wafRulesLock = new();
static List<WafLimitRootMap> appliedWafRules = new();
public static string modpath;
public static ModuleConf conf;
public void Configure(ConfigureModel app)
{
app.services.AddDbContextFactory<MusicContext>(MusicContext.ConfiguringDbBuilder);
}
public void Loaded(InitspaceModel initspace)
{
modpath = initspace.path;
updateConf();
EventListener.UpdateInitFile += updateConf;
Directory.CreateDirectory("database/music");
MusicContext.Initialization(initspace.app.ApplicationServices);
}
public void Dispose()
{
EventListener.UpdateInitFile -= updateConf;
RemoveWafRules();
}
void updateConf()
{
conf = ModuleInvoke.Init("Music", new ModuleConf()
{
useproxy = false,
useproxystream = false,
globalnameproxy = null,
proxy = null,
default_metadata_provider = "musicbrainz",
default_audio_provider = "youtubeaudio",
default_auth_provider = "",
client_debug_enabled = false,
stats_clear_enabled = false,
daily_reset_enabled = false,
youtube_audio_enabled = true,
spotify_search_fallback_enabled = false,
spotify_discovery_enabled = true,
spotify_country = "us",
sefon_audio_enabled = true,
soundcloud_enabled = true,
soundcloud_discovery_enabled = true,
soundcloud_audio_enabled = true,
soundcloud_auth_enabled = false,
applemusic_country = "us",
applemusic_album_resolver = "auto",
soundcloud_client_id = "",
soundcloud_client_secret = "",
soundcloud_redirect_uri = "",
soundcloud_country = "US",
z3fm_enabled = false,
z3fm_audio_enabled = false,
z3fm_proxy_enabled = false,
z3fm_proxy_url = "",
z3fm_proxy_username = "",
z3fm_proxy_password = "",
limit_map = new List<WafLimitRootMap>()
{
new("^/music", new WafLimitMap { limit = 15, second = 1 })
}
});
ApplyWafRules(conf.limit_map);
MusicProxyService.ConfigurationChanged();
}
static void ApplyWafRules(IReadOnlyCollection<WafLimitRootMap> currentRules)
{
var waf = CoreInit.conf?.WAF;
if (waf == null)
return;
lock (wafRulesLock)
{
var limitMap = waf.limit_map?.ToList() ?? new List<WafLimitRootMap>();
RemoveAppliedWafRules(limitMap);
var nextAppliedRules = new List<WafLimitRootMap>();
foreach (var rule in currentRules ?? Array.Empty<WafLimitRootMap>())
{
if (rule == null || (string.IsNullOrWhiteSpace(rule.path) && string.IsNullOrWhiteSpace(rule.pattern)))
continue;
limitMap.Insert(0, rule);
nextAppliedRules.Add(rule);
}
// WAF может одновременно обслуживать запросы: публикуем новый снимок
// списка одной записью, не меняя коллекцию, которую он перечисляет.
waf.limit_map = limitMap;
appliedWafRules = nextAppliedRules;
}
}
static void RemoveWafRules()
{
lock (wafRulesLock)
{
var waf = CoreInit.conf?.WAF;
if (waf != null)
{
var limitMap = waf.limit_map?.ToList() ?? new List<WafLimitRootMap>();
RemoveAppliedWafRules(limitMap);
waf.limit_map = limitMap;
}
appliedWafRules = new List<WafLimitRootMap>();
}
}
static void RemoveAppliedWafRules(List<WafLimitRootMap> limitMap)
{
if (limitMap == null || appliedWafRules.Count == 0)
return;
limitMap.RemoveAll(existing => appliedWafRules.Any(applied => ReferenceEquals(existing, applied)));
}
}
-309
View File
@@ -1,309 +0,0 @@
using System.Text.Json.Serialization;
namespace Music;
public class MusicProviderRef
{
public string provider { get; set; }
public string external_id { get; set; }
}
public class MusicImage
{
public string url { get; set; }
public int? width { get; set; }
public int? height { get; set; }
}
public class MusicArtist
{
public string id { get; set; }
public string name { get; set; }
public string sort_name { get; set; }
public string country { get; set; }
public string description { get; set; }
public int? search_score { get; set; }
public List<MusicImage> images { get; set; } = new();
public List<MusicProviderRef> provider_refs { get; set; } = new();
public List<MusicAlbum> albums { get; set; } = new();
public List<MusicBrowseSection> sections { get; set; } = new();
}
public class MusicAlbum
{
public string id { get; set; }
public string title { get; set; }
public string artist_id { get; set; }
public string artist_name { get; set; }
public string lookup_query { get; set; }
public string lookup_provider { get; set; }
public int? year { get; set; }
public string date { get; set; }
public string type { get; set; }
public string release_id { get; set; }
public string description { get; set; }
public int? search_score { get; set; }
public List<string> secondary_types { get; set; } = new();
public List<MusicImage> images { get; set; } = new();
public List<MusicProviderRef> provider_refs { get; set; } = new();
public List<MusicTrack> tracks { get; set; } = new();
}
public class MusicTrack
{
public string id { get; set; }
public string title { get; set; }
public string artist_id { get; set; }
public string artist_name { get; set; }
public List<string> artists { get; set; } = new();
public string album_id { get; set; }
public string album_title { get; set; }
public string isrc { get; set; }
public int? duration_ms { get; set; }
public int? track_number { get; set; }
public int? disc_number { get; set; }
public string date { get; set; }
public int? search_score { get; set; }
public List<MusicImage> images { get; set; } = new();
public List<MusicProviderRef> provider_refs { get; set; } = new();
// добавлено автопродолжением очереди; не влияет на резолв источника
public bool auto_radio { get; set; }
}
public class MusicAudioMatch
{
public string provider_id { get; set; }
public string id { get; set; }
public string title { get; set; }
public List<string> artists { get; set; } = new();
public string album_title { get; set; }
public string isrc { get; set; }
public int? duration_ms { get; set; }
public string payload { get; set; }
// выбран пользователем вручную: обходит эвристики релевантности
// и не перезаписывается авто-подбором
public bool pinned { get; set; }
}
public class MusicLyricsResponse
{
public bool available { get; set; }
public bool retry { get; set; }
public string message { get; set; }
public string source { get; set; }
public string source_mode { get; set; }
public bool synced { get; set; }
public List<MusicLyricsLine> lines { get; set; } = new();
public string plain { get; set; }
}
public class MusicLyricsLine
{
public int time_ms { get; set; }
public string text { get; set; }
}
public class MusicPlaybackSource
{
public string provider_id { get; set; }
public string url { get; set; }
public string external_url { get; set; }
public string mime_type { get; set; }
public int? bitrate { get; set; }
public string quality { get; set; }
public Dictionary<string, string> headers { get; set; } = new();
[JsonIgnore]
public string proxy_url { get; set; }
[JsonIgnore]
public string proxy_username { get; set; }
[JsonIgnore]
public string proxy_password { get; set; }
[JsonIgnore]
public string proxy_scope { get; set; }
}
public class MusicAuthState
{
public string provider_id { get; set; }
public string provider_name { get; set; }
public bool authenticated { get; set; }
public bool requires_auth { get; set; }
public bool token_ready { get; set; }
public bool web_ready { get; set; }
public string mode { get; set; }
public string message { get; set; }
}
public class YandexMusicCredentials
{
public string ym_token { get; set; }
public string ym_session_id { get; set; }
public string ym_sessionid2 { get; set; }
public string ym_yandexuid { get; set; }
public string ym_yandex_login { get; set; }
public string ym_l_token { get; set; }
}
public class YandexMusicMatchPayload
{
public string track_id { get; set; }
public string album_id { get; set; }
public string album_title { get; set; }
public int? bitrate_kbps { get; set; }
public string codec { get; set; }
}
public class MusicProviderDescriptor
{
public string id { get; set; }
public string name { get; set; }
public string type { get; set; }
public bool enabled { get; set; }
public bool requires_auth { get; set; }
public List<string> capabilities { get; set; } = new();
}
public class MusicSection
{
public string id { get; set; }
public string title { get; set; }
public string endpoint { get; set; }
public string type { get; set; }
}
public class MusicBrowseSection
{
public string id { get; set; }
public string title { get; set; }
public string type { get; set; }
public string source_provider { get; set; }
public bool has_more { get; set; }
public string next_page { get; set; }
public List<MusicAlbum> albums { get; set; } = new();
public List<MusicArtist> artists { get; set; } = new();
public List<MusicTrack> tracks { get; set; } = new();
}
public class MusicSearchResult
{
public List<MusicArtist> artists { get; set; } = new();
public List<MusicAlbum> albums { get; set; } = new();
public List<MusicTrack> tracks { get; set; } = new();
}
public class MusicSearchResponse
{
public string query { get; set; }
public string status { get; set; }
public string metadata_provider { get; set; }
public bool metadata_pending { get; set; }
public List<string> audio_providers { get; set; } = new();
public List<MusicArtist> artists { get; set; } = new();
public List<MusicAlbum> albums { get; set; } = new();
public List<MusicTrack> tracks { get; set; } = new();
public List<MusicBrowseSection> search_sections { get; set; } = new();
}
public class MusicRecentlyPlayedItem
{
public MusicTrack track { get; set; }
public DateTime played_at { get; set; }
}
public class MusicUserPlaylistSummary
{
public string id { get; set; }
public string title { get; set; }
public int track_count { get; set; }
public List<MusicImage> images { get; set; } = new();
public MusicUserPlaylistSource source { get; set; }
}
public class MusicUserPlaylistSource
{
public string type { get; set; }
public string url { get; set; }
public string user_id { get; set; }
public string playlist_id { get; set; }
public string title { get; set; }
}
public class MusicUserPlaylistImportResult
{
public bool available { get; set; }
public string message { get; set; }
public string playlist_id { get; set; }
public string title { get; set; }
public int track_count { get; set; }
public bool truncated { get; set; }
public List<MusicTrack> tracks { get; set; } = new();
public MusicUserPlaylistSource source { get; set; }
}
public class MusicHomeResponse
{
public string title { get; set; }
public string status { get; set; }
public string version { get; set; }
public List<MusicSection> sections { get; set; } = new();
public List<MusicProviderDescriptor> metadata_providers { get; set; } = new();
public List<MusicProviderDescriptor> audio_providers { get; set; } = new();
public List<MusicProviderDescriptor> auth_providers { get; set; } = new();
public List<MusicRecentlyPlayedItem> recently_played { get; set; } = new();
public List<MusicUserPlaylistSummary> user_playlists { get; set; } = new();
public List<MusicDailyMixSummary> daily_mixes { get; set; } = new();
public List<MusicBrowseSection> browse_sections { get; set; } = new();
public bool browse_sections_warming { get; set; }
}
public class MusicPlayResponse
{
public bool available { get; set; }
public string reason { get; set; }
public string message { get; set; }
public string track_id { get; set; }
public MusicAudioMatch selected_match { get; set; }
public List<MusicPlaybackSource> sources { get; set; } = new();
}
public class MusicMatchesResponse
{
public bool available { get; set; }
public string message { get; set; }
public string track_id { get; set; }
public MusicAudioMatch selected_match { get; set; }
public List<MusicAudioMatch> matches { get; set; } = new();
}
public class MusicDailyMixSummary
{
public int day { get; set; }
public string title { get; set; }
public bool today { get; set; }
public List<string> artists { get; set; } = new();
}
public class MusicDailyMixResponse
{
public bool available { get; set; }
public string message { get; set; }
public int day { get; set; }
public string title { get; set; }
public List<string> artists { get; set; } = new();
public List<MusicTrack> tracks { get; set; } = new();
}
public class MusicRadioRequest
{
public List<MusicTrack> seeds { get; set; } = new();
public List<MusicTrack> exclude { get; set; } = new();
}
public class MusicRadioResponse
{
public bool available { get; set; }
public string message { get; set; }
public List<MusicTrack> tracks { get; set; } = new();
}
-39
View File
@@ -1,39 +0,0 @@
namespace Music;
internal static class MusicIsrc
{
public static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var normalized = new char[12];
int length = 0;
foreach (char current in value.Trim())
{
if (current == '-' || char.IsWhiteSpace(current))
continue;
if (!char.IsAsciiLetterOrDigit(current) || length >= normalized.Length)
return null;
normalized[length++] = char.ToUpperInvariant(current);
}
if (length != normalized.Length
|| !char.IsAsciiLetter(normalized[0])
|| !char.IsAsciiLetter(normalized[1]))
{
return null;
}
for (int i = 5; i < normalized.Length; i++)
{
if (!char.IsAsciiDigit(normalized[i]))
return null;
}
return new string(normalized);
}
}
-78
View File
@@ -1,78 +0,0 @@
using Shared.Models.AppConf;
using Shared.Models.Base;
namespace Music;
public class ModuleConf : Iproxy
{
public bool useproxy { get; set; }
public bool useproxystream { get; set; }
public string globalnameproxy { get; set; }
public ProxySettings proxy { get; set; }
public string default_metadata_provider { get; set; }
public string default_audio_provider { get; set; }
public string default_auth_provider { get; set; }
public bool client_debug_enabled { get; set; }
public bool stats_clear_enabled { get; set; }
// dev-кнопка «Обновить миксы недели» (пересборка раскладки по salt);
// в обычном UX неделя обновляется сама по понедельникам
public bool daily_reset_enabled { get; set; }
public bool youtube_audio_enabled { get; set; }
// Spotify-корректор поиска: когда MusicBrainz не понял «человеческий» ввод
// (кириллица западного артиста, опечатка), спросить у Spotify каноническое
// имя артиста и переспросить MB им. Хрупко к ротации persisted-хэша Spotify,
// потому default off; деградирует в «как без него» без падений.
public bool spotify_search_fallback_enabled { get; set; }
public bool spotify_discovery_enabled { get; set; }
public string spotify_country { get; set; }
public bool sefon_audio_enabled { get; set; }
public bool soundcloud_enabled { get; set; }
public bool soundcloud_discovery_enabled { get; set; }
public bool soundcloud_audio_enabled { get; set; }
public bool soundcloud_auth_enabled { get; set; }
public string applemusic_country { get; set; }
// auto | applemusic | spotify | soundcloud | musicbrainz
public string applemusic_album_resolver { get; set; }
public string soundcloud_client_id { get; set; }
public string soundcloud_client_secret { get; set; }
public string soundcloud_redirect_uri { get; set; }
public string soundcloud_country { get; set; }
public bool z3fm_enabled { get; set; }
public bool z3fm_audio_enabled { get; set; }
public bool z3fm_proxy_enabled { get; set; }
public string z3fm_proxy_url { get; set; }
public string z3fm_proxy_username { get; set; }
public string z3fm_proxy_password { get; set; }
public List<WafLimitRootMap> limit_map { get; set; } = new();
}
-19
View File
@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="plugin.js">
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,62 +0,0 @@
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);
}
@@ -1,481 +0,0 @@
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];
}
}
@@ -1,62 +0,0 @@
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>();
}
}
@@ -1,672 +0,0 @@
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();
}
}
@@ -1,87 +0,0 @@
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
};
}
}
@@ -1,361 +0,0 @@
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;
}
}
@@ -1,708 +0,0 @@
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);
}
}
@@ -1,51 +0,0 @@
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>();
}
}
@@ -1,626 +0,0 @@
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
};
}
}
@@ -1,56 +0,0 @@
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);
}
}
@@ -1,390 +0,0 @@
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();
}
}
@@ -1,63 +0,0 @@
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()
};
}
}
@@ -1,314 +0,0 @@
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()}";
}
@@ -1,183 +0,0 @@
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();
}
}
@@ -1,707 +0,0 @@
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
@@ -1,298 +0,0 @@
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];
}
}
@@ -1,108 +0,0 @@
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; }
}
@@ -1,748 +0,0 @@
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}";
}
}
@@ -1,87 +0,0 @@
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
@@ -1,276 +0,0 @@
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);
}
}
@@ -1,662 +0,0 @@
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
@@ -1,266 +0,0 @@
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);
}
@@ -1,378 +0,0 @@
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;
}
}
-359
View File
@@ -1,359 +0,0 @@
# Music
Музыкальный модуль для [Lampac] — превращает Lampa в полноценный музыкальный сервис: главный экран с полками и чартами, поиск, экраны артистов и альбомов, пользовательские плейлисты, импорт из Spotify / Apple Music / SoundCloud, синхронизированные тексты песен и отдельный музыкальный плеер для iOS, Android и Windows.
Модуль серверный (C# / ASP.NET, динамическая компиляция при старте Lampac) с единым клиентом для Lampa ([plugin.js](./plugin.js)), который отдаётся как `/music.js`.
## Возможности
- **Главный экран** — недавно прослушанное, недавние альбомы/артисты/поиски, пользовательские плейлисты и discovery-полки (Apple Music, Spotify, VK Top, SoundCloud).
- **Поиск** — артисты, альбомы, треки; каноническая база метаданных MusicBrainz, дополнительные секции из YouTube Music.
- **Воспроизведение** — автоматический подбор playable-источника (YouTube, Sefon, SoundCloud, опционально Z3Fm) с точным поиском по ISRC, когда каталог его предоставляет, и fallback по названию/артисту; выбранный вручную источник закрепляется (`pinned`) и переживает перезапуски.
- **Пользовательские плейлисты** — создание, наполнение из любого экрана, перестановка треков, хранение на сервере с привязкой к профилю браузера.
- **Импорт плейлистов и альбомов по ссылке** — Spotify, Apple Music, SoundCloud; без API-ключей и без логина. Повторная синхронизация — слияние: локальный порядок и ручные правки сохраняются, новые треки источника добавляются в начало.
- **Управление очередью отовсюду** — лонг-тап по треку в любом списке: «Играть следующим», «Добавить в очередь», «Убрать из очереди»; перестановка и удаление также из шита очереди плеера. Очередь переживает перезапуск клиента (снапшот с восстановлением позиции).
- **Радио** — «Радио от трека» из меню любого трека (волна от выбранной песни) и автоподборка в конец очереди; движок похожести — рекомендации SoundCloud (related/station) со слоистым пулом и жёстким дедупом.
- **Статистика прослушиваний** — «Твой топ»: играбельный альбом из самых прослушиваемых треков, появляется после накопления данных; счётчики по дням в SQLite.
- **Выбор плеера для музыки** — отдельная настройка в «Настройки → Плеер», независимая от глобального плеера Lampa (встроенный, Music Player на Android/Windows, iOS, VLC и другие внешние).
- **Тексты песен** — синхронизированная (караоке) и обычная лирика через lrclib с фоллбеком на YouTube Music; ручная подгонка тайминга на телефоне и с пульта телевизора.
- **iOS lock screen** — в standalone-режиме плеера: play/pause, next/prev, перемотка скраббером, корректные метаданные и обложка, автопереход треков, устойчивое возобновление на заблокированном экране.
- **Стриминг через сервер** — клиент никогда не ходит на внешние CDN напрямую: единый relay `/music/stream` с поддержкой Range-запросов, тикетами и автоматическим повторным резолвом протухших ссылок.
## Архитектура
Модуль не пытается сделать «универсальный парсер любого сайта». Каждый внешний источник подключается в одной или нескольких строго разделённых ролях:
| Роль | Ответственность | Текущие источники |
| --- | --- | --- |
| **Metadata** | канонические артисты, альбомы, треки | MusicBrainz |
| **Discovery** | полки, чарты, подборки, секции поиска | Apple Music, Spotify, VK Top, SoundCloud, YouTube Music |
| **Audio** | playable match и stream sources | YouTube (основной), Sefon, SoundCloud, Z3Fm (опц.) |
| **Auth** | внешние пользовательские аккаунты | SoundCloud (каркас, выключен по умолчанию) |
| **Images** | обогащение картинок | Discogs (фото артистов) |
| **Import** | импорт плейлистов по ссылке | Spotify, Apple Music, SoundCloud |
Ключевые принципы:
- **Metadata-база одна** — MusicBrainz. Discovery- и audio-источники не становятся источником правды о каталоге.
- **Home всегда быстрый** — discovery-провайдеры опрашиваются параллельно с бюджетом времени; медленный или упавший источник не блокирует ответ, его секция догревается в фоне.
- **Durable отделён от volatile** — SQLite хранит только результат действий пользователя (история, плейлисты, pinned-источники, учётные данные); все кэши живут в `HybridFileCache` и памяти.
```mermaid
flowchart TD
Client["plugin.js / Lampa UI"] --> Api["Controllers"]
Api --> Catalog["MusicCatalogService"]
Api --> Playback["MusicPlaybackService / MusicResolver"]
Api --> Playlists["MusicUserPlaylistService"]
Api --> Lyrics["MusicLyricsService"]
Catalog --> Metadata["Metadata providers"]
Catalog --> Discovery["Discovery providers"]
Playback --> Audio["Audio providers"]
Playback --> Relay["/music/stream relay"]
Playlists --> DB["Music.sql (SQLite)"]
Lyrics --> Cache["HybridFileCache"]
Metadata --> Cache
Audio --> Cache
```
### Структура каталога
```
Controllers/ HTTP API: ApiController, SearchController, PlaybackController, AuthController
Models/ Contracts.cs — DTO контрактов API
Providers/
Abstractions/ интерфейсы провайдеров
Metadata/ MusicBrainz
Discovery/ AppleMusic, Spotify, VkTopChart, SoundCloud, YouTubeMusic
Audio/ YouTube, Sefon, Z3Fm
SoundCloud/ общий site-support (partial: Catalog / Audio / Import / Auth)
Spotify/ импорт публичных плейлистов/альбомов (анонимный embed-токен)
AppleMusic/ импорт публичных плейлистов/альбомов (анонимный web-JWT)
Services/
Catalog/ оркестрация home / search / секций, реестр провайдеров
Playback/ резолвер источников, stream relay, тикеты, история
Playlists/ пользовательские и импортированные плейлисты
Lyrics/ lrclib + YouTube Music fallback
Images/ image proxy, Discogs
Cache/ metadata cache, source-match cache
SQL/ MusicContext.cs — durable SQLite с self-healing и safe backup
plugin.js весь клиент Lampa, отдаётся как /music.js
ModInit.cs регистрация модуля и значения конфигурации по умолчанию
ModuleConf.cs схема настроек
manifest.json dynamic: true, зависимость YoutubeExplode
```
## Установка
1. Поместите каталог `Music` в `Modules/` вашей установки Lampac. Модуль объявлен как `dynamic` и компилируется автоматически при старте.
2. Перезапустите Lampac. При первом старте создаётся `database/music/` с SQLite-базой.
3. Клиентский плагин доступен по адресу `{host}/music.js` — подключите его в Lampa штатным способом (`initPlugins` в конфигурации LampaWeb либо вручную как плагин).
Проверка, что сервер поднялся:
```bash
curl -fsS 'http://127.0.0.1:9118/music/home'
curl -fsS 'http://127.0.0.1:9118/music/providers'
```
## Конфигурация
Настройки задаются в `init.conf` Lampac под ключом `Music` и подхватываются без перезапуска. Пример:
```jsonc
{
"Music": {
"useproxy": false,
"useproxystream": false,
"applemusic_country": "ru",
"applemusic_album_resolver": "auto",
"spotify_discovery_enabled": true,
"spotify_country": "ua",
"soundcloud_country": "UK",
"z3fm_enabled": true,
"z3fm_audio_enabled": true
}
}
```
Полная схема ([ModuleConf.cs](./ModuleConf.cs)) со значениями по умолчанию ([ModInit.cs](./ModInit.cs)):
| Параметр | По умолчанию | Описание |
| --- | --- | --- |
| `default_metadata_provider` | `musicbrainz` | metadata-база каталога |
| `default_audio_provider` | `youtubeaudio` | основной audio-провайдер |
| `default_auth_provider` | *(пусто)* | auth-провайдер по умолчанию |
| `useproxy` | `false` | прокси для API, поиска и discovery; используется стандартный proxy Lampac |
| `useproxystream` | `false` | прокси для получения manifest и передачи audio-stream |
| `globalnameproxy` | *(пусто)* | имя proxy из глобального `globalproxy` Lampac |
| `proxy` | *(пусто)* | локальная настройка proxy Music; имеет приоритет над глобальной |
| `client_debug_enabled` | `false` | приём клиентских трейсов на `/music/clientlog` (только для отладки) |
| `stats_clear_enabled` | `false` | показывать сервисную кнопку очистки статистики «Твоего топа» в фильтре |
| `youtube_audio_enabled` | `true` | YouTube как audio-источник (через YoutubeExplode) |
| `spotify_search_fallback_enabled` | `false` | дополнительные результаты поиска Spotify |
| `spotify_discovery_enabled` | `true` | региональная полка плейлистов Spotify на главном экране |
| `spotify_country` | `us` | профиль плейлистов Spotify: `UA`, `CA`, `US`, `RU`, `PL`, `FR`, `DE`; для остальных ISO-кодов используется явно подписанный Global-профиль |
| `sefon_audio_enabled` | `true` | Sefon как дополнительный audio-источник |
| `soundcloud_enabled` | `true` | SoundCloud целиком |
| `soundcloud_discovery_enabled` | `true` | полки/подборки SoundCloud |
| `soundcloud_audio_enabled` | `true` | SoundCloud как audio-источник |
| `soundcloud_auth_enabled` | `false` | вход в аккаунт SoundCloud (OAuth) |
| `applemusic_country` | `us` | регион Apple Music Charts (ISO 3166-1 alpha-2) |
| `applemusic_album_resolver` | `auto` | открытие альбомов из Apple-полок: `auto`, `applemusic`, `spotify`, `soundcloud` или `musicbrainz` |
| `soundcloud_client_id` | *(пусто)* | собственный client_id; без него скрапится публичный |
| `soundcloud_client_secret` | *(пусто)* | для OAuth-потока |
| `soundcloud_redirect_uri` | *(пусто)* | для OAuth-потока |
| `soundcloud_country` | `US` | регион SoundCloud Charts: `US` или `UK` (`GB` нормализуется в `UK`) |
| `z3fm_enabled` | `false` | Z3Fm целиком (требует Playwright для антибота) |
| `z3fm_audio_enabled` | `false` | Z3Fm как audio-источник |
| `z3fm_proxy_enabled` / `z3fm_proxy_url` / `z3fm_proxy_username` / `z3fm_proxy_password` | `false` / *(пусто)* | прокси для Z3Fm |
| `limit_map` | `^/music`: 15 req/s | WAF rate-limit для эндпоинтов модуля |
Каждый профиль Spotify содержит одну полку с 15 плейлистами: пять основных региональных, пять дополнительных региональных тематических и пять глобальных тематических. Для `CA`, `US`, `PL`, `FR`, `DE` используются официальные подборки Spotify; профили `UA` и `RU` частично состоят из публичных локальных плейлистов. После смены `spotify_country` нужен перезапуск Lampac; недоступный плейлист пропускается, не блокируя остальные discovery-источники.
`applemusic_album_resolver` задаёт приоритет открытия карточек Apple Music Charts. `auto` использует цепочку Apple ID → Spotify → SoundCloud → MusicBrainz; `applemusic` — Apple ID → MusicBrainz; `spotify` — Spotify → MusicBrainz; `soundcloud` — SoundCloud → MusicBrainz; `musicbrainz` сохраняет старое поведение. В SoundCloud учитываются только результаты из секции альбомов, пользовательские плейлисты исключены. Spotify участвует при включённом `spotify_search_fallback_enabled`, SoundCloud — при включённом `soundcloud_discovery_enabled`; неизвестное значение строго нормализуется в `auto`.
## HTTP API
Все эндпоинты живут под префиксом `/music`. Внешние картинки в ответах проксируются через image-proxy, stream-ссылки — через relay: клиенту не нужны прямые запросы к внешним сервисам.
### Клиент и служебные
| Метод | Эндпоинт | Назначение |
| --- | --- | --- |
| GET | `/music.js`, `/music/js/{token}` | клиентский плагин |
| GET | `/music/providers` | список активных провайдеров |
| POST | `/music/clientlog` | приём клиентских трейсов (при `client_debug_enabled`) |
### Каталог и поиск
| Метод | Эндпоинт | Назначение |
| --- | --- | --- |
| GET | `/music`, `/music/home` | главный экран |
| GET | `/music/section?id=` | полный список секции («Ещё» / пагинация) |
| GET | `/music/search?q=` | поиск |
| GET | `/music/artist?id=` | экран артиста |
| GET | `/music/artistsection` | догрузка секций артиста |
| GET | `/music/artistimg` | lazy-обогащение фото артиста (Discogs) |
| GET | `/music/album?id=` | экран альбома |
| GET | `/music/track` | данные трека |
### Воспроизведение
| Метод | Эндпоинт | Назначение |
| --- | --- | --- |
| GET | `/music/play` | playable-ответ для трека (источники + stream URL) |
| GET | `/music/stream` | relay стрима (Range, тикеты, авто re-resolve) |
| GET | `/music/matches` | кандидаты audio-источников для трека |
| POST | `/music/match/select` | закрепить ручной выбор источника (`pinned`) |
| POST | `/music/match/reset` | сбросить закреплённый выбор |
| GET | `/music/playlist.m3u` | плейлист очереди в формате M3U |
| POST | `/music/history/mark` | обновить историю; `count_play=true` инкрементит счётчик статистики, `played_ms` добавляет фактически прослушанное время |
| POST | `/music/history/remove` | удалить запись истории |
| GET | `/music/stats/top` | топ треков профиля + условия разблокировки «Твоего топа» |
| POST | `/music/stats/clear` | очистить дневную статистику текущего профиля, не трогая историю прослушиваний |
### Плейлисты
| Метод | Эндпоинт | Назначение |
| --- | --- | --- |
| GET | `/music/playlists` | список плейлистов профиля |
| GET | `/music/playlists/tracks` | треки плейлиста |
| POST | `/music/playlists/create` | создать плейлист |
| POST | `/music/playlists/delete` | удалить плейлист |
| POST | `/music/playlists/import` | импорт по ссылке (Spotify / Apple Music / SoundCloud) |
| POST | `/music/playlists/sync` | пересинхронизировать импортированный плейлист |
| POST | `/music/playlists/track/add` | добавить трек |
| POST | `/music/playlists/track/remove` | убрать трек |
| POST | `/music/playlists/track/move` | переставить трек внутри плейлиста (`position` — финальный индекс) |
| POST | `/music/radio` | подобрать похожие треки (автоподборка очереди и «Радио от трека») |
| GET | `/music/daily` | микс дня недели (`day` 1–7, опц. `salt` — счётчик сброса): треки двух артистов из истории + похожего |
### Прочее
| Метод | Эндпоинт | Назначение |
| --- | --- | --- |
| GET | `/music/lyrics` | текст песни (synced/plain) |
| GET | `/music/auth/state` | состояние внешней авторизации |
| POST | `/music/auth/save` | сохранить учётные данные |
| POST | `/music/auth/logout` | выйти |
## Как это работает
### Резолв и воспроизведение трека
1. Клиент вызывает `/music/play` с идентификатором трека и fallback-параметрами (артист, название, длительность).
2. [MusicPlaybackService](./Services/Playback/MusicPlaybackService.cs) собирает track DTO, [MusicResolver](./Services/Playback/MusicResolver.cs) подбирает audio match: сначала закреплённый пользователем `pinned`-источник, затем кэшированный авто-матч, затем поиск по провайдерам с ранжированием.
3. Выбранный провайдер возвращает stream sources; сервер превращает их в тикетные URL `/music/stream?...` — прямые ссылки на CDN клиенту не отдаются.
4. Relay отдаёт поток с поддержкой Range. Если тикет жив, а upstream-ссылка умерла (403/404/410), выполняется прозрачный повторный резолв по fallback-параметрам — трек не прерывается ни при протухании ссылки, ни после перезапуска сервера.
Ручной выбор источника («Источники» в интерфейсе, включая поиск произвольным запросом) сохраняется как `pinned` в SQLite и имеет безусловный приоритет над эвристиками — это спасает треки с «грязными» метаданными из чартов.
### Режимы плеера
У клиента две принципиально разные реальности воспроизведения:
- **`inner`** — встроенный плеер Lampa. На iOS lock screen поддерживает play/pause, next/prev, метаданные и обложку; перемотка скраббером в этом режиме сознательно не поддерживается (подробный разбор: [docs/ios-embedded-lockscreen-seek.md](./docs/ios-embedded-lockscreen-seek.md)).
- **`ios`** — standalone audio-плеер модуля для iPhone: собственный полноэкранный интерфейс (очередь, shuffle/repeat, таймер сна, лирика, свайпы), полный Media Session (включая скраббер перемотки), keep-alive аудиосессии и восстановление воспроизведения на заблокированном экране.
Эти режимы не следует смешивать — у них разная логика Media Session и жизненного цикла audio-элемента.
### Плейлисты и импорт
Плейлисты хранятся на сервере (SQLite, таблица `user_playlists`) с привязкой к профилю браузера — той же identity, что и история прослушиваний. Payload плейлиста содержит полные объекты треков, поэтому плейлист самодостаточен и не зависит от доступности внешних каталогов.
Импорт по ссылке работает без ключей и логина:
- **Spotify** — анонимный токен со страницы embed-плеера + внутренний GraphQL API с пагинацией; импорт атомарный (сбой любой страницы отменяет импорт целиком).
- **Apple Music** — анонимный developer-JWT из веб-бандла music.apple.com + публичный catalog API; поддержаны плейлисты (включая пользовательские `pl.u-…`) и альбомы, все storefront'ы.
- **SoundCloud** — публичный client_id (скрапится автоматически либо задаётся в конфиге).
Импортированный плейлист хранит `source` и может быть пересинхронизирован. Синхронизация зеркалит источник: ручные изменения в таком плейлисте будут перезаписаны.
### Тексты песен
`/music/lyrics` возвращает синхронизированный (LRC, построчный тайминг) или обычный текст. Основной источник — [lrclib.net](https://lrclib.net); при отсутствии synced-варианта пробуется fallback через YouTube Music. Кэширование дифференцированное: synced-результаты живут долго, plain-only и «не найдено» — коротко, сетевые сбои не кэшируются вовсе и помечаются `retry` для клиента.
Для синхронизированного текста клиент позволяет подогнать тайминг в диапазоне от −10 до +10 секунд с шагом 0,5 секунды. `+` показывает текст позже, `−` — раньше; нажатие на значение сбрасывает сдвиг в ноль. В standalone-плеере управление находится в окне «Текст», во встроенном плеере — над лирикой и отдельными кнопками `−` / `+` на панели для управления пультом. Сдвиг действует только на текущий трек, сбрасывается при его смене и не показывается для обычного текста без таймингов.
### Радио
У радио два входа:
- **«Радио от трека»** — пункт в лонг-тап меню любого трека: выбранный трек стартует первым, дальше — волна из ~20 подобранных. При неудаче подбора текущая очередь не меняется.
- **Автоподборка** — opt-in тумблер («Автоподборка треков» в фильтре и чип «Подборка» в фулл-плеере — одна настройка, выключена по умолчанию): когда очередь подходит к концу, в неё догружаются похожие треки. В шите очереди граница отмечается разделителем «Дальше автоподборка».
Движок похожести — слоистый пул на сервере (`MusicRadioService`):
1. **Рекомендации SoundCloud по треку** — `related` и `track-station` (их коллаборативная фильтрация), основной слой;
2. **Топ-треки сид-артистов** (YouTube Music / SoundCloud поиск) — только backfill при недоборе.
Пулы чередуются round-robin, чтобы выдача не вырождалась в одного исполнителя.
Контракт:
- работает только для управляемой очереди модуля (`ios` / `inner`), не для внешних m3u-плееров;
- `POST /music/radio` принимает seed-треки и явный `exclude` текущей очереди; сиды всегда исключаются из выдачи;
- дедуп: `track.id`, нормализованные `artist + title`, история прослушиваний за сутки;
- шумофильтр: миксы/подкасты/реакции, треки короче 45с и длиннее 12мин; cover/karaoke/sped up/slowed/nightcore/remix — только если этих слов не было в сиде; при полностью кириллическом сид-контексте латинский мусор отсекается;
- триггер автоподборки — предпоследний трек по порядку воспроизведения (с учётом shuffle), не чаще одного запроса на поколение очереди;
- `repeat=all` отключает автоподборку: зацикленная очередь — осознанный выбор;
- если кандидатов мало, сервер честно отвечает `available:false` и клиент ничего не добавляет.
Главный риск фичи — не код, а качество рекомендаций; настройка ведётся по живым промахам, не «по формуле». SponsorBlock для YouTube-треков сознательно не в roadmap (нет реальной боли, внешний skip-сервис рискует ложными пропусками); YouTube Mix как движок рекомендаций проверен и отвергнут — анонимный доступ закрыт YouTube («Войдите в аккаунт»), логин-пути модуль не использует.
### Статистика: «Твой топ»
Статистика подаётся не экраном с цифрами, а **играбельным альбомом**: карточка «Твой топ» появляется на главном экране рядом с плейлистами только после накопления данных (10 разных треков и либо фаворит с 10 прослушиваниями, либо 100 прослушиваний суммарно). Внутри — топ треков с настоящими «Слушать»/«Перемешать», числом прослушиваний у каждой строки и сухой метой в духе «247 прослушиваний · примерно 18 ч · артисты заработали ≈ $0.99 · почти кофе».
Данные: таблица `track_stats_daily` (профиль, трек, день, счётчики, `last_played`) — только счётчики, витрина треков джойнится из `playback_history`. `play_count` инкрементится исключительно на честном play-пути (`count_play=true` после реальной задержки воспроизведения), а `total_ms` пополняется фактическими кусками `played_ms` без повторного увеличения счётчика; обновления payload статистику не трогают. Дневная гранулярность оставляет пространство для будущих окон («за месяц», «На повторе», «Вернуть в ротацию»).
### Хранение данных
**Durable (SQLite, `database/music/Music.sql`)** — только то, что выражает действие пользователя или долгоживущую identity: история прослушиваний, дневная статистика (`track_stats_daily`), пользовательские плейлисты, pinned-выборы источников, учётные данные внешних сервисов. База защищена self-healing (карантин повреждённого файла вместе с WAL/SHM-сайдкарами, восстановление из последнего валидного бэкапа) и безопасным бэкапом через SQLite backup API.
**Volatile (`HybridFileCache` + память)** — кэши метаданных, авто-матчей, лирики, картинок, stream-тикеты. Волатильный кэш никогда не попадает в SQLite.
## Расширение: новый источник
Источник подключается не «сайтом целиком», а конкретной ролью. Рекомендуемый порядок внедрения незнакомого сервиса: **Discovery → Audio → Auth** — сначала источник безопасно появляется в интерфейсе, затем добавляется воспроизведение.
Общий порядок:
1. Определить, что источник реально даёт (полки? стримы? аккаунт?).
2. Реализовать провайдер под соответствующий интерфейс из [Providers/Abstractions](./Providers/Abstractions).
3. Добавить флаги в [ModuleConf.cs](./ModuleConf.cs) и значения по умолчанию в [ModInit.cs](./ModInit.cs) (для audio-источников default — `false`, пока не проверено качество матчей).
4. Зарегистрировать провайдер в [MusicProviderRegistry.cs](./Services/Catalog/MusicProviderRegistry.cs).
5. Проверить через API, не трогая остальную архитектуру.
### Контракты по ролям
**Discovery** (`IMusicDiscoveryProvider`): `GetHomeSectionsAsync` — короткие секции для home, `GetSectionAsync` — полный список для «Ещё»/пагинации.
**Audio** (`IMusicAudioProvider`): `SearchAsync` — кандидаты `MusicAudioMatch` по `MusicTrack`; `GetStreamsAsync` — playable `MusicPlaybackSource`; `GetPreferredStreamAsync` — быстрый exact-id путь, если он есть, иначе `null`.
Проверка:
```bash
curl -fsS 'http://127.0.0.1:9118/music/home'
curl -fsS 'http://127.0.0.1:9118/music/section?id=examplemusic:top'
curl -fsS 'http://127.0.0.1:9118/music/matches?title=Song&artist_name=Artist&audio_provider=examplemusicaudio'
```
### Обязательные требования к провайдерам
- Не делать новый источник metadata-базой без отдельного архитектурного решения.
- Не блокировать `/music/home` долгими запросами — уважать бюджет времени.
- Не кэшировать сетевой сбой как «пусто навсегда»; пустые результаты кэшировать только коротко.
- Stream отдавать через `/music/stream`, а не напрямую клиенту.
- Уважать `pinned`-выбор пользователя.
- Внешние картинки возвращать в обычных полях ответа, чтобы их подхватил `MusicImageProxyService`.
## Разработка
После изменения серверного кода — пересборка (модуль компилируется при старте контейнера):
```bash
docker compose up -d --build lampac
```
После изменения только `plugin.js` пересборка не нужна (файл монтируется), но клиент Lampa кэширует плагин — нужно поднять revision в конфигурации подключения плагина и полностью перезапустить клиент.
Smoke-проверка:
```bash
curl -fsS 'http://127.0.0.1:9118/music/home'
curl -fsS 'http://127.0.0.1:9118/music/search?q=nirvana'
curl -fsS 'http://127.0.0.1:9118/music/providers'
```
Клиентская отладка: включить `client_debug_enabled`, трейсы клиента будут приходить на `/music/clientlog` и попадать в логи сервера. После отладки — выключить.
## Известные ограничения
- Discovery/импорт Spotify, импорт Apple Music и авто-получение SoundCloud client_id опираются на недокументированные публичные механизмы этих сервисов; при изменениях на их стороне интеграция может временно ломаться до обновления модуля (симптом для Spotify — `PersistedQueryNotFound` в логах).
- Перемотка скраббером на iOS lock screen поддерживается только в standalone-режиме плеера (`ios`), не во встроенном (`inner`).
- Синхронизация импортированного плейлиста — недеструктивное слияние: порядок и ручные правки сохраняются, новинки добавляются в начало; но треки, удалённые в источнике, локально не удаляются (убрать можно только вручную).
- Управление очередью (играть следующим, убрать, радио-догрузка) работает только для управляемой очереди модуля (`ios` / `inner`); внешние плееры получают разовый плейлист без возможности менять его после запуска.
- Статистика накапливается с момента включения фичи: прослушивания до неё известны только как «последний раз слушал», без счётчиков.
- Лайки/библиотека внешних сервисов переносятся через публичный плейлист (скопировать Liked Songs в публичный плейлист и импортировать по ссылке); прямой доступ к библиотеке требует логина и не реализован.
-614
View File
@@ -1,614 +0,0 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Threading;
namespace Music;
public class MusicContext : DbContext
{
public static readonly string semaphoreKey = "Music";
static readonly string DatabaseDirectoryPath = Path.Combine("database", "music");
static readonly string DatabaseFilePath = Path.Combine(DatabaseDirectoryPath, "Music.sql");
static readonly string BackupDirectoryPath = Path.Combine(DatabaseDirectoryPath, "backups");
static readonly TimeSpan BackupInterval = TimeSpan.FromHours(6);
static readonly TimeSpan MinBackupSpacing = TimeSpan.FromHours(1);
const int MaxBackupFiles = 5;
static readonly SemaphoreSlim backupLock = new(1, 1);
static Timer backupTimer;
public static IDbContextFactory<MusicContext> Factory { get; private set; }
public static MusicContext Create()
{
if (Factory != null)
return Factory.CreateDbContext();
return new MusicContext();
}
public static void Initialization(IServiceProvider applicationServices)
{
Directory.CreateDirectory(DatabaseDirectoryPath);
Directory.CreateDirectory(BackupDirectoryPath);
ValidateOrRecoverDatabase();
Factory = applicationServices.GetService<IDbContextFactory<MusicContext>>();
bool schemaChanged;
using (var sqlDb = Factory?.CreateDbContext() ?? new MusicContext())
{
ApplyConnectionPragmas(sqlDb.Database.GetDbConnection());
sqlDb.Database.EnsureCreated();
schemaChanged = EnsureSchema(sqlDb);
}
if (schemaChanged)
CompactDatabase();
CreateBackupSnapshotIfNeeded(force: schemaChanged);
TrimOldBackups();
StartBackupTimer();
}
static readonly string _connection = new SqliteConnectionStringBuilder
{
DataSource = DatabaseFilePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Private,
DefaultTimeout = 30,
Pooling = false
}.ToString();
public static string ConnectionString => _connection;
public DbSet<MusicAuthCredentialSqlModel> auth_credentials { get; set; }
public DbSet<MusicPlaybackHistorySqlModel> playback_history { get; set; }
public static void ConfiguringDbBuilder(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseSqlite(_connection);
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
ConfiguringDbBuilder(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<MusicAuthCredentialSqlModel>()
.HasIndex(t => new { t.profile_id, t.provider_id })
.IsUnique();
modelBuilder.Entity<MusicPlaybackHistorySqlModel>()
.HasIndex(t => new { t.profile_id, t.track_id })
.IsUnique();
}
static bool EnsureSchema(MusicContext db)
{
bool changed = false;
db.Database.ExecuteSqlRaw("""
CREATE TABLE IF NOT EXISTS auth_credentials (
Id INTEGER NOT NULL CONSTRAINT PK_auth_credentials PRIMARY KEY AUTOINCREMENT,
profile_id TEXT NOT NULL DEFAULT '',
provider_id TEXT NOT NULL,
payload TEXT NOT NULL,
updated TEXT NOT NULL
);
""");
changed |= EnsureColumn(db, "auth_credentials", "profile_id", "TEXT NOT NULL DEFAULT ''");
db.Database.ExecuteSqlRaw("""
DROP INDEX IF EXISTS IX_auth_credentials_provider_id;
""");
db.Database.ExecuteSqlRaw("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_auth_credentials_profile_id_provider_id
ON auth_credentials (profile_id, provider_id);
""");
db.Database.ExecuteSqlRaw("""
CREATE TABLE IF NOT EXISTS playback_history (
Id INTEGER NOT NULL CONSTRAINT PK_playback_history PRIMARY KEY AUTOINCREMENT,
profile_id TEXT NOT NULL DEFAULT '',
track_id TEXT NOT NULL,
payload TEXT NOT NULL,
updated TEXT NOT NULL
);
""");
changed |= EnsureColumn(db, "playback_history", "profile_id", "TEXT NOT NULL DEFAULT ''");
db.Database.ExecuteSqlRaw("""
DROP INDEX IF EXISTS IX_playback_history_track_id;
""");
db.Database.ExecuteSqlRaw("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_playback_history_profile_id_track_id
ON playback_history (profile_id, track_id);
""");
db.Database.ExecuteSqlRaw("""
CREATE INDEX IF NOT EXISTS IX_playback_history_profile_id_updated
ON playback_history (profile_id, updated DESC);
""");
db.Database.ExecuteSqlRaw("""
CREATE TABLE IF NOT EXISTS audio_source_matches (
Id INTEGER NOT NULL CONSTRAINT PK_audio_source_matches PRIMARY KEY AUTOINCREMENT,
track_id TEXT NOT NULL,
provider_scope TEXT NOT NULL,
payload TEXT NOT NULL,
updated TEXT NOT NULL
);
""");
db.Database.ExecuteSqlRaw("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_audio_source_matches_track_id_provider_scope
ON audio_source_matches (track_id, provider_scope);
""");
// в durable-таблице живёт только ручной выбор (pinned) — авто-матчи
// хранятся в volatile cache; заодно чистит авто-записи, попавшие сюда
// в промежуточной ревизии, когда SaveAsync писал в базу всё подряд
db.Database.ExecuteSqlRaw("""
DELETE FROM audio_source_matches
WHERE payload NOT LIKE '%"pinned":true%';
""");
// дневная статистика прослушиваний: только счётчики, payload трека
// живёт в playback_history (джойн по track_id при чтении); дневная
// гранулярность нужна для окон «месяц/год/на повторе/забытые»
db.Database.ExecuteSqlRaw("""
CREATE TABLE IF NOT EXISTS track_stats_daily (
Id INTEGER NOT NULL CONSTRAINT PK_track_stats_daily PRIMARY KEY AUTOINCREMENT,
profile_id TEXT NOT NULL DEFAULT '',
track_id TEXT NOT NULL,
day TEXT NOT NULL,
play_count INTEGER NOT NULL DEFAULT 0,
total_ms INTEGER NOT NULL DEFAULT 0,
last_played TEXT NOT NULL
);
""");
db.Database.ExecuteSqlRaw("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_track_stats_daily_profile_track_day
ON track_stats_daily (profile_id, track_id, day);
""");
db.Database.ExecuteSqlRaw("""
CREATE INDEX IF NOT EXISTS IX_track_stats_daily_profile_day
ON track_stats_daily (profile_id, day);
""");
db.Database.ExecuteSqlRaw("""
CREATE TABLE IF NOT EXISTS user_playlists (
Id INTEGER NOT NULL CONSTRAINT PK_user_playlists PRIMARY KEY AUTOINCREMENT,
profile_id TEXT NOT NULL DEFAULT '',
playlist_id TEXT NOT NULL,
title TEXT NOT NULL,
payload TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
updated TEXT NOT NULL
);
""");
changed |= EnsureColumn(db, "user_playlists", "source", "TEXT NOT NULL DEFAULT ''");
db.Database.ExecuteSqlRaw("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_user_playlists_profile_id_playlist_id
ON user_playlists (profile_id, playlist_id);
""");
changed |= DropLegacyCacheTables(db);
return changed;
}
static bool EnsureColumn(MusicContext db, string tableName, string columnName, string definition)
{
using var connection = new SqliteConnection(_connection);
connection.Open();
using var exists = connection.CreateCommand();
exists.CommandText = $"PRAGMA table_info({tableName});";
using var reader = exists.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), columnName, StringComparison.OrdinalIgnoreCase))
return false;
}
using var alter = connection.CreateCommand();
alter.CommandText = $"ALTER TABLE {tableName} ADD COLUMN {columnName} {definition};";
alter.ExecuteNonQuery();
return true;
}
static bool DropLegacyCacheTables(MusicContext db)
{
bool changed = TableExists("metadata_cache") || TableExists("source_matches");
db.Database.ExecuteSqlRaw("""
DROP INDEX IF EXISTS IX_metadata_cache_provider_id_entity_type_cache_key;
DROP INDEX IF EXISTS IX_source_matches_track_id_provider_id;
DROP TABLE IF EXISTS metadata_cache;
DROP TABLE IF EXISTS source_matches;
""");
return changed;
}
static bool TableExists(string tableName)
{
using var connection = new SqliteConnection(_connection);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT 1
FROM sqlite_master
WHERE type = 'table' AND name = $table_name
LIMIT 1;
""";
command.Parameters.AddWithValue("$table_name", tableName);
return command.ExecuteScalar() != null;
}
static void ApplyConnectionPragmas(System.Data.Common.DbConnection dbConnection)
{
if (dbConnection is not SqliteConnection connection)
return;
bool shouldClose = connection.State != System.Data.ConnectionState.Open;
if (shouldClose)
connection.Open();
try
{
using var command = connection.CreateCommand();
command.CommandText = """
PRAGMA busy_timeout = 10000;
PRAGMA synchronous = FULL;
""";
command.ExecuteNonQuery();
}
finally
{
if (shouldClose)
connection.Close();
}
}
static void StartBackupTimer()
{
backupTimer ??= new Timer(_ =>
{
try
{
CreateBackupSnapshotIfNeeded(force: false);
}
catch
{
}
}, null, BackupInterval, BackupInterval);
}
static void CompactDatabase()
{
if (!File.Exists(DatabaseFilePath))
return;
using var connection = new SqliteConnection(_connection);
connection.Open();
ApplyConnectionPragmas(connection);
using var checkpoint = connection.CreateCommand();
checkpoint.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
checkpoint.ExecuteNonQuery();
using var vacuum = connection.CreateCommand();
vacuum.CommandText = "VACUUM;";
vacuum.ExecuteNonQuery();
}
static void ValidateOrRecoverDatabase()
{
if (!DatabaseFileExists(DatabaseFilePath))
return;
if (IsDatabaseValid(DatabaseFilePath))
return;
string corruptPath = Path.Combine(DatabaseDirectoryPath, $"Music.sql.corrupt-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}");
// If quarantine fails, stop initialization. Never overwrite the original
// or discard its WAL after a failed move.
File.Move(DatabaseFilePath, corruptPath);
// сайдкары битой базы нельзя оставлять рядом с восстановленным файлом:
// SQLite попытается проиграть чужой WAL и испортит свежую копию
try
{
QuarantineSidecarFiles(corruptPath);
RestoreLatestValidBackup();
}
catch
{
// Leave the original in place for the next startup attempt if a
// sidecar or backup is temporarily inaccessible.
foreach (var suffix in new[] { "-wal", "-shm" })
{
if (DatabaseFileExists(corruptPath + suffix))
File.Move(corruptPath + suffix, DatabaseFilePath + suffix);
}
File.Move(corruptPath, DatabaseFilePath);
throw;
}
}
static void QuarantineSidecarFiles(string corruptPath)
{
foreach (var suffix in new[] { "-wal", "-shm" })
{
string sidecar = DatabaseFilePath + suffix;
if (DatabaseFileExists(sidecar))
File.Move(sidecar, corruptPath + suffix);
}
}
static bool RestoreLatestValidBackup()
{
if (!Directory.Exists(BackupDirectoryPath))
return false;
foreach (var backupFile in Directory.GetFiles(BackupDirectoryPath, "Music-*.sqlite")
.OrderByDescending(File.GetLastWriteTimeUtc))
{
if (!IsDatabaseValid(backupFile))
continue;
string restorePath = DatabaseFilePath + ".restore-" + Guid.NewGuid().ToString("N");
try
{
File.Copy(backupFile, restorePath);
File.Move(restorePath, DatabaseFilePath);
}
finally
{
if (File.Exists(restorePath))
File.Delete(restorePath);
}
return true;
}
return false;
}
static bool DatabaseFileExists(string path)
{
// File.Exists also returns false on permission/I/O errors. Those must
// not be mistaken for a missing database during recovery.
try
{
File.GetAttributes(path);
return true;
}
catch (FileNotFoundException) { return false; }
catch (DirectoryNotFoundException) { return false; }
}
static bool IsDatabaseValid(string path)
{
try
{
if (!DatabaseFileExists(path))
return false;
var info = new FileInfo(path);
if (info.Length < 100)
return false;
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
Span<byte> header = stackalloc byte[16];
if (stream.Read(header) != header.Length)
return false;
if (!header.SequenceEqual(Encoding.ASCII.GetBytes("SQLite format 3\0")))
return false;
}
var builder = new SqliteConnectionStringBuilder
{
DataSource = path,
Mode = SqliteOpenMode.ReadOnly,
Pooling = false,
DefaultTimeout = 5
};
using var connection = new SqliteConnection(builder.ToString());
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "PRAGMA quick_check;";
var result = command.ExecuteScalar()?.ToString();
return string.Equals(result, "ok", StringComparison.OrdinalIgnoreCase);
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 11 || ex.SqliteErrorCode == 26)
{
// SQLITE_CORRUPT / SQLITE_NOTADB are evidence of corruption.
// Busy, locked, permission and I/O failures propagate, preserving
// the database instead of silently rolling it back to a backup.
return false;
}
}
static void CreateBackupSnapshotIfNeeded(bool force)
{
if (!backupLock.Wait(0))
return;
try
{
Directory.CreateDirectory(BackupDirectoryPath);
if (!force)
{
var latest = Directory.GetFiles(BackupDirectoryPath, "Music-*.sqlite")
.Select(path => new FileInfo(path))
.OrderByDescending(file => file.LastWriteTimeUtc)
.FirstOrDefault();
if (latest != null && DateTime.UtcNow - latest.LastWriteTimeUtc < MinBackupSpacing)
return;
if (latest != null && File.GetLastWriteTimeUtc(DatabaseFilePath) <= latest.LastWriteTimeUtc)
return;
}
if (!IsDatabaseValid(DatabaseFilePath))
return;
string tempPath = Path.Combine(BackupDirectoryPath, $"Music-{DateTime.UtcNow:yyyyMMdd-HHmmss}.tmp");
string finalPath = Path.ChangeExtension(tempPath, ".sqlite");
try
{
if (File.Exists(tempPath))
File.Delete(tempPath);
using var source = new SqliteConnection(_connection);
source.Open();
ApplyConnectionPragmas(source);
var backupBuilder = new SqliteConnectionStringBuilder
{
DataSource = tempPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
};
using var destination = new SqliteConnection(backupBuilder.ToString());
destination.Open();
source.BackupDatabase(destination);
}
catch
{
try
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
catch
{
}
throw;
}
if (!IsDatabaseValid(tempPath))
{
File.Delete(tempPath);
TryDeleteSidecarFiles(tempPath);
return;
}
if (File.Exists(finalPath))
File.Delete(finalPath);
File.Move(tempPath, finalPath);
TryDeleteSidecarFiles(tempPath);
TrimOldBackups();
}
finally
{
backupLock.Release();
}
}
static void TrimOldBackups()
{
var backupFiles = Directory.GetFiles(BackupDirectoryPath, "Music-*.sqlite")
.Select(path => new FileInfo(path))
.OrderByDescending(file => file.LastWriteTimeUtc)
.ToList();
foreach (var file in backupFiles)
TryDeleteSidecarFiles(file.FullName);
foreach (var file in backupFiles.Skip(MaxBackupFiles))
{
try
{
file.Delete();
}
catch
{
}
}
}
static void TryDeleteSidecarFiles(string basePath)
{
foreach (var suffix in new[] { "-wal", "-shm" })
{
try
{
string sidecar = basePath + suffix;
if (File.Exists(sidecar))
File.Delete(sidecar);
}
catch
{
}
}
}
}
public class MusicAuthCredentialSqlModel
{
[Key]
public long Id { get; set; }
[Required]
public string profile_id { get; set; }
[Required]
public string provider_id { get; set; }
[Required]
public string payload { get; set; }
public DateTime updated { get; set; }
}
public class MusicPlaybackHistorySqlModel
{
[Key]
public long Id { get; set; }
[Required]
public string profile_id { get; set; }
[Required]
public string track_id { get; set; }
[Required]
public string payload { get; set; }
public DateTime updated { get; set; }
}
@@ -1,26 +0,0 @@
namespace Music;
public static class MusicAuthService
{
public static async Task<List<MusicAuthState>> GetStatesAsync(string profileId, CancellationToken cancellationToken = default)
{
var result = new List<MusicAuthState>();
foreach (var provider in MusicProviderRegistry.AuthProviders.Where(i => i.Enabled))
result.Add(await provider.GetStateAsync(profileId, cancellationToken));
return result;
}
public static Task<bool> SaveAsync(string provider, string payload, string profileId, CancellationToken cancellationToken = default)
{
var auth = MusicProviderRegistry.GetAuthProvider(provider);
return auth == null ? Task.FromResult(false) : auth.SaveAsync(payload, profileId, cancellationToken);
}
public static Task LogoutAsync(string provider, string profileId, CancellationToken cancellationToken = default)
{
var auth = MusicProviderRegistry.GetAuthProvider(provider);
return auth == null ? Task.CompletedTask : auth.LogoutAsync(profileId, cancellationToken);
}
}
@@ -1,103 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace Music;
public static class MusicAuthStorageService
{
public static async Task<T> GetAsync<T>(string profileId, string providerId, CancellationToken cancellationToken = default) where T : class
{
if (string.IsNullOrWhiteSpace(providerId))
return null;
using var db = MusicContext.Create();
string normalizedProfileId = NormalizeProfileId(profileId);
var row = await db.auth_credentials
.Where(i => i.provider_id == providerId && i.profile_id == normalizedProfileId)
.OrderByDescending(i => i.updated)
.FirstOrDefaultAsync(cancellationToken);
if (row == null && normalizedProfileId != string.Empty)
{
row = await db.auth_credentials
.Where(i => i.provider_id == providerId && i.profile_id == string.Empty)
.OrderByDescending(i => i.updated)
.FirstOrDefaultAsync(cancellationToken);
}
return row == null ? null : MusicJson.Deserialize<T>(row.payload);
}
public static async Task SaveAsync<T>(string profileId, string providerId, T payload, CancellationToken cancellationToken = default) where T : class
{
if (string.IsNullOrWhiteSpace(providerId) || payload == null)
return;
using var db = MusicContext.Create();
string normalizedProfileId = NormalizeProfileId(profileId);
var existing = await db.auth_credentials
.FirstOrDefaultAsync(i => i.profile_id == normalizedProfileId && i.provider_id == providerId, cancellationToken);
if (existing == null)
{
db.auth_credentials.Add(new MusicAuthCredentialSqlModel
{
profile_id = normalizedProfileId,
provider_id = providerId,
payload = MusicJson.Serialize(payload),
updated = DateTime.UtcNow
});
}
else
{
existing.profile_id = normalizedProfileId;
existing.payload = MusicJson.Serialize(payload);
existing.updated = DateTime.UtcNow;
db.auth_credentials.Update(existing);
}
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
try
{
if (await semaphore.WaitAsync())
await db.SaveChangesAsync(cancellationToken);
}
finally
{
semaphore.Release();
}
}
public static async Task DeleteAsync(string profileId, string providerId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(providerId))
return;
using var db = MusicContext.Create();
string normalizedProfileId = NormalizeProfileId(profileId);
var existing = await db.auth_credentials
.Where(i => i.provider_id == providerId && (i.profile_id == normalizedProfileId || i.profile_id == string.Empty))
.ToListAsync(cancellationToken);
if (existing.Count == 0)
return;
db.auth_credentials.RemoveRange(existing);
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
try
{
if (await semaphore.WaitAsync())
await db.SaveChangesAsync(cancellationToken);
}
finally
{
semaphore.Release();
}
}
static string NormalizeProfileId(string profileId)
=> string.IsNullOrWhiteSpace(profileId) ? "global" : profileId.Trim().ToLowerInvariant();
}
@@ -1,38 +0,0 @@
using Microsoft.AspNetCore.Http;
using Shared.Models.Base;
using Shared.Services.Utilities;
namespace Music;
public static class MusicProfileIdentity
{
public static string Resolve(RequestModel requestInfo, HttpRequest request)
{
string profileId = requestInfo?.user_uid;
if (string.IsNullOrWhiteSpace(profileId) && request != null)
{
profileId = request.Query["uid"].FirstOrDefault() ?? request.Query["account_email"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(profileId) && request.HasFormContentType)
{
try
{
profileId = request.Form["uid"].FirstOrDefault() ?? request.Form["account_email"].FirstOrDefault();
}
catch
{
}
}
}
if (!string.IsNullOrWhiteSpace(profileId))
return profileId.Trim().ToLowerInvariant();
string fingerprint = $"{requestInfo?.IP}|{requestInfo?.UserAgent}".Trim('|');
if (!string.IsNullOrWhiteSpace(fingerprint))
return $"anon:{CrypTo.md5(fingerprint)}";
return "global";
}
}
@@ -1,150 +0,0 @@
using Shared.Services.Hybrid;
using System.Collections.Concurrent;
namespace Music;
public static class MusicMetadataCacheService
{
const string CacheKeyPrefix = "music:metadata:v2";
// пустой результат — чаще разовый сбой провайдера, чем правда:
// полный TTL превращал его в залипшее «ничего не найдено»
static readonly TimeSpan emptyTtl = TimeSpan.FromMinutes(20);
sealed class PendingFill<T> where T : class
{
public CancellationToken OwnerCancellation { get; init; }
public Lazy<Task<T>> Work { get; init; }
}
static class PendingFills<T> where T : class
{
public static readonly ConcurrentDictionary<string, PendingFill<T>> Items = new();
}
public static async Task<T> GetOrCreateAsync<T>(
string providerId,
string entityType,
string cacheKey,
TimeSpan ttl,
Func<Task<T>> factory,
CancellationToken cancellationToken = default,
Func<T, TimeSpan> ttlSelector = null) where T : class
{
cancellationToken.ThrowIfCancellationRequested();
var cached = await GetAsync<T>(providerId, entityType, cacheKey, cancellationToken);
if (cached != null)
return cached;
if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(cacheKey))
return await CreateAndSaveAsync(providerId, entityType, cacheKey, ttl, factory, cancellationToken, ttlSelector);
string key = BuildCacheKey(providerId, entityType, cacheKey);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
PendingFill<T> candidate = null;
candidate = new PendingFill<T>
{
OwnerCancellation = cancellationToken,
Work = new Lazy<Task<T>>(async () =>
{
try
{
// A previous fill may have finished between the miss and registration.
var existing = await GetAsync<T>(providerId, entityType, cacheKey, cancellationToken);
if (existing != null)
return existing;
return await CreateAndSaveAsync(providerId, entityType, cacheKey, ttl, factory, cancellationToken, ttlSelector);
}
finally
{
PendingFills<T>.Items.TryRemove(new KeyValuePair<string, PendingFill<T>>(key, candidate));
}
}, LazyThreadSafetyMode.ExecutionAndPublication)
};
var pending = PendingFills<T>.Items.GetOrAdd(key, candidate);
try
{
return await pending.Work.Value.WaitAsync(cancellationToken);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && pending.OwnerCancellation.IsCancellationRequested)
{
// Factories capture their caller's token. Retry with our own factory
// after an abandoned owner's fill, without cancelling other waiters.
}
}
}
static async Task<T> CreateAndSaveAsync<T>(
string providerId, string entityType, string cacheKey, TimeSpan ttl,
Func<Task<T>> factory, CancellationToken cancellationToken,
Func<T, TimeSpan> ttlSelector) where T : class
{
cancellationToken.ThrowIfCancellationRequested();
var created = await factory();
cancellationToken.ThrowIfCancellationRequested();
if (created != null)
{
TimeSpan selectedTtl = IsEmptyPayload(created)
? emptyTtl
: ttlSelector?.Invoke(created) ?? ttl;
await SaveAsync(providerId, entityType, cacheKey, created, selectedTtl, cancellationToken);
}
return created;
}
static bool IsEmptyPayload(object payload)
{
if (payload is System.Collections.ICollection collection)
return collection.Count == 0;
if (payload is MusicSearchResult search)
{
return (search.artists == null || search.artists.Count == 0)
&& (search.albums == null || search.albums.Count == 0)
&& (search.tracks == null || search.tracks.Count == 0);
}
return false;
}
public static async Task<T> GetAsync<T>(string providerId, string entityType, string cacheKey, CancellationToken cancellationToken = default) where T : class
{
if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(cacheKey))
return null;
var cache = HybridCache.Get();
// fileCache:false — иначе чтение слепо к свежим записям в tempDb-буфере
// HybridCache (до флаша ~15-60s), и повторный запрос делает полный рефетч
var entry = await cache.ReadCacheAsync<T>(BuildCacheKey(providerId, entityType, cacheKey), false, null, textJson: true);
return entry.succes ? entry.value : null;
}
public static Task SaveAsync<T>(string providerId, string entityType, string cacheKey, T payload, TimeSpan ttl, CancellationToken cancellationToken = default) where T : class
{
if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(cacheKey) || payload == null)
return Task.CompletedTask;
_ = cancellationToken;
HybridCache.Get().Set(BuildCacheKey(providerId, entityType, cacheKey), payload, ttl, textJson: true);
return Task.CompletedTask;
}
static string BuildCacheKey(string providerId, string entityType, string cacheKey)
{
return string.Join(':',
CacheKeyPrefix,
NormalizeSegment(providerId),
NormalizeSegment(entityType),
NormalizeKey(cacheKey));
}
static string NormalizeKey(string cacheKey) => cacheKey.Trim().ToLowerInvariant();
static string NormalizeSegment(string value) => value?.Trim().ToLowerInvariant() ?? string.Empty;
}
@@ -1,344 +0,0 @@
using Shared.Services.Hybrid;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Caching.Memory;
namespace Music;
public static class MusicSourceMatchService
{
const string CacheKeyPrefix = "music:sourcematch:v3";
const string MissingCacheKeyPrefix = "music:sourcematch-miss:v2";
static readonly TimeSpan MatchTtl = TimeSpan.FromDays(180);
static readonly TimeSpan MissingTtl = TimeSpan.FromMinutes(30);
const long MatchCacheSizeLimit = 10_000;
static readonly MemoryCache matchCache = new(new MemoryCacheOptions
{
SizeLimit = MatchCacheSizeLimit
});
static readonly MemoryCache missingCache = new(new MemoryCacheOptions());
// Fixed stripes bound memory use; one track/mode shares a gate across providers
// so reset, manual selection and late automatic results are ordered together.
static readonly SemaphoreSlim[] stateLocks = Enumerable.Range(0, 64)
.Select(_ => new SemaphoreSlim(1, 1)).ToArray();
static SemaphoreSlim StateLock(string trackId, string playbackMode)
{
string key = NormalizeTrackId(trackId) + ":" + MusicPlaybackModeService.Normalize(playbackMode);
return stateLocks[(int)((uint)key.GetHashCode() % (uint)stateLocks.Length)];
}
static MusicAudioMatch CopyMatch(MusicAudioMatch match)
=> match == null ? null : MusicJson.Deserialize<MusicAudioMatch>(MusicJson.Serialize(match));
static string ScopeProviderId(string providerId, string playbackMode)
{
providerId = providerId?.Trim();
if (string.IsNullOrWhiteSpace(providerId))
return providerId;
return $"{providerId}:{MusicPlaybackModeService.Normalize(playbackMode)}";
}
public static async Task<MusicAudioMatch> GetAsync(string trackId, string providerId, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(providerId))
return null;
var stateLock = StateLock(trackId, playbackMode);
await stateLock.WaitAsync(cancellationToken);
try
{
string scopedProviderId = ScopeProviderId(providerId, playbackMode);
string cacheKey = BuildCacheKey(trackId, scopedProviderId);
if (matchCache.TryGetValue(cacheKey, out MusicAudioMatch cachedMatch))
return CopyMatch(cachedMatch);
var dbMatch = await ReadDbMatchAsync(trackId, scopedProviderId, cancellationToken);
if (dbMatch != null)
{
SetMatchCache(cacheKey, dbMatch);
return CopyMatch(dbMatch);
}
var entry = await HybridCache.Get().ReadCacheAsync<MusicAudioMatch>(cacheKey, false, null, textJson: true);
if (entry.succes && entry.value != null)
{
// hybrid — территория авто-подбора: pinned авторитетен только из БД,
// иначе legacy-запись воскресит сброшенный пользователем выбор
entry.value = CopyMatch(entry.value);
entry.value.pinned = false;
SetMatchCache(cacheKey, entry.value);
}
return entry.succes ? CopyMatch(entry.value) : null;
}
finally
{
stateLock.Release();
}
}
// сброс ручного выбора: убирает pinned-строки трека в данном режиме,
// авто-подбор в volatile-кэше не трогаем — резолвер продолжит с него
public static async Task<bool> DeleteAsync(string trackId, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId))
return false;
var stateLock = StateLock(trackId, playbackMode);
await stateLock.WaitAsync(cancellationToken);
try
{
if (!await DeleteDbMatchesAsync(trackId, playbackMode, cancellationToken))
return false;
foreach (var provider in MusicProviderRegistry.AudioProviders)
matchCache.Remove(BuildCacheKey(trackId, ScopeProviderId(provider.Id, playbackMode)));
return true;
}
finally
{
stateLock.Release();
}
}
static async Task<bool> DeleteDbMatchesAsync(string trackId, string playbackMode, CancellationToken cancellationToken)
{
trackId = NormalizeTrackId(trackId);
if (string.IsNullOrWhiteSpace(trackId))
return false;
string mode = MusicPlaybackModeService.Normalize(playbackMode);
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
try
{
if (!await semaphore.WaitAsync())
{
Console.WriteLine("[Music] source match reset skipped: db semaphore timeout");
return false;
}
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM audio_source_matches
WHERE track_id = $track_id
AND provider_scope LIKE $scope_pattern;
""";
command.Parameters.AddWithValue("$track_id", trackId);
command.Parameters.AddWithValue("$scope_pattern", "%:" + NormalizeProviderScope(mode));
await command.ExecuteNonQueryAsync(cancellationToken);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[Music] source match reset failed: {ex.Message}");
return false;
}
finally
{
semaphore.Release();
}
}
public static async Task<bool> SaveAsync(string trackId, MusicAudioMatch match, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId) || match == null || string.IsNullOrWhiteSpace(match.provider_id) || string.IsNullOrWhiteSpace(match.id))
return false;
var stateLock = StateLock(trackId, playbackMode);
await stateLock.WaitAsync(cancellationToken);
try
{
match = CopyMatch(match);
string scopedProviderId = ScopeProviderId(match.provider_id, playbackMode);
string cacheKey = BuildCacheKey(trackId, scopedProviderId);
if (match.pinned)
{
// ручной выбор — durable state; memory обновляем только после
// успешной записи, чтобы «Сохранено» клиенту не врало
if (!await SaveDbMatchAsync(trackId, scopedProviderId, match, cancellationToken))
return false;
}
else
{
// авто-матч — volatile cache: SQLite не трогаем (правило модуля),
// in-process свежесть обеспечивает matchCache ниже
HybridCache.Get().Set(cacheKey, match, MatchTtl, textJson: true);
// A resolver may have started before the user pinned a source.
// Keep the automatic result volatile, but never replace that choice.
var pinned = await ReadDbMatchAsync(trackId, scopedProviderId, cancellationToken);
if (pinned != null)
match = pinned;
}
SetMatchCache(cacheKey, match);
missingCache.Remove(BuildMissingCacheKey(trackId, scopedProviderId));
HybridCache.Get().Set(BuildMissingCacheKey(trackId, scopedProviderId), false, MissingTtl, textJson: true);
return true;
}
finally
{
stateLock.Release();
}
}
public static async Task<bool> IsMarkedMissingAsync(string trackId, string providerId, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(providerId))
return false;
_ = cancellationToken;
string scopedProviderId = ScopeProviderId(providerId, playbackMode);
string missingKey = BuildMissingCacheKey(trackId, scopedProviderId);
if (missingCache.TryGetValue(missingKey, out bool cachedMissing))
return cachedMissing;
var entry = await HybridCache.Get().ReadCacheAsync<bool>(BuildMissingCacheKey(trackId, scopedProviderId), false, null, textJson: true);
if (entry.succes && entry.value)
missingCache.Set(missingKey, true, MissingTtl);
return entry.succes && entry.value;
}
public static async Task MarkMissingAsync(string trackId, string providerId, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(providerId))
return;
var stateLock = StateLock(trackId, playbackMode);
await stateLock.WaitAsync(cancellationToken);
try
{
string scopedProviderId = ScopeProviderId(providerId, playbackMode);
string missingKey = BuildMissingCacheKey(trackId, scopedProviderId);
// A lookup may have started before the user pinned a source and
// finished afterwards. Never let that stale miss hide durable state.
if (await ReadDbMatchAsync(trackId, scopedProviderId, cancellationToken) != null)
{
missingCache.Remove(missingKey);
HybridCache.Get().Set(missingKey, false, MissingTtl, textJson: true);
return;
}
missingCache.Set(missingKey, true, MissingTtl);
HybridCache.Get().Set(missingKey, true, MissingTtl, textJson: true);
}
finally
{
stateLock.Release();
}
}
static string BuildCacheKey(string trackId, string scopedProviderId)
{
return string.Join(':',
CacheKeyPrefix,
NormalizeTrackId(trackId),
NormalizeProviderScope(scopedProviderId));
}
static string BuildMissingCacheKey(string trackId, string scopedProviderId)
{
return string.Join(':',
MissingCacheKeyPrefix,
NormalizeTrackId(trackId),
NormalizeProviderScope(scopedProviderId));
}
static void SetMatchCache(string cacheKey, MusicAudioMatch match)
{
if (string.IsNullOrWhiteSpace(cacheKey) || match == null)
return;
matchCache.Set(cacheKey, match, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = MatchTtl,
Size = 1
});
}
static async Task<MusicAudioMatch> ReadDbMatchAsync(string trackId, string scopedProviderId, CancellationToken cancellationToken)
{
trackId = NormalizeTrackId(trackId);
scopedProviderId = NormalizeProviderScope(scopedProviderId);
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(scopedProviderId))
return null;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT payload
FROM audio_source_matches
WHERE track_id = $track_id
AND provider_scope = $provider_scope
LIMIT 1;
""";
command.Parameters.AddWithValue("$track_id", trackId);
command.Parameters.AddWithValue("$provider_scope", scopedProviderId);
var payload = await command.ExecuteScalarAsync(cancellationToken) as string;
return string.IsNullOrWhiteSpace(payload) ? null : MusicJson.Deserialize<MusicAudioMatch>(payload);
}
static async Task<bool> SaveDbMatchAsync(string trackId, string scopedProviderId, MusicAudioMatch match, CancellationToken cancellationToken)
{
trackId = NormalizeTrackId(trackId);
scopedProviderId = NormalizeProviderScope(scopedProviderId);
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(scopedProviderId) || match == null)
return false;
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
try
{
if (!await semaphore.WaitAsync())
{
Console.WriteLine("[Music] source match save skipped: db semaphore timeout");
return false;
}
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO audio_source_matches (track_id, provider_scope, payload, updated)
VALUES ($track_id, $provider_scope, $payload, $updated)
ON CONFLICT(track_id, provider_scope) DO UPDATE SET
payload = excluded.payload,
updated = excluded.updated;
""";
command.Parameters.AddWithValue("$track_id", trackId);
command.Parameters.AddWithValue("$provider_scope", scopedProviderId);
command.Parameters.AddWithValue("$payload", MusicJson.Serialize(match));
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[Music] source match save failed: {ex.Message}");
return false;
}
finally
{
semaphore.Release();
}
}
static string NormalizeTrackId(string trackId) => trackId?.Trim() ?? string.Empty;
static string NormalizeProviderScope(string providerId) => providerId?.Trim().ToLowerInvariant() ?? string.Empty;
}
@@ -1,797 +0,0 @@
namespace Music;
public static class MusicCatalogService
{
static readonly TimeSpan searchCacheTtl = TimeSpan.FromHours(6);
static readonly TimeSpan artistCacheTtl = TimeSpan.FromDays(7);
static readonly TimeSpan albumCacheTtl = TimeSpan.FromDays(7);
static readonly TimeSpan trackCacheTtl = TimeSpan.FromDays(7);
static readonly TimeSpan discoveryHomeProviderResponseBudget = TimeSpan.FromSeconds(2);
static readonly TimeSpan discoveryHomeProviderWarmTimeout = TimeSpan.FromSeconds(10);
static readonly TimeSpan discoverySectionProviderTimeout = TimeSpan.FromSeconds(8);
static readonly TimeSpan searchSectionProviderTimeout = TimeSpan.FromSeconds(4);
static readonly TimeSpan searchMetadataResponseBudget = TimeSpan.FromSeconds(2);
static readonly TimeSpan searchMetadataTimeout = TimeSpan.FromSeconds(20);
const int recentSectionLimit = 100;
const int browseSectionLimit = 20;
const int browseSectionFullLimit = 100;
const int searchSectionLimit = 8;
const int searchSectionFullLimit = 24;
// v31: RU->latin fallback metadata-поиска для кириллических запросов
// западных артистов ("эминем" -> "eminem"), без fuzzy-расширения.
// v34: Spotify artist screen добирает полные discography/appears-on секции
// отдельными artistsection-запросами вместо короткого overview.
// v35: уточнённые Spotify release labels для singles/EP/appears-on.
// v36: Spotify singles/EP отдаются как track-section, не album-section.
// v37: track metadata сохраняет ISRC для точного audio matching.
// v38: Spotify tracks сохраняют album_id для быстрого нативного
// разрешения длительности без ошибочного fallback в MusicBrainz.
const string metadataCacheVersion = "v38";
static readonly object homeWarmLock = new();
static readonly Dictionary<string, Task<List<MusicBrowseSection>>> homeWarmTasks = new(StringComparer.OrdinalIgnoreCase);
public static async Task<MusicHomeResponse> GetHomeAsync(string profileId, int dailySalt = 0, CancellationToken cancellationToken = default)
{
var sections = new List<MusicSection>
{
new() { id = "search", title = "Search", endpoint = "/music/search", type = "search" },
new() { id = "providers", title = "Providers", endpoint = "/music/providers", type = "info" }
};
if (MusicProviderRegistry.AuthProviders.Any(i => i.Enabled))
sections.Add(new MusicSection { id = "auth", title = "Auth", endpoint = "/music/auth/state", type = "info" });
var recentlyPlayedTask = MusicPlaybackHistoryService.GetRecentAsync(profileId, recentSectionLimit, cancellationToken);
var userPlaylistsTask = MusicUserPlaylistService.ListAsync(profileId, cancellationToken);
// сводки миксов недели — чистое чтение истории + детерминированная
// раскладка, без внешних HTTP; треки грузятся лениво в /music/daily
var dailyMixesTask = MusicDailyMixService.GetSummariesAsync(profileId, dailySalt, cancellationToken);
var browseSectionsTask = GetBrowseSectionsAsync(cancellationToken);
await Task.WhenAll(recentlyPlayedTask, userPlaylistsTask, dailyMixesTask, browseSectionsTask);
var browseSectionsResult = await browseSectionsTask;
return new MusicHomeResponse
{
title = "Music",
status = "ok",
version = "0.1.0",
sections = sections,
metadata_providers = MusicProviderRegistry.DescribeMetadata(),
audio_providers = MusicProviderRegistry.DescribeAudio(),
auth_providers = MusicProviderRegistry.DescribeAuth(),
recently_played = await recentlyPlayedTask,
user_playlists = await userPlaylistsTask,
daily_mixes = await dailyMixesTask,
browse_sections = browseSectionsResult.sections,
browse_sections_warming = browseSectionsResult.warming
};
}
public static async Task<MusicBrowseSection> GetBrowseSectionAsync(string sectionId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sectionId))
return null;
foreach (var provider in MusicProviderRegistry.DiscoveryProviders.Where(i => i.Enabled))
{
var section = await SafeGetSectionAsync(provider, sectionId, browseSectionFullLimit, cancellationToken);
if (section != null)
return section;
}
return null;
}
public static async Task<MusicSearchResponse> SearchAsync(string query, string provider = null, bool expanded = false, CancellationToken cancellationToken = default)
{
var metadata = MusicProviderRegistry.GetMetadataProvider(provider);
var metadataTask = SearchMetadataAsync(metadata, query, expanded, cancellationToken);
ObserveBackgroundTask(metadataTask);
var searchSectionsTask = GetSearchSectionsAsync(query, expanded, cancellationToken);
var metadataBudgetTask = Task.Delay(searchMetadataResponseBudget, cancellationToken);
var searchSections = await searchSectionsTask;
var hasSearchSections = searchSections.Count > 0;
(MusicSearchResult result, bool pending) metadataState;
if (hasSearchSections)
metadataState = await WaitForSearchMetadataForResponseAsync(metadataTask, metadataBudgetTask, cancellationToken);
else
metadataState = (await metadataTask, false);
var result = metadataState.result;
if (result.artists != null && result.artists.Count > 0)
await DiscogsArtistImageService.ApplyCachedAsync(result.artists, cancellationToken);
bool hasResults =
(result.artists?.Count > 0)
|| (result.albums?.Count > 0)
|| (result.tracks?.Count > 0)
|| hasSearchSections;
return new MusicSearchResponse
{
query = query,
status = hasResults ? "ok" : "empty",
metadata_provider = metadata?.Id,
metadata_pending = metadataState.pending,
audio_providers = MusicProviderRegistry.AudioProviders.Where(i => i.Enabled).Select(i => i.Id).ToList(),
artists = result.artists,
albums = result.albums,
tracks = result.tracks,
search_sections = searchSections
};
}
static async Task<(MusicSearchResult result, bool pending)> WaitForSearchMetadataForResponseAsync(Task<MusicSearchResult> metadataTask, Task budgetTask, CancellationToken cancellationToken)
{
if (metadataTask.IsCompleted)
return (await metadataTask, false);
var completed = await Task.WhenAny(metadataTask, budgetTask);
if (completed == metadataTask)
return (await metadataTask, false);
cancellationToken.ThrowIfCancellationRequested();
return (new MusicSearchResult(), true);
}
static void ObserveBackgroundTask(Task task)
{
_ = task.ContinueWith(completed =>
{
_ = completed.Exception;
}, TaskContinuationOptions.OnlyOnFaulted);
}
static async Task<MusicSearchResult> SearchMetadataAsync(IMusicMetadataProvider metadata, string query, bool expanded, CancellationToken cancellationToken)
{
if (metadata == null || string.IsNullOrWhiteSpace(query))
return new MusicSearchResult();
try
{
// MusicBrainz живёт на rate-limit и 20s HttpClient-таймауте:
// ответ поиска ждёт metadata только короткое окно, но сама metadata-ветка
// может догреться дольше и положить результат в кэш. Таймауты не кэшируются.
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(searchMetadataTimeout);
return await MusicMetadataCacheService.GetOrCreateAsync(
metadata.Id,
expanded ? "search_full" : "search",
VersionedKey((expanded ? "full|" : "base|") + NormalizeSearchKey(query)),
searchCacheTtl,
() => metadata.SearchAsync(query, expanded, timeoutCts.Token),
timeoutCts.Token
) ?? new MusicSearchResult();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new MusicSearchResult();
}
}
public static Task<MusicArtist> GetArtistAsync(string id, string provider = null, CancellationToken cancellationToken = default)
{
if (YouTubeMusicSearchSupport.IsChannelArtist(provider, id))
return YouTubeMusicSearchSupport.GetChannelArtistAsync(id, cancellationToken);
if (SoundCloudSupport.IsUserArtist(provider, id))
return SoundCloudSupport.GetUserArtistAsync(id, cancellationToken);
if (SpotifySupport.IsSearchEnabled && SpotifySupport.IsSpotifyArtist(provider, id))
return MusicMetadataCacheService.GetOrCreateAsync(
SpotifySupport.ProviderId, "artist", VersionedKey(id), artistCacheTtl,
() => SpotifySupport.GetArtistAsync(id, cancellationToken), cancellationToken);
var metadata = MusicProviderRegistry.GetMetadataProvider(provider);
bool isMusicBrainz = metadata is MusicBrainzMetadataProvider;
return metadata == null
? Task.FromResult<MusicArtist>(null)
: MusicMetadataCacheService.GetOrCreateAsync(
metadata.Id,
"artist",
// Bypass old week-long empty discographies without invalidating other caches.
VersionedKey(isMusicBrainz ? $"artist-discography:v2:{id}" : id),
artistCacheTtl,
() => metadata.GetArtistAsync(id, cancellationToken),
cancellationToken,
ttlSelector: artist => isMusicBrainz
&& (artist.albums?.Count ?? 0) == 0
&& (artist.sections?.Count ?? 0) == 0
? TimeSpan.FromMinutes(20)
: artistCacheTtl
);
}
public static Task<MusicBrowseSection> GetArtistSectionAsync(string id, string provider = null, string page = null, int limit = 20, string artistName = null, CancellationToken cancellationToken = default)
{
if (SoundCloudSupport.IsUserSection(provider, id))
return SoundCloudSupport.GetUserSectionAsync(id, page, limit, cancellationToken);
if (SpotifySupport.IsSearchEnabled && SpotifySupport.IsSpotifyArtistSection(provider, id))
return MusicMetadataCacheService.GetOrCreateAsync(
SpotifySupport.ProviderId,
"artist_section",
VersionedKey($"{id}|{page}|{limit}|{artistName}"),
searchCacheTtl,
() => SpotifySupport.GetArtistSectionAsync(id, page, limit, artistName, cancellationToken),
cancellationToken
);
return Task.FromResult<MusicBrowseSection>(null);
}
public static Task<MusicAlbum> GetAlbumAsync(string id, string provider = null, CancellationToken cancellationToken = default)
{
if (YouTubeMusicSearchSupport.IsPlaylistAlbum(provider, id))
return YouTubeMusicSearchSupport.GetPlaylistAlbumAsync(id, cancellationToken);
if (AppleMusicSupport.IsCatalogAlbum(provider, id))
return MusicMetadataCacheService.GetOrCreateAsync(
AppleMusicSupport.ProviderId, "album", VersionedKey(id), albumCacheTtl,
() => AppleMusicSupport.GetCatalogAlbumAsync(id, cancellationToken), cancellationToken);
if (AppleMusicDiscoveryProvider.IsChartAlbum(provider, id))
{
var discovery = MusicProviderRegistry.DiscoveryProviders
.FirstOrDefault(i => string.Equals(i.Id, AppleMusicDiscoveryProvider.ProviderId, StringComparison.OrdinalIgnoreCase)) as AppleMusicDiscoveryProvider;
return discovery?.Enabled == true
? MusicMetadataCacheService.GetOrCreateAsync(
AppleMusicDiscoveryProvider.ProviderId,
"album",
VersionedKey($"{AppleMusicDiscoveryProvider.CurrentCountry}|{id}"),
albumCacheTtl,
() => discovery.GetAlbumAsync(id, cancellationToken),
cancellationToken)
: Task.FromResult<MusicAlbum>(null);
}
if (SpotifyDiscoveryProvider.IsPlaylistAlbum(provider, id))
{
var discovery = MusicProviderRegistry.DiscoveryProviders
.FirstOrDefault(i => string.Equals(i.Id, provider, StringComparison.OrdinalIgnoreCase)) as SpotifyDiscoveryProvider;
return discovery?.Enabled == true
? discovery.GetPlaylistAlbumAsync(id, cancellationToken)
: Task.FromResult<MusicAlbum>(null);
}
if (SoundCloudSupport.IsUserTracksAlbum(provider, id))
return SoundCloudSupport.GetUserTracksAlbumAsync(id, cancellationToken);
if (SpotifySupport.IsSearchEnabled && SpotifySupport.IsSpotifyAlbum(provider, id))
return MusicMetadataCacheService.GetOrCreateAsync(
SpotifySupport.ProviderId, "album", VersionedKey(id), albumCacheTtl,
() => SpotifySupport.GetAlbumAsync(id, cancellationToken), cancellationToken);
if (string.Equals(provider, SoundCloudSupport.DiscoveryProviderId, StringComparison.OrdinalIgnoreCase))
{
var discovery = MusicProviderRegistry.DiscoveryProviders
.FirstOrDefault(i => string.Equals(i.Id, provider, StringComparison.OrdinalIgnoreCase)) as SoundCloudDiscoveryProvider;
return discovery?.Enabled == true
? discovery.GetAlbumAsync(id, cancellationToken)
: Task.FromResult<MusicAlbum>(null);
}
var metadata = MusicProviderRegistry.GetMetadataProvider(provider);
return metadata == null
? Task.FromResult<MusicAlbum>(null)
: MusicMetadataCacheService.GetOrCreateAsync(
metadata.Id,
"album",
VersionedKey(id),
albumCacheTtl,
() => metadata.GetAlbumAsync(id, cancellationToken),
cancellationToken
);
}
public static Task<MusicTrack> GetTrackAsync(string id, string provider = null, CancellationToken cancellationToken = default)
{
if (string.Equals(provider, SoundCloudSupport.DiscoveryProviderId, StringComparison.OrdinalIgnoreCase))
return SoundCloudSupport.GetTrackAsync(id, cancellationToken);
if (ShouldSkipMetadataTrackLookup(id, provider))
return Task.FromResult<MusicTrack>(null);
var metadata = MusicProviderRegistry.GetMetadataProvider(provider);
return metadata == null
? Task.FromResult<MusicTrack>(null)
: MusicMetadataCacheService.GetOrCreateAsync(
metadata.Id,
"track",
VersionedKey(id),
trackCacheTtl,
() => metadata.GetTrackAsync(id, cancellationToken),
cancellationToken
);
}
static async Task<(List<MusicBrowseSection> sections, bool warming)> GetBrowseSectionsAsync(CancellationToken cancellationToken)
{
var providers = MusicProviderRegistry.DiscoveryProviders.Where(i => i.Enabled).ToList();
if (providers.Count == 0)
return (new List<MusicBrowseSection>(), false);
var results = await Task.WhenAll(providers.Select(provider =>
GetHomeSectionsForResponseAsync(provider, browseSectionLimit, cancellationToken)));
var sections = new List<MusicBrowseSection>();
bool warming = false;
foreach (var (providerSections, providerWarming) in results)
{
warming |= providerWarming;
if (providerSections != null && providerSections.Count > 0)
sections.AddRange(providerSections);
}
return (sections, warming);
}
static async Task<List<MusicBrowseSection>> GetSearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(query))
return new List<MusicBrowseSection>();
var tasks = new List<Task<List<MusicBrowseSection>>>();
if (YouTubeMusicSearchSupport.IsSearchEnabled)
tasks.Add(SafeGetSearchSectionsAsync(token => BuildYouTubeMusicSearchSectionsAsync(query, expanded, token), cancellationToken));
if (SoundCloudSupport.IsDiscoveryEnabled)
tasks.Add(SafeGetSearchSectionsAsync(token => BuildSoundCloudSearchSectionsAsync(query, expanded, token), cancellationToken));
if (MusicProviderRegistry.AudioProviders.Any(i => i.Enabled && string.Equals(i.Id, SefonSupport.ProviderId, StringComparison.OrdinalIgnoreCase)))
tasks.Add(SafeGetSearchSectionsAsync(async token => ToSearchSectionList(await BuildSefonSearchSectionAsync(query, expanded, token)), cancellationToken));
if (SpotifySupport.IsSearchEnabled)
tasks.Add(SafeGetSearchSectionsAsync(token => BuildSpotifySearchSectionsAsync(query, expanded, token), cancellationToken));
if (tasks.Count == 0)
return new List<MusicBrowseSection>();
var results = await Task.WhenAll(tasks);
return results
.Where(i => i != null)
.SelectMany(i => i)
.Where(HasSearchSectionEntries)
.ToList();
}
static async Task<List<MusicBrowseSection>> BuildYouTubeMusicSearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken)
{
int trackLimit = expanded ? searchSectionFullLimit : searchSectionLimit;
int sideLimit = expanded ? 18 : 6;
string searchKey = NormalizeSearchKey(query);
var tracksTask = MusicMetadataCacheService.GetOrCreateAsync(
YouTubeMusicSearchSupport.ProviderId,
"search_tracks",
VersionedKey($"youtubemusic-tracks|{trackLimit}|{searchKey}"),
searchCacheTtl,
() => YouTubeMusicSearchSupport.SearchTracksByQueryAsync(query, trackLimit, cancellationToken),
cancellationToken
);
var playlistsTask = MusicMetadataCacheService.GetOrCreateAsync(
YouTubeMusicSearchSupport.ProviderId,
"search_playlists",
VersionedKey($"youtubemusic-playlists|{sideLimit}|{searchKey}"),
searchCacheTtl,
() => YouTubeMusicSearchSupport.SearchPlaylistsByQueryAsync(query, sideLimit, cancellationToken),
cancellationToken
);
var artistsTask = MusicMetadataCacheService.GetOrCreateAsync(
YouTubeMusicSearchSupport.ProviderId,
"search_artists",
VersionedKey($"youtubemusic-artists|{sideLimit}|{searchKey}"),
searchCacheTtl,
() => YouTubeMusicSearchSupport.SearchArtistsByQueryAsync(query, sideLimit, cancellationToken),
cancellationToken
);
await Task.WhenAll(tracksTask, playlistsTask, artistsTask);
var tracks = await tracksTask ?? new List<MusicTrack>();
var playlists = await playlistsTask ?? new List<MusicAlbum>();
var artists = await artistsTask ?? new List<MusicArtist>();
var sections = new List<MusicBrowseSection>();
if (tracks.Count > 0)
{
sections.Add(new MusicBrowseSection
{
id = YouTubeMusicSearchSupport.TracksSectionId,
title = "Треки",
type = "tracks",
source_provider = YouTubeMusicSearchSupport.ProviderId,
has_more = !expanded && tracks.Count >= trackLimit,
tracks = tracks
});
}
if (playlists.Count > 0)
{
sections.Add(new MusicBrowseSection
{
id = YouTubeMusicSearchSupport.PlaylistsSectionId,
title = "Плейлисты / альбомы",
type = "albums",
source_provider = YouTubeMusicSearchSupport.ProviderId,
has_more = !expanded && playlists.Count >= sideLimit,
albums = playlists
});
}
if (artists.Count > 0)
{
sections.Add(new MusicBrowseSection
{
id = YouTubeMusicSearchSupport.ArtistsSectionId,
title = "Исполнители / каналы",
type = "artists",
source_provider = YouTubeMusicSearchSupport.ProviderId,
has_more = !expanded && artists.Count >= sideLimit,
artists = artists
});
}
return sections;
}
static async Task<List<MusicBrowseSection>> BuildSpotifySearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken)
{
int trackLimit = expanded ? searchSectionFullLimit : searchSectionLimit;
int sideLimit = expanded ? 18 : 6;
string searchKey = NormalizeSearchKey(query);
var artistsTask = MusicMetadataCacheService.GetOrCreateAsync(
SpotifySupport.ProviderId, "search_artists",
VersionedKey($"spotify-artists|{sideLimit}|{searchKey}"), searchCacheTtl,
() => SpotifySupport.SearchArtistsAsync(query, sideLimit, cancellationToken), cancellationToken);
var albumsTask = MusicMetadataCacheService.GetOrCreateAsync(
SpotifySupport.ProviderId, "search_albums",
VersionedKey($"spotify-albums|{sideLimit}|{searchKey}"), searchCacheTtl,
() => SpotifySupport.SearchAlbumsAsync(query, sideLimit, cancellationToken), cancellationToken);
var tracksTask = MusicMetadataCacheService.GetOrCreateAsync(
SpotifySupport.ProviderId, "search_tracks",
VersionedKey($"spotify-tracks|{trackLimit}|{searchKey}"), searchCacheTtl,
() => SpotifySupport.SearchTracksAsync(query, trackLimit, cancellationToken), cancellationToken);
await Task.WhenAll(artistsTask, albumsTask, tracksTask);
var artists = await artistsTask ?? new List<MusicArtist>();
var albums = await albumsTask ?? new List<MusicAlbum>();
var tracks = await tracksTask ?? new List<MusicTrack>();
var sections = new List<MusicBrowseSection>();
if (artists.Count > 0)
sections.Add(new MusicBrowseSection
{
id = SpotifySupport.ArtistsSectionId,
title = "Исполнители",
type = "artists",
source_provider = SpotifySupport.ProviderId,
has_more = false,
artists = artists
});
if (albums.Count > 0)
sections.Add(new MusicBrowseSection
{
id = SpotifySupport.AlbumsSectionId,
title = "Альбомы",
type = "albums",
source_provider = SpotifySupport.ProviderId,
has_more = false,
albums = albums
});
if (tracks.Count > 0)
sections.Add(new MusicBrowseSection
{
id = SpotifySupport.TracksSectionId,
title = "Треки",
type = "tracks",
source_provider = SpotifySupport.ProviderId,
has_more = false,
tracks = tracks
});
return sections;
}
static bool ShouldSkipMetadataTrackLookup(string id, string provider)
{
// Spotify здесь является каталогом/источником discovery, но не
// IMusicMetadataProvider. Без этого gate GetMetadataProvider("spotify")
// откатывается на дефолтный MusicBrainz и до его сетевого таймаута
// пытается найти там spotify:track:* перед каждым первым запуском.
if (StartsWithAny(id, "inline:", "youtube:", "sefon:", "soundcloud:", "spotify:"))
return true;
return !string.IsNullOrWhiteSpace(provider)
&& MusicProviderRegistry.AudioProviders.Any(i => string.Equals(i.Id, provider, StringComparison.OrdinalIgnoreCase));
}
static bool StartsWithAny(string value, params string[] prefixes)
{
if (string.IsNullOrWhiteSpace(value))
return false;
return prefixes.Any(prefix => value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
static async Task<(List<MusicBrowseSection> sections, bool warming)> GetHomeSectionsForResponseAsync(IMusicDiscoveryProvider provider, int limit, CancellationToken cancellationToken)
{
var warmTask = GetOrStartHomeWarmTask(provider, limit);
if (warmTask.IsCompleted)
return (await warmTask, false);
var completed = await Task.WhenAny(warmTask, Task.Delay(discoveryHomeProviderResponseBudget, cancellationToken));
if (completed == warmTask)
return (await warmTask, false);
cancellationToken.ThrowIfCancellationRequested();
// warming=true: провайдер срезан по 2s-бюджету, прогрев продолжается в фоне —
// клиент по этому флагу тихо дозапрашивает home
return (new List<MusicBrowseSection>(), true);
}
static Task<List<MusicBrowseSection>> GetOrStartHomeWarmTask(IMusicDiscoveryProvider provider, int limit)
{
string key = $"{provider.Id}:{limit}";
lock (homeWarmLock)
{
if (homeWarmTasks.TryGetValue(key, out var running) && !running.IsCompleted)
return running;
var task = SafeGetHomeSectionsAsync(provider, limit);
homeWarmTasks[key] = task;
_ = task.ContinueWith(completed =>
{
lock (homeWarmLock)
{
if (homeWarmTasks.TryGetValue(key, out var current) && ReferenceEquals(current, completed))
homeWarmTasks.Remove(key);
}
if (completed.IsFaulted)
_ = completed.Exception;
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
return task;
}
}
static async Task<List<MusicBrowseSection>> SafeGetHomeSectionsAsync(IMusicDiscoveryProvider provider, int limit)
{
try
{
using var timeoutCts = new CancellationTokenSource(discoveryHomeProviderWarmTimeout);
return await provider.GetHomeSectionsAsync(limit, timeoutCts.Token) ?? new List<MusicBrowseSection>();
}
catch (OperationCanceledException)
{
return new List<MusicBrowseSection>();
}
catch
{
return new List<MusicBrowseSection>();
}
}
static async Task<MusicBrowseSection> SafeGetSectionAsync(IMusicDiscoveryProvider provider, string sectionId, int limit, CancellationToken cancellationToken)
{
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(discoverySectionProviderTimeout);
return await provider.GetSectionAsync(sectionId, limit, timeoutCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
catch
{
return null;
}
}
static async Task<MusicBrowseSection> SafeGetSearchSectionAsync(Func<CancellationToken, Task<MusicBrowseSection>> factory, CancellationToken cancellationToken)
{
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(searchSectionProviderTimeout);
return await factory(timeoutCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
catch
{
return null;
}
}
static async Task<List<MusicBrowseSection>> SafeGetSearchSectionsAsync(Func<CancellationToken, Task<List<MusicBrowseSection>>> factory, CancellationToken cancellationToken)
{
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(searchSectionProviderTimeout);
return await factory(timeoutCts.Token) ?? new List<MusicBrowseSection>();
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new List<MusicBrowseSection>();
}
catch
{
return new List<MusicBrowseSection>();
}
}
static List<MusicBrowseSection> ToSearchSectionList(MusicBrowseSection section)
{
return section == null
? new List<MusicBrowseSection>()
: new List<MusicBrowseSection> { section };
}
static async Task<List<MusicBrowseSection>> BuildSoundCloudSearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken)
{
int trackLimit = expanded ? searchSectionFullLimit : searchSectionLimit;
int sideLimit = expanded ? 18 : 6;
string searchKey = NormalizeSearchKey(query);
var tracksTask = MusicMetadataCacheService.GetOrCreateAsync(
SoundCloudSupport.DiscoveryProviderId,
"search_tracks",
VersionedKey($"soundcloud-tracks|{trackLimit}|{searchKey}"),
searchCacheTtl,
() => SoundCloudSupport.SearchTracksByQueryAsync(query, trackLimit, cancellationToken),
cancellationToken
);
var playlistsTask = MusicMetadataCacheService.GetOrCreateAsync(
SoundCloudSupport.DiscoveryProviderId,
"search_playlists",
VersionedKey($"soundcloud-playlists|{sideLimit}|{searchKey}"),
searchCacheTtl,
() => SoundCloudSupport.SearchPlaylistsByQueryAsync(query, sideLimit, cancellationToken),
cancellationToken
);
var albumsTask = MusicMetadataCacheService.GetOrCreateAsync(
SoundCloudSupport.DiscoveryProviderId,
"search_albums",
VersionedKey($"soundcloud-albums|{sideLimit}|{searchKey}"),
searchCacheTtl,
() => SoundCloudSupport.SearchAlbumsByQueryAsync(query, sideLimit, cancellationToken),
cancellationToken
);
var artistsTask = MusicMetadataCacheService.GetOrCreateAsync(
SoundCloudSupport.DiscoveryProviderId,
"search_artists",
VersionedKey($"soundcloud-artists|{sideLimit}|{searchKey}"),
searchCacheTtl,
() => SoundCloudSupport.SearchArtistsByQueryAsync(query, sideLimit, cancellationToken),
cancellationToken
);
await Task.WhenAll(tracksTask, albumsTask, playlistsTask, artistsTask);
var tracks = await tracksTask ?? new List<MusicTrack>();
var albums = await albumsTask ?? new List<MusicAlbum>();
var playlists = await playlistsTask ?? new List<MusicAlbum>();
var artists = await artistsTask ?? new List<MusicArtist>();
var sections = new List<MusicBrowseSection>();
if (tracks.Count > 0)
{
sections.Add(new MusicBrowseSection
{
id = SoundCloudSupport.SearchTracksSectionId,
title = "Треки",
type = "tracks",
source_provider = SoundCloudSupport.DiscoveryProviderId,
has_more = !expanded && tracks.Count >= trackLimit,
tracks = tracks
});
}
if (albums.Count > 0)
{
sections.Add(new MusicBrowseSection
{
id = SoundCloudSupport.SearchAlbumsSectionId,
title = "Альбомы",
type = "albums",
source_provider = SoundCloudSupport.DiscoveryProviderId,
has_more = !expanded && albums.Count >= sideLimit,
albums = albums
});
}
if (playlists.Count > 0)
{
sections.Add(new MusicBrowseSection
{
id = SoundCloudSupport.SearchPlaylistsSectionId,
title = "Плейлисты",
type = "albums",
source_provider = SoundCloudSupport.DiscoveryProviderId,
has_more = !expanded && playlists.Count >= sideLimit,
albums = playlists
});
}
if (artists.Count > 0)
{
sections.Add(new MusicBrowseSection
{
id = SoundCloudSupport.SearchArtistsSectionId,
title = "Исполнители",
type = "artists",
source_provider = SoundCloudSupport.DiscoveryProviderId,
has_more = !expanded && artists.Count >= sideLimit,
artists = artists
});
}
return sections;
}
static async Task<MusicBrowseSection> BuildSefonSearchSectionAsync(string query, bool expanded, CancellationToken cancellationToken)
{
int limit = expanded ? searchSectionFullLimit : searchSectionLimit;
var tracks = await MusicMetadataCacheService.GetOrCreateAsync(
SefonSupport.ProviderId,
"search_tracks",
VersionedKey($"sefon|{limit}|{NormalizeSearchKey(query)}"),
searchCacheTtl,
() => SefonSupport.SearchTracksByQueryAsync(query, limit, cancellationToken),
cancellationToken
) ?? new List<MusicTrack>();
return tracks.Count == 0
? null
: new MusicBrowseSection
{
id = "search:sefon",
title = "Sefon",
type = "tracks",
source_provider = SefonSupport.ProviderId,
has_more = !expanded && tracks.Count >= limit,
tracks = tracks
};
}
static bool HasSearchSectionEntries(MusicBrowseSection section)
{
return section != null
&& ((section.tracks?.Count > 0) || (section.albums?.Count > 0) || (section.artists?.Count > 0));
}
static string NormalizeSearchKey(string query) => query.Trim().ToLowerInvariant();
static string VersionedKey(string value) => $"{metadataCacheVersion}|{value}";
}
@@ -1,111 +0,0 @@
namespace Music;
public static class MusicProviderRegistry
{
static readonly List<IMusicMetadataProvider> metadataProviders = new()
{
new MusicBrainzMetadataProvider()
};
static readonly List<IMusicDiscoveryProvider> discoveryProviders = new()
{
new AppleMusicDiscoveryProvider(),
new SpotifyDiscoveryProvider(),
new VkTopChartDiscoveryProvider(),
new SoundCloudDiscoveryProvider()
};
static readonly List<IMusicAudioProvider> audioProviders = new()
{
new YouTubeAudioProvider(),
new SefonAudioProvider(),
new SoundCloudAudioProvider()
};
static readonly IMusicAudioProvider z3FmAudioProvider = new Z3FmAudioProvider();
static readonly List<IMusicAuthProvider> authProviders = new()
{
new SoundCloudAuthProvider()
};
public static IReadOnlyList<IMusicMetadataProvider> MetadataProviders => metadataProviders;
public static IReadOnlyList<IMusicDiscoveryProvider> DiscoveryProviders => discoveryProviders;
public static IReadOnlyList<IMusicAudioProvider> AudioProviders => GetAudioProviders();
public static IReadOnlyList<IMusicAuthProvider> AuthProviders => authProviders;
public static IMusicMetadataProvider GetMetadataProvider(string id = null)
{
string target = string.IsNullOrWhiteSpace(id) ? ModInit.conf.default_metadata_provider : id;
return metadataProviders.FirstOrDefault(i => i.Id == target && i.Enabled) ?? metadataProviders.FirstOrDefault(i => i.Enabled);
}
public static IMusicAudioProvider GetAudioProvider(string id = null)
{
string target = string.IsNullOrWhiteSpace(id) ? ModInit.conf.default_audio_provider : id;
return AudioProviders.FirstOrDefault(i => i.Id == target && i.Enabled) ?? AudioProviders.FirstOrDefault(i => i.Enabled);
}
public static IMusicDiscoveryProvider GetDiscoveryProvider(string id = null)
{
string target = string.IsNullOrWhiteSpace(id) ? null : id;
return discoveryProviders.FirstOrDefault(i => i.Id == target && i.Enabled) ?? discoveryProviders.FirstOrDefault(i => i.Enabled);
}
public static IMusicAuthProvider GetAuthProvider(string id = null)
{
string target = string.IsNullOrWhiteSpace(id) ? ModInit.conf.default_auth_provider : id;
return authProviders.FirstOrDefault(i => i.Id == target && i.Enabled) ?? authProviders.FirstOrDefault(i => i.Enabled);
}
public static List<MusicProviderDescriptor> DescribeMetadata() => metadataProviders.Select(i => new MusicProviderDescriptor
{
id = i.Id,
name = i.Name,
type = "metadata",
enabled = i.Enabled,
requires_auth = false,
capabilities = new List<string> { "search", "artist", "album", "track", "browse" }
}).ToList();
public static List<MusicProviderDescriptor> DescribeAudio() => AudioProviders.Select(i => new MusicProviderDescriptor
{
id = i.Id,
name = i.Name,
type = "audio",
enabled = i.Enabled,
requires_auth = i.RequiresAuth,
capabilities = BuildAudioCapabilities(i)
}).ToList();
public static List<MusicProviderDescriptor> DescribeAuth() => authProviders.Select(i => new MusicProviderDescriptor
{
id = i.Id,
name = i.Name,
type = "auth",
enabled = i.Enabled,
requires_auth = true,
capabilities = new List<string> { "state", "save", "logout" }
}).ToList();
static List<string> BuildAudioCapabilities(IMusicAudioProvider provider)
{
var capabilities = new List<string> { "match", "streams", "playback", "audio" };
if (string.Equals(provider?.Id, "youtubeaudio", StringComparison.OrdinalIgnoreCase))
capabilities.Add("video");
if (provider?.RequiresAuth == true)
capabilities.Add("auth");
return capabilities;
}
static IReadOnlyList<IMusicAudioProvider> GetAudioProviders()
{
if (z3FmAudioProvider.Enabled)
return audioProviders.Concat(new[] { z3FmAudioProvider }).ToList();
return audioProviders;
}
}
-121
View File
@@ -1,121 +0,0 @@
using Shared.Services;
using System.Net;
using System.Net.Http;
namespace Music;
public static class MusicHttp
{
public static HttpClient CreateClient(string providerId, MusicProxyPurpose purpose = MusicProxyPurpose.Api)
=> new(new RoutingHandler(providerId, purpose));
public static HttpClient GetTransport(MusicProxyLease lease)
{
HttpClientHandler handler = null;
if (lease?.Enabled == true)
{
handler = new HttpClientHandler
{
AllowAutoRedirect = true,
AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip | DecompressionMethods.Deflate,
Proxy = lease.Proxy,
UseProxy = true,
ServerCertificateCustomValidationCallback = Http.AlwaysAllowCertificate
};
}
return FriendlyHttp.MessageClient(
"base",
handler,
out _,
allowAutoRedirect: true,
findNoRedirectClient: true);
}
public static bool IsProxyFailureStatus(HttpStatusCode statusCode)
=> statusCode is HttpStatusCode.ProxyAuthenticationRequired
or HttpStatusCode.BadGateway
or HttpStatusCode.ServiceUnavailable
or HttpStatusCode.GatewayTimeout;
public static bool IsTransientFailureStatus(HttpStatusCode statusCode)
=> statusCode is HttpStatusCode.Unauthorized
or HttpStatusCode.Forbidden
or HttpStatusCode.RequestTimeout
or HttpStatusCode.TooManyRequests
|| (int)statusCode >= 500;
public static bool IsProxyFailure(Exception exception)
{
for (var current = exception; current != null; current = current.InnerException)
{
if (current is HttpRequestException)
return true;
}
return false;
}
sealed class RoutingHandler : HttpMessageHandler
{
readonly string providerId;
readonly MusicProxyPurpose purpose;
public RoutingHandler(string providerId, MusicProxyPurpose purpose)
{
this.providerId = providerId;
this.purpose = purpose;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var lease = MusicProxyService.Acquire(providerId, purpose);
var transport = GetTransport(lease);
using var routedRequest = await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
try
{
var response = await transport.SendAsync(routedRequest, cancellationToken).ConfigureAwait(false);
if (lease.Enabled && IsProxyFailureStatus(response.StatusCode))
lease.Failure();
else
lease.Success();
return response;
}
catch (Exception ex)
{
if (lease.Enabled && IsProxyFailure(ex))
lease.Failure();
throw;
}
}
static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri)
{
Version = request.Version,
VersionPolicy = request.VersionPolicy
};
foreach (var header in request.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
foreach (var option in request.Options)
clone.Options.Set(new HttpRequestOptionsKey<object>(option.Key), option.Value);
if (request.Content != null)
{
clone.Content = new ByteArrayContent(await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false));
foreach (var header in request.Content.Headers)
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return clone;
}
}
}
@@ -1,135 +0,0 @@
using Shared.Services;
using System.Collections.Concurrent;
using System.Net;
namespace Music;
public enum MusicProxyPurpose
{
Api,
Stream
}
public sealed class MusicProxyLease
{
readonly ProxyManager manager;
internal MusicProxyLease(string scope, ProxyManager manager, WebProxy proxy, (string ip, string username, string password) data)
{
Scope = scope;
this.manager = manager;
Proxy = proxy;
Data = data;
}
public string Scope { get; }
public WebProxy Proxy { get; }
public (string ip, string username, string password) Data { get; }
public bool Enabled => Proxy != null;
public string RouteKey => MusicProxyService.BuildRouteKey(
Scope,
Data.ip ?? Proxy?.Address?.ToString(),
Data.username,
Data.password
);
public void Success() => manager?.Success();
public void Failure() => manager?.Refresh();
public void ApplyTo(MusicPlaybackSource source, bool overwrite = false)
{
if (source == null || !Enabled || (!overwrite && !string.IsNullOrWhiteSpace(source.proxy_url)))
return;
source.proxy_url = Data.ip ?? Proxy.Address?.ToString();
source.proxy_username = Data.username;
source.proxy_password = Data.password;
source.proxy_scope = Scope;
}
}
public static class MusicProxyService
{
sealed record ManagerEntry(ModuleConf Conf, ProxyManager Manager);
static readonly ConcurrentDictionary<string, ManagerEntry> managers = new(StringComparer.OrdinalIgnoreCase);
static long configurationVersion;
public static long ConfigurationVersion => Interlocked.Read(ref configurationVersion);
public static void ConfigurationChanged()
{
Interlocked.Increment(ref configurationVersion);
managers.Clear();
}
public static MusicProxyLease Acquire(string providerId, MusicProxyPurpose purpose)
{
var conf = ModInit.conf;
bool enabled = purpose == MusicProxyPurpose.Api
? conf?.useproxy == true
: conf?.useproxystream == true;
if (!enabled || conf == null)
return new MusicProxyLease(null, null, null, default);
string provider = string.IsNullOrWhiteSpace(providerId) ? "shared" : providerId.Trim().ToLowerInvariant();
string scope = $"Music:{provider}:{purpose.ToString().ToLowerInvariant()}";
var entry = managers.AddOrUpdate(
scope,
_ => new ManagerEntry(conf, new ProxyManager(scope, conf)),
(_, current) => ReferenceEquals(current.Conf, conf)
? current
: new ManagerEntry(conf, new ProxyManager(scope, conf)));
var selected = entry.Manager.BaseGet();
return new MusicProxyLease(scope, entry.Manager, selected.proxy, selected.data);
}
public static void ApplyStreamProxy(string providerId, IEnumerable<MusicPlaybackSource> sources)
{
if (sources == null)
return;
MusicProxyLease lease = null;
foreach (var source in sources)
{
if (source == null || !string.IsNullOrWhiteSpace(source.proxy_url))
continue;
lease ??= Acquire(providerId, MusicProxyPurpose.Stream);
lease.ApplyTo(source);
}
}
public static string CurrentRouteKey(string providerId, MusicProxyPurpose purpose)
=> Acquire(providerId, purpose).RouteKey;
public static string SourceRouteKey(MusicPlaybackSource source)
=> source == null
? "direct"
: BuildRouteKey(source.proxy_scope, source.proxy_url, source.proxy_username, source.proxy_password);
internal static string BuildRouteKey(string scope, string proxyUrl, string username, string password)
{
if (string.IsNullOrWhiteSpace(proxyUrl))
return "direct";
return $"{scope ?? string.Empty}|{proxyUrl.Trim()}|{username ?? string.Empty}|{password ?? string.Empty}";
}
public static void ReportSuccess(string scope)
=> GetCurrentManager(scope)?.Success();
public static void ReportFailure(string scope)
=> GetCurrentManager(scope)?.Refresh();
static ProxyManager GetCurrentManager(string scope)
{
if (string.IsNullOrWhiteSpace(scope))
return null;
return managers.TryGetValue(scope, out var entry) ? entry.Manager : null;
}
}
@@ -1,342 +0,0 @@
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Collections.Concurrent;
namespace Music;
public static class DiscogsArtistImageService
{
static readonly HttpClient httpClient = MusicHttp.CreateClient("discogs");
static readonly SemaphoreSlim requestGate = new(1, 1);
static readonly ConcurrentDictionary<string, Task<List<MusicImage>>> pendingLookups = new();
static DateTime nextRequestAt = DateTime.MinValue;
static readonly TimeSpan cacheTtl = TimeSpan.FromDays(30);
const string cacheVersion = "v6";
const string baseUrl = "https://api.discogs.com";
const string userAgent = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)";
static DiscogsArtistImageService()
{
httpClient.Timeout = TimeSpan.FromSeconds(20);
}
public static async Task EnrichAsync(List<MusicArtist> artists, CancellationToken cancellationToken = default)
{
if (artists == null || artists.Count == 0)
return;
var seen = new HashSet<string>();
foreach (var artist in artists)
{
if (artist == null)
continue;
string normalized = NormalizeArtistName(artist.name);
if (string.IsNullOrWhiteSpace(normalized) || !seen.Add(normalized))
continue;
await EnrichAsync(artist, cancellationToken);
}
}
public static async Task ApplyCachedAsync(List<MusicArtist> artists, CancellationToken cancellationToken = default)
{
if (artists == null || artists.Count == 0)
return;
var seen = new HashSet<string>();
foreach (var artist in artists)
{
if (artist == null || !string.IsNullOrWhiteSpace(artist.images?.FirstOrDefault()?.url) || string.IsNullOrWhiteSpace(artist.name))
continue;
string normalized = NormalizeArtistName(artist.name);
if (string.IsNullOrWhiteSpace(normalized) || !seen.Add(normalized))
continue;
var images = await GetCachedImagesAsync(artist.name, artist.country, cancellationToken);
if (images.Count > 0)
artist.images = images;
}
}
public static void WarmupMissing(List<MusicArtist> artists)
{
if (artists == null || artists.Count == 0)
return;
var queue = artists
.Where(i => i != null && string.IsNullOrWhiteSpace(i.images?.FirstOrDefault()?.url) && !string.IsNullOrWhiteSpace(i.name))
.GroupBy(i => BuildCacheKey(i.name, i.country))
.Select(i => i.First())
.Take(3)
.ToList();
if (queue.Count == 0)
return;
_ = Task.Run(async () =>
{
foreach (var artist in queue)
{
try
{
await ResolveImagesAsync(artist, CancellationToken.None);
}
catch
{
}
}
});
}
public static async Task EnrichAsync(MusicArtist artist, CancellationToken cancellationToken = default)
{
if (artist == null || !string.IsNullOrWhiteSpace(artist.images?.FirstOrDefault()?.url) || string.IsNullOrWhiteSpace(artist.name))
return;
var images = await ResolveImagesAsync(artist, cancellationToken);
if (images.Count > 0)
artist.images = images;
}
public static async Task<List<MusicImage>> ResolveImagesAsync(MusicArtist artist, CancellationToken cancellationToken = default)
{
if (artist == null || string.IsNullOrWhiteSpace(artist.name))
return new List<MusicImage>();
string cacheKey = BuildCacheKey(artist.name, artist.country);
var cached = await GetCachedImagesAsync(artist.name, artist.country, cancellationToken);
if (cached.Count > 0)
return cached;
var pending = pendingLookups.GetOrAdd(cacheKey, _ => LookupAndCacheAsync(cacheKey, artist.name, artist.country));
try
{
return await pending;
}
finally
{
if (pending.IsCompleted)
pendingLookups.TryRemove(new KeyValuePair<string, Task<List<MusicImage>>>(cacheKey, pending));
}
}
public static async Task<List<MusicImage>> GetCachedImagesAsync(string artistName, string country = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(artistName))
return new List<MusicImage>();
string cacheKey = BuildCacheKey(artistName, country);
var exact = await MusicMetadataCacheService.GetAsync<List<MusicImage>>(
"discogs",
"artist-image",
cacheKey,
cancellationToken
) ?? new List<MusicImage>();
if (exact.Count > 0 || string.IsNullOrWhiteSpace(country))
return exact;
return await MusicMetadataCacheService.GetAsync<List<MusicImage>>(
"discogs",
"artist-image",
BuildCacheKey(artistName, null),
cancellationToken
) ?? new List<MusicImage>();
}
static async Task<List<MusicImage>> LookupAndCacheAsync(string cacheKey, string artistName, string country)
{
return await MusicMetadataCacheService.GetOrCreateAsync(
"discogs",
"artist-image",
cacheKey,
cacheTtl,
() => LookupImagesAsync(artistName, country, CancellationToken.None),
CancellationToken.None
) ?? new List<MusicImage>();
}
static async Task<List<MusicImage>> LookupImagesAsync(string artistName, string country, CancellationToken cancellationToken)
{
var searchJson = await GetJsonAsync($"database/search?q={HttpUtility.UrlEncode(artistName)}&type=artist&per_page=5", cancellationToken);
// null = сеть/429 — НЕ кэшируем (GetOrCreateAsync пропускает null),
// чтобы сбой Discogs не превращался в «у артиста нет фото»
if (searchJson == null)
return null;
var best = SelectBestArtist(searchJson["results"] as JsonArray, artistName, country);
if (best == null)
return new List<MusicImage>();
var id = best["id"]?.GetValue<int?>();
if (id == null || id <= 0)
return new List<MusicImage>();
var artistJson = await GetJsonAsync($"artists/{id}", cancellationToken);
if (artistJson == null)
return null;
return ParseImages(artistJson["images"] as JsonArray);
}
static JsonObject SelectBestArtist(JsonArray results, string artistName, string country)
{
if (results == null || results.Count == 0)
return null;
string target = NormalizeArtistName(artistName);
int bestScore = int.MinValue;
JsonObject best = null;
foreach (var item in results.OfType<JsonObject>())
{
int score = ScoreArtist(item, target, country);
if (score > bestScore)
{
bestScore = score;
best = item;
}
}
return bestScore > 0 ? best : null;
}
static int ScoreArtist(JsonObject item, string target, string country)
{
string rawTitle = item["title"]?.GetValue<string>() ?? string.Empty;
string title = NormalizeArtistName(rawTitle);
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(target))
return 0;
if (title != target)
return 0;
int score = 100;
if (string.Equals(rawTitle.Trim().ToLowerInvariant(), target, StringComparison.Ordinal))
score += 15;
if (rawTitle.Contains("(")) score -= 10;
if (Regex.IsMatch(rawTitle, @"\(\d+\)\s*$")) score -= 10;
if (!string.IsNullOrWhiteSpace(country) && rawTitle.Contains(country, StringComparison.OrdinalIgnoreCase))
score += 5;
string thumb = item["thumb"]?.GetValue<string>() ?? string.Empty;
string cover = item["cover_image"]?.GetValue<string>() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(thumb) || !string.IsNullOrWhiteSpace(cover))
score += 3;
return score;
}
static string NormalizeArtistName(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = Regex.Replace(value, @"\s*\(\d+\)\s*$", string.Empty);
value = Regex.Replace(value, @"[^0-9\p{L}\s]+", " ");
value = Regex.Replace(value, @"\s+", " ").Trim();
return value.ToLowerInvariant();
}
static List<MusicImage> ParseImages(JsonArray items)
{
if (items == null || items.Count == 0)
return new List<MusicImage>();
var primary = items
.OfType<JsonObject>()
.OrderByDescending(item => string.Equals(item["type"]?.GetValue<string>(), "primary", StringComparison.OrdinalIgnoreCase))
.FirstOrDefault();
if (primary == null)
return new List<MusicImage>();
string small = primary["uri150"]?.GetValue<string>();
string full = primary["uri"]?.GetValue<string>();
int width = primary["width"]?.GetValue<int?>() ?? 0;
int height = primary["height"]?.GetValue<int?>() ?? 0;
if (string.IsNullOrWhiteSpace(small) && string.IsNullOrWhiteSpace(full))
return new List<MusicImage>();
var result = new List<MusicImage>();
if (!string.IsNullOrWhiteSpace(small))
{
result.Add(new()
{
url = small,
width = 150,
height = 150
});
}
if (!string.IsNullOrWhiteSpace(full) && width > 0 && height > 0)
{
result.Add(new()
{
url = full,
width = width,
height = height
});
}
return result
.GroupBy(i => i.url)
.Select(i => i.First())
.OrderBy(i => i.width)
.ToList();
}
static async Task<JsonObject> GetJsonAsync(string path, CancellationToken cancellationToken)
{
await RespectRateLimitAsync(cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/{path}");
request.Headers.TryAddWithoutValidation("User-Agent", userAgent);
request.Headers.TryAddWithoutValidation("Accept", "application/json");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonNode.Parse(json) as JsonObject;
}
static async Task RespectRateLimitAsync(CancellationToken cancellationToken)
{
await requestGate.WaitAsync(cancellationToken);
try
{
var now = DateTime.UtcNow;
if (nextRequestAt > now)
await Task.Delay(nextRequestAt - now, cancellationToken);
// анонимный лимит Discogs — 25 запросов/мин, т.е. не чаще ~2.4s
nextRequestAt = DateTime.UtcNow.AddMilliseconds(2500);
}
finally
{
requestGate.Release();
}
}
static string BuildCacheKey(string artistName, string country)
=> $"{cacheVersion}|{NormalizeArtistName(artistName)}|{(country ?? string.Empty).Trim().ToLowerInvariant()}";
}
@@ -1,346 +0,0 @@
using Shared;
using Shared.Models.Base;
namespace Music;
public static class MusicImageProxyService
{
static readonly BaseSettings init = new()
{
plugin = "Music"
};
// Create a shallow copy without listing every DTO property. The delegate is
// bound once; new metadata fields are preserved automatically.
static readonly Func<object, object> shallowCopy = typeof(object)
.GetMethod("MemberwiseClone", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
.CreateDelegate<Func<object, object>>();
static T Copy<T>(T value) where T : class => (T)shallowCopy(value);
// Only rewritten image branches are copied. Reused branches stay read-only:
// controllers serialize this result without modifying its DTOs or lists.
public static T Apply<T>(BaseController controller, T response)
{
if (controller == null || response == null)
return response;
return (T)Rewrite(controller, response);
}
static object Rewrite(BaseController controller, object value)
{
return value switch
{
MusicSearchResponse response => RewriteSearch(controller, response),
MusicHomeResponse response => RewriteHome(controller, response),
MusicStatsTopResult response => RewriteStats(controller, response),
MusicArtist artist => RewriteArtist(controller, artist),
MusicAlbum album => RewriteAlbum(controller, album),
MusicTrack track => RewriteTrack(controller, track),
MusicBrowseSection section => RewriteSection(controller, section),
MusicUserPlaylistSummary playlist => RewritePlaylist(controller, playlist),
MusicUserPlaylistImportResult response => RewriteImport(controller, response),
MusicDailyMixResponse response => RewriteDaily(controller, response),
MusicRadioResponse response => RewriteRadio(controller, response),
MusicRecentlyPlayedItem item => RewriteHistory(controller, item),
MusicStatsTopTrack item => RewriteStatsTrack(controller, item),
MusicImage image => RewriteImage(controller, image),
List<MusicTrack> tracks => RewriteList(controller, tracks),
List<MusicUserPlaylistSummary> playlists => RewriteList(controller, playlists),
MusicTrack[] tracks => RewriteArray(controller, tracks),
MusicUserPlaylistSummary[] playlists => RewriteArray(controller, playlists),
_ => value
};
}
static List<T> RewriteList<T>(BaseController controller, List<T> items) where T : class
{
if (items == null)
return null;
List<T> copy = null;
for (int i = 0; i < items.Count; i++)
{
var item = (T)Rewrite(controller, items[i]);
if (ReferenceEquals(item, items[i]))
continue;
copy ??= new List<T>(items);
copy[i] = item;
}
return copy ?? items;
}
static T[] RewriteArray<T>(BaseController controller, T[] items) where T : class
{
T[] copy = null;
for (int i = 0; i < items.Length; i++)
{
var item = (T)Rewrite(controller, items[i]);
if (ReferenceEquals(item, items[i]))
continue;
copy ??= (T[])items.Clone();
copy[i] = item;
}
return copy ?? items;
}
static MusicSearchResponse RewriteSearch(BaseController controller, MusicSearchResponse value)
{
var artists = RewriteList(controller, value.artists);
var albums = RewriteList(controller, value.albums);
var tracks = RewriteList(controller, value.tracks);
var search_sections = RewriteList(controller, value.search_sections);
if (ReferenceEquals(artists, value.artists)
&& ReferenceEquals(albums, value.albums)
&& ReferenceEquals(tracks, value.tracks)
&& ReferenceEquals(search_sections, value.search_sections))
return value;
var copy = Copy(value);
copy.artists = artists;
copy.albums = albums;
copy.tracks = tracks;
copy.search_sections = search_sections;
return copy;
}
static MusicHomeResponse RewriteHome(BaseController controller, MusicHomeResponse value)
{
var browse_sections = RewriteList(controller, value.browse_sections);
var recently_played = RewriteList(controller, value.recently_played);
var user_playlists = RewriteList(controller, value.user_playlists);
if (ReferenceEquals(browse_sections, value.browse_sections)
&& ReferenceEquals(recently_played, value.recently_played)
&& ReferenceEquals(user_playlists, value.user_playlists))
return value;
var copy = Copy(value);
copy.browse_sections = browse_sections;
copy.recently_played = recently_played;
copy.user_playlists = user_playlists;
return copy;
}
static MusicStatsTopResult RewriteStats(BaseController controller, MusicStatsTopResult value)
{
var tracks = RewriteList(controller, value.tracks);
if (ReferenceEquals(tracks, value.tracks))
return value;
var copy = Copy(value);
copy.tracks = tracks;
return copy;
}
static MusicArtist RewriteArtist(BaseController controller, MusicArtist value)
{
var images = RewriteList(controller, value.images);
var albums = RewriteList(controller, value.albums);
var sections = RewriteList(controller, value.sections);
if (ReferenceEquals(images, value.images)
&& ReferenceEquals(albums, value.albums)
&& ReferenceEquals(sections, value.sections))
return value;
var copy = Copy(value);
copy.images = images;
copy.albums = albums;
copy.sections = sections;
return copy;
}
static MusicAlbum RewriteAlbum(BaseController controller, MusicAlbum value)
{
var images = RewriteList(controller, value.images);
var tracks = RewriteList(controller, value.tracks);
if (ReferenceEquals(images, value.images)
&& ReferenceEquals(tracks, value.tracks))
return value;
var copy = Copy(value);
copy.images = images;
copy.tracks = tracks;
return copy;
}
static MusicTrack RewriteTrack(BaseController controller, MusicTrack value)
{
var images = RewriteList(controller, value.images);
if (ReferenceEquals(images, value.images))
return value;
var copy = Copy(value);
copy.images = images;
return copy;
}
static MusicBrowseSection RewriteSection(BaseController controller, MusicBrowseSection value)
{
var albums = RewriteList(controller, value.albums);
var artists = RewriteList(controller, value.artists);
var tracks = RewriteList(controller, value.tracks);
if (ReferenceEquals(albums, value.albums)
&& ReferenceEquals(artists, value.artists)
&& ReferenceEquals(tracks, value.tracks))
return value;
var copy = Copy(value);
copy.albums = albums;
copy.artists = artists;
copy.tracks = tracks;
return copy;
}
static MusicUserPlaylistSummary RewritePlaylist(BaseController controller, MusicUserPlaylistSummary value)
{
var images = RewriteList(controller, value.images);
if (ReferenceEquals(images, value.images))
return value;
var copy = Copy(value);
copy.images = images;
return copy;
}
static MusicUserPlaylistImportResult RewriteImport(BaseController controller, MusicUserPlaylistImportResult value)
{
var tracks = RewriteList(controller, value.tracks);
if (ReferenceEquals(tracks, value.tracks))
return value;
var copy = Copy(value);
copy.tracks = tracks;
return copy;
}
static MusicDailyMixResponse RewriteDaily(BaseController controller, MusicDailyMixResponse value)
{
var tracks = RewriteList(controller, value.tracks);
if (ReferenceEquals(tracks, value.tracks))
return value;
var copy = Copy(value);
copy.tracks = tracks;
return copy;
}
static MusicRadioResponse RewriteRadio(BaseController controller, MusicRadioResponse value)
{
var tracks = RewriteList(controller, value.tracks);
if (ReferenceEquals(tracks, value.tracks))
return value;
var copy = Copy(value);
copy.tracks = tracks;
return copy;
}
static MusicRecentlyPlayedItem RewriteHistory(BaseController controller, MusicRecentlyPlayedItem value)
{
var track = (MusicTrack)Rewrite(controller, value.track);
if (ReferenceEquals(track, value.track))
return value;
var copy = Copy(value);
copy.track = track;
return copy;
}
static MusicStatsTopTrack RewriteStatsTrack(BaseController controller, MusicStatsTopTrack value)
{
var track = (MusicTrack)Rewrite(controller, value.track);
if (ReferenceEquals(track, value.track))
return value;
var copy = Copy(value);
copy.track = track;
return copy;
}
static MusicImage RewriteImage(BaseController controller, MusicImage image)
{
string url = image.url;
if (string.IsNullOrWhiteSpace(url))
return image;
// Normalize even when proxying is disabled (e.g. file:// TV clients).
if (url.StartsWith("//", StringComparison.Ordinal))
url = "https:" + url;
// Repair legacy cached proxy URLs without modifying the cached image.
if (TryRewriteProxyHost(url, controller.host, out string rewritten))
url = rewritten;
else if (NeedProxy(url, controller.host))
url = controller.HostImgProxy(init, url);
if (string.Equals(url, image.url, StringComparison.Ordinal))
return image;
var copy = Copy(image);
copy.url = url;
return copy;
}
static bool TryRewriteProxyHost(string url, string host, out string rewritten)
{
rewritten = null;
int index = url.IndexOf("/proxyimg", StringComparison.OrdinalIgnoreCase);
if (index <= 0 || string.IsNullOrWhiteSpace(host))
return false;
string prefix = url.Substring(0, index);
if (string.Equals(prefix, host, StringComparison.OrdinalIgnoreCase))
return false;
// префикс должен быть полноценным origin (а не куском чужого пути)
if (!Uri.TryCreate(prefix, UriKind.Absolute, out var prefixUri) ||
!prefixUri.Scheme.StartsWith("http", StringComparison.OrdinalIgnoreCase))
return false;
rewritten = host + url.Substring(index);
return true;
}
static bool NeedProxy(string url, string host)
{
if (string.IsNullOrWhiteSpace(url))
return false;
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("blob:", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
return false;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return false;
if (!uri.Scheme.StartsWith("http", StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrWhiteSpace(host) &&
Uri.TryCreate(host, UriKind.Absolute, out var hostUri) &&
string.Equals(uri.Host, hostUri.Host, StringComparison.OrdinalIgnoreCase))
return false;
return !url.Contains("/proxyimg", StringComparison.OrdinalIgnoreCase);
}
}
@@ -1,284 +0,0 @@
using Shared.Services.Hybrid;
using System.Globalization;
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace Music;
// текст песен через lrclib.net (публичный API без ключей, как в Spotube);
// лирика — volatile metadata-обогащение, кэш в HybridFileCache
public static class MusicLyricsService
{
const string CacheKeyPrefix = "music:lyrics:v12";
const string YouTubeNoSyncedCacheKeyPrefix = "music:lyrics:yt-nosynced:v1";
static readonly TimeSpan SyncedTtl = TimeSpan.FromDays(30);
static readonly TimeSpan ShortTtl = TimeSpan.FromHours(1);
static readonly TimeSpan YouTubeNoSyncedTtl = TimeSpan.FromHours(6);
static readonly HttpClient httpClient = CreateClient();
static HttpClient CreateClient()
{
var client = MusicHttp.CreateClient("lyrics");
client.Timeout = TimeSpan.FromSeconds(12);
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)");
return client;
}
public static async Task<MusicLyricsResponse> GetAsync(string title, string artistName, string albumTitle, int? durationMs, string youtubeId = null, CancellationToken cancellationToken = default)
{
title = title?.Trim();
artistName = artistName?.Trim();
youtubeId = NormalizeYouTubeId(youtubeId);
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artistName))
return new MusicLyricsResponse { available = false, message = "Track metadata is empty." };
string cacheKey = BuildCacheKey(title, artistName, durationMs, youtubeId);
var cached = await HybridCache.Get().ReadCacheAsync<MusicLyricsResponse>(cacheKey, false, null, textJson: true);
if (cached.succes && cached.value != null)
return cached.value;
MusicLyricsResponse result;
try
{
result = await FetchAsync(title, artistName, albumTitle, durationMs, youtubeId, cancellationToken)
?? new MusicLyricsResponse { available = false, message = "Lyrics not found." };
}
catch (Exception ex)
{
Console.WriteLine($"[Music] lyrics fetch failed: {ex.Message}");
// сетевые сбои не кэшируем — следующий запрос попробует снова
return new MusicLyricsResponse { available = false, retry = true, message = "Lyrics provider unavailable." };
}
// Долгий кэш заслуживает только synced. Plain-only мог появиться из-за
// временного сбоя YouTube Music fallback, поэтому partial/retry не кэшируем.
if (!result.retry)
HybridCache.Get().Set(cacheKey, result, result.available && result.synced ? SyncedTtl : ShortTtl, textJson: true);
return result;
}
static async Task<MusicLyricsResponse> FetchAsync(string title, string artistName, string albumTitle, int? durationMs, string youtubeId, CancellationToken cancellationToken)
{
MusicLyricsResponse lrclib = null;
bool lrclibFailed = false;
try
{
lrclib = await FetchLrcLibAsync(title, artistName, albumTitle, durationMs, cancellationToken);
}
catch
{
lrclibFailed = true;
}
if (lrclib != null && lrclib.synced)
return lrclib;
MusicLyricsResponse youtubeMusic = null;
bool youtubeMusicFailed = false;
bool probeSyncedOnly = lrclib != null;
string youTubeNoSyncedKey = probeSyncedOnly ? BuildYouTubeNoSyncedCacheKey(title, artistName, durationMs, youtubeId) : null;
if (probeSyncedOnly)
{
var noSyncedCached = await HybridCache.Get().ReadCacheAsync<bool>(youTubeNoSyncedKey, false, null, textJson: true);
if (noSyncedCached.succes && noSyncedCached.value)
return lrclib;
}
try
{
youtubeMusic = await YouTubeMusicLyricsFallback.GetAsync(title, artistName, durationMs, youtubeId, probeSyncedOnly, cancellationToken);
}
catch (Exception ex)
{
youtubeMusicFailed = true;
Console.WriteLine($"[Music] youtube music lyrics fallback failed: {ex.Message}");
}
if (youtubeMusic != null && youtubeMusic.synced)
return youtubeMusic;
if (probeSyncedOnly && !youtubeMusicFailed)
HybridCache.Get().Set(youTubeNoSyncedKey, true, YouTubeNoSyncedTtl, textJson: true);
if (lrclib != null)
{
if (youtubeMusicFailed)
lrclib.retry = true;
return lrclib;
}
if (youtubeMusic != null)
return youtubeMusic;
// Если хоть один провайдер не успел/упал, не кэшируем «нет текста»:
// следующий запрос может попасть в живой источник.
if (lrclibFailed || youtubeMusicFailed)
throw new TimeoutException("lyrics provider timed out");
return null;
}
static async Task<MusicLyricsResponse> FetchLrcLibAsync(string title, string artistName, string albumTitle, int? durationMs, CancellationToken cancellationToken)
{
string getUrl = "https://lrclib.net/api/get?track_name=" + Uri.EscapeDataString(title)
+ "&artist_name=" + Uri.EscapeDataString(artistName)
+ (string.IsNullOrWhiteSpace(albumTitle) ? "" : "&album_name=" + Uri.EscapeDataString(albumTitle))
+ (durationMs > 0 ? "&duration=" + (durationMs.Value / 1000) : "");
string searchUrl = "https://lrclib.net/api/search?track_name=" + Uri.EscapeDataString(title)
+ "&artist_name=" + Uri.EscapeDataString(artistName);
// lrclib бывает медленным: оба запроса параллельно с общим бюджетом,
// чтобы ответ гарантированно уложился в терпение клиента
using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
budget.CancelAfter(TimeSpan.FromSeconds(9));
var getTask = RequestJsonAsync(getUrl, budget.Token);
var searchTask = RequestJsonAsync(searchUrl, budget.Token);
JsonNode getNode = null, searchNode = null;
bool getFailed = false, searchFailed = false;
try { getNode = await getTask; } catch { getFailed = true; }
try { searchNode = await searchTask; } catch { searchFailed = true; }
var exact = BuildResponse(getNode as JsonObject);
if (exact != null && exact.synced)
return exact;
// приоритет: synced-точный → synced-из-поиска → plain-точный → plain-из-поиска
MusicLyricsResponse searched = null;
if (searchNode is JsonArray items && items.Count > 0)
{
int targetSeconds = durationMs > 0 ? durationMs.Value / 1000 : 0;
JsonObject best = null;
JsonObject bestPlain = null;
foreach (var item in items.OfType<JsonObject>())
{
if (targetSeconds > 0)
{
double itemDuration = item["duration"]?.GetValue<double?>() ?? 0;
if (itemDuration > 0 && Math.Abs(itemDuration - targetSeconds) > 10)
continue;
}
if (!string.IsNullOrWhiteSpace(item["syncedLyrics"]?.GetValue<string>()))
{
best = item;
break;
}
bestPlain ??= string.IsNullOrWhiteSpace(item["plainLyrics"]?.GetValue<string>()) ? null : item;
}
searched = BuildResponse(best ?? bestPlain);
}
if (searched != null && searched.synced)
return searched;
var result = exact ?? searched;
// ничего не нашли, но часть запросов упала по таймауту — это не «нет
// текста», а сбой: бросаем, чтобы результат НЕ закэшировался
if (result == null && (getFailed || searchFailed))
throw new TimeoutException("lyrics provider timed out");
return result;
}
static async Task<JsonNode> RequestJsonAsync(string url, CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
string json = await response.Content.ReadAsStringAsync(cancellationToken);
return string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json);
}
static MusicLyricsResponse BuildResponse(JsonObject item)
{
if (item == null)
return null;
string syncedRaw = item["syncedLyrics"]?.GetValue<string>();
string plain = item["plainLyrics"]?.GetValue<string>();
var lines = ParseSyncedLyrics(syncedRaw);
if (lines.Count == 0 && string.IsNullOrWhiteSpace(plain))
return null;
return new MusicLyricsResponse
{
available = true,
message = "ok",
source = "lrclib",
source_mode = lines.Count > 0 ? "synced" : "plain",
synced = lines.Count > 0,
lines = lines,
plain = plain
};
}
static List<MusicLyricsLine> ParseSyncedLyrics(string lrc)
{
var lines = new List<MusicLyricsLine>();
if (string.IsNullOrWhiteSpace(lrc))
return lines;
foreach (var raw in lrc.Split('\n'))
{
var match = Regex.Match(raw, @"^\s*\[(\d+):(\d+(?:\.\d+)?)\]\s*(.*)$");
if (!match.Success)
continue;
double minutes = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
double seconds = double.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
lines.Add(new MusicLyricsLine
{
time_ms = (int)Math.Round((minutes * 60 + seconds) * 1000),
text = match.Groups[3].Value.Trim()
});
}
return lines.OrderBy(i => i.time_ms).ToList();
}
static string BuildCacheKey(string title, string artistName, int? durationMs, string youtubeId)
{
// длительность — в корзинах по 10s, чтобы близкие рипы попадали в один кэш
int bucket = durationMs > 0 ? durationMs.Value / 10000 : 0;
return string.Join(':', CacheKeyPrefix, artistName.ToLowerInvariant(), title.ToLowerInvariant(), bucket.ToString(), youtubeId ?? "auto");
}
static string BuildYouTubeNoSyncedCacheKey(string title, string artistName, int? durationMs, string youtubeId)
{
int bucket = durationMs > 0 ? durationMs.Value / 10000 : 0;
return string.Join(':', YouTubeNoSyncedCacheKeyPrefix, artistName.ToLowerInvariant(), title.ToLowerInvariant(), bucket.ToString(), youtubeId ?? "auto");
}
static string NormalizeYouTubeId(string youtubeId)
{
if (string.IsNullOrWhiteSpace(youtubeId))
return null;
youtubeId = youtubeId.Trim();
if (youtubeId.StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
youtubeId = youtubeId["youtube:".Length..].Trim();
return Regex.IsMatch(youtubeId, @"^[A-Za-z0-9_-]{6,32}$") ? youtubeId : null;
}
}
@@ -1,773 +0,0 @@
using System.Globalization;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace Music;
internal static class YouTubeMusicLyricsFallback
{
const string HomeUrl = "https://music.youtube.com/";
const string InnertubeBaseUrl = "https://music.youtube.com/youtubei/v1/";
const string SongsSearchParams = "EgWKAQIIAWoMEA4QChADEAQQCRAF";
const int MaxCandidates = 4;
static readonly HttpClient httpClient = CreateClient();
static readonly SemaphoreSlim configLock = new(1, 1);
static YouTubeMusicClientConfig cachedConfig;
static DateTimeOffset configExpiresAt;
static HttpClient CreateClient()
{
var client = MusicHttp.CreateClient("youtubemusic");
client.Timeout = TimeSpan.FromSeconds(12);
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15");
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", HomeUrl.TrimEnd('/'));
client.DefaultRequestHeaders.TryAddWithoutValidation("Referer", HomeUrl);
return client;
}
public static async Task<MusicLyricsResponse> GetAsync(string title, string artistName, int? durationMs, string youtubeId = null, bool syncedOnly = false, CancellationToken cancellationToken = default)
{
if (!YouTubeMusicSearchSupport.IsSearchEnabled || string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artistName))
return null;
using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
budget.CancelAfter(TimeSpan.FromSeconds(12));
var config = await GetConfigAsync(budget.Token);
youtubeId = NormalizeVideoId(youtubeId);
MusicLyricsResponse plainFallback = null;
if (!string.IsNullOrWhiteSpace(youtubeId))
{
// Прямой YouTube id может прийти с грязной карточки, поэтому доверяем
// ему только если он отдаёт настоящие тайминги. Plain ищем через
// канонический поиск по title/artist ниже.
var directLyrics = await FetchLyricsAsync(config, youtubeId, durationMs, budget.Token, syncedOnly: true);
if (directLyrics != null && directLyrics.synced)
return directLyrics;
}
var candidates = await SearchSongCandidatesAsync(config, title, artistName, durationMs, budget.Token);
foreach (var candidate in candidates)
{
var lyrics = await FetchLyricsAsync(config, candidate.VideoId, durationMs, budget.Token, syncedOnly);
if (lyrics != null && lyrics.synced)
return lyrics;
if (!syncedOnly)
plainFallback ??= lyrics;
}
return plainFallback;
}
static MusicLyricsResponse BuildPlainResponse(string plain)
{
if (string.IsNullOrWhiteSpace(plain))
return null;
return new MusicLyricsResponse
{
available = true,
message = "ok",
source = "youtube_music",
source_mode = "plain",
synced = false,
plain = plain.Trim()
};
}
static string NormalizeVideoId(string videoId)
{
if (string.IsNullOrWhiteSpace(videoId))
return null;
videoId = videoId.Trim();
if (videoId.StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
videoId = videoId["youtube:".Length..].Trim();
return Regex.IsMatch(videoId, @"^[A-Za-z0-9_-]{6,32}$") ? videoId : null;
}
static async Task<YouTubeMusicClientConfig> GetConfigAsync(CancellationToken cancellationToken)
{
if (cachedConfig != null && DateTimeOffset.UtcNow < configExpiresAt)
return cachedConfig;
await configLock.WaitAsync(cancellationToken);
try
{
if (cachedConfig != null && DateTimeOffset.UtcNow < configExpiresAt)
return cachedConfig;
string html = await httpClient.GetStringAsync(HomeUrl, cancellationToken);
string apiKey = MatchConfigValue(html, "INNERTUBE_API_KEY");
string clientVersion = MatchConfigValue(html, "INNERTUBE_CLIENT_VERSION");
string visitorData = MatchConfigValue(html, "VISITOR_DATA");
if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(clientVersion))
throw new InvalidOperationException("YouTube Music InnerTube config not found.");
cachedConfig = new YouTubeMusicClientConfig
{
ApiKey = apiKey,
ClientVersion = clientVersion,
VisitorData = visitorData
};
configExpiresAt = DateTimeOffset.UtcNow.AddHours(6);
return cachedConfig;
}
finally
{
configLock.Release();
}
}
static string MatchConfigValue(string html, string key)
{
var match = Regex.Match(html ?? string.Empty, $"\"{Regex.Escape(key)}\"\\s*:\\s*\"([^\"]+)\"");
return match.Success ? Regex.Unescape(match.Groups[1].Value) : null;
}
static async Task<List<YouTubeMusicSongCandidate>> SearchSongCandidatesAsync(YouTubeMusicClientConfig config, string title, string artistName, int? durationMs, CancellationToken cancellationToken)
{
int targetMs = durationMs.GetValueOrDefault();
var result = new List<YouTubeMusicSongCandidate>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var query in BuildCandidateQueries(title, artistName))
{
var root = await PostAsync(config, "search", new JsonObject
{
["context"] = BuildContext(config),
["query"] = query.Query,
["params"] = SongsSearchParams
}, cancellationToken);
var candidates = new List<YouTubeMusicSongCandidate>();
CollectSongCandidates(root, candidates);
foreach (var candidate in candidates.Where(i => IsRelevantCandidate(i, query.Title, query.Artist, targetMs, query.RequireArtist)))
{
if (string.IsNullOrWhiteSpace(candidate.VideoId) || !seen.Add(candidate.VideoId))
continue;
result.Add(candidate);
if (result.Count >= MaxCandidates)
return result;
}
}
return result;
}
static async Task<MusicLyricsResponse> FetchLyricsAsync(YouTubeMusicClientConfig config, string videoId, int? durationMs, CancellationToken cancellationToken, bool syncedOnly = false)
{
if (string.IsNullOrWhiteSpace(videoId))
return null;
var next = await PostAsync(config, "next", new JsonObject
{
["context"] = BuildContext(config),
["videoId"] = videoId,
["playlistId"] = "RDAMVM" + videoId,
["enablePersistentPlaylistPanel"] = true,
["isAudioOnly"] = true,
["tunerSettingValue"] = "AUTOMIX_SETTING_NORMAL",
["watchEndpointMusicSupportedConfigs"] = new JsonObject
{
["watchEndpointMusicConfig"] = new JsonObject
{
["hasPersistentPlaylistPanel"] = true,
["musicVideoType"] = "MUSIC_VIDEO_TYPE_ATV"
}
}
}, cancellationToken);
string browseId = FindBrowseIdByPageType(next, "MUSIC_PAGE_TYPE_TRACK_LYRICS");
if (string.IsNullOrWhiteSpace(browseId))
return null;
var timedBrowse = await PostAsync(config, "browse", new JsonObject
{
["context"] = BuildContext(config, mobile: true),
["browseId"] = browseId
}, cancellationToken);
var timedLines = FindTimedLyricsLines(timedBrowse);
if (timedLines.Count > 0)
{
return new MusicLyricsResponse
{
available = true,
message = "ok",
source = "youtube_music",
source_mode = "synced",
synced = true,
lines = timedLines,
plain = string.Join("\n", timedLines.Select(i => i.text).Where(i => !string.IsNullOrWhiteSpace(i)))
};
}
if (syncedOnly)
return null;
string staticPlain = FindStaticLyricsText(timedBrowse);
if (!string.IsNullOrWhiteSpace(staticPlain))
return BuildPlainResponse(staticPlain);
string mobilePlain = FindDescriptionShelfText(timedBrowse);
if (!string.IsNullOrWhiteSpace(mobilePlain) && !mobilePlain.Contains("Lyrics not available", StringComparison.OrdinalIgnoreCase))
return BuildPlainResponse(mobilePlain);
var browse = await PostAsync(config, "browse", new JsonObject
{
["context"] = BuildContext(config),
["browseId"] = browseId
}, cancellationToken);
string lyrics = FindDescriptionShelfText(browse);
if (string.IsNullOrWhiteSpace(lyrics) || lyrics.Contains("Lyrics not available", StringComparison.OrdinalIgnoreCase))
return null;
return BuildPlainResponse(lyrics);
}
static async Task<JsonNode> PostAsync(YouTubeMusicClientConfig config, string endpoint, JsonObject body, CancellationToken cancellationToken)
{
string url = InnertubeBaseUrl + endpoint + "?key=" + Uri.EscapeDataString(config.ApiKey);
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
using var response = await httpClient.PostAsync(url, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if ((int)response.StatusCode == 401 || (int)response.StatusCode == 403)
InvalidateConfig();
throw new HttpRequestException($"YouTube Music {endpoint} returned {(int)response.StatusCode}");
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
return string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json);
}
static void InvalidateConfig()
{
cachedConfig = null;
configExpiresAt = DateTimeOffset.MinValue;
}
static JsonObject BuildContext(YouTubeMusicClientConfig config, bool mobile = false)
{
var client = new JsonObject
{
["clientName"] = mobile ? "ANDROID_MUSIC" : "WEB_REMIX",
["clientVersion"] = mobile ? "7.21.50" : config.ClientVersion,
["hl"] = "ru",
["gl"] = "US"
};
if (!string.IsNullOrWhiteSpace(config.VisitorData))
client["visitorData"] = config.VisitorData;
return new JsonObject
{
["client"] = client
};
}
static void CollectSongCandidates(JsonNode node, List<YouTubeMusicSongCandidate> candidates)
{
if (node is JsonObject obj)
{
if (obj["musicResponsiveListItemRenderer"] is JsonObject renderer)
{
string videoId = FindWatchVideoId(renderer);
if (!string.IsNullOrWhiteSpace(videoId))
{
var texts = ExtractFlexTexts(renderer);
string title = texts.FirstOrDefault();
string joined = string.Join(" ", texts.Where(i => !string.IsNullOrWhiteSpace(i)));
if (!string.IsNullOrWhiteSpace(title))
{
candidates.Add(new YouTubeMusicSongCandidate
{
VideoId = videoId,
Title = title,
Description = joined,
DurationMs = ParseDurationMs(joined)
});
}
}
}
foreach (var child in obj)
CollectSongCandidates(child.Value, candidates);
}
else if (node is JsonArray array)
{
foreach (var child in array)
CollectSongCandidates(child, candidates);
}
}
static string FindWatchVideoId(JsonNode node)
{
if (node is JsonObject obj)
{
if (obj["watchEndpoint"] is JsonObject endpoint)
{
string videoId = endpoint["videoId"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(videoId))
return videoId;
}
foreach (var child in obj)
{
string result = FindWatchVideoId(child.Value);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
string result = FindWatchVideoId(child);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
return null;
}
static List<string> ExtractFlexTexts(JsonObject renderer)
{
var result = new List<string>();
if (renderer["flexColumns"] is not JsonArray columns)
return result;
foreach (var column in columns.OfType<JsonObject>())
{
var textNode = column["musicResponsiveListItemFlexColumnRenderer"]?["text"];
string text = ExtractText(textNode);
if (!string.IsNullOrWhiteSpace(text))
result.Add(Regex.Replace(text.Trim(), @"\s+", " "));
}
return result;
}
static string ExtractText(JsonNode node)
{
if (node is not JsonObject obj)
return null;
if (obj["simpleText"] != null)
return obj["simpleText"]?.GetValue<string>();
if (obj["runs"] is JsonArray runs)
return string.Concat(runs.OfType<JsonObject>().Select(i => i["text"]?.GetValue<string>() ?? string.Empty));
return null;
}
static int? ParseDurationMs(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var matches = Regex.Matches(text, @"(?<!\d)(\d{1,2}):(\d{2})(?::(\d{2}))?(?!\d)");
if (matches.Count == 0)
return null;
var match = matches[^1];
int first = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
int second = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
int third = match.Groups[3].Success ? int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture) : 0;
int seconds = match.Groups[3].Success
? first * 3600 + second * 60 + third
: first * 60 + second;
return seconds * 1000;
}
static List<YouTubeMusicLyricsQuery> BuildCandidateQueries(string title, string artistName)
{
var result = new List<YouTubeMusicLyricsQuery>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string cleanTitle = CleanSearchTitle(title);
string artist = CleanSearchTitle(artistName);
void add(string query, string queryTitle, string queryArtist, bool requireArtist)
{
query = Regex.Replace(query ?? string.Empty, @"\s+", " ").Trim();
queryTitle = Regex.Replace(queryTitle ?? string.Empty, @"\s+", " ").Trim();
queryArtist = Regex.Replace(queryArtist ?? string.Empty, @"\s+", " ").Trim();
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(queryTitle))
return;
string key = NormalizeText($"{query}|{queryTitle}|{queryArtist}|{requireArtist}");
if (!seen.Add(key))
return;
result.Add(new YouTubeMusicLyricsQuery
{
Query = query,
Title = queryTitle,
Artist = queryArtist,
RequireArtist = requireArtist
});
}
add($"{artistName} {title}", title, artistName, requireArtist: true);
if (!string.Equals(cleanTitle, title, StringComparison.Ordinal))
add($"{artist} {cleanTitle}", cleanTitle, artist, requireArtist: !string.IsNullOrWhiteSpace(artist));
var trailing = ExtractTrailingArtist(cleanTitle);
if (!string.IsNullOrWhiteSpace(trailing.title) && !string.IsNullOrWhiteSpace(trailing.artist))
add($"{trailing.artist} {trailing.title}", trailing.title, trailing.artist, requireArtist: true);
add(cleanTitle, cleanTitle, null, requireArtist: false);
return result;
}
static string CleanSearchTitle(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = Regex.Replace(value, @"#\S+", " ");
value = Regex.Replace(value, @"\b(official|audio|video|lyrics?|visualizer|remaster(?:ed)?|clip)\b", " ", RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"[\[\](){}]", " ");
return Regex.Replace(value, @"\s+", " ").Trim();
}
static (string title, string artist) ExtractTrailingArtist(string title)
{
if (string.IsNullOrWhiteSpace(title))
return (null, null);
var parts = title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
return (null, null);
string last = parts[^1].Trim();
string normalizedLast = NormalizeText(last);
if (normalizedLast.Length < 3)
return (null, null);
if (!Regex.IsMatch(last, @"^\p{L}[\p{L}\p{Nd}._-]*$", RegexOptions.IgnoreCase))
return (null, null);
if (Regex.IsMatch(normalizedLast, @"^(remix|mix|edit|audio|video|lyrics|music|official|cover|live)$", RegexOptions.IgnoreCase))
return (null, null);
string baseTitle = string.Join(' ', parts.Take(parts.Length - 1)).Trim();
return string.IsNullOrWhiteSpace(baseTitle) ? (null, null) : (baseTitle, last);
}
static bool IsRelevantCandidate(YouTubeMusicSongCandidate candidate, string title, string artistName, int targetMs, bool requireArtist = true)
{
string wantedTitle = NormalizeTitle(title);
string candidateTitle = NormalizeTitle(candidate.Title);
string wantedArtist = NormalizeText(artistName);
string candidateDescription = NormalizeText(candidate.Description);
if (string.IsNullOrWhiteSpace(wantedTitle) || string.IsNullOrWhiteSpace(candidateTitle))
return false;
bool titleMatches = candidateTitle == wantedTitle
|| candidateTitle.Contains(wantedTitle, StringComparison.Ordinal)
|| wantedTitle.Contains(candidateTitle, StringComparison.Ordinal)
|| TitleTokenCoverage(wantedTitle, candidateTitle) >= 0.75;
if (!titleMatches)
return false;
bool artistMatches = string.IsNullOrWhiteSpace(wantedArtist) || candidateDescription.Contains(wantedArtist, StringComparison.Ordinal);
if (!artistMatches)
{
var artistTokens = wantedArtist.Split(' ', StringSplitOptions.RemoveEmptyEntries);
artistMatches = artistTokens.Any(i => i.Length > 2 && candidateDescription.Contains(i, StringComparison.Ordinal));
}
if (requireArtist && !artistMatches)
return false;
if (targetMs > 0 && candidate.DurationMs > 0 && Math.Abs(candidate.DurationMs.Value - targetMs) > 25000)
return false;
return true;
}
static string NormalizeTitle(string value)
{
value = Regex.Replace(value ?? string.Empty, @"\b(feat\.?|ft\.?|featuring)\b.*$", " ", RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"\((official|audio|video|lyrics?|visualizer|remaster(ed)?|clip)[^)]*\)", " ", RegexOptions.IgnoreCase);
return NormalizeText(value);
}
static double TitleTokenCoverage(string wantedTitle, string candidateTitle)
{
var wantedTokens = wantedTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (wantedTokens.Length == 0)
return 0;
var candidateTokens = candidateTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.ToArray();
int matched = wantedTokens.Count(wanted => candidateTokens.Any(candidate => TokensClose(wanted, candidate)));
return matched / (double)wantedTokens.Length;
}
static bool TokensClose(string left, string right)
{
if (left == right)
return true;
int min = Math.Min(left.Length, right.Length);
if (min < 4)
return false;
return left.AsSpan(0, 4).SequenceEqual(right.AsSpan(0, 4));
}
static string NormalizeText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
string decomposed = value.ToLowerInvariant().Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(decomposed.Length);
foreach (char c in decomposed)
{
var category = CharUnicodeInfo.GetUnicodeCategory(c);
if (category != UnicodeCategory.NonSpacingMark)
builder.Append(c);
}
return Regex.Replace(builder.ToString().Normalize(NormalizationForm.FormC), @"[^\p{L}\p{Nd}]+", " ").Trim();
}
static string FindBrowseIdByPageType(JsonNode node, string pageType)
{
if (node is JsonObject obj)
{
if (obj["browseEndpoint"] is JsonObject endpoint)
{
string browseId = endpoint["browseId"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(browseId) && ContainsPageType(endpoint, pageType))
return browseId;
}
foreach (var child in obj)
{
string result = FindBrowseIdByPageType(child.Value, pageType);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
string result = FindBrowseIdByPageType(child, pageType);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
return null;
}
static bool ContainsPageType(JsonNode node, string pageType)
{
if (node is JsonObject obj)
{
foreach (var item in obj)
{
if (item.Key == "pageType" && item.Value?.GetValue<string>() == pageType)
return true;
if (ContainsPageType(item.Value, pageType))
return true;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
if (ContainsPageType(child, pageType))
return true;
}
}
return false;
}
static string FindDescriptionShelfText(JsonNode node)
{
if (node is JsonObject obj)
{
if (obj["musicDescriptionShelfRenderer"] is JsonObject shelf)
{
string text = ExtractText(shelf["description"]);
if (!string.IsNullOrWhiteSpace(text))
return text;
}
foreach (var child in obj)
{
string result = FindDescriptionShelfText(child.Value);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
string result = FindDescriptionShelfText(child);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
return null;
}
static List<MusicLyricsLine> FindTimedLyricsLines(JsonNode node)
{
if (node is JsonObject obj)
{
if (obj["timedLyricsData"] is JsonArray timed)
{
var lines = timed
.OfType<JsonObject>()
.Select(ParseTimedLyricsLine)
.Where(i => i != null)
.OrderBy(i => i.time_ms)
.ToList();
if (lines.Count > 0)
return lines;
}
foreach (var child in obj)
{
var result = FindTimedLyricsLines(child.Value);
if (result.Count > 0)
return result;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
var result = FindTimedLyricsLines(child);
if (result.Count > 0)
return result;
}
}
return new List<MusicLyricsLine>();
}
static string FindStaticLyricsText(JsonNode node)
{
if (node is JsonObject obj)
{
if (obj["timedLyricsData"] is JsonArray timed)
{
var lines = timed
.OfType<JsonObject>()
.Select(i => i["lyricLine"]?.GetValue<string>()?.Trim())
.Where(i => !string.IsNullOrWhiteSpace(i) && !string.Equals(i, "♪", StringComparison.Ordinal))
.ToList();
if (lines.Count > 0)
return string.Join("\n", lines);
}
foreach (var child in obj)
{
string result = FindStaticLyricsText(child.Value);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
else if (node is JsonArray array)
{
foreach (var child in array)
{
string result = FindStaticLyricsText(child);
if (!string.IsNullOrWhiteSpace(result))
return result;
}
}
return null;
}
static MusicLyricsLine ParseTimedLyricsLine(JsonObject item)
{
string text = item["lyricLine"]?.GetValue<string>()?.Trim();
string start = item["cueRange"]?["startTimeMilliseconds"]?.GetValue<string>();
if (!long.TryParse(start, NumberStyles.Integer, CultureInfo.InvariantCulture, out long timeMs))
return null;
return new MusicLyricsLine
{
time_ms = (int)Math.Max(0, Math.Min(int.MaxValue, timeMs)),
text = string.Equals(text, "♪", StringComparison.Ordinal) ? "" : text
};
}
class YouTubeMusicClientConfig
{
public string ApiKey { get; set; }
public string ClientVersion { get; set; }
public string VisitorData { get; set; }
}
class YouTubeMusicSongCandidate
{
public string VideoId { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public int? DurationMs { get; set; }
}
class YouTubeMusicLyricsQuery
{
public string Query { get; set; }
public string Title { get; set; }
public string Artist { get; set; }
public bool RequireArtist { get; set; }
}
}
File diff suppressed because it is too large Load Diff
@@ -1,197 +0,0 @@
using Microsoft.Data.Sqlite;
namespace Music;
public static class MusicPlaybackHistoryService
{
public static async Task SaveAsync(string profileId, MusicTrack track, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(profileId))
return;
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
try
{
if (!await semaphore.WaitAsync())
return;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO playback_history (profile_id, track_id, payload, updated)
VALUES ($profile_id, $track_id, $payload, $updated)
ON CONFLICT(profile_id, track_id) DO UPDATE SET
payload = excluded.payload,
updated = excluded.updated;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$track_id", track.id);
command.Parameters.AddWithValue("$payload", MusicJson.Serialize(track));
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken);
}
finally
{
semaphore.Release();
}
}
public static async Task<List<MusicRecentlyPlayedItem>> GetRecentAsync(string profileId, int limit = 12, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
var items = new List<MusicRecentlyPlayedItem>();
if (string.IsNullOrWhiteSpace(profileId))
return items;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT payload, updated
FROM playback_history
WHERE profile_id = $profile_id
ORDER BY updated DESC
LIMIT $limit;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$limit", Math.Max(1, limit));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var payload = reader.IsDBNull(0) ? null : reader.GetString(0);
if (string.IsNullOrWhiteSpace(payload))
continue;
var track = MusicJson.Deserialize<MusicTrack>(payload);
if (track == null)
continue;
items.Add(new MusicRecentlyPlayedItem
{
track = track,
played_at = reader.IsDBNull(1) ? DateTime.UtcNow : reader.GetDateTime(1)
});
}
return items;
}
public static async Task<MusicTrack> GetTrackAsync(string profileId, string trackId, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
trackId = string.IsNullOrWhiteSpace(trackId) ? null : trackId.Trim();
if (string.IsNullOrWhiteSpace(profileId) || string.IsNullOrWhiteSpace(trackId))
return null;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT payload
FROM playback_history
WHERE profile_id = $profile_id
AND track_id = $track_id
LIMIT 1;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$track_id", trackId);
var payload = await command.ExecuteScalarAsync(cancellationToken) as string;
return string.IsNullOrWhiteSpace(payload) ? null : MusicJson.Deserialize<MusicTrack>(payload);
}
public static async Task<Dictionary<string, MusicTrack>> GetTracksAsync(string profileId, IEnumerable<string> trackIds, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
if (string.IsNullOrWhiteSpace(profileId) || trackIds == null)
return new Dictionary<string, MusicTrack>(StringComparer.Ordinal);
var normalizedIds = trackIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
if (normalizedIds.Count == 0)
return new Dictionary<string, MusicTrack>(StringComparer.Ordinal);
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
var placeholders = new List<string>(normalizedIds.Count);
for (int i = 0; i < normalizedIds.Count; i++)
{
string parameterName = $"$track_id_{i}";
placeholders.Add(parameterName);
command.Parameters.AddWithValue(parameterName, normalizedIds[i]);
}
command.CommandText = $"""
SELECT track_id, payload
FROM playback_history
WHERE profile_id = $profile_id
AND track_id IN ({string.Join(", ", placeholders)});
""";
command.Parameters.AddWithValue("$profile_id", profileId);
var result = new Dictionary<string, MusicTrack>(normalizedIds.Count, StringComparer.Ordinal);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var trackId = reader.IsDBNull(0) ? null : reader.GetString(0);
var payload = reader.IsDBNull(1) ? null : reader.GetString(1);
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(payload))
continue;
var track = MusicJson.Deserialize<MusicTrack>(payload);
if (track != null)
result[trackId] = track;
}
return result;
}
public static async Task<bool> RemoveAsync(string profileId, string trackId, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
trackId = string.IsNullOrWhiteSpace(trackId) ? null : trackId.Trim();
if (string.IsNullOrWhiteSpace(profileId) || string.IsNullOrWhiteSpace(trackId))
return false;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM playback_history
WHERE profile_id = $profile_id
AND track_id = $track_id;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$track_id", trackId);
return await command.ExecuteNonQueryAsync(cancellationToken) > 0;
}
static string NormalizeProfileId(string profileId)
=> string.IsNullOrWhiteSpace(profileId) ? null : profileId.Trim().ToLowerInvariant();
}
@@ -1,21 +0,0 @@
namespace Music;
public static class MusicPlaybackModeService
{
public const string Audio = "audio";
public const string Video = "video";
public static string Normalize(string mode)
{
mode = mode?.Trim().ToLowerInvariant();
return mode switch
{
Video => Video,
_ => Audio
};
}
public static bool IsVideo(string mode)
=> Normalize(mode) == Video;
}
@@ -1,215 +0,0 @@
using System.Text;
namespace Music;
public static class MusicPlaybackService
{
public static async Task<MusicTrack> ResolveRequestTrackAsync(string id, string provider, string title, string artistName, string albumId, string albumTitle, int? durationMs, string date, string isrc)
{
string normalizedIsrc = MusicIsrc.Normalize(isrc);
MusicTrack track = null;
// Spotify search отдаёт точный track/album id, но без duration/isrc.
// Достаём трек из его родного queryAlbum (кэшируется каталогом), вместо
// заведомо бесполезного поиска spotify:track:* через MusicBrainz.
if (!string.IsNullOrWhiteSpace(id)
&& id.StartsWith("spotify:track:", StringComparison.OrdinalIgnoreCase)
&& !durationMs.HasValue
&& !string.IsNullOrWhiteSpace(albumId)
&& albumId.StartsWith("spotify:album:", StringComparison.OrdinalIgnoreCase))
{
var albumTask = MusicCatalogService.GetAlbumAsync(albumId, SpotifySupport.ProviderId);
if (await Task.WhenAny(albumTask, Task.Delay(2500)) == albumTask)
{
var album = await albumTask;
track = album?.tracks?.FirstOrDefault(i => string.Equals(i?.id, id, StringComparison.OrdinalIgnoreCase));
}
}
else
{
track = await MusicCatalogService.GetTrackAsync(id, provider);
}
if (track != null)
{
track = CopyTrack(track);
track.isrc = MusicIsrc.Normalize(track.isrc) ?? normalizedIsrc;
return track;
}
if (string.IsNullOrWhiteSpace(title))
return null;
return new MusicTrack
{
id = string.IsNullOrWhiteSpace(id) ? BuildInlineTrackId(title, artistName, albumTitle, durationMs, normalizedIsrc) : id,
title = title,
artist_name = artistName,
album_id = albumId,
album_title = albumTitle,
isrc = normalizedIsrc,
duration_ms = durationMs,
date = date
};
}
static MusicTrack CopyTrack(MusicTrack track)
{
return new MusicTrack
{
id = track.id,
title = track.title,
artist_id = track.artist_id,
artist_name = track.artist_name,
artists = track.artists?.ToList() ?? new List<string>(),
album_id = track.album_id,
album_title = track.album_title,
isrc = track.isrc,
duration_ms = track.duration_ms,
track_number = track.track_number,
disc_number = track.disc_number,
date = track.date,
search_score = track.search_score,
images = track.images?.ToList() ?? new List<MusicImage>(),
provider_refs = track.provider_refs?.ToList() ?? new List<MusicProviderRef>(),
auto_radio = track.auto_radio
};
}
public static async Task<MusicPlayResponse> ResolveTrackAsync(MusicTrack track, string audioProvider, string streamMode, string playbackMode, string profileId, CancellationToken cancellationToken = default, bool refreshSources = false)
{
var result = await MusicResolver.ResolveTrackAsync(track, audioProvider, playbackMode, profileId, cancellationToken, refreshSources);
if (result?.sources != null && result.sources.Count > 0)
result.sources = MusicStreamModeService.Order(result.sources, streamMode);
return result;
}
public static async Task<MusicPlayResponse> ResolvePreferredTrackAsync(MusicTrack track, string audioProvider, string streamMode, string playbackMode, string profileId, CancellationToken cancellationToken = default)
{
var result = await MusicResolver.ResolvePreferredTrackAsync(track, audioProvider, playbackMode, profileId, cancellationToken);
if (result?.sources != null && result.sources.Count > 0)
result.sources = MusicStreamModeService.Order(result.sources, streamMode);
return result;
}
public static void PrepareStreamSources(string host, MusicTrack track, string provider, string audioProvider, string streamMode, string playbackMode, MusicPlayResponse result)
{
if (string.IsNullOrWhiteSpace(host) || track == null || result?.sources == null || result.sources.Count == 0)
return;
string resolvedProvider = ResolveTrackProvider(track, provider);
var fallbackParams = BuildStreamFallbackParams(track, resolvedProvider, audioProvider, streamMode, playbackMode, null, null);
foreach (var source in result.sources)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
continue;
source.external_url = source.url;
string ticket = MusicStreamTicketService.Create(source);
if (!string.IsNullOrWhiteSpace(ticket))
{
var query = new List<string> { $"ticket={Uri.EscapeDataString(ticket)}" };
query.AddRange(fallbackParams);
if (!string.IsNullOrWhiteSpace(source.quality))
query.Add($"quality={Uri.EscapeDataString(source.quality)}");
source.url = $"{host}/music/stream?{string.Join("&", query)}";
}
source.headers = new Dictionary<string, string>();
}
}
public static MusicPlaybackSource SelectSource(List<MusicPlaybackSource> sources, string quality, string streamMode)
{
if (sources == null || sources.Count == 0)
return null;
if (!string.IsNullOrWhiteSpace(quality))
{
var exact = sources.FirstOrDefault(i => string.Equals(i.quality, quality, StringComparison.OrdinalIgnoreCase));
if (exact != null)
return exact;
}
return MusicStreamModeService.Order(sources, streamMode).FirstOrDefault();
}
public static string ResolveTrackProvider(MusicTrack track, string fallbackProvider)
{
if (!string.IsNullOrWhiteSpace(fallbackProvider))
return fallbackProvider;
if (track?.provider_refs == null || track.provider_refs.Count == 0)
return null;
foreach (var providerRef in track.provider_refs)
{
if (!string.IsNullOrWhiteSpace(providerRef?.provider))
return providerRef.provider.Trim();
}
return null;
}
public static List<string> BuildStreamFallbackParams(MusicTrack track, string provider, string audioProvider, string streamMode, string playbackMode, string uid, string accountEmail)
{
var url = new List<string>();
if (!string.IsNullOrWhiteSpace(track?.id))
url.Add($"id={Uri.EscapeDataString(track.id)}");
if (!string.IsNullOrWhiteSpace(provider))
url.Add($"provider={Uri.EscapeDataString(provider)}");
if (!string.IsNullOrWhiteSpace(audioProvider))
url.Add($"audio_provider={Uri.EscapeDataString(audioProvider)}");
if (!string.IsNullOrWhiteSpace(streamMode))
url.Add($"stream_mode={Uri.EscapeDataString(streamMode)}");
if (!string.IsNullOrWhiteSpace(playbackMode))
url.Add($"playback_mode={Uri.EscapeDataString(playbackMode)}");
if (!string.IsNullOrWhiteSpace(track?.title))
url.Add($"title={Uri.EscapeDataString(track.title)}");
if (!string.IsNullOrWhiteSpace(track?.artist_name))
url.Add($"artist_name={Uri.EscapeDataString(track.artist_name)}");
if (!string.IsNullOrWhiteSpace(track?.album_id))
url.Add($"album_id={Uri.EscapeDataString(track.album_id)}");
if (!string.IsNullOrWhiteSpace(track?.album_title))
url.Add($"album_title={Uri.EscapeDataString(track.album_title)}");
string isrc = MusicIsrc.Normalize(track?.isrc);
if (isrc != null)
url.Add($"isrc={Uri.EscapeDataString(isrc)}");
if (track?.duration_ms.HasValue == true)
url.Add($"duration_ms={track.duration_ms.Value}");
if (!string.IsNullOrWhiteSpace(track?.date))
url.Add($"date={Uri.EscapeDataString(track.date)}");
if (!string.IsNullOrWhiteSpace(uid))
url.Add($"uid={Uri.EscapeDataString(uid)}");
if (!string.IsNullOrWhiteSpace(accountEmail))
url.Add($"account_email={Uri.EscapeDataString(accountEmail)}");
return url;
}
static string BuildInlineTrackId(string title, string artistName, string albumTitle, int? durationMs, string isrc)
{
string key = $"{artistName}::{title}::{albumTitle}::{durationMs}::{isrc}";
using var md5 = System.Security.Cryptography.MD5.Create();
var bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(key));
return "inline:" + Convert.ToHexString(bytes).ToLowerInvariant();
}
}
@@ -1,327 +0,0 @@
using System.Text;
namespace Music;
public sealed class MusicPlaylistRequest
{
public string ids { get; set; }
public string provider { get; set; }
public string audio_provider { get; set; }
public string stream_mode { get; set; }
public string playback_mode { get; set; }
public string playlist_strategy { get; set; }
public string profile_id { get; set; }
public string host { get; set; }
public string uid { get; set; }
public string account_email { get; set; }
public CancellationToken cancellation_token { get; set; }
}
public static class MusicPlaylistService
{
public static async Task<string> BuildAsync(MusicPlaylistRequest request)
{
request ??= new MusicPlaylistRequest();
if (string.IsNullOrWhiteSpace(request.ids))
return string.Empty;
// защитный кап: 500 = лимит окна снапшота очереди, длиннее клиент
// не шлёт; всё сверх — обрезаем, а не отказываем (устойчивость)
const int maxPlaylistIds = 500;
const int maxTrackIdLength = 256;
var trackIds = request.ids
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(id => !string.IsNullOrWhiteSpace(id) && id.Length <= maxTrackIdLength)
.Take(maxPlaylistIds)
.ToArray();
if (trackIds.Length == 0)
return string.Empty;
var tracks = await ResolvePlaylistTracksAsync(trackIds, request);
if (string.Equals(request.audio_provider, SefonSupport.ProviderId, StringComparison.OrdinalIgnoreCase))
return await BuildResolvedExternalPlaylistAsync(trackIds, tracks, request);
if (string.Equals(request.audio_provider, SoundCloudSupport.AudioProviderId, StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(request.playlist_strategy, "resolved", StringComparison.OrdinalIgnoreCase))
return await BuildResolvedExternalPlaylistAsync(trackIds, tracks, request);
return await BuildHybridSoundCloudExternalPlaylistAsync(trackIds, tracks, request);
}
return BuildInternalPlaylist(trackIds, tracks, request);
}
static async Task<MusicTrack[]> ResolvePlaylistTracksAsync(string[] trackIds, MusicPlaylistRequest request)
{
const int metadataResolveParallelism = 6;
var tracks = new MusicTrack[trackIds.Length];
var historyTracks = await MusicPlaybackHistoryService.GetTracksAsync(request.profile_id, trackIds, request.cancellation_token);
for (int i = 0; i < trackIds.Length; i++)
{
if (historyTracks.TryGetValue(trackIds[i], out var historyTrack))
tracks[i] = historyTrack;
}
if (string.IsNullOrWhiteSpace(request.provider))
return tracks;
using var resolveGate = new SemaphoreSlim(metadataResolveParallelism);
var resolveTasks = new List<Task>();
async Task ResolveTrackAtAsync(int index)
{
await resolveGate.WaitAsync(request.cancellation_token);
try
{
tracks[index] = await MusicCatalogService.GetTrackAsync(trackIds[index], request.provider, request.cancellation_token);
}
finally
{
resolveGate.Release();
}
}
for (int i = 0; i < trackIds.Length; i++)
{
if (tracks[i] == null)
resolveTasks.Add(ResolveTrackAtAsync(i));
}
if (resolveTasks.Count > 0)
await Task.WhenAll(resolveTasks);
return tracks;
}
static string BuildInternalPlaylist(string[] trackIds, MusicTrack[] tracks, MusicPlaylistRequest request)
{
var playlist = new StringBuilder();
playlist.AppendLine("#EXTM3U");
for (int i = 0; i < trackIds.Length; i++)
{
var track = tracks[i];
string id = trackIds[i];
int position = i + 1;
string title = BuildTitle(track, position);
int seconds = GetSeconds(track);
playlist.AppendLine($"#EXTINF:{seconds},{EscapePlaylistTitle(title)}");
string playlistUrl = BuildPlaylistEntryUrl(track, id, request);
if (!string.IsNullOrWhiteSpace(playlistUrl))
playlist.AppendLine(playlistUrl);
}
return playlist.ToString();
}
static async Task<string> BuildHybridSoundCloudExternalPlaylistAsync(string[] trackIds, MusicTrack[] tracks, MusicPlaylistRequest request)
{
var playlist = new StringBuilder();
playlist.AppendLine("#EXTM3U");
const int leadChunkSize = 6;
int leadIndex = -1;
var leadResolvedEntries = new Dictionary<int, PlaylistResolvedEntry>();
for (int offset = 0; offset < trackIds.Length && leadIndex < 0; offset += leadChunkSize)
{
int chunkCount = Math.Min(leadChunkSize, trackIds.Length - offset);
var tasks = new Task<PlaylistResolvedEntry>[chunkCount];
for (int chunkOffset = 0; chunkOffset < chunkCount; chunkOffset++)
{
int index = offset + chunkOffset;
var track = tracks[index];
if (track == null)
continue;
tasks[chunkOffset] = ResolvePreferredPlaylistEntryAsync(index, track, request);
}
await Task.WhenAll(tasks.Where(task => task != null));
for (int chunkOffset = 0; chunkOffset < chunkCount; chunkOffset++)
{
int index = offset + chunkOffset;
var task = tasks[chunkOffset];
var entry = task?.Result;
if (entry == null || string.IsNullOrWhiteSpace(entry.Url))
continue;
leadResolvedEntries[index] = entry;
if (leadIndex < 0)
leadIndex = index;
}
}
if (leadIndex < 0)
return playlist.ToString();
for (int i = leadIndex; i < trackIds.Length; i++)
{
var track = tracks[i];
string trackId = trackIds[i];
if (track == null)
continue;
if (leadResolvedEntries.TryGetValue(i, out var resolvedEntry) && !string.IsNullOrWhiteSpace(resolvedEntry.Url))
{
playlist.AppendLine($"#EXTINF:{resolvedEntry.Seconds},{EscapePlaylistTitle(resolvedEntry.Title)}");
playlist.AppendLine(resolvedEntry.Url);
continue;
}
string playlistUrl = BuildPlaylistEntryUrl(track, trackId, request);
if (string.IsNullOrWhiteSpace(playlistUrl))
continue;
playlist.AppendLine($"#EXTINF:{GetSeconds(track)},{EscapePlaylistTitle(BuildTitle(track, i + 1))}");
playlist.AppendLine(playlistUrl);
}
return playlist.ToString();
}
static async Task<string> BuildResolvedExternalPlaylistAsync(string[] trackIds, MusicTrack[] tracks, MusicPlaylistRequest request)
{
var entries = new PlaylistResolvedEntry[trackIds.Length];
using var concurrency = new SemaphoreSlim(8);
var tasks = trackIds
.Select((trackId, index) => ResolveResolvedPlaylistEntryAsync(index, tracks[index], request, entries, concurrency))
.ToArray();
await Task.WhenAll(tasks);
var playlist = new StringBuilder();
playlist.AppendLine("#EXTM3U");
foreach (var entry in entries)
{
if (entry == null || string.IsNullOrWhiteSpace(entry.Url))
continue;
playlist.AppendLine($"#EXTINF:{entry.Seconds},{EscapePlaylistTitle(entry.Title)}");
playlist.AppendLine(entry.Url);
}
return playlist.ToString();
}
static async Task ResolveResolvedPlaylistEntryAsync(int index, MusicTrack track, MusicPlaylistRequest request, PlaylistResolvedEntry[] entries, SemaphoreSlim concurrency)
{
await concurrency.WaitAsync(request.cancellation_token);
try
{
if (track == null)
return;
var entry = await ResolvePreferredPlaylistEntryAsync(index, track, request);
if (entry != null)
entries[index] = entry;
}
catch
{
}
finally
{
concurrency.Release();
}
}
static async Task<PlaylistResolvedEntry> ResolvePreferredPlaylistEntryAsync(int index, MusicTrack track, MusicPlaylistRequest request)
{
if (track == null)
return null;
var result = await MusicPlaybackService.ResolvePreferredTrackAsync(track, request.audio_provider, request.stream_mode, request.playback_mode, request.profile_id, request.cancellation_token);
if (result?.available != true || result.sources == null || result.sources.Count == 0)
return null;
var source = MusicPlaybackService.SelectSource(result.sources, null, request.stream_mode);
if (source == null || string.IsNullOrWhiteSpace(source.url))
return null;
string url = source.url;
string resolvedAudioProvider = string.IsNullOrWhiteSpace(source.provider_id) ? request.audio_provider : source.provider_id;
bool canUseDirectExternalUrl =
(string.Equals(resolvedAudioProvider, SefonSupport.ProviderId, StringComparison.OrdinalIgnoreCase)
|| string.Equals(resolvedAudioProvider, SoundCloudSupport.AudioProviderId, StringComparison.OrdinalIgnoreCase))
&& (source.headers == null || source.headers.Count == 0);
if (!canUseDirectExternalUrl)
{
MusicPlaybackService.PrepareStreamSources(request.host, track, MusicPlaybackService.ResolveTrackProvider(track, request.provider), resolvedAudioProvider, request.stream_mode, request.playback_mode, result);
source = MusicPlaybackService.SelectSource(result.sources, null, request.stream_mode);
if (source == null || string.IsNullOrWhiteSpace(source.url))
return null;
url = source.url;
}
return new PlaylistResolvedEntry
{
Title = BuildTitle(track, index + 1),
Seconds = GetSeconds(track),
Url = url
};
}
static string BuildPlaylistEntryUrl(MusicTrack track, string trackId, MusicPlaylistRequest request)
{
trackId = string.IsNullOrWhiteSpace(trackId) ? track?.id : trackId.Trim();
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(request.host))
return null;
string resolvedProvider = MusicPlaybackService.ResolveTrackProvider(track, request.provider);
var fallbackTrack = track ?? new MusicTrack { id = trackId };
if (string.IsNullOrWhiteSpace(fallbackTrack.id))
fallbackTrack.id = trackId;
var url = MusicPlaybackService.BuildStreamFallbackParams(
fallbackTrack,
resolvedProvider,
request.audio_provider,
request.stream_mode,
request.playback_mode,
request.uid,
request.account_email
);
return $"{request.host}/music/stream?{string.Join("&", url)}";
}
static string BuildTitle(MusicTrack track, int position)
{
return string.IsNullOrWhiteSpace(track?.artist_name)
? track?.title ?? $"Track {position}"
: $"{track.artist_name} - {track.title}";
}
static int GetSeconds(MusicTrack track)
{
return track?.duration_ms.HasValue == true
? (int)Math.Max(0, Math.Round(track.duration_ms.Value / 1000d))
: -1;
}
static string EscapePlaylistTitle(string title)
=> (title ?? string.Empty).Replace("\r", " ").Replace("\n", " ");
sealed class PlaylistResolvedEntry
{
public string Title { get; set; }
public int Seconds { get; set; }
public string Url { get; set; }
}
}
@@ -1,753 +0,0 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace Music;
public static class MusicRadioService
{
static readonly TimeSpan searchCacheTtl = TimeSpan.FromHours(6);
static readonly TimeSpan radioTimeout = TimeSpan.FromSeconds(8);
const int maxSeedArtists = 5;
const int providerQueryLimit = 12;
const int maxPoolPerArtist = 24;
const string cacheVersion = "radio-v3-metadata";
public static async Task<MusicRadioResponse> GetAsync(string profileId, MusicRadioRequest request, int limit = 20, CancellationToken cancellationToken = default)
{
request ??= new MusicRadioRequest();
limit = Math.Clamp(limit, 5, 30);
var seeds = NormalizeTrackList(request.seeds);
var exclude = NormalizeTrackList(request.exclude);
if (seeds.Count == 0)
seeds = exclude.TakeLast(Math.Min(5, exclude.Count)).ToList();
var artists = ExtractSeedArtists(seeds);
if (artists.Count == 0)
return Unavailable("Недостаточно данных для радио.");
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(radioTimeout);
// сиды исключаем всегда, даже если клиент прислал пустой exclude —
// иначе радио может вернуть сам сид-трек под другим id
var excludedIds = new HashSet<string>(exclude.Concat(seeds).Select(i => i.id).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
var excludedKeys = new HashSet<string>(exclude.Concat(seeds).Select(BuildTrackDedupeKey).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
var excludedTitles = new HashSet<string>(exclude.Concat(seeds).Select(BuildTrackTitleKey).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
try
{
foreach (var recent in await MusicPlaybackHistoryService.GetRecentAsync(profileId, 150, timeoutCts.Token))
{
if (recent == null || recent.played_at.ToUniversalTime() < DateTime.UtcNow.AddDays(-1))
continue;
if (!string.IsNullOrWhiteSpace(recent.track?.id))
excludedIds.Add(recent.track.id);
string key = BuildTrackDedupeKey(recent.track);
if (!string.IsNullOrWhiteSpace(key))
excludedKeys.Add(key);
string titleKey = BuildTrackTitleKey(recent.track);
if (!string.IsNullOrWhiteSpace(titleKey))
excludedTitles.Add(titleKey);
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
}
var output = new List<MusicTrack>();
var outputIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var outputKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var outputTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// «шумные» слова допустимы в кандидате, только если были в самом сиде
// (слушаешь remix — получай remix, иначе это мусор)
var seedNoisyWords = CollectSeedNoisyWords(seeds);
// script-affinity включаем только когда ВЕСЬ сид-контекст кириллический:
// при Any смешанная очередь (Eminem + Miyagi) резала бы латинские
// кандидаты целиком, включая related-пул латинского сида
bool requireCyrillic = seeds.Count > 0
&& seeds.All(seed => ContainsCyrillic(seed?.title) || ContainsCyrillic(seed?.artist_name));
// слой 1: рекомендации SoundCloud ПО ТРЕКУ (related/station) — живое
// радио вместо «топ треков артиста»; artist-пулы остаются как backfill
var relatedTasks = SoundCloudSupport.IsDiscoveryEnabled
? seeds.Take(3).Select(seed => LoadRelatedPoolAsync(seed, timeoutCts.Token)).ToList()
: new List<Task<List<MusicTrack>>>();
try
{
await Task.WhenAll(relatedTasks);
}
catch
{
}
static List<List<MusicTrack>> CollectPools(IEnumerable<Task<List<MusicTrack>>> tasks) => tasks
.Where(task => task.IsCompletedSuccessfully)
.Select(task => task.Result ?? new List<MusicTrack>())
.Where(pool => pool.Count > 0)
.ToList();
FillRoundRobin(CollectPools(relatedTasks), output, limit, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles);
if (output.Count < limit)
{
// Backfill is intentionally lazy: if SoundCloud related/station gave
// enough tracks, do not spend requests on generic artist search.
var artistTasks = artists.Select(artist => LoadArtistPoolAsync(artist, timeoutCts.Token)).ToList();
try
{
await Task.WhenAll(artistTasks);
}
catch
{
}
FillRoundRobin(CollectPools(artistTasks), output, limit, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles);
}
return output.Count >= Math.Min(limit, 8)
? Available(output)
: Unavailable("Радио не нашло достаточно похожих треков.");
}
// round-robin по пулам: 1-й трек каждого пула, потом 2-е и т.д. — иначе
// первый пул целиком заполняет лимит и «радио» вырождается в один источник
// (internal: переиспользуется MusicDailyMixService для «Миксов недели»)
internal static void FillRoundRobin(
List<List<MusicTrack>> pools,
List<MusicTrack> output,
int limit,
HashSet<string> seedNoisyWords,
bool requireCyrillic,
HashSet<string> excludedIds,
HashSet<string> excludedKeys,
HashSet<string> excludedTitles,
HashSet<string> outputIds,
HashSet<string> outputKeys,
HashSet<string> outputTitles,
Dictionary<string, int> outputArtistCounts = null,
int maxTracksPerArtist = 0)
{
for (int position = 0; output.Count < limit; position++)
{
bool anyLeft = false;
foreach (var pool in pools)
{
if (position >= pool.Count)
continue;
anyLeft = true;
var candidate = NormalizeCandidateMetadata(pool[position]);
if (maxTracksPerArtist > 0
&& outputArtistCounts != null
&& !CanAddArtistCandidate(candidate, outputArtistCounts, maxTracksPerArtist))
{
continue;
}
if (!AcceptCandidate(candidate, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles))
continue;
candidate.auto_radio = true;
output.Add(candidate);
AddArtistCandidate(candidate, outputArtistCounts);
if (output.Count >= limit)
break;
}
if (!anyLeft)
break;
}
}
static bool CanAddArtistCandidate(MusicTrack track, Dictionary<string, int> counts, int maxTracksPerArtist)
{
string key = BuildArtistBucketKey(track);
if (string.IsNullOrWhiteSpace(key))
return true;
return !counts.TryGetValue(key, out int count) || count < maxTracksPerArtist;
}
static void AddArtistCandidate(MusicTrack track, Dictionary<string, int> counts)
{
if (counts == null)
return;
string key = BuildArtistBucketKey(track);
if (string.IsNullOrWhiteSpace(key))
return;
counts[key] = counts.TryGetValue(key, out int count) ? count + 1 : 1;
}
static string BuildArtistBucketKey(MusicTrack track)
{
// Структурированный список хранит primary artist первым. Иначе
// "Artist, Guest" и "Artist" попадают в разные bucket'ы и обходят artist-cap.
string artist = CleanupArtistName(track?.artists?.FirstOrDefault());
if (string.IsNullOrWhiteSpace(artist))
artist = CleanupArtistName(track?.artist_name);
return string.IsNullOrWhiteSpace(artist)
? string.Empty
: MusicMapSupport.NormalizeName(artist);
}
static MusicRadioResponse Available(List<MusicTrack> tracks) => new()
{
available = true,
tracks = tracks ?? new List<MusicTrack>()
};
static MusicRadioResponse Unavailable(string message) => new()
{
available = false,
message = message
};
static List<MusicTrack> NormalizeTrackList(IEnumerable<MusicTrack> tracks)
{
var result = new List<MusicTrack>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var track in tracks ?? Enumerable.Empty<MusicTrack>())
{
if (track == null)
continue;
string id = (track.id ?? string.Empty).Trim();
string key = !string.IsNullOrWhiteSpace(id)
? id
: BuildTrackDedupeKey(track);
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
continue;
if (!string.IsNullOrWhiteSpace(id))
track.id = id;
result.Add(track);
}
return result;
}
static List<string> ExtractSeedArtists(IEnumerable<MusicTrack> seeds)
{
var artists = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var track in seeds ?? Enumerable.Empty<MusicTrack>())
{
foreach (var artist in EnumerateTrackArtists(track))
{
string value = CleanupArtistName(artist);
if (string.IsNullOrWhiteSpace(value) || !seen.Add(NormalizeText(value)))
continue;
artists.Add(value);
if (artists.Count >= maxSeedArtists)
return artists;
}
}
return artists;
}
static IEnumerable<string> EnumerateTrackArtists(MusicTrack track)
{
if (!string.IsNullOrWhiteSpace(track?.artist_name))
yield return track.artist_name;
foreach (var artist in track?.artists ?? new List<string>())
{
if (!string.IsNullOrWhiteSpace(artist))
yield return artist;
}
}
internal static string CleanupArtistName(string value)
{
value = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = Regex.Replace(value, @"\s+(feat\.?|ft\.?|featuring|with|vs\.?)\s+.*$", "", RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"\s+", " ").Trim();
return value;
}
internal static Task<List<MusicTrack>> LoadRelatedPoolAsync(MusicTrack seed, CancellationToken cancellationToken)
{
string artist = CleanupArtistName(seed?.artist_name);
string title = (seed?.title ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(artist) || string.IsNullOrWhiteSpace(title))
return Task.FromResult(new List<MusicTrack>());
string query = $"{artist} {title}";
return LoadCachedTracksAsync(
SoundCloudSupport.DiscoveryProviderId,
query,
token => SoundCloudSupport.FindRelatedTracksByQueryAsync(query, artist, title, maxPoolPerArtist, token),
cancellationToken,
cacheType: "radio_related",
cacheLimit: maxPoolPerArtist);
}
internal static async Task<List<MusicTrack>> LoadArtistPoolAsync(
string artist,
CancellationToken cancellationToken,
bool requireConfirmedArtist = false)
{
var tasks = new List<Task<List<MusicTrack>>>();
string cacheType = requireConfirmedArtist ? "radio_tracks_confirmed" : "radio_tracks";
if (YouTubeMusicSearchSupport.IsSearchEnabled)
tasks.Add(LoadCachedTracksAsync(
YouTubeMusicSearchSupport.ProviderId,
artist,
token => YouTubeMusicSearchSupport.SearchTracksByQueryAsync(
artist,
providerQueryLimit,
token,
artist,
requireConfirmedArtist),
cancellationToken,
cacheType));
if (SoundCloudSupport.IsDiscoveryEnabled)
tasks.Add(LoadCachedTracksAsync(
SoundCloudSupport.DiscoveryProviderId,
artist,
token => SoundCloudSupport.SearchTracksByQueryAsync(artist, providerQueryLimit, token),
cancellationToken,
cacheType));
if (tasks.Count == 0)
return new List<MusicTrack>();
try
{
await Task.WhenAll(tasks);
}
catch
{
}
var candidates = tasks
.Where(i => i.IsCompletedSuccessfully)
.SelectMany(i => i.Result ?? new List<MusicTrack>())
.Select(track => NormalizeArtistPoolCandidate(track, artist, !requireConfirmedArtist))
.Where(i => i != null)
.Take(maxPoolPerArtist)
.ToList();
if (requireConfirmedArtist && !candidates.Any(IsConfirmedExternalArtistEvidence))
return new List<MusicTrack>();
return candidates;
}
static bool IsConfirmedExternalArtistEvidence(MusicTrack track)
{
if ((track?.id ?? string.Empty).StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
return true;
return (track?.id ?? string.Empty).StartsWith("soundcloud:", StringComparison.OrdinalIgnoreCase)
&& MusicIsrc.Normalize(track.isrc) != null;
}
static MusicTrack NormalizeArtistPoolCandidate(MusicTrack track, string requestedArtist, bool allowTitlePrefix)
{
track = NormalizeCandidateMetadata(track);
string artist = CleanupArtistName(requestedArtist);
if (track == null || string.IsNullOrWhiteSpace(artist))
return track;
string requested = NormalizeText(artist);
string actual = NormalizeText(track.artist_name);
string title = NormalizeText(track.title);
if (string.IsNullOrWhiteSpace(requested))
return track;
if (!string.IsNullOrWhiteSpace(actual)
&& (actual == requested
|| actual.Contains(requested, StringComparison.OrdinalIgnoreCase)
|| (actual.Length >= 4 && requested.Contains(actual, StringComparison.OrdinalIgnoreCase))))
{
// SoundCloud/YouTube sometimes expose channel names like
// "032 stas mikhaylov"; if the target artist is clearly embedded,
// keep the candidate but display the canonical artist name.
if (actual != requested)
{
track.artist_name = artist;
track.artists = new List<string> { artist };
}
return track;
}
if (allowTitlePrefix
&& !string.IsNullOrWhiteSpace(title)
&& title.StartsWith(requested + " ", StringComparison.OrdinalIgnoreCase))
{
track.artist_name = artist;
track.artists = new List<string> { artist };
track.title = StripLeadingArtistFromTitle(track.title, artist);
return track;
}
return null;
}
static string StripLeadingArtistFromTitle(string title, string artist)
{
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
return title;
string pattern = @"^\s*" + Regex.Escape(artist.Trim()) + @"\s*(?:[-–—:]|&)?\s*";
string stripped = Regex.Replace(title, pattern, "", RegexOptions.IgnoreCase).Trim();
return string.IsNullOrWhiteSpace(stripped) ? title : stripped;
}
static async Task<List<MusicTrack>> LoadCachedTracksAsync(string providerId, string query, Func<CancellationToken, Task<List<MusicTrack>>> factory, CancellationToken cancellationToken, string cacheType = "radio_tracks", int cacheLimit = providerQueryLimit)
{
try
{
string key = $"{cacheVersion}|{providerId}|{cacheLimit}|{NormalizeText(query)}";
return await MusicMetadataCacheService.GetOrCreateAsync(
providerId,
cacheType,
key,
searchCacheTtl,
() => factory(cancellationToken),
cancellationToken
) ?? new List<MusicTrack>();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new List<MusicTrack>();
}
}
// «шумные» слова в названии кандидата допустимы только если встречались
// в самих сидах: слушаешь remix/nightcore — радио вправе их предлагать
static readonly string[] conditionalNoisyWords =
{
"cover", "karaoke", "sped up", "slowed", "nightcore", "remix",
"8d audio", "bass boosted", "reverb", "edit", "mashup",
"type beat", "freestyle"
};
internal static HashSet<string> CollectSeedNoisyWords(IEnumerable<MusicTrack> seeds)
{
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var seed in seeds ?? Enumerable.Empty<MusicTrack>())
{
string padded = $" {NormalizeNoiseText(seed?.title)} ";
foreach (var word in conditionalNoisyWords)
{
if (padded.Contains($" {word} ", StringComparison.OrdinalIgnoreCase))
allowed.Add(word);
}
}
return allowed;
}
static bool AcceptCandidate(
MusicTrack track,
HashSet<string> seedNoisyWords,
bool requireCyrillic,
HashSet<string> excludedIds,
HashSet<string> excludedKeys,
HashSet<string> excludedTitles,
HashSet<string> outputIds,
HashSet<string> outputKeys,
HashSet<string> outputTitles)
{
if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(track.title))
return false;
string id = track.id.Trim();
string key = BuildTrackDedupeKey(track);
string titleKey = BuildTrackTitleKey(track);
if (!LooksLikeStandaloneSong(track, titleKey, seedNoisyWords, requireCyrillic))
return false;
if (excludedIds.Contains(id) || outputIds.Contains(id))
return false;
if (!string.IsNullOrWhiteSpace(key) && (excludedKeys.Contains(key) || outputKeys.Contains(key)))
return false;
if (!string.IsNullOrWhiteSpace(titleKey) && (excludedTitles.Contains(titleKey) || outputTitles.Contains(titleKey)))
return false;
outputIds.Add(id);
if (!string.IsNullOrWhiteSpace(key))
outputKeys.Add(key);
if (!string.IsNullOrWhiteSpace(titleKey))
outputTitles.Add(titleKey);
return true;
}
internal static MusicTrack NormalizeCandidateMetadata(MusicTrack track)
{
if (track == null)
return track;
// Provider/cache objects can be reused by radio and weekly mixes. Never
// rewrite those shared instances while preparing one response.
var result = CloneTrack(track);
result.title = CleanupCandidateTitle(result.title);
if (string.IsNullOrWhiteSpace(result.title)
|| !TrySplitArtistTitle(result.title, out var artist, out var title))
{
return result;
}
string currentArtist = CleanupArtistName(result.artist_name);
if (!string.IsNullOrWhiteSpace(currentArtist)
&& !IsGenericUploaderName(currentArtist)
&& !ArtistNamesMatch(currentArtist, artist))
{
// Structured provider metadata is more trustworthy than an
// unverified multi-dash title. Keeping it intact also preserves an
// exact provider id for playback instead of inventing a new pair.
return result;
}
result.artist_name = artist;
result.artists = new List<string> { artist };
result.title = CleanupCandidateTitle(title);
return result;
}
static bool TrySplitArtistTitle(string rawTitle, out string artist, out string title)
{
artist = null;
title = null;
var match = Regex.Match(
rawTitle ?? string.Empty,
@"^\s*(?<artist>.{2,80}?)\s+[-–—]\s+(?<title>.{2,180})\s*$");
if (!match.Success)
return false;
string artistPart = match.Groups["artist"].Value;
string titlePart = match.Groups["title"].Value;
artistPart = Regex.Replace(artistPart, @"^\s*[\d#._-]+\s*", "").Trim();
titlePart = Regex.Replace(titlePart, @"^\s*[\d#._-]+\s*", "").Trim();
if (string.IsNullOrWhiteSpace(artistPart) || string.IsNullOrWhiteSpace(titlePart))
return false;
if (NormalizeText(artistPart).Length < 2 || NormalizeText(titlePart).Length < 2)
return false;
if (artistPart.Length > 80 || titlePart.Length > 120)
return false;
artist = artistPart;
title = titlePart;
return true;
}
static bool ArtistNamesMatch(string left, string right)
{
string a = NormalizeText(CleanupArtistName(left));
string b = NormalizeText(CleanupArtistName(right));
return !string.IsNullOrWhiteSpace(a)
&& !string.IsNullOrWhiteSpace(b)
&& (a == b
|| (a.Length >= 4 && b.Contains(a, StringComparison.Ordinal))
|| (b.Length >= 4 && a.Contains(b, StringComparison.Ordinal)));
}
static bool IsGenericUploaderName(string value)
{
string normalized = NormalizeText(value);
return normalized is "soundcloud" or "youtube" or "unknown" or "various artists";
}
static string CleanupCandidateTitle(string value)
{
value = Regex.Replace(value ?? string.Empty, @"\s+", " ").Trim();
if (string.IsNullOrWhiteSpace(value))
return value;
bool filename = Regex.IsMatch(value, @"\.(aac|flac|m4a|mp3|ogg|opus|wav|webm)\s*$", RegexOptions.IgnoreCase);
if (!filename)
return value;
value = Regex.Replace(value, @"\.(aac|flac|m4a|mp3|ogg|opus|wav|webm)\s*$", string.Empty, RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"_+", " ");
value = Regex.Replace(value, @"^\s*[-–—.]+\s*|\s*[-–—.]+\s*$", string.Empty);
return Regex.Replace(value, @"\s+", " ").Trim();
}
static MusicTrack CloneTrack(MusicTrack track)
{
return new MusicTrack
{
id = track.id,
title = track.title,
artist_id = track.artist_id,
artist_name = track.artist_name,
artists = track.artists?.ToList() ?? new List<string>(),
album_id = track.album_id,
album_title = track.album_title,
isrc = track.isrc,
duration_ms = track.duration_ms,
track_number = track.track_number,
disc_number = track.disc_number,
date = track.date,
search_score = track.search_score,
images = track.images?.ToList() ?? new List<MusicImage>(),
provider_refs = track.provider_refs?.ToList() ?? new List<MusicProviderRef>(),
auto_radio = track.auto_radio
};
}
static bool LooksLikeStandaloneSong(MusicTrack track, string titleKey, HashSet<string> seedNoisyWords, bool requireCyrillic)
{
int durationMs = track.duration_ms ?? 0;
if (durationMs > 0 && (durationMs < 45_000 || durationMs > 12 * 60_000))
return false;
if (string.IsNullOrWhiteSpace(titleKey))
return false;
string padded = $" {titleKey} ";
string noisePadded = $" {NormalizeNoiseText(track?.title)} ";
if (requireCyrillic && !ContainsCyrillic(track?.title) && !ContainsCyrillic(track?.artist_name))
return false;
string[] noisy =
{
" nonstop ",
" megamix ",
" full album ",
" playlist ",
" reaction ",
" podcast ",
" interview ",
" instrumental beat ",
" dj set ",
" live set ",
" preview ",
" prod "
};
if (noisy.Any(word => padded.Contains(word, StringComparison.OrdinalIgnoreCase) || noisePadded.Contains(word, StringComparison.OrdinalIgnoreCase)))
return false;
foreach (var word in conditionalNoisyWords)
{
if (seedNoisyWords != null && seedNoisyWords.Contains(word))
continue;
if (noisePadded.Contains($" {word} ", StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
internal static bool ContainsCyrillic(string value)
=> !string.IsNullOrWhiteSpace(value) && value.Any(c => c is >= '\u0400' and <= '\u04FF');
static string BuildTrackDedupeKey(MusicTrack track)
{
if (track == null)
return string.Empty;
string artist = NormalizeText(track.artist_name);
string title = BuildTrackTitleKey(track);
return string.IsNullOrWhiteSpace(title)
? string.Empty
: $"{artist}|{title}";
}
static string BuildTrackTitleKey(MusicTrack track)
{
string rawTitle = track?.title ?? string.Empty;
rawTitle = Regex.Replace(rawTitle, @"^\s*.{1,80}\s+[-–—]\s+", "", RegexOptions.IgnoreCase);
string title = NormalizeText(rawTitle);
string artist = NormalizeText(track?.artist_name);
if (!string.IsNullOrWhiteSpace(artist) && title.StartsWith(artist + " ", StringComparison.OrdinalIgnoreCase))
title = title.Substring(artist.Length).Trim();
foreach (var artistName in track?.artists ?? new List<string>())
{
string normalizedArtist = NormalizeText(artistName);
if (!string.IsNullOrWhiteSpace(normalizedArtist) && title.StartsWith(normalizedArtist + " ", StringComparison.OrdinalIgnoreCase))
title = title.Substring(normalizedArtist.Length).Trim();
}
return title;
}
internal static string NormalizeText(string value)
{
value = RemoveDiacritics((value ?? string.Empty).ToLowerInvariant());
value = Regex.Replace(value, @"\([^)]*\)|\[[^\]]*\]", " ");
value = Regex.Replace(value, @"\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b", " ", RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"[^a-z0-9а-яёіїєґ]+", " ");
return Regex.Replace(value, @"\s+", " ").Trim();
}
static string NormalizeNoiseText(string value)
{
value = RemoveDiacritics((value ?? string.Empty).ToLowerInvariant());
value = Regex.Replace(value, @"\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b", " ", RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"[^a-z0-9а-яёіїєґ]+", " ");
return Regex.Replace(value, @"\s+", " ").Trim();
}
static string RemoveDiacritics(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var c in normalized)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)
builder.Append(c);
}
return builder.ToString().Normalize(NormalizationForm.FormC);
}
}
@@ -1,493 +0,0 @@
using System.Collections.Concurrent;
namespace Music;
public static class MusicResolver
{
// Кэш stream-sources (только youtubeaudio): манифест YouTube стоил ~1-1.5s
// на КАЖДЫЙ тёплый /music/play, хотя googlevideo-ссылки живут часами.
// TTL 30min — с запасом короче срока жизни ссылок. У Sefon/SoundCloud свои
// (короткие/сессионные) сроки жизни — их не кэшируем. Ошибки и пустые
// списки не кэшируются. Хранится и отдаётся ГЛУБОКИМИ КОПИЯМИ:
// PrepareStreamSources мутирует source.url под хост запроса — общий
// инстанс запёк бы хост первого клиента (урок отравления image-proxy).
// Инвалидация: refreshSources=true (пере-резолв из /music/stream после
// смерти upstream-ссылки в relay) пропускает чтение и перезаписывает свежим.
static readonly TimeSpan sourcesCacheTtl = TimeSpan.FromMinutes(30);
const int sourcesCacheCapacity = 512;
static readonly ConcurrentDictionary<string, (DateTime at, List<MusicPlaybackSource> sources)> sourcesCache = new(StringComparer.Ordinal);
// кап жёсткий: чтение lock-free, но trim + вставка под общим lock, иначе
// параллельные запросы одновременно пройдут проверку Count и вместе её пробьют
static readonly object sourcesCacheWriteLock = new();
public static async Task<MusicPlayResponse> ResolveTrackAsync(MusicTrack track, string provider = null, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default, bool refreshSources = false)
{
return await ResolveTrackCoreAsync(track, provider, playbackMode, profileId, preferSingleSource: false, refreshSources, cancellationToken);
}
public static async Task<MusicPlayResponse> ResolvePreferredTrackAsync(MusicTrack track, string provider = null, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
return await ResolveTrackCoreAsync(track, provider, playbackMode, profileId, preferSingleSource: true, refreshSources: false, cancellationToken);
}
static async Task<MusicPlayResponse> ResolveTrackCoreAsync(MusicTrack track, string provider, string playbackMode, string profileId, bool preferSingleSource, bool refreshSources, CancellationToken cancellationToken)
{
if (track == null)
{
return new MusicPlayResponse
{
available = false,
message = "Track not found."
};
}
var requestedProviders = GetProviderOrder(provider);
var requestedProviderIds = requestedProviders
.Select(i => i.Id)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var providers = ExpandProviderOrder(track, requestedProviders);
if (providers.Count == 0)
{
return new MusicPlayResponse
{
available = false,
message = "Audio provider not found.",
track_id = track.id
};
}
MusicAudioMatch firstMatch = null;
bool hadTransientFailure = false;
foreach (var sourceProvider in providers)
{
var providerTrack = requestedProviderIds.Contains(sourceProvider.Id)
? track
: BuildFallbackSearchTrack(track);
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
selectedMatch = NormalizeSelectedMatch(providerTrack, sourceProvider, selectedMatch);
// A durable manual choice is authoritative even when an older
// negative lookup for this provider is still cached.
if (selectedMatch?.pinned != true
&& sourceProvider.CacheMissingMatches
&& await MusicSourceMatchService.IsMarkedMissingAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken))
continue;
bool providerHadTransientFailure = false;
if (selectedMatch != null)
{
if (firstMatch == null)
firstMatch = selectedMatch;
List<MusicPlaybackSource> selectedSources;
try
{
selectedSources = await ResolveSourcesAsync(sourceProvider, selectedMatch, preferSingleSource, playbackMode, profileId, refreshSources, cancellationToken);
}
catch (MusicAudioTransientException ex)
{
providerHadTransientFailure = true;
hadTransientFailure = true;
Console.WriteLine($"[Music] {ex.Message}");
selectedSources = new List<MusicPlaybackSource>();
}
if (selectedSources.Count > 0)
{
return new MusicPlayResponse
{
available = true,
message = "ok",
track_id = track.id,
selected_match = selectedMatch,
sources = selectedSources.ToList()
};
}
}
IReadOnlyList<MusicAudioMatch> matches;
try
{
matches = await GetOrderedMatchesAsync(providerTrack, sourceProvider, selectedMatch, playbackMode, profileId, cancellationToken);
}
catch (MusicAudioTransientException ex)
{
providerHadTransientFailure = true;
hadTransientFailure = true;
Console.WriteLine($"[Music] {ex.Message}");
matches = Array.Empty<MusicAudioMatch>();
}
foreach (var match in matches)
{
if (selectedMatch != null && match.id == selectedMatch.id)
continue;
if (firstMatch == null)
firstMatch = match;
List<MusicPlaybackSource> sources;
try
{
sources = await ResolveSourcesAsync(sourceProvider, match, preferSingleSource, playbackMode, profileId, refreshSources, cancellationToken);
}
catch (MusicAudioTransientException ex)
{
providerHadTransientFailure = true;
hadTransientFailure = true;
Console.WriteLine($"[Music] {ex.Message}");
continue;
}
if (sources.Count == 0)
continue;
// временный сбой источников у пинованного матча не должен
// стирать ручной выбор — играем fallback без перезаписи
if (selectedMatch == null || !selectedMatch.pinned)
await MusicSourceMatchService.SaveAsync(track.id, match, playbackMode, cancellationToken);
return new MusicPlayResponse
{
available = true,
message = "ok",
track_id = track.id,
selected_match = match,
sources = sources.ToList()
};
}
if (sourceProvider.CacheMissingMatches && !providerHadTransientFailure)
await MusicSourceMatchService.MarkMissingAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
}
return new MusicPlayResponse
{
available = false,
reason = hadTransientFailure ? "transient" : "not_found",
message = hadTransientFailure ? "Audio source is temporarily unavailable." : "No audio source resolved yet.",
track_id = track.id,
selected_match = firstMatch,
sources = new List<MusicPlaybackSource>()
};
}
static async Task<List<MusicPlaybackSource>> ResolveSourcesAsync(IMusicAudioProvider provider, MusicAudioMatch match, bool preferSingleSource, string playbackMode, string profileId, bool refreshSources, CancellationToken cancellationToken)
{
if (provider == null || match == null)
return new List<MusicPlaybackSource>();
if (preferSingleSource)
{
var preferred = await provider.TryGetPreferredStreamAsync(match, playbackMode, profileId, cancellationToken);
if (!string.IsNullOrWhiteSpace(preferred?.url))
{
var preferredSources = new List<MusicPlaybackSource> { preferred };
MusicProxyService.ApplyStreamProxy(provider.Id, preferredSources);
return preferredSources;
}
}
bool cacheable = string.Equals(provider.Id, "youtubeaudio", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(match.id);
string cacheKey = cacheable
? BuildSourcesCacheKey(
provider.Id,
match.id,
playbackMode,
MusicProxyService.CurrentRouteKey(provider.Id, MusicProxyPurpose.Stream)
)
: null;
if (cacheable && !refreshSources
&& sourcesCache.TryGetValue(cacheKey, out var entry)
&& DateTime.UtcNow - entry.at < sourcesCacheTtl)
return CloneSources(entry.sources);
var sources = await provider.GetStreamsAsync(match, playbackMode, profileId, cancellationToken);
var result = sources?
.Where(source => !string.IsNullOrWhiteSpace(source?.url))
.ToList() ?? new List<MusicPlaybackSource>();
MusicProxyService.ApplyStreamProxy(provider.Id, result);
if (cacheable && result.Count > 0)
{
string resultCacheKey = BuildSourcesCacheKey(
provider.Id,
match.id,
playbackMode,
MusicProxyService.SourceRouteKey(result[0])
);
lock (sourcesCacheWriteLock)
{
if (!sourcesCache.ContainsKey(resultCacheKey) && sourcesCache.Count >= sourcesCacheCapacity)
{
// сначала протухшие; если их нет — вытесняем старейшие,
// иначе при равномерном трафике свежие записи переполнят кап
foreach (var stale in sourcesCache.Where(i => DateTime.UtcNow - i.Value.at >= sourcesCacheTtl).Select(i => i.Key).ToList())
sourcesCache.TryRemove(stale, out _);
while (sourcesCache.Count >= sourcesCacheCapacity)
{
var oldest = sourcesCache.OrderBy(i => i.Value.at).FirstOrDefault();
if (oldest.Key == null || !sourcesCache.TryRemove(oldest.Key, out _))
break;
}
}
sourcesCache[resultCacheKey] = (DateTime.UtcNow, CloneSources(result));
}
}
return result;
}
static string BuildSourcesCacheKey(string providerId, string matchId, string playbackMode, string routeKey)
=> $"{providerId}|{matchId}|{playbackMode ?? string.Empty}|route:{routeKey}|cfg:{MusicProxyService.ConfigurationVersion}";
static List<MusicPlaybackSource> CloneSources(List<MusicPlaybackSource> sources)
{
return sources.Select(source => new MusicPlaybackSource
{
provider_id = source.provider_id,
url = source.url,
external_url = source.external_url,
mime_type = source.mime_type,
bitrate = source.bitrate,
quality = source.quality,
headers = source.headers != null ? new Dictionary<string, string>(source.headers) : new(),
proxy_url = source.proxy_url,
proxy_username = source.proxy_username,
proxy_password = source.proxy_password,
proxy_scope = source.proxy_scope
}).ToList();
}
public static async Task<MusicMatchesResponse> GetMatchesAsync(MusicTrack track, string provider = null, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (track == null)
{
return new MusicMatchesResponse
{
available = false,
message = "Track not found."
};
}
var providers = GetProviderOrder(provider);
if (providers.Count == 0)
{
return new MusicMatchesResponse
{
available = false,
message = "Audio provider not found.",
track_id = track.id
};
}
foreach (var sourceProvider in providers)
{
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
selectedMatch = NormalizeSelectedMatch(track, sourceProvider, selectedMatch);
var matches = await GetOrderedMatchesAsync(track, sourceProvider, selectedMatch, playbackMode, profileId, cancellationToken);
if (matches.Count == 0)
continue;
return new MusicMatchesResponse
{
available = true,
message = "ok",
track_id = track.id,
selected_match = selectedMatch ?? matches.First(),
matches = matches.ToList()
};
}
return new MusicMatchesResponse
{
available = false,
message = "No audio matches resolved yet.",
track_id = track.id
};
}
// «Найти вручную»: матчи по сырому пользовательскому запросу вместо
// метаданных трека — последний рубеж для совсем битых метаданных
public static async Task<MusicMatchesResponse> GetMatchesByQueryAsync(MusicTrack track, string provider, string searchQuery, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (track == null || string.IsNullOrWhiteSpace(searchQuery))
{
return new MusicMatchesResponse
{
available = false,
message = "Query is empty.",
track_id = track?.id
};
}
foreach (var sourceProvider in GetProviderOrder(provider))
{
var matches = await SearchMatchesByQueryAsync(sourceProvider, searchQuery, cancellationToken);
if (matches.Count == 0)
continue;
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
selectedMatch = NormalizeSelectedMatch(track, sourceProvider, selectedMatch);
return new MusicMatchesResponse
{
available = true,
message = "ok",
track_id = track.id,
selected_match = selectedMatch,
matches = matches.ToList()
};
}
return new MusicMatchesResponse
{
available = false,
message = "No matches for query.",
track_id = track.id
};
}
static async Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(IMusicAudioProvider provider, string searchQuery, CancellationToken cancellationToken)
{
return provider == null || !provider.Enabled
? Array.Empty<MusicAudioMatch>()
: await provider.SearchMatchesByQueryAsync(searchQuery, cancellationToken);
}
public static async Task<bool> SelectMatchAsync(MusicTrack track, string provider, string matchId, string playbackMode = null, string profileId = null, string searchQuery = null, CancellationToken cancellationToken = default)
{
if (track == null || string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(matchId))
return false;
var sourceProvider = MusicProviderRegistry.GetAudioProvider(provider);
if (sourceProvider == null || !sourceProvider.Enabled)
return false;
IReadOnlyList<MusicAudioMatch> matches;
if (!string.IsNullOrWhiteSpace(searchQuery))
{
// кандидат из ручного поиска валидируется тем же запросом
matches = await SearchMatchesByQueryAsync(sourceProvider, searchQuery, cancellationToken);
}
else
{
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
selectedMatch = NormalizeSelectedMatch(track, sourceProvider, selectedMatch);
matches = await GetOrderedMatchesAsync(track, sourceProvider, selectedMatch, playbackMode, profileId, cancellationToken);
}
var match = matches.FirstOrDefault(i => i.id == matchId && i.provider_id == provider);
if (match == null)
return false;
match.pinned = true;
return await MusicSourceMatchService.SaveAsync(track.id, match, playbackMode, cancellationToken);
}
static List<IMusicAudioProvider> GetProviderOrder(string provider)
{
if (!string.IsNullOrWhiteSpace(provider))
{
var selected = MusicProviderRegistry.GetAudioProvider(provider);
return selected == null ? new List<IMusicAudioProvider>() : new List<IMusicAudioProvider> { selected };
}
var ordered = new List<IMusicAudioProvider>();
var preferred = MusicProviderRegistry.GetAudioProvider();
if (preferred != null)
ordered.Add(preferred);
ordered.AddRange(MusicProviderRegistry.AudioProviders.Where(i => i.Enabled && ordered.All(x => x.Id != i.Id)));
return ordered;
}
static List<IMusicAudioProvider> ExpandProviderOrder(MusicTrack track, List<IMusicAudioProvider> providers)
{
if (track == null || providers == null || providers.Count == 0)
return providers;
var expanded = providers.ToList();
foreach (var provider in providers)
{
foreach (var fallbackId in provider.GetFallbackProviderIds(track) ?? Array.Empty<string>())
{
var fallback = MusicProviderRegistry.GetAudioProvider(fallbackId);
if (fallback == null || !fallback.Enabled || expanded.Any(i => string.Equals(i.Id, fallback.Id, StringComparison.OrdinalIgnoreCase)))
continue;
expanded.Add(fallback);
}
}
return expanded;
}
static MusicTrack BuildFallbackSearchTrack(MusicTrack track)
{
if (track == null)
return null;
return new MusicTrack
{
title = track.title,
artist_id = track.artist_id,
artist_name = track.artist_name,
artists = track.artists?.ToList() ?? new List<string>(),
album_id = track.album_id,
album_title = track.album_title,
isrc = track.isrc,
duration_ms = track.duration_ms,
track_number = track.track_number,
disc_number = track.disc_number,
date = track.date,
search_score = track.search_score,
images = track.images?.ToList() ?? new List<MusicImage>()
};
}
static async Task<IReadOnlyList<MusicAudioMatch>> GetOrderedMatchesAsync(MusicTrack track, IMusicAudioProvider provider, MusicAudioMatch selectedMatch, string playbackMode, string profileId, CancellationToken cancellationToken)
{
selectedMatch = NormalizeSelectedMatch(track, provider, selectedMatch);
var matches = (await provider.MatchTrackAsync(track, playbackMode, profileId, cancellationToken)).ToList();
if (selectedMatch == null)
return matches;
var ordered = new List<MusicAudioMatch> { selectedMatch };
ordered.AddRange(matches.Where(i => i.id != selectedMatch.id));
return ordered;
}
static MusicAudioMatch NormalizeSelectedMatch(MusicTrack track, IMusicAudioProvider provider, MusicAudioMatch selectedMatch)
{
if (selectedMatch == null)
return null;
bool relevant = provider?.IsRelevantMatch(track, selectedMatch) == true;
if (!relevant && provider?.ShouldValidatePinnedMatch(track, selectedMatch) == true)
return null;
// ручной выбор пользователя авторитетен: эвристики релевантности написаны
// против протухших АВТО-матчей, а здесь метаданные трека могут быть битыми
// (пользователь ровно поэтому и выбирал источник вручную)
if (selectedMatch.pinned)
return selectedMatch;
return relevant ? selectedMatch : null;
}
}
@@ -1,258 +0,0 @@
using Microsoft.Data.Sqlite;
namespace Music;
/// <summary>
/// Дневная статистика прослушиваний (track_stats_daily). Счётчики отдельно
/// от витрины: payload трека живёт в playback_history и джойнится при чтении.
/// Инкремент вызывается ТОЛЬКО из честного play-пути (count_play=true в mark) —
/// обновления payload (смена источника и т.п.) статистику не трогают.
/// </summary>
public static class MusicStatsService
{
// unlock «Твоего топа»: 10 разных треков обязательны (иначе «альбом из
// трёх песен»), плюс либо фаворит на повторе, либо просто наслушан объём —
// fallback для тех, кто слушает широко и не гоняет один трек по кругу
const int UnlockTrackPlays = 10;
const int UnlockDistinctTracks = 10;
const int UnlockTotalPlays = 100;
public static Task IncrementPlayAsync(string profileId, MusicTrack track, CancellationToken cancellationToken = default)
=> RecordPlayAsync(profileId, track, true, null, cancellationToken);
public static async Task<int> ClearAsync(string profileId, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
if (string.IsNullOrWhiteSpace(profileId))
return 0;
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
var acquired = false;
try
{
if (!await semaphore.WaitAsync())
return 0;
acquired = true;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM track_stats_daily
WHERE profile_id = $profile_id;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
return await command.ExecuteNonQueryAsync(cancellationToken);
}
finally
{
if (acquired)
semaphore.Release();
}
}
public static async Task RecordPlayAsync(string profileId, MusicTrack track, bool countPlay, long? playedMs = null, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(profileId))
return;
long durationMs = Math.Max(0, track.duration_ms ?? 0);
long listenedMs = Math.Max(0, playedMs ?? 0);
// Старые клиенты присылали только count_play=true; для совместимости
// оставляем прежнюю оценку полной длительностью. Новый клиент досылает
// реальные куски played_ms, поэтому накрутка не превращается в часы.
if (listenedMs <= 0 && countPlay)
listenedMs = durationMs;
if (durationMs > 0 && listenedMs > durationMs)
listenedMs = durationMs;
// sanity для треков без duration: один mark не должен записать сутки.
listenedMs = Math.Min(listenedMs, TimeSpan.FromHours(6).Ticks / TimeSpan.TicksPerMillisecond);
if (!countPlay && listenedMs <= 0)
return;
var now = DateTime.UtcNow;
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
var acquired = false;
try
{
if (!await semaphore.WaitAsync())
return;
acquired = true;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = countPlay
? """
INSERT INTO track_stats_daily (profile_id, track_id, day, play_count, total_ms, last_played)
VALUES ($profile_id, $track_id, $day, 1, $total_ms, $last_played)
ON CONFLICT(profile_id, track_id, day) DO UPDATE SET
play_count = play_count + 1,
total_ms = total_ms + excluded.total_ms,
last_played = excluded.last_played;
"""
: """
UPDATE track_stats_daily
SET total_ms = total_ms + $total_ms,
last_played = $last_played
WHERE profile_id = $profile_id
AND track_id = $track_id
AND day = $day
AND play_count > 0;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$track_id", track.id);
command.Parameters.AddWithValue("$day", now.ToString("yyyy-MM-dd"));
command.Parameters.AddWithValue("$total_ms", listenedMs);
command.Parameters.AddWithValue("$last_played", now);
await command.ExecuteNonQueryAsync(cancellationToken);
}
finally
{
if (acquired)
semaphore.Release();
}
}
public static async Task<MusicStatsTopResult> GetTopAsync(string profileId, int limit = 30, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
var result = new MusicStatsTopResult();
if (string.IsNullOrWhiteSpace(profileId))
return result;
limit = Math.Clamp(limit, 1, 100);
var rows = new List<(string trackId, long plays, long totalMs, DateTime lastPlayed)>();
long totalPlays = 0;
long totalMs = 0;
long distinctTracks = 0;
await using (var connection = new SqliteConnection(MusicContext.ConnectionString))
{
await connection.OpenAsync(cancellationToken);
await using (var totals = connection.CreateCommand())
{
totals.CommandText = """
SELECT COALESCE(SUM(play_count), 0), COALESCE(SUM(total_ms), 0), COUNT(DISTINCT track_id)
FROM track_stats_daily
WHERE profile_id = $profile_id;
""";
totals.Parameters.AddWithValue("$profile_id", profileId);
await using var totalsReader = await totals.ExecuteReaderAsync(cancellationToken);
if (await totalsReader.ReadAsync(cancellationToken))
{
totalPlays = totalsReader.GetInt64(0);
totalMs = totalsReader.GetInt64(1);
distinctTracks = totalsReader.GetInt64(2);
}
}
await using (var top = connection.CreateCommand())
{
top.CommandText = """
SELECT track_id, SUM(play_count) AS plays, SUM(total_ms) AS ms, MAX(last_played) AS last
FROM track_stats_daily
WHERE profile_id = $profile_id
GROUP BY track_id
ORDER BY plays DESC, last DESC
LIMIT $limit;
""";
top.Parameters.AddWithValue("$profile_id", profileId);
top.Parameters.AddWithValue("$limit", limit);
await using var reader = await top.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var trackId = reader.IsDBNull(0) ? null : reader.GetString(0);
if (string.IsNullOrWhiteSpace(trackId))
continue;
rows.Add((
trackId,
reader.GetInt64(1),
reader.GetInt64(2),
reader.IsDBNull(3) ? DateTime.MinValue : reader.GetDateTime(3)
));
}
}
}
result.total_plays = totalPlays;
result.total_ms = totalMs;
long maxTrackPlays = rows.Count > 0 ? rows[0].plays : 0;
result.unlocked = distinctTracks >= UnlockDistinctTracks
&& (maxTrackPlays >= UnlockTrackPlays || totalPlays >= UnlockTotalPlays);
if (!result.unlocked || rows.Count == 0)
return result;
// витрина: payload треков из playback_history (mark всегда пишет туда же)
var payloads = await MusicPlaybackHistoryService.GetTracksAsync(profileId, rows.Select(i => i.trackId), cancellationToken);
foreach (var row in rows)
{
if (!payloads.TryGetValue(row.trackId, out var track) || track == null)
continue;
result.tracks.Add(new MusicStatsTopTrack
{
track = track,
play_count = row.plays,
total_ms = row.totalMs,
last_played = row.lastPlayed == DateTime.MinValue ? null : row.lastPlayed
});
}
result.available = result.tracks.Count > 0;
return result;
}
static string NormalizeProfileId(string profileId)
=> string.IsNullOrWhiteSpace(profileId) ? null : profileId.Trim().ToLowerInvariant();
}
public class MusicStatsTopResult
{
public bool available { get; set; }
public bool unlocked { get; set; }
public long total_plays { get; set; }
public long total_ms { get; set; }
public List<MusicStatsTopTrack> tracks { get; set; } = new();
}
public class MusicStatsTopTrack
{
public MusicTrack track { get; set; }
public long play_count { get; set; }
public long total_ms { get; set; }
public DateTime? last_played { get; set; }
}
@@ -1,78 +0,0 @@
namespace Music;
public static class MusicStreamModeService
{
public const string Auto = "auto";
public const string Best = "best";
public const string Low = "low";
public const string PreferM4a = "m4a";
public const string PreferWebm = "webm";
public static string Normalize(string mode)
{
mode = mode?.Trim().ToLowerInvariant();
return mode switch
{
Best => Best,
Low => Low,
PreferM4a => PreferM4a,
PreferWebm => PreferWebm,
_ => Auto
};
}
public static List<MusicPlaybackSource> Order(IEnumerable<MusicPlaybackSource> sources, string mode)
{
if (sources == null)
return new List<MusicPlaybackSource>();
mode = Normalize(mode);
var list = sources.Where(i => i != null && !string.IsNullOrWhiteSpace(i.url)).ToList();
return mode switch
{
Best => list
.OrderByDescending(GetBitrate)
.ThenByDescending(IsMp4Like)
.ToList(),
Low => list
.OrderBy(GetBitrate)
.ThenByDescending(IsMp4Like)
.ToList(),
PreferM4a => list
.OrderByDescending(IsMp4Like)
.ThenByDescending(GetBitrate)
.ToList(),
PreferWebm => list
.OrderByDescending(IsWebmLike)
.ThenByDescending(GetBitrate)
.ToList(),
_ => list
.OrderByDescending(IsMp4Like)
.ThenByDescending(GetBitrate)
.ToList()
};
}
static int GetBitrate(MusicPlaybackSource source)
=> source?.bitrate ?? 0;
static bool IsMp4Like(MusicPlaybackSource source)
{
string mime = source?.mime_type?.ToLowerInvariant() ?? string.Empty;
string quality = source?.quality?.ToLowerInvariant() ?? string.Empty;
return mime.Contains("mp4") || quality.Contains("m4a") || quality.Contains("mp4");
}
static bool IsWebmLike(MusicPlaybackSource source)
{
string mime = source?.mime_type?.ToLowerInvariant() ?? string.Empty;
string quality = source?.quality?.ToLowerInvariant() ?? string.Empty;
return mime.Contains("webm") || quality.Contains("webm");
}
}
@@ -1,282 +0,0 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
namespace Music;
public static class MusicStreamRelayService
{
static readonly HttpClient musicStreamClient = CreateMusicStreamClient();
static readonly System.Collections.Concurrent.ConcurrentDictionary<string, ProxyClientEntry> proxyClients = new();
static readonly TimeSpan proxyClientIdleTtl = TimeSpan.FromMinutes(30);
const int maxProxyClients = 128;
static readonly Timer proxyClientCleanupTimer = new(
_ => CleanupProxyClients(),
null,
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(5)
);
sealed class ProxyClientEntry
{
public ProxyClientEntry(HttpClient client)
{
Client = client;
LastUsedUtc = DateTime.UtcNow;
}
public readonly object Sync = new();
public HttpClient Client { get; }
public DateTime LastUsedUtc { get; set; }
public int ActiveRequests { get; set; }
public bool Retired { get; set; }
}
sealed class ClientLease : IDisposable
{
ProxyClientEntry entry;
public ClientLease(HttpClient client, ProxyClientEntry entry = null)
{
Client = client;
this.entry = entry;
}
public HttpClient Client { get; }
public void Dispose()
{
var current = Interlocked.Exchange(ref entry, null);
if (current == null)
return;
lock (current.Sync)
{
current.ActiveRequests = Math.Max(0, current.ActiveRequests - 1);
current.LastUsedUtc = DateTime.UtcNow;
}
}
}
// failOnUpstreamError: не транслировать клиенту мёртвую ссылку или ошибку
// прокси, а вернуть null — вызывающий пере-резолвит источник и маршрут.
public static async Task<ActionResult> RelayAsync(HttpContext httpContext, MusicPlaybackSource source, bool failOnUpstreamError = false)
{
if (httpContext == null || source == null || string.IsNullOrWhiteSpace(source.url))
return failOnUpstreamError ? null : new StatusCodeResult(404);
using var request = new HttpRequestMessage(HttpMethod.Get, source.url);
if (source.headers != null)
{
foreach (var header in source.headers)
{
if (string.IsNullOrWhiteSpace(header.Key) || string.IsNullOrWhiteSpace(header.Value))
continue;
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
if (httpContext.Request.Headers.TryGetValue("Range", out var range) && !string.IsNullOrWhiteSpace(range.ToString()))
request.Headers.TryAddWithoutValidation("Range", range.ToString());
using var clientLease = AcquireClient(source);
HttpClient client = clientLease.Client;
HttpResponseMessage response;
try
{
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, httpContext.RequestAborted);
}
catch (OperationCanceledException) when (httpContext.RequestAborted.IsCancellationRequested)
{
return new EmptyResult();
}
catch (Exception ex) when (failOnUpstreamError)
{
ReportProxyFailure(source, ex);
return null;
}
catch (Exception ex)
{
ReportProxyFailure(source, ex);
throw;
}
using (response)
{
bool proxyFailure = MusicHttp.IsProxyFailureStatus(response.StatusCode);
if (proxyFailure)
MusicProxyService.ReportFailure(source.proxy_scope);
else
MusicProxyService.ReportSuccess(source.proxy_scope);
if (failOnUpstreamError && (IsDeadUpstreamStatus(response.StatusCode) || proxyFailure))
return null;
httpContext.Response.StatusCode = (int)response.StatusCode;
httpContext.Response.Headers.Remove("transfer-encoding");
CopyResponseHeader(httpContext, "Accept-Ranges", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Content-Range", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Content-Length", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Content-Type", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Cache-Control", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Expires", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Last-Modified", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "ETag", response.Headers, response.Content.Headers);
if (!string.IsNullOrWhiteSpace(source.mime_type))
httpContext.Response.ContentType = source.mime_type;
try
{
await using var stream = await response.Content.ReadAsStreamAsync(httpContext.RequestAborted);
await stream.CopyToAsync(httpContext.Response.Body, httpContext.RequestAborted);
}
catch (OperationCanceledException)
{
// клиент ушёл (перемотка/стоп/смена трека) — штатный путь
}
catch (IOException)
{
// upstream оборвал середину стрима: рвём соединение, чтобы клиент
// сделал range-retry, а не принял обрыв за конец файла
httpContext.Abort();
}
return new EmptyResult();
}
}
static bool IsDeadUpstreamStatus(HttpStatusCode statusCode)
=> statusCode is HttpStatusCode.Forbidden
or HttpStatusCode.NotFound
or HttpStatusCode.Gone
or HttpStatusCode.RequestedRangeNotSatisfiable;
static void ReportProxyFailure(MusicPlaybackSource source, Exception exception)
{
if (MusicHttp.IsProxyFailure(exception))
MusicProxyService.ReportFailure(source?.proxy_scope);
}
static ClientLease AcquireClient(MusicPlaybackSource source)
{
var proxy = CreateMusicStreamProxy(source);
if (proxy == null)
return new ClientLease(musicStreamClient);
// клиент на прокси кэшируется: без этого каждый range-запрос платит
// новый TLS-хендшейк через прокси. Lease не даёт очистке закрыть
// HttpClient, пока через него идёт активный stream/range-запрос.
string key = $"{source.proxy_url}|{source.proxy_username}|{source.proxy_password}";
while (true)
{
var entry = proxyClients.GetOrAdd(key, _ => new ProxyClientEntry(CreateProxyClient(proxy)));
lock (entry.Sync)
{
if (entry.Retired)
continue;
entry.ActiveRequests++;
entry.LastUsedUtc = DateTime.UtcNow;
return new ClientLease(entry.Client, entry);
}
}
}
static HttpClient CreateProxyClient(WebProxy proxy)
{
var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
Proxy = proxy,
UseProxy = true
};
handler.ServerCertificateCustomValidationCallback += (_, _, _, _) => true;
return new HttpClient(handler)
{
Timeout = Timeout.InfiniteTimeSpan
};
}
static void CleanupProxyClients()
{
try
{
var now = DateTime.UtcNow;
foreach (var item in proxyClients.ToArray().OrderBy(i => i.Value.LastUsedUtc))
{
bool overLimit = proxyClients.Count > maxProxyClients;
bool expired = now - item.Value.LastUsedUtc >= proxyClientIdleTtl;
if (!overLimit && !expired)
continue;
HttpClient retiredClient = null;
lock (item.Value.Sync)
{
if (item.Value.Retired || item.Value.ActiveRequests > 0)
continue;
if (proxyClients.TryRemove(item.Key, out var removed) && ReferenceEquals(removed, item.Value))
{
item.Value.Retired = true;
retiredClient = item.Value.Client;
}
}
retiredClient?.Dispose();
}
}
catch
{
// Очистка кэша не должна влиять на активные стримы.
}
}
static HttpClient CreateMusicStreamClient()
{
var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli
};
handler.ServerCertificateCustomValidationCallback += (_, _, _, _) => true;
return new HttpClient(handler)
{
Timeout = Timeout.InfiniteTimeSpan
};
}
static void CopyResponseHeader(HttpContext httpContext, string headerName, HttpResponseHeaders responseHeaders, HttpContentHeaders contentHeaders)
{
if (responseHeaders.TryGetValues(headerName, out var headerValues) || contentHeaders.TryGetValues(headerName, out headerValues))
httpContext.Response.Headers[headerName] = headerValues.ToArray();
}
static WebProxy CreateMusicStreamProxy(MusicPlaybackSource source)
{
if (source == null || string.IsNullOrWhiteSpace(source.proxy_url))
return null;
if (!Uri.TryCreate(source.proxy_url, UriKind.Absolute, out var proxyUri))
return null;
NetworkCredential credentials = null;
if (!string.IsNullOrWhiteSpace(source.proxy_username))
credentials = new NetworkCredential(source.proxy_username, source.proxy_password ?? string.Empty);
return new WebProxy(proxyUri, false, null, credentials);
}
}
@@ -1,125 +0,0 @@
using System.Collections.Concurrent;
namespace Music;
public static class MusicStreamTicketService
{
sealed class TicketEntry
{
public MusicPlaybackSource source;
// Interlocked по long: touch идёт из параллельных range-запросов без lock
public long lastAccessTicks;
public DateTime absoluteExpireUtc;
}
// пауза на локскрине может длиться долго, поэтому тикет продлевается на каждом
// обращении (sliding) вместо жёстких 30 минут; absolute cap ограничен временем
// жизни upstream-ссылок (googlevideo и т.п.)
static readonly TimeSpan slidingLifetime = TimeSpan.FromHours(2);
static readonly TimeSpan absoluteLifetime = TimeSpan.FromHours(6);
// кап жёсткий. Create() обязан вернуть живой тикет, поэтому вытеснение самых
// старых неактивных записей выполняется ДО вставки под тем же lock:
// MemoryCache.SizeLimit так не умеет — мог бы не сохранить запись, хотя билет
// уже ушёл клиенту (тогда /music/stream платил бы лишним re-resolve)
const int maxTickets = 2048;
static readonly object writeLock = new();
static readonly ConcurrentDictionary<string, TicketEntry> tickets = new(StringComparer.Ordinal);
public static string Create(MusicPlaybackSource source)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
return null;
var now = DateTime.UtcNow;
string ticket = Guid.NewGuid().ToString("N");
lock (writeLock)
{
if (tickets.Count >= maxTickets)
TrimNoLock(now);
tickets[ticket] = new TicketEntry
{
source = Clone(source),
lastAccessTicks = now.Ticks,
absoluteExpireUtc = now.Add(absoluteLifetime)
};
}
return ticket;
}
public static bool TryGet(string ticket, out MusicPlaybackSource source)
{
source = null;
if (string.IsNullOrWhiteSpace(ticket))
return false;
if (!tickets.TryGetValue(ticket, out var entry) || entry == null)
return false;
var now = DateTime.UtcNow;
if (now > entry.absoluteExpireUtc || now.Ticks - Interlocked.Read(ref entry.lastAccessTicks) > slidingLifetime.Ticks)
{
// remove-if-same: не задеваем запись, если под этим ключом что-то пересоздали
tickets.TryRemove(new KeyValuePair<string, TicketEntry>(ticket, entry));
return false;
}
// sliding touch
Interlocked.Exchange(ref entry.lastAccessTicks, now.Ticks);
source = Clone(entry.source);
return true;
}
static void TrimNoLock(DateTime now)
{
long slidingCutoffTicks = now.Ticks - slidingLifetime.Ticks;
foreach (var item in tickets)
{
if (now > item.Value.absoluteExpireUtc || Interlocked.Read(ref item.Value.lastAccessTicks) < slidingCutoffTicks)
tickets.TryRemove(item.Key, out _);
}
while (tickets.Count >= maxTickets)
{
string oldestKey = null;
long oldestTicks = long.MaxValue;
foreach (var item in tickets)
{
long ticks = Interlocked.Read(ref item.Value.lastAccessTicks);
if (ticks < oldestTicks)
{
oldestTicks = ticks;
oldestKey = item.Key;
}
}
if (oldestKey == null || !tickets.TryRemove(oldestKey, out _))
break;
}
}
static MusicPlaybackSource Clone(MusicPlaybackSource source)
{
return new MusicPlaybackSource
{
provider_id = source.provider_id,
url = source.url,
external_url = source.external_url,
mime_type = source.mime_type,
bitrate = source.bitrate,
quality = source.quality,
headers = source.headers?.ToDictionary(k => k.Key, v => v.Value) ?? new Dictionary<string, string>(),
proxy_url = source.proxy_url,
proxy_username = source.proxy_username,
proxy_password = source.proxy_password,
proxy_scope = source.proxy_scope
};
}
}
@@ -1,77 +0,0 @@
using System.Text.Json;
namespace Music;
// Общие хелперы импортёров плейлистов (SoundCloud / Spotify / Apple Music).
//
// Контракт импорта: результат либо ПОЛНЫЙ, либо available=false с message —
// частично скачанный список сохранять нельзя (sync перезаписывает payload
// целиком, усечёнка молча потеряла бы треки пользователя). Единственное
// допустимое усечение — явный кап MaxImportTracks с truncated=true.
public static class MusicPlaylistImportHelpers
{
public static MusicUserPlaylistImportResult ImportUnavailable(string message)
=> new()
{
available = false,
message = message,
tracks = new List<MusicTrack>()
};
// дедуп по id с перенумерацией track_number по итоговому порядку
public static List<MusicTrack> DeduplicateTracks(IEnumerable<MusicTrack> tracks)
{
var result = new List<MusicTrack>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var track in tracks ?? Enumerable.Empty<MusicTrack>())
{
if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id))
continue;
track.track_number = result.Count + 1;
result.Add(track);
}
return result;
}
public static JsonElement? GetProperty(JsonElement element, params string[] path)
{
var current = element;
foreach (var name in path)
{
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(name, out var next) || next.ValueKind == JsonValueKind.Null)
return null;
current = next;
}
return current;
}
public static JsonElement? GetProperty(JsonElement? element, params string[] path)
=> element == null ? null : GetProperty(element.Value, path);
public static string GetString(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
return null;
return property.ValueKind == JsonValueKind.String ? property.GetString() : null;
}
public static int? GetInt(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
return null;
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out int value))
return value;
if (property.ValueKind == JsonValueKind.String && int.TryParse(property.GetString(), out int parsed))
return parsed;
return null;
}
}
@@ -1,637 +0,0 @@
using Microsoft.Data.Sqlite;
namespace Music;
// пользовательские плейлисты — durable state (намерение пользователя),
// скоуп per-profile как у playback_history; payload = JSON-массив MusicTrack,
// плейлист самодостаточен и не требует метадата-лукапов при открытии
public static class MusicUserPlaylistService
{
public static async Task<List<MusicUserPlaylistSummary>> ListAsync(string profileId, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfile(profileId);
var result = new List<MusicUserPlaylistSummary>();
try
{
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT playlist_id, title, payload, source
FROM user_playlists
WHERE profile_id = $profile_id
ORDER BY updated DESC;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var tracks = ParseTracks(reader.IsDBNull(2) ? null : reader.GetString(2));
result.Add(BuildSummary(
reader.GetString(0),
reader.GetString(1),
tracks,
ParseSource(reader.IsDBNull(3) ? null : reader.GetString(3))
));
}
}
catch (Exception ex)
{
Console.WriteLine($"[Music] playlists list failed: {ex.Message}");
}
return result;
}
public static async Task<MusicUserPlaylistSummary> GetSummaryAsync(string profileId, string playlistId, CancellationToken cancellationToken = default)
{
playlistId = playlistId?.Trim();
if (string.IsNullOrWhiteSpace(playlistId))
return null;
profileId = NormalizeProfile(profileId);
try
{
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT playlist_id, title, payload, source
FROM user_playlists
WHERE profile_id = $profile_id AND playlist_id = $playlist_id
LIMIT 1;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$playlist_id", playlistId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
return null;
var tracks = ParseTracks(reader.IsDBNull(2) ? null : reader.GetString(2));
return BuildSummary(
reader.GetString(0),
reader.GetString(1),
tracks,
ParseSource(reader.IsDBNull(3) ? null : reader.GetString(3))
);
}
catch (Exception ex)
{
Console.WriteLine($"[Music] playlist summary failed: {ex.Message}");
return null;
}
}
public static async Task<List<MusicTrack>> GetTracksAsync(string profileId, string playlistId, CancellationToken cancellationToken = default)
{
string payload = await ReadPayloadAsync(NormalizeProfile(profileId), playlistId?.Trim(), cancellationToken);
return ParseTracks(payload);
}
// треки ВСЕХ плейлистов профиля (свежие плейлисты первыми) — источник
// артистов для «Миксов недели»: у пользователя без истории, но с
// импортированным плейлистом, миксы собираются из его артистов
public static async Task<List<MusicTrack>> GetAllTracksAsync(string profileId, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfile(profileId);
var result = new List<MusicTrack>();
try
{
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT payload
FROM user_playlists
WHERE profile_id = $profile_id
ORDER BY updated DESC;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
result.AddRange(ParseTracks(reader.IsDBNull(0) ? null : reader.GetString(0)));
}
catch (Exception ex)
{
Console.WriteLine($"[Music] playlists all tracks failed: {ex.Message}");
}
return result;
}
public static async Task<string> CreateAsync(string profileId, string title, CancellationToken cancellationToken = default)
{
title = title?.Trim();
if (string.IsNullOrWhiteSpace(title))
return null;
profileId = NormalizeProfile(profileId);
string playlistId = Guid.NewGuid().ToString("N");
bool saved = await WriteAsync(async connection =>
{
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO user_playlists (profile_id, playlist_id, title, payload, updated)
VALUES ($profile_id, $playlist_id, $title, '[]', $updated);
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$playlist_id", playlistId);
command.Parameters.AddWithValue("$title", title);
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
return await command.ExecuteNonQueryAsync(cancellationToken) > 0;
}, cancellationToken);
return saved ? playlistId : null;
}
// импорт по ссылке: сервис определяется хостом (Spotify / Apple Music / SoundCloud),
// SoundCloud остаётся веткой по умолчанию (короткие on.soundcloud.com ссылки)
static Task<MusicUserPlaylistImportResult> ImportByUrlAsync(string url, CancellationToken cancellationToken)
{
if (SpotifySupport.CanHandleUrl(url))
return SpotifySupport.ImportPlaylistAsync(url, cancellationToken);
if (AppleMusicSupport.CanHandleUrl(url))
return AppleMusicSupport.ImportPlaylistAsync(url, cancellationToken);
return SoundCloudSupport.ImportPlaylistAsync(url, cancellationToken);
}
static Task<MusicUserPlaylistImportResult> ImportBySourceAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken)
{
if (IsSpotifySource(source))
return SpotifySupport.ImportPlaylistAsync(source, cancellationToken);
if (IsAppleMusicSource(source))
return AppleMusicSupport.ImportPlaylistAsync(source, cancellationToken);
return SoundCloudSupport.ImportPlaylistAsync(source, cancellationToken);
}
public static async Task<MusicUserPlaylistImportResult> ImportAsync(string profileId, string url, CancellationToken cancellationToken = default)
{
var imported = await ImportByUrlAsync(url, cancellationToken);
if (imported?.available != true || imported.tracks == null || imported.tracks.Count == 0 || imported.source == null)
return imported ?? new MusicUserPlaylistImportResult { available = false, message = "Импорт не удался." };
profileId = NormalizeProfile(profileId);
imported.title = string.IsNullOrWhiteSpace(imported.title) ? "Импорт" : imported.title.Trim();
imported.track_count = imported.tracks.Count;
string savedPlaylistId = null;
bool saved = await WriteAsync(async connection =>
{
savedPlaylistId = await FindSourcePlaylistIdAsync(connection, profileId, imported.source, cancellationToken)
?? Guid.NewGuid().ToString("N");
return await UpsertPlaylistAsync(connection, profileId, savedPlaylistId, imported.title, imported.tracks, imported.source, cancellationToken) > 0;
}, cancellationToken);
if (!saved)
{
imported.available = false;
imported.message = "Не удалось сохранить импортированный плейлист.";
return imported;
}
imported.playlist_id = savedPlaylistId;
return imported;
}
public static async Task<MusicUserPlaylistImportResult> SyncAsync(string profileId, string playlistId, CancellationToken cancellationToken = default)
{
playlistId = playlistId?.Trim();
profileId = NormalizeProfile(profileId);
if (string.IsNullOrWhiteSpace(playlistId))
return new MusicUserPlaylistImportResult { available = false, message = "Плейлист не выбран." };
var source = await GetSourceAsync(profileId, playlistId, cancellationToken);
if (source == null || !IsSyncableSource(source))
return new MusicUserPlaylistImportResult { available = false, playlist_id = playlistId, message = "У плейлиста нет источника для обновления." };
var imported = await ImportBySourceAsync(source, cancellationToken);
if (imported?.available != true || imported.tracks == null || imported.tracks.Count == 0)
return imported ?? new MusicUserPlaylistImportResult { available = false, playlist_id = playlistId, message = "Обновление не удалось." };
imported.playlist_id = playlistId;
imported.title = string.IsNullOrWhiteSpace(imported.title) ? source.title ?? "Импорт" : imported.title.Trim();
imported.track_count = imported.tracks.Count;
imported.source ??= source;
bool saved = await WriteAsync(async connection =>
{
// sync — слияние, а не зеркало: локальный порядок и ручные правки
// сохраняются, новые треки источника добавляются В НАЧАЛО
string currentPayload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken);
if (currentPayload == null)
return false; // Deleted while the source was being fetched.
var merged = MergeSyncedTracks(ParseTracks(currentPayload), imported.tracks);
imported.tracks = merged;
imported.track_count = merged.Count;
return await UpsertPlaylistAsync(connection, profileId, playlistId, imported.title, merged, imported.source, cancellationToken, updateOnly: true) > 0;
}, cancellationToken);
if (!saved)
{
imported.available = false;
imported.message = "Не удалось обновить плейлист.";
}
return imported;
}
static List<MusicTrack> MergeSyncedTracks(List<MusicTrack> local, List<MusicTrack> source)
{
source ??= new List<MusicTrack>();
if (local == null || local.Count == 0)
return source;
var localIds = new HashSet<string>(
local.Where(i => !string.IsNullOrWhiteSpace(i?.id)).Select(i => i.id),
StringComparer.Ordinal);
// новинки источника — наверх, в порядке источника
var fresh = source
.Where(i => !string.IsNullOrWhiteSpace(i?.id) && !localIds.Contains(i.id))
.ToList();
// существующие треки остаются в локальном порядке, но метаданные
// обновляются свежими из источника (обложки, длительность и т.п.);
// пропавшие из источника и добавленные вручную — не удаляются
var sourceById = new Dictionary<string, MusicTrack>(StringComparer.Ordinal);
foreach (var track in source)
{
if (!string.IsNullOrWhiteSpace(track?.id))
sourceById.TryAdd(track.id, track);
}
var merged = new List<MusicTrack>(fresh.Count + local.Count);
merged.AddRange(fresh);
foreach (var track in local)
{
if (track == null)
continue;
merged.Add(!string.IsNullOrWhiteSpace(track.id) && sourceById.TryGetValue(track.id, out var updated)
? updated
: track);
}
return merged;
}
public static Task<bool> DeleteAsync(string profileId, string playlistId, CancellationToken cancellationToken = default)
{
playlistId = playlistId?.Trim();
if (string.IsNullOrWhiteSpace(playlistId))
return Task.FromResult(false);
profileId = NormalizeProfile(profileId);
return WriteAsync(async connection =>
{
await using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM user_playlists
WHERE profile_id = $profile_id AND playlist_id = $playlist_id;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$playlist_id", playlistId);
return await command.ExecuteNonQueryAsync(cancellationToken) > 0;
}, cancellationToken);
}
public static async Task<bool> AddTrackAsync(string profileId, string playlistId, MusicTrack track, CancellationToken cancellationToken = default)
{
playlistId = playlistId?.Trim();
if (string.IsNullOrWhiteSpace(playlistId) || track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(track.title))
return false;
profileId = NormalizeProfile(profileId);
return await WriteAsync(async connection =>
{
string payload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken);
if (payload == null)
return false;
var tracks = ParseTracks(payload);
if (tracks.Any(i => i != null && string.Equals(i.id, track.id, StringComparison.Ordinal)))
return true;
tracks.Add(track);
return await UpdatePayloadAsync(connection, profileId, playlistId, tracks, cancellationToken) > 0;
}, cancellationToken);
}
public static async Task<bool> RemoveTrackAsync(string profileId, string playlistId, string trackId, CancellationToken cancellationToken = default)
{
playlistId = playlistId?.Trim();
trackId = trackId?.Trim();
if (string.IsNullOrWhiteSpace(playlistId) || string.IsNullOrWhiteSpace(trackId))
return false;
profileId = NormalizeProfile(profileId);
return await WriteAsync(async connection =>
{
string payload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken);
if (payload == null)
return false;
var tracks = ParseTracks(payload);
var next = tracks.Where(i => i != null && !string.Equals(i.id, trackId, StringComparison.Ordinal)).ToList();
if (next.Count == tracks.Count)
return false;
return await UpdatePayloadAsync(connection, profileId, playlistId, next, cancellationToken) > 0;
}, cancellationToken);
}
public static async Task<bool> MoveTrackAsync(string profileId, string playlistId, string trackId, int position, CancellationToken cancellationToken = default)
{
playlistId = playlistId?.Trim();
trackId = trackId?.Trim();
if (string.IsNullOrWhiteSpace(playlistId) || string.IsNullOrWhiteSpace(trackId))
return false;
profileId = NormalizeProfile(profileId);
return await WriteAsync(async connection =>
{
string payload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken);
if (payload == null)
return false;
var tracks = ParseTracks(payload);
int index = tracks.FindIndex(i => i != null && string.Equals(i.id, trackId, StringComparison.Ordinal));
if (index < 0)
return false;
int target = Math.Clamp(position, 0, tracks.Count - 1);
if (target == index)
return true;
var track = tracks[index];
tracks.RemoveAt(index);
tracks.Insert(target, track);
return await UpdatePayloadAsync(connection, profileId, playlistId, tracks, cancellationToken) > 0;
}, cancellationToken);
}
static async Task<int> UpdatePayloadAsync(SqliteConnection connection, string profileId, string playlistId, List<MusicTrack> tracks, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = """
UPDATE user_playlists
SET payload = $payload, updated = $updated
WHERE profile_id = $profile_id AND playlist_id = $playlist_id;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$playlist_id", playlistId);
command.Parameters.AddWithValue("$payload", MusicJson.Serialize(tracks));
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
return await command.ExecuteNonQueryAsync(cancellationToken);
}
static async Task<int> UpsertPlaylistAsync(SqliteConnection connection, string profileId, string playlistId, string title, List<MusicTrack> tracks, MusicUserPlaylistSource source, CancellationToken cancellationToken, bool updateOnly = false)
{
await using var command = connection.CreateCommand();
command.CommandText = updateOnly ? """
UPDATE user_playlists
SET title = $title, payload = $payload, source = $source, updated = $updated
WHERE profile_id = $profile_id AND playlist_id = $playlist_id;
""" : """
INSERT INTO user_playlists (profile_id, playlist_id, title, payload, source, updated)
VALUES ($profile_id, $playlist_id, $title, $payload, $source, $updated)
ON CONFLICT(profile_id, playlist_id) DO UPDATE SET
title = excluded.title,
payload = excluded.payload,
source = excluded.source,
updated = excluded.updated;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$playlist_id", playlistId);
command.Parameters.AddWithValue("$title", title);
command.Parameters.AddWithValue("$payload", MusicJson.Serialize(tracks ?? new List<MusicTrack>()));
command.Parameters.AddWithValue("$source", SerializeSource(source));
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
return await command.ExecuteNonQueryAsync(cancellationToken);
}
static async Task<string> ReadPayloadAsync(string profileId, string playlistId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(playlistId))
return null;
try
{
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT payload
FROM user_playlists
WHERE profile_id = $profile_id AND playlist_id = $playlist_id
LIMIT 1;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$playlist_id", playlistId);
return await command.ExecuteScalarAsync(cancellationToken) as string;
}
catch (Exception ex)
{
Console.WriteLine($"[Music] playlist read failed: {ex.Message}");
return null;
}
}
static async Task<string> ReadPayloadAsync(SqliteConnection connection, string profileId, string playlistId, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT payload
FROM user_playlists
WHERE profile_id = $profile_id AND playlist_id = $playlist_id
LIMIT 1;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$playlist_id", playlistId);
return await command.ExecuteScalarAsync(cancellationToken) as string;
}
static async Task<MusicUserPlaylistSource> GetSourceAsync(string profileId, string playlistId, CancellationToken cancellationToken)
{
try
{
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT source
FROM user_playlists
WHERE profile_id = $profile_id AND playlist_id = $playlist_id
LIMIT 1;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$playlist_id", playlistId);
return ParseSource(await command.ExecuteScalarAsync(cancellationToken) as string);
}
catch (Exception ex)
{
Console.WriteLine($"[Music] playlist source read failed: {ex.Message}");
return null;
}
}
static async Task<string> FindSourcePlaylistIdAsync(SqliteConnection connection, string profileId, MusicUserPlaylistSource source, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT playlist_id, source
FROM user_playlists
WHERE profile_id = $profile_id AND source <> '';
""";
command.Parameters.AddWithValue("$profile_id", profileId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var existing = ParseSource(reader.IsDBNull(1) ? null : reader.GetString(1));
if (IsSameSource(existing, source))
return reader.GetString(0);
}
return null;
}
static async Task<bool> WriteAsync(Func<SqliteConnection, Task<bool>> operation, CancellationToken cancellationToken)
{
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
bool acquired = false;
try
{
if (!await semaphore.WaitAsync())
{
Console.WriteLine("[Music] playlist write skipped: db semaphore timeout");
return false;
}
acquired = true;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
return await operation(connection);
}
catch (Exception ex)
{
Console.WriteLine($"[Music] playlist write failed: {ex.Message}");
return false;
}
finally
{
if (acquired)
semaphore.Release();
}
}
static List<MusicTrack> ParseTracks(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
return new List<MusicTrack>();
try
{
return MusicJson.Deserialize<List<MusicTrack>>(payload) ?? new List<MusicTrack>();
}
catch
{
return new List<MusicTrack>();
}
}
static MusicUserPlaylistSummary BuildSummary(string playlistId, string title, List<MusicTrack> tracks, MusicUserPlaylistSource source)
{
tracks ??= new List<MusicTrack>();
return new MusicUserPlaylistSummary
{
id = playlistId,
title = title,
track_count = tracks.Count,
images = tracks.FirstOrDefault(i => i?.images != null && i.images.Count > 0)?.images ?? new List<MusicImage>(),
source = source
};
}
static MusicUserPlaylistSource ParseSource(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
return null;
try
{
return MusicJson.Deserialize<MusicUserPlaylistSource>(payload);
}
catch
{
return null;
}
}
static string SerializeSource(MusicUserPlaylistSource source)
=> source == null || string.IsNullOrWhiteSpace(source.type) ? string.Empty : MusicJson.Serialize(source);
static bool IsSoundCloudSource(MusicUserPlaylistSource source)
=> source != null && !string.IsNullOrWhiteSpace(source.type) && source.type.StartsWith("soundcloud_", StringComparison.OrdinalIgnoreCase);
static bool IsSpotifySource(MusicUserPlaylistSource source)
=> source != null && !string.IsNullOrWhiteSpace(source.type) && source.type.StartsWith("spotify_", StringComparison.OrdinalIgnoreCase);
static bool IsAppleMusicSource(MusicUserPlaylistSource source)
=> source != null && !string.IsNullOrWhiteSpace(source.type) && source.type.StartsWith("applemusic_", StringComparison.OrdinalIgnoreCase);
static bool IsSyncableSource(MusicUserPlaylistSource source)
=> IsSoundCloudSource(source) || IsSpotifySource(source) || IsAppleMusicSource(source);
static bool IsSameSource(MusicUserPlaylistSource left, MusicUserPlaylistSource right)
{
if (left == null || right == null)
return false;
if (!string.Equals(left.type, right.type, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrWhiteSpace(left.user_id) || !string.IsNullOrWhiteSpace(right.user_id))
return string.Equals(left.user_id, right.user_id, StringComparison.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(left.playlist_id) || !string.IsNullOrWhiteSpace(right.playlist_id))
return string.Equals(left.playlist_id, right.playlist_id, StringComparison.OrdinalIgnoreCase);
return string.Equals(left.url, right.url, StringComparison.OrdinalIgnoreCase);
}
static string NormalizeProfile(string profileId)
=> string.IsNullOrWhiteSpace(profileId) ? "global" : profileId.Trim().ToLowerInvariant();
}
-17
View File
@@ -1,17 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Music;
public static class MusicJson
{
static readonly JsonSerializerOptions options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, options);
public static T Deserialize<T>(string value) => JsonSerializer.Deserialize<T>(value, options);
}
-158
View File
@@ -1,158 +0,0 @@
# Архитектура клиента (plugin.js)
Этот документ — карта для разработчика, открывающего [plugin.js](../plugin.js) впервые. Серверная сторона описана в [README.md](../README.md);
## Почему монолит
Весь клиент — один файл, который сервер отдаёт как `/music.js` (с подстановкой `{localhost}`, `{client_debug_enabled}`, `{stats_clear_enabled}` и `{daily_reset_enabled}` при отдаче). Это осознанное решение: файл монтируется в контейнер живьём, правка не требует пересборки, а Lampa загружает плагин одним запросом. Плата — размер (~17 тыс. строк), поэтому навигация построена на двух уровнях маркеров:
- `// ===== ИМЯ =====` — 14 крупных секций (список в шапке файла);
- `// --- имя ---` — подсекции внутри больших секций.
Шапка файла содержит карту секций, точки входа, легенду именований и typedef основных DTO — начинать чтение оттуда.
## Поток данных
### Главный экран
```
/music/home ──> MUSIC_HOME_CACHE (снапшот секций)
│
├── локальные полки из Lampa.Storage: недавние
│ поиски/альбомы/артисты, закладки, «Мои плейлисты»
└── discovery-полки сервера (Apple Music, VK Top, SoundCloud)
карточка = entry (mapTrackCard / mapAlbumCard / mapArtistCard / mapPlaylistCard)
«Ещё» ──> /music/section?id=... (полный список, пагинация)
```
Recent-полки живут в `Lampa.Storage` и синхронизируются между экранами событием `MUSIC_RECENT_EVENT` — компоненты подписываются через `bindRecentListener` и обновляют только затронутую секцию, не перерисовывая home целиком.
После рестарта сервера discovery-провайдеры могут не успеть в 2-секундный бюджет `/music/home` — сервер тогда ставит `browse_sections_warming: true`, а клиент тихо дозапрашивает home (3s → 5s → 8s) и **доклеивает** недостающие полки в конец без перерисовки и потери фокуса (`homeLoadToken` гейтит устаревшие таймеры).
### Запуск трека
```
тап по треку
└─> warmupStandaloneIosGesture() — синхронно В ЖЕСТЕ (только player=ios)
└─> /music/play?... — резолв источника на сервере
└─> { track, selected_match, sources[] }
└─> pickPlaybackSource(sources) — m4a-first для ios-пути
└─> buildResolvedPlayback() — playback-объект
├─ player=ios → startStandaloneIosAudioPlayback (свой <audio>)
└─ player=inner → Lampa.Player.play (штатный плеер)
```
Все stream-URL ведут на серверный relay `/music/stream` — клиент не ходит на CDN напрямую.
### Прифетч-слой
Холодный резолв `/music/play` стоит секунды (YouTube-поиск + матчинг на сервере), поэтому кэш (`PLAY_PREFETCH_CACHE`, TTL 10 минут) греется заранее **тремя** независимыми механизмами. Все ходят через `requestPlay` (общий кэш + single-flight: повторный запрос присоединяется к летящему), только греют кэш и ничего не запускают:
| Прифетч | Триггер | Таймер |
| --- | --- | --- |
| `schedulePlayPrefetch` | `hover:focus` по карточке (ТВ-паттерн) | общий 180ms, «грей последнее под фокусом» |
| `scheduleNextTrackPrefetch` | ~3s после реального старта звука | свой; гейт поколением очереди; зеркалит арифметику embedded-next (`index+1` по `queueTracks`) |
| `scheduleFirstTrackPrefetch` | открытие экрана альбома/плейлиста | свой; 1 трек, на standalone iOS — стартовое окно `getStandaloneIosInitialResolveCount` треков (запуск там ждёт жадный резолв этого окна) |
Не переиспользовать таймеры между прифетчами — hover-семантика (сброс на каждом фокусе) конфликтует с отложенными.
Два инварианта тайминга вокруг кэша: cache-hit в `requestPlay` отдаёт ответ **строго асинхронно** (standalone-запуск рассчитан на резолв вне жеста), а `startStandaloneIosAudioPlayback` держит **session-settle** ~700ms между жестовым silent-warmup и прицеплением реального src — iOS фиксирует capabilities Now Playing в первые сотни ms сессии, мгновенная подмена src строила сессию на emptied-элементе.
### Мёртвый трек в очереди
Провал резолва — не тупик: на **честный** отказ сервера (`available: false`) очередь автоматически шагает дальше (`maybeAutoSkipUnavailableTrack`), сетевой сбой (`null`/битый ответ) — стоп, как раньше (iOS душит сеть скрытой страницы — авто-скип прощёлкал бы живые треки под локскрином). Кап 3 скипа подряд, счётчик сбрасывается только на реальном старте звука; repeat-one не скипается.
## Два режима воспроизведения
| | `player=ios` (standalone) | `player=inner` (embedded) |
| --- | --- | --- |
| Media owner | собственный `<audio>` модуля | видеоэлемент `Lampa.PlayerVideo` |
| UI | мини-бар + фулл-плеер (свайпы, шиты) | штатный интерфейс Lampa |
| Lock screen | полный: play/pause/prev/next/скраббер | play/pause/prev/next, без скраббера |
| Состояние | `MUSIC_IOS_AUDIO` | `MUSIC_EMBEDDED_IOS` |
| Код | секции QUEUE / IOS PLAYBACK, IOS FULL PLAYER | подсекция embedded в IOS FULL PLAYER + LAMPA PLAYER BRIDGE |
Режимы нельзя смешивать: у них разные media owner и разная логика Media Session. Почему в embedded-режиме нет скраббера — разобрано в [ios-embedded-lockscreen-seek.md](./ios-embedded-lockscreen-seek.md).
## Жизненный цикл standalone iOS-плеера
Это самая хрупкая зона модуля. Каждый шаг ниже существует из-за конкретного поведения iOS/WebKit; подробности — в «почему»-комментариях у функций.
```
тап «играть» (жестовый контекст!)
├─ warmupStandaloneIosGesture() тихий play() выдаёт элементу user-gesture
│ кредит — иначе iOS игнорирует lock-screen команды
├─ armStandaloneIosMediaSessionHandlers()
│ все обработчики ДО первого play(): iOS фиксирует
│ возможности Now Playing при создании сессии
└─ startStandaloneIosKeepAlive() Web Audio-контекст держит аудио-сессию,
чтобы resume работал на заблокированном экране
играет трек
├─ timeupdate → обновление бара/фулл-плеера (UI-ключи против лишних перерисовок)
├─ maybeSyntheticStandaloneIosEnded() ручной автопереход: WebKit парсит
│ YouTube fMP4 с удвоенной длительностью,
│ штатный 'ended' не стреляет
└─ scheduleStandaloneIosLockKick() pause()+play() через 400ms после
блокировки — включает скраббер
команда с lock screen (play/pause/seekto)
├─ pause-as-play: повторный pause на стоящем треке при живом keep-alive = play
└─ watchStandaloneIosPlayProgress() watchdog: резолв play() не доказывает
├─ attempt 0: pause()+play() звук — проверяем прогресс currentTime
└─ attempt 1: recoverStandaloneIosPlayback (переприцепка src, страховка)
stop / конец плейлиста
└─ stopStandaloneIosKeepAlive(), снапшот очереди очищается
```
## Снапшот очереди (v2)
Очередь переживает перезапуск клиента через два ключа `Lampa.Storage` — разнесены, чтобы частый тик не пересобирал всю очередь (это грело телефон):
- `queue_blob_v2` — окно ≤500 треков + режимы (repeat/shuffle/провайдер) + `snapshotId`; пишется **по событиям** (смена трека/состава, toggles, приближение к краю окна);
- `queue_position_v2` — крошечная запись `snapshotId`/`trackId`/`currentTime`; пишется каждые 4 секунды.
Связка через `snapshotId`: позиция от чужого поколения блоба игнорируется. TTL 14 дней считается по свежести позиции. Restore читает v2 с fallback на legacy-ключ v1. `snapshotId` также служит маркером поколения очереди для радио (один запрос автоподборки на поколение).
## Радио на клиенте
Два входа, одна настройка (`radio_autoplay_enabled`): тумблер в фильтре и чип «Подборка» в фулл-плеере. Автоподборка триггерится, когда играет предпоследний трек **по порядку воспроизведения** (при shuffle — по `standaloneIosOrder`, не по физическому индексу); guard — один POST `/music/radio` на поколение очереди + `pending`-флаг. Клиент шлёт seed-треки и `exclude` всей очереди (сервер её не видит). Добавленные треки несут флаг `auto_radio` (едет в снапшот), в шите очереди перед первым из них — разделитель «Дальше автоподборка». «Радио от трека» (`startRadioFromTrack`) строит **новую** очередь: сид первым, волна следом; при неудаче текущая очередь не трогается. `repeat=all`, restored-очередь до play и внешние m3u-плееры автоподборку отключают.
## Анти-гонки
Асинхронные колбэки защищены токенами (`prepareToken`, `playWatchToken`, `switchToken`, `MUSIC_PLAY_LAUNCH_TOKEN`, `homeLoadToken`): перед применением результата колбэк сверяет токен с текущим и молча выходит, если операция устарела (пользователь переключил трек, начался новый watch). `MUSIC_PLAY_LAUNCH_TOKEN` — last-tap-wins для `playTrack`: каждый вызов инвалидирует незавершённые флоу предыдущего, стейловый запуск не доезжает до плеера (раньше двойной тап по медленно резолвящемуся треку давал два одновременных плеера). Новых async-путей это правило тоже касается.
## Отладка
- `client_debug_enabled: true` в конфиге модуля → `traceStandaloneIosAudio` и heat-метрики шлют события на `/music/clientlog`, сервер пишет их в лог. Это единственный способ отлаживать lock screen на реальном iPhone. После отладки — выключить.
- Имена трейсов стабильные (`play-nudge-ok`, `keepalive-start`, `media-session-pause-as-play`…) — по ним можно грепать историю в логах контейнера.
## Глоссарий
| Термин | Значение |
| --- | --- |
| entry | карточка полки: `{ type, id, title, subtitle, badge, image, raw }` |
| section | полка home или полный экран списка (`/music/section`) |
| match | кандидат audio-источника для трека (`MusicAudioMatch`) |
| pinned | ручной выбор источника; авторитетнее любых эвристик, живёт в SQLite |
| warmup | тихий `play()` синхронно в жесте — выдаёт user-gesture кредит |
| keep-alive | беззвучный Web Audio-контекст, держит аудио-сессию во время паузы |
| nudge | цикл `pause()+play()` — единственное, что размораживает clock элемента |
| lock-kick | rate-переход после блокировки, включающий скраббер на lock screen (на бете iOS 27 не действует — сломано платформой) |
| session-settle | выдержка ~700ms между warmup и реальным src: iOS фиксирует capabilities сессии в первые сотни ms |
| auto-skip | автопропуск трека с честным `available: false`; кап 3 подряд, сеть не скипается |
| warming | `browse_sections_warming` в `/music/home`: полки ещё греются, клиент дозапросит и доклеит |
| synthetic-ended | ручной автопереход, когда WebKit завысил длительность и `'ended'` не стреляет |
| sane-duration | выбор между длительностью стрима и метаданных при диком расхождении |
| snapshot / restore | сохранение очереди в Storage (blob + position, v2) и восстановление после перезапуска |
| snapshotId | поколение очереди: связывает blob с позицией, гейтит радио-запросы |
| auto_radio | флаг трека, добавленного автоподборкой; рисует разделитель в очереди |
| heat-метрики | счётчики горячих путей, уходят на `/music/clientlog` при отладке |
## Правила изменений
1. После правки `plugin.js` поднять revision в `lampac-docker/plugins/override/lampainit-invc.js`, иначе клиенты держат кэшированный JS.
2. Всё, что касается standalone iOS-плеера, тестировать на реальном iPhone с заблокированным экраном — симулятор и десктоп-браузер не воспроизводят поведение аудио-сессий iOS.
3. Не удалять «бессмысленные» на вид pause/play-циклы и таймауты — почти каждый из них компенсирует конкретное поведение iOS (см. комментарии). История экспериментов: [ios-embedded-lockscreen-seek.md](./ios-embedded-lockscreen-seek.md).
@@ -1,110 +0,0 @@
# iOS Embedded Lock Screen Seek
Документ фиксирует эксперимент с перемоткой во встроенном плеере Lampa на iPhone. Это относится к режиму `currentExternalPlayer() === 'inner'`, где музыка играет через обычный `Lampa.PlayerVideo`, а не к standalone iOS-плееру модуля Music.
## Что хотели получить
- На заблокированном экране iOS должны работать play/pause, next/prev и скраббер перемотки.
- Встроенный плеер внутри Lampa должен оставаться обычным: звук, прогресс и кнопки не должны расходиться между интерфейсом Lampa и lock screen.
## Что уже работает
- Media Session metadata: название, артист, обложка.
- Play/pause на lock screen.
- Next/prev на lock screen, когда очередь Lampa доступна.
- Keep-alive для iOS-аудиосессии.
## Что пробовали
### 1. Прямой `seekto` на stock media
Регистрировали `navigator.mediaSession.setActionHandler('seekto', ...)`, заранее вызывали `setPositionState`, прогревали Media Session в пользовательском жесте.
Результат:
- В видимом плеере данные duration/currentTime были корректные.
- `seekable` у media-элемента был заполнен, значит серверный stream/range был рабочий.
- На lock screen iOS либо не активировал скраббер, либо двигал его без стабильного события `seekto`.
Вывод: для встроенного video/audio Lampa одного Media Session API недостаточно. iOS не гарантирует, что lock-screen scrubber будет вызывать наш JS-handler.
### 2. `seekforward` / `seekbackward`
Проверяли обработчики `seekforward` и `seekbackward`, чтобы вместо скраббера хотя бы работали шаги.
Результат:
- iOS заменял кнопки next/prev на `+10/-10`.
- Это ломало более важный сценарий переключения треков.
Вывод: в embedded-режиме эти handlers держать нельзя, пока next/prev важнее перемотки.
### 3. Скрытый real audio driver
Добавляли отдельный `<audio>` как настоящий audio owner, а stock video Lampa пытались держать как UI-оболочку.
Результат:
- Скраббер становился активнее.
- Появились гонки двух media-элементов: stock video и hidden audio спорили за play/pause/currentTime/ended.
- На переключениях иногда играло несколько треков или терялось состояние.
Вывод: два независимых media owner в одном плеере не подходят. Это не локальный фикс, а архитектурный конфликт.
### 4. Muted/audio shim
Пробовали держать audio shim рядом со stock video, в том числе muted и audible-варианты, синхронизировать timeupdate/seeking/seeked обратно в video.
Результат:
- Иногда lock screen начинал показывать скраббер.
- Muted shim не давал стабильного seek.
- Audible shim мог забирать аудиосессию, но тогда stock video нужно было глушить, а при сбое shim появлялся риск тишины.
- Обратная синхронизация seek ломала встроенный плеер: перемотка то работала, то зависала, то возвращалась назад.
Вывод: shim улучшал видимость controls, но не давал стабильной модели владения временем.
### 5. Hidden audio lead while locked
Пробовали делать hidden audio главным только на время блокировки, а при возврате отдавать управление stock video.
Результат:
- Поведение стало нестабильным и в плеере, и на lock screen.
- Переходы foreground/background создавали новые гонки.
Вывод: переключать owner в зависимости от visibility нельзя без глубокой переделки плеера.
## Что реально помогло
- Регистрировать только `play`, `pause`, `previoustrack`, `nexttrack`.
- Не регистрировать `seekforward`/`seekbackward`, иначе iOS показывает шаги `+10/-10` вместо next/prev.
- Keep-alive оставлять отдельно от seek: он помогает удерживать аудиосессию, но не решает ownership скраббера.
- Если когда-нибудь возвращаться к seek, нужен один главный media owner, а не stock video плюс hidden audio.
## Текущее решение
Экспериментальная перемотка встроенного iOS-плеера удалена из production-кода. В embedded lock-screen режиме остаются:
- play/pause;
- next/prev;
- metadata/artwork;
- без `seekto` handler и без embedded `setPositionState`, чтобы iOS не показывал полумёртвый скраббер.
Это лучше текущего состояния с нестабильным seek, потому что не ломает звук и не создаёт несколько одновременно играющих треков.
## Как правильно вернуться к задаче
Нужен не патч поверх `plugin.js`, а один из двух вариантов:
1. Сделать музыку во встроенном режиме настоящим `<audio>`-плеером на уровне создания `Lampa.PlayerVideo`, чтобы у iOS был один media owner.
2. Вынести music playback в отдельный player shell, где UI Lampa управляет тем же `<audio>`, который видит Media Session.
После этого можно вернуть:
- `seekto` handler;
- `setPositionState`;
- lock-screen scrubber;
- синхронизацию прогресса без hidden shim.
До такой переделки перемотку на lock screen лучше считать неподдержанной для embedded-плеера.
-17
View File
@@ -1,17 +0,0 @@
{
"enable": true,
"dynamic": true,
"references": [
"YoutubeExplode"
],
"tree": [
"Controllers",
"Models",
"Providers",
"SQL",
"Services",
"GlobalUsings.cs",
"ModInit.cs",
"ModuleConf.cs"
]
}
-17934
View File
File diff suppressed because it is too large Load Diff