remove Music
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 в публичный плейлист и импортировать по ссылке); прямой доступ к библиотеке требует логина и не реализован.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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-плеера.
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"enable": true,
|
||||
"dynamic": true,
|
||||
"references": [
|
||||
"YoutubeExplode"
|
||||
],
|
||||
"tree": [
|
||||
"Controllers",
|
||||
"Models",
|
||||
"Providers",
|
||||
"SQL",
|
||||
"Services",
|
||||
"GlobalUsings.cs",
|
||||
"ModInit.cs",
|
||||
"ModuleConf.cs"
|
||||
]
|
||||
}
|
||||
-17934
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user