diff --git a/mods/Music/Controllers/ApiController.cs b/mods/Music/Controllers/ApiController.cs deleted file mode 100644 index ec2c553..0000000 --- a/mods/Music/Controllers/ApiController.cs +++ /dev/null @@ -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 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 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 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 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 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 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 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 PlaylistTrackAdd(string id, string track) - { - string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); - - MusicTrack parsed = null; - try - { - parsed = string.IsNullOrWhiteSpace(track) ? null : MusicJson.Deserialize(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 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 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 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 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 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 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 parseTracks(string value, int maxItems) - { - if (string.IsNullOrWhiteSpace(value) || value.Length > maxRawJsonLength) - return new List(); - - try - { - var parsed = MusicJson.Deserialize>(value) ?? new List(); - return parsed.Count > maxItems ? parsed.Take(maxItems).ToList() : parsed; - } - catch - { - return new List(); - } - } - - 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 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 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)); - } -} diff --git a/mods/Music/Controllers/AuthController.cs b/mods/Music/Controllers/AuthController.cs deleted file mode 100644 index 5f0bb43..0000000 --- a/mods/Music/Controllers/AuthController.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Music; - -public class AuthController : BaseController -{ - [HttpGet] - [Route("music/auth/state")] - async public Task 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 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 Logout(string provider) - { - string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); - await MusicAuthService.LogoutAsync(provider, profileId); - return ContentTo(MusicJson.Serialize(new - { - provider, - logged_out = true - })); - } -} diff --git a/mods/Music/Controllers/PlaybackController.cs b/mods/Music/Controllers/PlaybackController.cs deleted file mode 100644 index 242d670..0000000 --- a/mods/Music/Controllers/PlaybackController.cs +++ /dev/null @@ -1,169 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Music; - -public class PlaybackController : BaseController -{ - [HttpGet] - [Route("music/matches")] - async public Task 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 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 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 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 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 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(MusicJson.Serialize(track)); - track.images = new List { 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 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 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"); - } -} diff --git a/mods/Music/Controllers/SearchController.cs b/mods/Music/Controllers/SearchController.cs deleted file mode 100644 index 92be12a..0000000 --- a/mods/Music/Controllers/SearchController.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Music; - -public class SearchController : BaseController -{ - [HttpGet] - [Route("music/search")] - async public Task 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 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 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 ArtistImage(string id, string name, string country, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(name)) - return ContentTo(MusicJson.Serialize(new { available = false, id, images = new List() })); - - var artist = new MusicArtist - { - id = id, - name = name, - country = country - }; - - artist.images = await DiscogsArtistImageService.ResolveImagesAsync(artist, cancellationToken) ?? new List(); - 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 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 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)); - } -} diff --git a/mods/Music/GlobalUsings.cs b/mods/Music/GlobalUsings.cs deleted file mode 100644 index 480296d..0000000 --- a/mods/Music/GlobalUsings.cs +++ /dev/null @@ -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; diff --git a/mods/Music/ModInit.cs b/mods/Music/ModInit.cs deleted file mode 100644 index 600adf9..0000000 --- a/mods/Music/ModInit.cs +++ /dev/null @@ -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 appliedWafRules = new(); - - public static string modpath; - public static ModuleConf conf; - - public void Configure(ConfigureModel app) - { - app.services.AddDbContextFactory(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() - { - new("^/music", new WafLimitMap { limit = 15, second = 1 }) - } - }); - - ApplyWafRules(conf.limit_map); - MusicProxyService.ConfigurationChanged(); - } - - static void ApplyWafRules(IReadOnlyCollection currentRules) - { - var waf = CoreInit.conf?.WAF; - if (waf == null) - return; - - lock (wafRulesLock) - { - var limitMap = waf.limit_map?.ToList() ?? new List(); - RemoveAppliedWafRules(limitMap); - - var nextAppliedRules = new List(); - - foreach (var rule in currentRules ?? Array.Empty()) - { - 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(); - RemoveAppliedWafRules(limitMap); - waf.limit_map = limitMap; - } - - appliedWafRules = new List(); - } - } - - static void RemoveAppliedWafRules(List limitMap) - { - if (limitMap == null || appliedWafRules.Count == 0) - return; - - limitMap.RemoveAll(existing => appliedWafRules.Any(applied => ReferenceEquals(existing, applied))); - } -} diff --git a/mods/Music/Models/Contracts.cs b/mods/Music/Models/Contracts.cs deleted file mode 100644 index cad4811..0000000 --- a/mods/Music/Models/Contracts.cs +++ /dev/null @@ -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 images { get; set; } = new(); - public List provider_refs { get; set; } = new(); - public List albums { get; set; } = new(); - public List 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 secondary_types { get; set; } = new(); - public List images { get; set; } = new(); - public List provider_refs { get; set; } = new(); - public List 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 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 images { get; set; } = new(); - public List 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 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 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 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 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 albums { get; set; } = new(); - public List artists { get; set; } = new(); - public List tracks { get; set; } = new(); -} - -public class MusicSearchResult -{ - public List artists { get; set; } = new(); - public List albums { get; set; } = new(); - public List 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 audio_providers { get; set; } = new(); - public List artists { get; set; } = new(); - public List albums { get; set; } = new(); - public List tracks { get; set; } = new(); - public List 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 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 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 sections { get; set; } = new(); - public List metadata_providers { get; set; } = new(); - public List audio_providers { get; set; } = new(); - public List auth_providers { get; set; } = new(); - public List recently_played { get; set; } = new(); - public List user_playlists { get; set; } = new(); - public List daily_mixes { get; set; } = new(); - public List 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 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 matches { get; set; } = new(); -} - -public class MusicDailyMixSummary -{ - public int day { get; set; } - public string title { get; set; } - public bool today { get; set; } - public List 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 artists { get; set; } = new(); - public List tracks { get; set; } = new(); -} - -public class MusicRadioRequest -{ - public List seeds { get; set; } = new(); - public List exclude { get; set; } = new(); -} - -public class MusicRadioResponse -{ - public bool available { get; set; } - public string message { get; set; } - public List tracks { get; set; } = new(); -} diff --git a/mods/Music/Models/MusicIsrc.cs b/mods/Music/Models/MusicIsrc.cs deleted file mode 100644 index 91c981f..0000000 --- a/mods/Music/Models/MusicIsrc.cs +++ /dev/null @@ -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); - } -} diff --git a/mods/Music/ModuleConf.cs b/mods/Music/ModuleConf.cs deleted file mode 100644 index 330faba..0000000 --- a/mods/Music/ModuleConf.cs +++ /dev/null @@ -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 limit_map { get; set; } = new(); -} diff --git a/mods/Music/Music.csproj b/mods/Music/Music.csproj deleted file mode 100644 index c5b2704..0000000 --- a/mods/Music/Music.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - net10.0 - library - true - - - - - - - - - Always - - - - diff --git a/mods/Music/Providers/Abstractions/Contracts.cs b/mods/Music/Providers/Abstractions/Contracts.cs deleted file mode 100644 index a0edcdb..0000000 --- a/mods/Music/Providers/Abstractions/Contracts.cs +++ /dev/null @@ -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 SearchAsync(string query, bool expanded = false, CancellationToken cancellationToken = default); - Task GetArtistAsync(string id, CancellationToken cancellationToken = default); - Task GetAlbumAsync(string id, CancellationToken cancellationToken = default); - Task GetTrackAsync(string id, CancellationToken cancellationToken = default); -} - -public interface IMusicDiscoveryProvider -{ - string Id { get; } - string Name { get; } - bool Enabled { get; } - - Task> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default); - Task 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> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default); - Task> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default); - Task> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default); - Task 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 GetFallbackProviderIds(MusicTrack track); -} - -public interface IMusicAuthProvider -{ - string Id { get; } - string Name { get; } - bool Enabled { get; } - - Task GetStateAsync(string profileId = null, CancellationToken cancellationToken = default); - Task SaveAsync(string payload, string profileId = null, CancellationToken cancellationToken = default); - Task LogoutAsync(string profileId = null, CancellationToken cancellationToken = default); -} diff --git a/mods/Music/Providers/AppleMusic/AppleMusicSupport.cs b/mods/Music/Providers/AppleMusic/AppleMusicSupport.cs deleted file mode 100644 index d32561a..0000000 --- a/mods/Music/Providers/AppleMusic/AppleMusicSupport.cs +++ /dev/null @@ -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 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(null); - } - - public static async Task 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 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 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 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 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 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 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(); - 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 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(); - 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(), - tracks = tracks, - provider_refs = new List - { - new() { provider = ProviderId, external_id = albumId } - } - }, null); - } - - // маппит data[] страницы (плейлист-треки или альбом-треки); возвращает СЫРОЕ - // число элементов страницы — по нему считается offset (часть элементов фильтруется) - static int MapTrackPage(JsonElement root, List tracks, List 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 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() : new List { 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 - { - new() { provider = ProviderId, external_id = trackId } - } - }; - } - - // artwork.url — шаблон с {w}x{h}; просим два размера, крупный первым - static List MapArtwork(JsonElement? artwork) - { - var result = new List(); - 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]; - } - -} diff --git a/mods/Music/Providers/Audio/Sefon/SefonAudioProvider.cs b/mods/Music/Providers/Audio/Sefon/SefonAudioProvider.cs deleted file mode 100644 index 47c4cfe..0000000 --- a/mods/Music/Providers/Audio/Sefon/SefonAudioProvider.cs +++ /dev/null @@ -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> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - if (track == null || MusicPlaybackModeService.IsVideo(playbackMode)) - return Array.Empty(); - - try - { - return await SefonSupport.SearchAsync(track, cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - throw new MusicAudioTransientException(Id, "match", ex); - } - } - - public Task> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - if (match == null || MusicPlaybackModeService.IsVideo(playbackMode)) - return Task.FromResult>(Array.Empty()); - - return Task.FromResult(SefonSupport.BuildSources(match)); - } - - public Task TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(null); - } - - public Task> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default) - { - return Task.FromResult>(Array.Empty()); - } - - public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match) - { - return SefonSupport.IsRelevantMatch(track, match); - } - - public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match) - { - return false; - } - - public IReadOnlyList GetFallbackProviderIds(MusicTrack track) - { - return Array.Empty(); - } -} diff --git a/mods/Music/Providers/Audio/Sefon/SefonSupport.cs b/mods/Music/Providers/Audio/Sefon/SefonSupport.cs deleted file mode 100644 index ddbd2d2..0000000 --- a/mods/Music/Providers/Audio/Sefon/SefonSupport.cs +++ /dev/null @@ -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> SearchAsync(MusicTrack track, CancellationToken cancellationToken = default) - { - var queries = BuildTrackQueries(track).Take(MaxSearchQueries).ToList(); - if (queries.Count == 0) - return new List(); - - var merged = new Dictionary(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> SearchTracksByQueryAsync(string query, int limit = 10, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(query)) - return new List(); - - var matches = await SearchQuerySafeAsync(query.Trim(), cancellationToken); - if (matches.Count == 0) - return new List(); - - 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> SearchQueryAsync(string query, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(query)) - return new List(); - - 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(); - } - - string html = await response.Content.ReadAsStringAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(html)) - return new List(); - - 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(); - - var matches = new List(); - - 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() : new List { 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> SearchQuerySafeAsync(string query, CancellationToken cancellationToken) - { - try - { - return await SearchQueryAsync(query, cancellationToken); - } - catch - { - return new List(); - } - } - - static async Task<(List matches, Exception error)> SearchQueryResultAsync(string query, CancellationToken cancellationToken) - { - try - { - return (await SearchQueryAsync(query, cancellationToken), null); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return (new List(), ex); - } - } - - public static IReadOnlyList BuildSources(MusicAudioMatch match) - { - var payload = string.IsNullOrWhiteSpace(match?.payload) - ? null - : MusicJson.Deserialize(match.payload); - - string sourceUrl = !string.IsNullOrWhiteSpace(payload?.stream_direct_url) - ? payload.stream_direct_url - : payload?.stream_url; - - if (string.IsNullOrWhiteSpace(sourceUrl)) - return Array.Empty(); - - var headers = new Dictionary(); - if (string.IsNullOrWhiteSpace(payload?.stream_direct_url)) - { - headers["Referer"] = string.IsNullOrWhiteSpace(payload.referer) ? $"{BaseUrl}/" : payload.referer; - headers["Origin"] = BaseUrl; - } - - return new List - { - new() - { - provider_id = ProviderId, - url = sourceUrl, - mime_type = "audio/mpeg", - quality = "MP3", - headers = headers - } - }; - } - - public static List RankMatches(MusicTrack query, IEnumerable 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() : new List { 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 BuildTrackQueries(MusicTrack track) - { - var queries = new List(); - 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 BuildNormalizedTitleVariants(string value) - { - var variants = new List(); - - 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 normalizedTitles) - { - return normalizedTitles.Any(i => - i.Contains("intro") - || i.Contains("outro") - || i.Contains("interlude") - || i.Contains("skit") - || i.Contains("snippet")); - } - - static List NormalizeArtistFragments(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return new List(); - - 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(); - } -} diff --git a/mods/Music/Providers/Audio/SoundCloud/SoundCloudAudioProvider.cs b/mods/Music/Providers/Audio/SoundCloud/SoundCloudAudioProvider.cs deleted file mode 100644 index bd565cb..0000000 --- a/mods/Music/Providers/Audio/SoundCloud/SoundCloudAudioProvider.cs +++ /dev/null @@ -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> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - if (track == null || MusicPlaybackModeService.IsVideo(playbackMode)) - return Array.Empty(); - - try - { - return await SoundCloudSupport.SearchAudioAsync(track, cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - throw new MusicAudioTransientException(Id, "match", ex); - } - } - - public async Task> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - if (match == null || MusicPlaybackModeService.IsVideo(playbackMode)) - return Array.Empty(); - - try - { - return await SoundCloudSupport.BuildAudioSourcesAsync(match, cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - throw new MusicAudioTransientException(Id, "stream", ex); - } - } - - public async Task 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> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default) - { - return Task.FromResult>(Array.Empty()); - } - - 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 GetFallbackProviderIds(MusicTrack track) - { - return new[] - { - SefonSupport.ProviderId, - YouTubeMusicSearchSupport.ProviderId - }; - } -} diff --git a/mods/Music/Providers/Audio/YouTube/YouTubeAudioProvider.cs b/mods/Music/Providers/Audio/YouTube/YouTubeAudioProvider.cs deleted file mode 100644 index c206d70..0000000 --- a/mods/Music/Providers/Audio/YouTube/YouTubeAudioProvider.cs +++ /dev/null @@ -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 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> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - if (track == null) - return Array.Empty(); - - var queries = YouTubeAudioSupport.BuildTrackQueries(track, playbackMode); - var directMatch = YouTubeAudioSupport.BuildDirectMatch(track); - if (queries.Count == 0) - return directMatch != null ? new[] { directMatch } : Array.Empty(); - - try - { - var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api); - using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease)); - var results = new List(); - var seen = new HashSet(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 { 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> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default) - { - query = query?.Trim(); - if (string.IsNullOrWhiteSpace(query)) - return Array.Empty(); - - try - { - var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api); - using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease)); - var results = new List(); - - 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> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - if (match == null || string.IsNullOrWhiteSpace(match.id)) - return Array.Empty(); - - 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(); - - throw new MusicAudioTransientException(Id, "stream", ex); - } - } - - return Array.Empty(); - } - - public Task TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(null); - } - - public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match) - { - return YouTubeAudioSupport.IsRelevantMatch(track, match); - } - - public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match) - { - return false; - } - - public IReadOnlyList GetFallbackProviderIds(MusicTrack track) - { - return Array.Empty(); - } - - static Task 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 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> FetchQueryResultsAsync(YoutubeClient youtube, string query, CancellationToken cancellationToken) - { - var list = new List(); - - await foreach (var video in youtube.Search.GetVideosAsync(query, cancellationToken)) - { - list.Add(video); - if (list.Count >= 16) - break; - } - - return list; - } - - static async Task 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; - } -} diff --git a/mods/Music/Providers/Audio/YouTube/YouTubeAudioSupport.cs b/mods/Music/Providers/Audio/YouTube/YouTubeAudioSupport.cs deleted file mode 100644 index 25cfa0b..0000000 --- a/mods/Music/Providers/Audio/YouTube/YouTubeAudioSupport.cs +++ /dev/null @@ -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 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(); - var seen = new HashSet(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 { track.artist_name } : track?.artists ?? new List(), - album_title = track?.album_title, - isrc = MusicIsrc.Normalize(track?.isrc), - duration_ms = track?.duration_ms, - payload = MusicJson.Serialize(new Dictionary - { - ["video_id"] = videoId - }) - }; - } - - public static List RankMatches(MusicTrack query, IEnumerable 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(); - } - - 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 ConvertSearchResults(IEnumerable videos) - { - var matches = new List(); - - 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() : new List { author }, - album_title = null, - duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null, - payload = MusicJson.Serialize(new Dictionary - { - ["video_id"] = id, - ["author"] = author ?? string.Empty - }) - }); - } - - return matches; - } - - public static IReadOnlyList ConvertAudioStreams(IEnumerable 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 - { - ["Referer"] = "https://www.youtube.com/", - ["Origin"] = "https://www.youtube.com" - } - }) - .ToList(); - } - - public static IReadOnlyList ConvertVideoStreams(IEnumerable 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 - { - ["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 BuildQueryArtists(string primaryArtist, IEnumerable artists) - { - var result = new List(); - var seen = new HashSet(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()) - { - 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(); - if (!string.IsNullOrWhiteSpace(query?.artist_name)) - expectedArtists.Add(query.artist_name); - - foreach (var artist in query?.artists ?? new List()) - { - 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()) - .Select(NormalizeText) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .ToList(); - - string rawCandidateText = NormalizeText($"{match?.title} {string.Join(" ", match?.artists ?? new List())}"); - - 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())}"); - 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); - } -} diff --git a/mods/Music/Providers/Audio/Z3Fm/Z3FmAudioProvider.cs b/mods/Music/Providers/Audio/Z3Fm/Z3FmAudioProvider.cs deleted file mode 100644 index 0a3d593..0000000 --- a/mods/Music/Providers/Audio/Z3Fm/Z3FmAudioProvider.cs +++ /dev/null @@ -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> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - if (!Enabled || track == null) - return Task.FromResult>(Array.Empty()); - - return Z3FmSupport.SearchAsync(track, cancellationToken); - } - - public Task> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - if (!Enabled || match == null) - return Task.FromResult>(Array.Empty()); - - return Z3FmSupport.BuildSourcesAsync(match, cancellationToken); - } - - public Task TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(null); - } - - public Task> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default) - { - return Task.FromResult>(Array.Empty()); - } - - public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match) - { - return Z3FmSupport.IsRelevantMatch(track, match); - } - - public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match) - { - return false; - } - - public IReadOnlyList GetFallbackProviderIds(MusicTrack track) - { - return Array.Empty(); - } -} diff --git a/mods/Music/Providers/Audio/Z3Fm/Z3FmSupport.cs b/mods/Music/Providers/Audio/Z3Fm/Z3FmSupport.cs deleted file mode 100644 index f7c4b95..0000000 --- a/mods/Music/Providers/Audio/Z3Fm/Z3FmSupport.cs +++ /dev/null @@ -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( - "
(?.*?)]*data-time=\"(?\\d+)\"[^>]*data-sid=\"(?\\d+)\"[^>]*data-url=\"(?/download/\\d+)\"[^>]*data-title=\"(?[^\"]+)\"[^>]*class=\"song-play[^\"]*\"", - RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled - ); - static readonly Regex artistRegex = new( - "
.*?]*href=\"(?/artist/\\d+)\"[^>]*>(?.*?)", - RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled - ); - static readonly Regex titleRegex = new( - "
.*?]*href=\"(?/song/\\d+)\"[^>]*>(?.*?)", - RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled - ); - static readonly Regex songPageDownloadRegex = new( - "data-sid=\"(?\\d+)\"\\s+data-url=\"(?/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 CreateBrowserHeaders() - { - return new Dictionary - { - ["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> SearchAsync(MusicTrack track, CancellationToken cancellationToken = default) - { - if (track == null || string.IsNullOrWhiteSpace(track.title)) - return Array.Empty(); - - var matches = new Dictionary(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() : new List { 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> BuildSourcesAsync(MusicAudioMatch match, CancellationToken cancellationToken = default) - { - var payload = string.IsNullOrWhiteSpace(match?.payload) - ? null - : MusicJson.Deserialize(match.payload); - - if (payload == null || string.IsNullOrWhiteSpace(payload.sid)) - return Array.Empty(); - - var context = await EnsureCookieContextAsync(payload, cancellationToken); - string url = BuildAbsoluteUrl(payload.download_url); - if (string.IsNullOrWhiteSpace(url)) - return Array.Empty(); - - var headers = new Dictionary - { - ["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 { source }; - } - - public static List RankMatches(MusicTrack query, IEnumerable 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 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 BuildQueryVariants(MusicTrack track) - { - var variants = new List(); - - 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 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 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 BuildArtistFragments(string artist) - { - artist = artist?.Trim(); - if (string.IsNullOrWhiteSpace(artist)) - return new List(); - - 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 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 - }; - } -} diff --git a/mods/Music/Providers/Auth/SoundCloud/SoundCloudAuthProvider.cs b/mods/Music/Providers/Auth/SoundCloud/SoundCloudAuthProvider.cs deleted file mode 100644 index c981ce0..0000000 --- a/mods/Music/Providers/Auth/SoundCloud/SoundCloudAuthProvider.cs +++ /dev/null @@ -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 GetStateAsync(string profileId = null, CancellationToken cancellationToken = default) - { - var credentials = await MusicAuthStorageService.GetAsync(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 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(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); - } -} diff --git a/mods/Music/Providers/Discovery/AppleMusic/AppleMusicDiscoveryProvider.cs b/mods/Music/Providers/Discovery/AppleMusic/AppleMusicDiscoveryProvider.cs deleted file mode 100644 index e0c6ed1..0000000 --- a/mods/Music/Providers/Discovery/AppleMusic/AppleMusicDiscoveryProvider.cs +++ /dev/null @@ -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> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default) - { - var feed = await GetTopAlbumsAsync(cancellationToken); - if (feed.Count == 0) - return new List(); - - var sections = new List - { - 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 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 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(null); - } - - async Task> GetTopAlbumsAsync(CancellationToken cancellationToken) - { - string country = GetCountry(); - var albums = await MusicMetadataCacheService.GetOrCreateAsync( - ProviderId, - "browse", - $"{country}:top-albums", - cacheTtl, - () => LoadTopAlbumsAsync(country, cancellationToken), - cancellationToken - ) ?? new List(); - - 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(), - images = album.images?.ToList() ?? new List(), - provider_refs = album.provider_refs?.ToList() ?? new List(), - tracks = album.tracks?.ToList() ?? new List() - }; - } - - async Task> 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(); - - 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(); - - 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()?.Trim(); - string artistName = item["artistName"]?.GetValue()?.Trim(); - - if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artistName)) - return null; - - string lookupQuery = $"{artistName} {title}"; - string artwork = UpgradeArtwork(item["artworkUrl100"]?.GetValue()); - string date = item["releaseDate"]?.GetValue(); - string appleId = item["id"]?.GetValue(); - 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() - : new List { new() { url = artwork, width = 600, height = 600 } }, - provider_refs = new List - { - new() { provider = ProviderId, external_id = externalId } - } - }; - } - - static string JoinGenres(JsonArray items) - { - if (items == null) - return null; - - var genres = items - .Select(node => node?["name"]?.GetValue()) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct() - .ToList(); - - return genres.Count == 0 ? null : string.Join(" · ", genres); - } - - static List BuildGenreSections(List feed, int limit) - { - if (feed == null || feed.Count == 0) - return new List(); - - 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 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 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(); - - 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(); - } -} diff --git a/mods/Music/Providers/Discovery/SoundCloud/SoundCloudDiscoveryProvider.cs b/mods/Music/Providers/Discovery/SoundCloud/SoundCloudDiscoveryProvider.cs deleted file mode 100644 index 62a17a2..0000000 --- a/mods/Music/Providers/Discovery/SoundCloud/SoundCloudDiscoveryProvider.cs +++ /dev/null @@ -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> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default) - { - return GetHomeSectionsInternalAsync(limit, cancellationToken); - } - - public Task GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default) - { - return GetSectionInternalAsync(sectionId, limit, cancellationToken); - } - - public Task GetAlbumAsync(string id, CancellationToken cancellationToken = default) - { - return SoundCloudSupport.GetChartAlbumAsync(id, cancellationToken); - } - - async Task> GetHomeSectionsInternalAsync(int limit, CancellationToken cancellationToken) - { - var albums = await SoundCloudSupport.GetChartAlbumsAsync(cancellationToken); - if (albums.Count == 0) - return new List(); - - return new List - { - 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 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() - }; - } -} diff --git a/mods/Music/Providers/Discovery/Spotify/SpotifyDiscoveryProvider.cs b/mods/Music/Providers/Discovery/Spotify/SpotifyDiscoveryProvider.cs deleted file mode 100644 index ae77b46..0000000 --- a/mods/Music/Providers/Discovery/Spotify/SpotifyDiscoveryProvider.cs +++ /dev/null @@ -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 globalPlaylists = new List - { - 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 themedPlaylists = new List - { - 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> countryThemedPlaylists = - new Dictionary>(StringComparer.OrdinalIgnoreCase) - { - ["GLOBAL"] = new List - { - 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 - { - new() { title = "Літні хіти України", playlistId = "37i9dQZF1DX86ZB9BGPT0a" }, - new() { title = "Українська ностальгія", playlistId = "3FUapgKzH0x7qSygyWS7rk" }, - new() { title = "Українські хіти 2000-х", playlistId = "3Id8TtStr3uzNv79RhOgmb" }, - new() { title = "Українські легенди", playlistId = "158jVnGiYCk96G6oJweWVz" }, - new() { title = "Українська вечірка", playlistId = "3wKPQ5RdRZymbAcEEkVRwT" } - }, - ["CA"] = new List - { - new() { title = "Классика канадского рока", playlistId = "37i9dQZF1DX4bKgmgKIPtO" }, - new() { title = "Канада: хиты 90-х", playlistId = "37i9dQZF1DX9NmM48Aqz3e" }, - new() { title = "Канадский хип-хоп", playlistId = "37i9dQZF1DX59ogDi1Z2XL" }, - new() { title = "Классика канадской инди-музыки", playlistId = "37i9dQZF1DX5dx2bmWL9SZ" }, - new() { title = "Канадское кантри", playlistId = "37i9dQZF1DWYV2Gh2QglGo" } - }, - ["US"] = new List - { - 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 - { - 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 - { - 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 - { - 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 - { - 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> countryPlaylists = - new Dictionary>(StringComparer.OrdinalIgnoreCase) - { - ["UA"] = new List - { - 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 - { - 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 - { - 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 - { - new() { title = "Русский Топ-50", playlistId = "1qkR4WrgRWFg1w3Qik1lTu" }, - new() { title = "Русские хиты", playlistId = "7xm8bTP6NFGMDudMHsC2qp" }, - new() { title = "Русский поп", playlistId = "4dL0DgSaYON6tODhwM72gw" }, - new() { title = "Русский рэп", playlistId = "0Y8djwSTijxFoC3Z0vdinF" }, - new() { title = "Русский рок", playlistId = "0Fb2Hc0MdcME6knQ0vFiby" } - }, - ["PL"] = new List - { - 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 - { - 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 - { - 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> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default) - { - var section = await BuildSectionAsync(limit, cancellationToken); - return section == null ? new List() : new List { section }; - } - - public async Task 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 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 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(); - - 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 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 AppendThemed(string country, IReadOnlyList 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 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(), - provider_refs = album.provider_refs?.ToList() ?? new List(), - tracks = includeTracks ? album.tracks?.ToList() ?? new List() : new List() - }; - } - - 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()}"; -} diff --git a/mods/Music/Providers/Discovery/VkTopChart/VkTopChartDiscoveryProvider.cs b/mods/Music/Providers/Discovery/VkTopChart/VkTopChartDiscoveryProvider.cs deleted file mode 100644 index b017b62..0000000 --- a/mods/Music/Providers/Discovery/VkTopChart/VkTopChartDiscoveryProvider.cs +++ /dev/null @@ -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> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default) - { - var feed = await GetTracksAsync(cancellationToken); - if (feed.Count == 0) - return new List(); - - return new List - { - 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 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> GetTracksAsync(CancellationToken cancellationToken) - { - return await MusicMetadataCacheService.GetOrCreateAsync( - providerId, - "browse", - "top200:tracks:v2", - cacheTtl, - () => LoadTracksAsync(cancellationToken), - cancellationToken - ) ?? new List(); - } - - async Task> 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(); - - var html = await response.Content.ReadAsStringAsync(cancellationToken); - var json = ExtractTracksJsonArray(html); - if (json == null) - return new List(); - - return json - .OfType() - .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()?.Trim(); - string artist = item["artist"]?.GetValue()?.Trim(); - string sourceId = item["id"]?.GetValue()?.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(); - int? position = item["position"]?.GetValue(); - - return new MusicTrack - { - id = trackId, - title = title, - artist_name = artist, - album_title = "VK Top 200", - track_number = position, - images = string.IsNullOrWhiteSpace(cover) - ? new List() - : new List { new() { url = cover, width = 640, height = 640 } }, - provider_refs = new List - { - 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(); - } -} diff --git a/mods/Music/Providers/Discovery/YouTubeMusic/YouTubeMusicSearchSupport.cs b/mods/Music/Providers/Discovery/YouTubeMusic/YouTubeMusicSearchSupport.cs deleted file mode 100644 index 16194da..0000000 --- a/mods/Music/Providers/Discovery/YouTubeMusic/YouTubeMusicSearchSupport.cs +++ /dev/null @@ -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> SearchTracksByQueryAsync( - string query, - int limit = 10, - CancellationToken cancellationToken = default, - string expectedArtist = null, - bool requireAuthorMatch = false) - { - if (string.IsNullOrWhiteSpace(query)) - return new List(); - - try - { - using var youtube = new YoutubeClient(youtubeHttp); - var videos = new List(); - var seen = new HashSet(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(); - } - } - - public static async Task> SearchPlaylistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(query)) - return new List(); - - try - { - using var youtube = new YoutubeClient(youtubeHttp); - var playlists = new List(); - var seen = new HashSet(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(); - } - } - - public static async Task> SearchArtistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(query)) - return new List(); - - try - { - using var youtube = new YoutubeClient(youtubeHttp); - var artists = new List(); - var seen = new HashSet(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(); - } - } - - public static async Task 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 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 - { - 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 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 - { - new() { provider = ProviderId, external_id = channelId } - }, - albums = new List - { - 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 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() : new List { artist }, - duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null, - images = ExtractImages(video), - provider_refs = new List - { - 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 - { - 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 - { - new() { provider = ProviderId, external_id = channelId } - } - }; - } - - static MusicAlbum BuildChannelUploadsAlbum(string channelId, string channelTitle, List 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(), - provider_refs = new List - { - new() { provider = ProviderId, external_id = channelId } - } - }; - } - - static async Task 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() : new List { 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 - { - 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() : new List { artist }, - album_id = album?.id, - album_title = album?.title, - duration_ms = GetDurationMs(video), - track_number = number, - images = ExtractImages(video), - provider_refs = new List - { - new() { provider = ProviderId, external_id = videoId } - } - }; - } - - static List ExtractImages(params object[] sources) - { - var images = new List(); - var seen = new HashSet(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(); - } -} diff --git a/mods/Music/Providers/Metadata/MusicBrainz/MusicBrainzMetadataProvider.cs b/mods/Music/Providers/Metadata/MusicBrainz/MusicBrainzMetadataProvider.cs deleted file mode 100644 index 55ef091..0000000 --- a/mods/Music/Providers/Metadata/MusicBrainz/MusicBrainzMetadataProvider.cs +++ /dev/null @@ -1,1553 +0,0 @@ -using System.Net.Http; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; - -namespace Music; - -public class MusicBrainzMetadataProvider : IMusicMetadataProvider -{ - static readonly HttpClient httpClient = MusicHttp.CreateClient("musicbrainz"); - static readonly TimeSpan requestTimeout = TimeSpan.FromSeconds(20); - static readonly SemaphoreSlim requestGate = new(1, 1); - static DateTime nextRequestAt = DateTime.MinValue; - - const string baseUrl = "https://musicbrainz.org/ws/2"; - const string userAgent = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)"; - - public string Id => "musicbrainz"; - public string Name => "MusicBrainz"; - public bool Enabled => true; - - static MusicBrainzMetadataProvider() - { - httpClient.Timeout = requestTimeout; - } - - public async Task SearchAsync(string query, bool expanded = false, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(query)) - return new MusicSearchResult(); - - query = query.Trim(); - - var artistQuery = query; - var usedTranslitArtistFallback = false; - var artistsJson = await GetJsonAsync($"artist?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 16 : 8)}&fmt=json", cancellationToken); - var artists = RankArtists(ParseArtists(artistsJson?["artists"] as JsonArray), query, expanded ? 16 : 8); - - if (artists.Count == 0 && MusicMapSupport.ContainsCyrillic(query)) - { - string fallbackQuery = MusicMapSupport.TransliterateCyrillicToLatinForSearch(query); - if (!string.IsNullOrWhiteSpace(fallbackQuery) - && !string.Equals(fallbackQuery, query, StringComparison.OrdinalIgnoreCase)) - { - var fallbackArtistsJson = await GetJsonAsync($"artist?query={HttpUtility.UrlEncode(fallbackQuery)}&limit={(expanded ? 16 : 8)}&fmt=json", cancellationToken); - var fallbackArtists = DeduplicateFallbackArtists( - RankArtists(ParseArtists(fallbackArtistsJson?["artists"] as JsonArray), fallbackQuery, expanded ? 16 : 8) - ); - if (fallbackArtists.Count > 0) - { - artistQuery = fallbackQuery; - artists = fallbackArtists; - usedTranslitArtistFallback = true; - } - } - } - - await DiscogsArtistImageService.ApplyCachedAsync(artists, cancellationToken); - DiscogsArtistImageService.WarmupMissing(artists); - - if (usedTranslitArtistFallback) - { - return new MusicSearchResult - { - artists = artists - }; - } - - var context = BuildSearchContext(artistQuery, artists); - var isArtistOnly = context?.BestArtist != null && context.IsArtistOnly; - var albumsTask = GetJsonAsync(BuildAlbumSearchPath(context, expanded), cancellationToken); - var tracksTask = isArtistOnly ? null : GetJsonAsync(BuildTrackSearchPath(context, expanded), cancellationToken); - var albumsJson = await albumsTask; - var allAlbums = ParseAlbums(albumsJson?["release-groups"] as JsonArray); - ApplyBestArtistFallback(allAlbums, context?.BestArtist); - - var albums = RankAlbums(allAlbums, context, expanded ? 100 : 24); - var preferredAlbumTitles = albums - .Take(8) - .Select(item => NormalizeText(item.title)) - .Where(item => !string.IsNullOrWhiteSpace(item)) - .ToHashSet(StringComparer.Ordinal); - var preferredAlbumArtists = albums - .Take(8) - .Select(item => NormalizeText(item.artist_name)) - .Where(item => !string.IsNullOrWhiteSpace(item)) - .ToHashSet(StringComparer.Ordinal); - - List tracks; - if (isArtistOnly) - tracks = await BuildArtistOnlyTracksAsync(context, allAlbums, preferredAlbumTitles, preferredAlbumArtists, expanded, cancellationToken); - else - { - var tracksJson = await tracksTask; - tracks = RankTracks(ParseTracks(tracksJson?["recordings"] as JsonArray), context, preferredAlbumTitles, preferredAlbumArtists, expanded ? 100 : 24); - } - - return new MusicSearchResult - { - artists = artists, - albums = albums, - tracks = tracks - }; - } - - static List DeduplicateFallbackArtists(IEnumerable artists) - { - var result = new List(); - var seen = new HashSet(StringComparer.Ordinal); - - foreach (var artist in artists ?? Enumerable.Empty()) - { - string key = NormalizeText(artist?.name); - if (string.IsNullOrWhiteSpace(key)) - key = artist?.id ?? string.Empty; - - if (!seen.Add(key)) - continue; - - result.Add(artist); - } - - return result; - } - - public async Task GetArtistAsync(string id, CancellationToken cancellationToken = default) - { - var artistTask = GetJsonAsync($"artist/{HttpUtility.UrlEncode(id)}?fmt=json", cancellationToken); - var albumsTask = GetJsonAsync($"release-group?artist={HttpUtility.UrlEncode(id)}&limit=12&fmt=json", cancellationToken); - - await Task.WhenAll(artistTask, albumsTask); - - var artistJson = await artistTask; - if (artistJson == null) - return null; - - var albumsJson = await albumsTask; - var artist = ParseArtist(artistJson); - artist.albums = ParseAlbums(albumsJson?["release-groups"] as JsonArray); - await DiscogsArtistImageService.EnrichAsync(artist, cancellationToken); - return artist; - } - - public async Task GetAlbumAsync(string id, CancellationToken cancellationToken = default) - { - var releaseJson = await GetJsonAsync($"release/{HttpUtility.UrlEncode(id)}?inc=recordings+artist-credits+release-groups+media+isrcs&fmt=json", cancellationToken); - if (releaseJson != null) - return ParseAlbumFromRelease(releaseJson); - - var releasesJson = await GetJsonAsync($"release?release-group={HttpUtility.UrlEncode(id)}&limit=12&status=official&inc=media&fmt=json", cancellationToken); - var releases = RankReleaseCandidates(releasesJson?["releases"] as JsonArray).Take(5).ToList(); - if (releases.Count == 0) - return null; - - MusicAlbum bestAlbum = null; - int bestScore = int.MinValue; - - foreach (var release in releases) - { - var releaseId = release["id"]?.GetValue(); - if (string.IsNullOrWhiteSpace(releaseId)) - continue; - - releaseJson = await GetJsonAsync($"release/{HttpUtility.UrlEncode(releaseId)}?inc=recordings+artist-credits+release-groups+media+isrcs&fmt=json", cancellationToken); - if (releaseJson == null) - continue; - - var parsedAlbum = ParseAlbumFromRelease(releaseJson); - if (parsedAlbum == null) - continue; - - var score = ScoreParsedRelease(parsedAlbum, release); - if (score > bestScore) - { - bestScore = score; - bestAlbum = parsedAlbum; - } - - if ((parsedAlbum.tracks?.Count ?? 0) >= 8 && !LooksLikeEditionNoise(parsedAlbum.title) && !LooksLikeEditionNoise(parsedAlbum.description)) - break; - } - - return bestAlbum; - } - - public async Task GetTrackAsync(string id, CancellationToken cancellationToken = default) - { - var trackJson = await GetJsonAsync($"recording/{HttpUtility.UrlEncode(id)}?inc=artist-credits+releases+isrcs&fmt=json", cancellationToken); - return trackJson == null ? null : ParseTrack(trackJson); - } - - static async Task GetJsonAsync(string path, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(path)) - return null; - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(requestTimeout); - var token = timeoutCts.Token; - long started = System.Diagnostics.Stopwatch.GetTimestamp(); - - try - { - for (int attempt = 0; attempt < 2; attempt++) - { - await RespectRateLimitAsync(token); - - TimeSpan delay; - 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, token); - if (response.IsSuccessStatusCode) - { - var json = await response.Content.ReadAsStringAsync(token); - return JsonNode.Parse(json) as JsonObject; - } - - if (attempt != 0 || (int)response.StatusCode is not (429 or 502 or 503 or 504)) - return null; - - var retryAfter = response.Headers.RetryAfter; - delay = retryAfter?.Delta - ?? (retryAfter?.Date is DateTimeOffset retryAt ? retryAt - DateTimeOffset.UtcNow : TimeSpan.Zero); - if (delay < TimeSpan.Zero) delay = TimeSpan.Zero; - } - - // Never shorten Retry-After or give the retry a fresh timeout budget. - var remaining = requestTimeout - System.Diagnostics.Stopwatch.GetElapsedTime(started); - if (delay >= remaining) return null; - if (delay > TimeSpan.Zero) await Task.Delay(delay, token); - } - - return null; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return null; - } - catch (HttpRequestException) - { - return null; - } - catch (System.Text.Json.JsonException) - { - return null; - } - } - - // MusicBrainz asks clients to keep Web Service requests at 1 req/s. - static async Task RespectRateLimitAsync(CancellationToken cancellationToken) - { - await requestGate.WaitAsync(cancellationToken); - - try - { - var now = DateTime.UtcNow; - if (nextRequestAt > now) - await Task.Delay(nextRequestAt - now, cancellationToken); - - nextRequestAt = DateTime.UtcNow.AddSeconds(1); - } - finally - { - requestGate.Release(); - } - } - - static List ParseArtists(JsonArray items) - { - if (items == null) - return new List(); - - return items - .Select(node => ParseArtist(node as JsonObject)) - .Where(item => item != null) - .ToList(); - } - - static MusicArtist ParseArtist(JsonObject item) - { - if (item == null) - return null; - - var id = item["id"]?.GetValue(); - if (string.IsNullOrWhiteSpace(id)) - return null; - - return new MusicArtist - { - id = id, - name = item["name"]?.GetValue(), - sort_name = item["sort-name"]?.GetValue(), - country = item["country"]?.GetValue(), - description = item["disambiguation"]?.GetValue(), - search_score = ParseInt(item["score"]), - provider_refs = new List - { - new() { provider = "musicbrainz", external_id = $"artist:{id}" } - } - }; - } - - static List ParseAlbums(JsonArray items) - { - if (items == null) - return new List(); - - return items - .Select(node => ParseAlbum(node as JsonObject)) - .Where(item => item != null) - .Where(item => string.IsNullOrWhiteSpace(item.type) || item.type is "Album" or "EP" or "Single" or "Soundtrack") - .ToList(); - } - - static MusicAlbum ParseAlbum(JsonObject item) - { - if (item == null) - return null; - - var id = item["id"]?.GetValue(); - if (string.IsNullOrWhiteSpace(id)) - return null; - - var artistCredit = item["artist-credit"] as JsonArray; - var artist = FirstArtist(artistCredit); - - return new MusicAlbum - { - id = id, - title = item["title"]?.GetValue(), - artist_id = artist.id, - artist_name = JoinArtistCredit(artistCredit), - year = ParseYear(item["first-release-date"]?.GetValue()), - date = item["first-release-date"]?.GetValue(), - type = item["primary-type"]?.GetValue(), - description = item["disambiguation"]?.GetValue(), - search_score = ParseInt(item["score"]), - secondary_types = ParseStringList(item["secondary-types"] as JsonArray), - images = BuildReleaseGroupImages(id), - provider_refs = new List - { - new() { provider = "musicbrainz", external_id = $"release-group:{id}" } - } - }; - } - - static List ParseTracks(JsonArray items) - { - if (items == null) - return new List(); - - return items - .Select(node => ParseTrack(node as JsonObject)) - .Where(item => item != null) - .ToList(); - } - - static MusicTrack ParseTrack(JsonObject item) - { - if (item == null) - return null; - - var id = item["id"]?.GetValue(); - if (string.IsNullOrWhiteSpace(id)) - return null; - - var artistCredit = item["artist-credit"] as JsonArray; - var release = SelectPreferredRelease(item["releases"] as JsonArray); - - return new MusicTrack - { - id = id, - title = item["title"]?.GetValue(), - artist_id = FirstArtist(artistCredit).id, - artist_name = JoinArtistCredit(artistCredit), - artists = ParseArtistNames(artistCredit), - album_id = release?["id"]?.GetValue(), - album_title = release?["title"]?.GetValue(), - isrc = ParseIsrc(item["isrcs"] as JsonArray), - duration_ms = item["length"]?.GetValue(), - date = release?["date"]?.GetValue(), - search_score = ParseInt(item["score"]), - provider_refs = new List - { - new() { provider = "musicbrainz", external_id = $"recording:{id}" } - } - }; - } - - static MusicAlbum ParseAlbumFromRelease(JsonObject release) - { - if (release == null) - return null; - - var releaseId = release["id"]?.GetValue(); - var releaseGroup = release["release-group"] as JsonObject; - var releaseGroupId = releaseGroup?["id"]?.GetValue(); - var releaseGroupTitle = releaseGroup?["title"]?.GetValue(); - var artistCredit = (releaseGroup?["artist-credit"] ?? release["artist-credit"]) as JsonArray; - var artist = FirstArtist(artistCredit); - var album = new MusicAlbum - { - id = releaseGroupId ?? releaseId, - release_id = releaseId, - title = releaseGroupTitle ?? release["title"]?.GetValue(), - artist_id = artist.id, - artist_name = JoinArtistCredit(artistCredit), - year = ParseYear(release["date"]?.GetValue() ?? releaseGroup?["first-release-date"]?.GetValue()), - date = release["date"]?.GetValue() ?? releaseGroup?["first-release-date"]?.GetValue(), - type = releaseGroup?["primary-type"]?.GetValue(), - description = releaseGroup?["disambiguation"]?.GetValue(), - images = !string.IsNullOrWhiteSpace(releaseGroupId) ? BuildReleaseGroupImages(releaseGroupId) : BuildReleaseImages(releaseId), - provider_refs = new List - { - new() { provider = "musicbrainz", external_id = $"release:{releaseId}" }, - new() { provider = "musicbrainz", external_id = $"release-group:{releaseGroupId ?? releaseId}" } - } - }; - - var media = release["media"] as JsonArray; - if (media != null) - { - foreach (var discNode in media.OfType()) - { - var discNumber = ParseInt(discNode["position"]); - var tracks = discNode["tracks"] as JsonArray; - if (tracks == null) - continue; - - foreach (var trackNode in tracks.OfType()) - { - var recording = trackNode["recording"] as JsonObject; - var trackId = recording?["id"]?.GetValue() ?? trackNode["id"]?.GetValue(); - if (string.IsNullOrWhiteSpace(trackId)) - continue; - - var trackArtistCredit = (recording?["artist-credit"] ?? trackNode["artist-credit"]) as JsonArray ?? artistCredit; - var trackArtist = FirstArtist(trackArtistCredit); - - album.tracks.Add(new MusicTrack - { - id = trackId, - title = recording?["title"]?.GetValue() ?? trackNode["title"]?.GetValue(), - artist_id = trackArtist.id, - artist_name = JoinArtistCredit(trackArtistCredit), - artists = ParseArtistNames(trackArtistCredit), - album_id = album.id, - album_title = album.title, - isrc = ParseIsrc(recording?["isrcs"] as JsonArray), - duration_ms = recording?["length"]?.GetValue() ?? trackNode["length"]?.GetValue(), - track_number = ParseInt(trackNode["number"]), - disc_number = discNumber, - date = album.date, - provider_refs = new List - { - new() { provider = "musicbrainz", external_id = $"recording:{trackId}" } - } - }); - } - } - } - - return album; - } - - static JsonObject SelectPreferredRelease(JsonArray releases) - { - return RankReleaseCandidates(releases).FirstOrDefault(); - } - - sealed class SearchContext - { - public string Query { get; init; } - public string NormalizedQuery { get; init; } - public MusicArtist BestArtist { get; init; } - public MusicArtist SecondaryArtist { get; init; } - public int BestArtistScore { get; init; } - public bool IsArtistOnly { get; init; } - public bool IsArtistPair { get; init; } - public bool IsMixed { get; init; } - public string TitleTerms { get; init; } - public string RankingQuery => !string.IsNullOrWhiteSpace(TitleTerms) ? TitleTerms : Query; - } - - sealed class ArtistTrackCandidate - { - public string Title { get; init; } - public string NormalizedTitle { get; init; } - public int? Year { get; init; } - } - - static SearchContext BuildSearchContext(string query, List artists) - { - var bestArtist = artists.FirstOrDefault(); - var bestArtistScore = bestArtist == null ? 0 : ScoreArtistCandidate(query, bestArtist); - if (!ShouldAnchorArtistQuery(query, bestArtist, bestArtistScore)) - { - bestArtist = null; - bestArtistScore = 0; - } - - var secondaryArtist = bestArtist == null - ? null - : artists - .Skip(1) - .FirstOrDefault(item => ShouldUseAsSecondaryArtist(query, bestArtist, item)); - var isArtistPair = bestArtist != null && secondaryArtist != null; - var isArtistOnly = bestArtist != null && IsExactArtistQuery(query, bestArtist); - var titleTerms = !isArtistOnly && !isArtistPair && bestArtistScore >= 180 ? ExtractQueryRemainder(query, bestArtist) : null; - - return new SearchContext - { - Query = query, - NormalizedQuery = NormalizeText(query), - BestArtist = bestArtist, - SecondaryArtist = secondaryArtist, - BestArtistScore = bestArtistScore, - IsArtistOnly = isArtistOnly, - IsArtistPair = isArtistPair, - IsMixed = !string.IsNullOrWhiteSpace(titleTerms), - TitleTerms = titleTerms - }; - } - - static void ApplyBestArtistFallback(List albums, MusicArtist bestArtist) - { - if (albums == null || bestArtist == null) - return; - - foreach (var album in albums.Where(item => string.IsNullOrWhiteSpace(item.artist_name))) - { - album.artist_id = bestArtist.id; - album.artist_name = bestArtist.name; - } - } - - static List RankArtists(List artists, string query, int limit) - { - return artists - .OrderByDescending(item => ScoreArtistCandidate(query, item)) - .ThenBy(item => item.name ?? string.Empty) - .Take(limit) - .ToList(); - } - - static string BuildAlbumSearchPath(SearchContext context, bool expanded) - { - if (context?.BestArtist != null && context.IsArtistOnly) - return $"release-group?artist={HttpUtility.UrlEncode(context.BestArtist.id)}&limit=100&fmt=json"; - - if (context?.BestArtist != null && context.IsArtistPair) - { - var query = $"{BuildArtistIdentityQuery(context.BestArtist)} AND {BuildArtistIdentityQuery(context.SecondaryArtist)}"; - return $"release-group?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 80 : 24)}&fmt=json"; - } - - if (context?.BestArtist != null && context.IsMixed) - { - var query = $"{BuildArtistIdentityQuery(context.BestArtist)} AND (releasegroup:{QuoteSearchTerm(context.TitleTerms)} OR release:{QuoteSearchTerm(context.TitleTerms)})"; - return $"release-group?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 80 : 24)}&fmt=json"; - } - - return $"release-group?query={HttpUtility.UrlEncode(context?.Query ?? string.Empty)}&limit={(expanded ? 80 : 24)}&fmt=json"; - } - - static string BuildTrackSearchPath(SearchContext context, bool expanded) - { - if (context?.BestArtist != null && context.IsArtistOnly) - { - var query = BuildArtistIdentityQuery(context.BestArtist); - return $"recording?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 140 : 60)}&fmt=json"; - } - - if (context?.BestArtist != null && context.IsArtistPair) - { - var query = $"{BuildArtistIdentityQuery(context.BestArtist)} AND {BuildArtistIdentityQuery(context.SecondaryArtist)}"; - return $"recording?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 120 : 40)}&fmt=json"; - } - - if (context?.BestArtist != null && context.IsMixed) - { - var query = $"{BuildArtistIdentityQuery(context.BestArtist)} AND recording:{QuoteSearchTerm(context.TitleTerms)}"; - return $"recording?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 120 : 40)}&fmt=json"; - } - - return $"recording?query={HttpUtility.UrlEncode(context?.Query ?? string.Empty)}&limit={(expanded ? 140 : 60)}&fmt=json"; - } - - static string BuildFeaturedTracksSearchPath(SearchContext context, IEnumerable preferredTrackTitles, bool expanded) - { - if (context?.BestArtist == null || preferredTrackTitles == null) - return null; - - var identityQuery = BuildArtistIdentityQuery(context.BestArtist); - var titleQuery = string.Join(" OR ", preferredTrackTitles - .Where(item => !string.IsNullOrWhiteSpace(item)) - .Select(item => - { - var quoted = QuoteSearchTerm(item); - return $"(recording:{quoted} AND (releasegroup:{quoted} OR release:{quoted}))"; - })); - - if (string.IsNullOrWhiteSpace(identityQuery) || string.IsNullOrWhiteSpace(titleQuery)) - return null; - - return $"recording?query={HttpUtility.UrlEncode($"({titleQuery}) AND {identityQuery}")}&limit={(expanded ? 300 : 200)}&fmt=json"; - } - - static string BuildSupplementalSinglesSearchPath(SearchContext context) - { - var name = context?.BestArtist?.name; - if (string.IsNullOrWhiteSpace(name)) - return null; - - return $"release-group?query={HttpUtility.UrlEncode($"artist:{QuoteSearchTerm(name)} AND primarytype:single")}&limit=60&fmt=json"; - } - - async Task> BuildArtistOnlyTracksAsync( - SearchContext context, - List albums, - HashSet preferredAlbumTitles, - HashSet preferredAlbumArtists, - bool expanded, - CancellationToken cancellationToken) - { - var candidateLimit = expanded ? 40 : 24; - var fallbackLimit = expanded ? 10 : 2; - var targetFeaturedCount = expanded ? 40 : 20; - var browseCandidates = SelectArtistTrackCandidates(albums); - ArtistTrackCandidate[] candidates; - - if (browseCandidates.Count >= 20) - { - candidates = browseCandidates - .Where(item => !LooksLikeCandidateSingleNoise(item.Title, context?.BestArtist)) - .Take(candidateLimit) - .ToArray(); - } - else - { - var candidateAlbums = new List(); - var supplementalSinglesJson = await GetJsonAsync(BuildSupplementalSinglesSearchPath(context), cancellationToken); - if (supplementalSinglesJson?["release-groups"] is JsonArray supplementalSingles) - candidateAlbums.AddRange(ParseAlbums(supplementalSingles)); - - ApplyBestArtistFallback(candidateAlbums, context?.BestArtist); - var supplementalCandidates = SelectArtistTrackCandidates(candidateAlbums); - candidates = MergeArtistTrackCandidates(browseCandidates, supplementalCandidates, context); - } - - var featuredTracks = new List(); - - if (candidates.Length > 0) - { - var bestByTitle = new Dictionary(StringComparer.Ordinal); - var chunkRequests = candidates - .Chunk(8) - .Select(chunk => new - { - candidates = chunk.ToArray(), - task = GetJsonAsync(BuildFeaturedTracksSearchPath(context, chunk.Select(item => item.Title), expanded), cancellationToken) - }) - .ToArray(); - - await Task.WhenAll(chunkRequests.Select(item => item.task)); - - foreach (var request in chunkRequests) - { - var json = await request.task; - CollectFeaturedTracks(bestByTitle, json?["recordings"] as JsonArray, context, request.candidates, preferredAlbumTitles); - } - - var missingCandidates = candidates - .Where(item => !bestByTitle.ContainsKey(item.NormalizedTitle)) - .Take(fallbackLimit) - .ToArray(); - - foreach (var candidate in missingCandidates) - { - var json = await GetJsonAsync(BuildFeaturedTracksSearchPath(context, new[] { candidate.Title }, expanded), cancellationToken); - CollectFeaturedTracks(bestByTitle, json?["recordings"] as JsonArray, context, new[] { candidate }, preferredAlbumTitles); - - if (bestByTitle.Count >= targetFeaturedCount) - break; - } - - featuredTracks = bestByTitle.Values - .OrderByDescending(item => item.score) - .ThenByDescending(item => ParseYear(item.track.date) ?? 0) - .ThenBy(item => item.track.title ?? string.Empty) - .Select(item => item.track) - .ToList(); - } - - if (featuredTracks.Count >= targetFeaturedCount) - return RankTracks(featuredTracks, context, preferredAlbumTitles, preferredAlbumArtists, expanded ? 100 : 24); - - var genericTracksJson = await GetJsonAsync(BuildTrackSearchPath(context, expanded), cancellationToken); - var genericTracks = ParseTracks(genericTracksJson?["recordings"] as JsonArray) - .Where(item => item != null) - .Where(item => !LooksLikeEditionNoise(item.album_title)) - .Where(item => !LooksLikeFeaturedTrackNoise(item.title)) - .ToList(); - - return RankTracks(featuredTracks.Concat(genericTracks).ToList(), context, preferredAlbumTitles, preferredAlbumArtists, expanded ? 100 : 24); - } - - static string BuildArtistIdentityQuery(MusicArtist artist) - { - var parts = new List(); - if (!string.IsNullOrWhiteSpace(artist?.id)) - parts.Add($"(arid:{artist.id})"); - if (!string.IsNullOrWhiteSpace(artist?.name)) - { - parts.Add($"(artistname:{QuoteSearchTerm(artist.name)})"); - parts.Add($"(creditname:{QuoteSearchTerm(artist.name)})"); - } - - if (!string.IsNullOrWhiteSpace(artist?.sort_name) - && !string.Equals(NormalizeText(artist.sort_name), NormalizeText(artist.name), StringComparison.Ordinal)) - { - parts.Add($"(artistname:{QuoteSearchTerm(artist.sort_name)})"); - parts.Add($"(creditname:{QuoteSearchTerm(artist.sort_name)})"); - } - - return parts.Count == 0 ? string.Empty : "(" + string.Join(" OR ", parts) + ")"; - } - - static List RankAlbums(List albums, SearchContext context, int limit) - { - return OrderAlbums(albums, context) - .Take(limit) - .ToList(); - } - - static List OrderAlbums(List albums, SearchContext context) - { - return albums - .GroupBy(item => item.id) - .Select(group => group.First()) - .OrderByDescending(item => ScoreAlbum(item, context)) - .ThenBy(item => AlbumSortYear(item, context)) - .ThenBy(item => item.title ?? string.Empty) - .ToList(); - } - - static List RankTracks(List tracks, SearchContext context, HashSet preferredAlbumTitles, HashSet preferredAlbumArtists, int limit) - { - return tracks - .GroupBy(BuildTrackDedupKey) - .Select(group => group - .OrderByDescending(item => ScoreTrack(item, context, preferredAlbumTitles, preferredAlbumArtists)) - .ThenBy(item => TrackSortYear(item, context)) - .ThenBy(item => item.title ?? string.Empty) - .First()) - .OrderByDescending(item => ScoreTrack(item, context, preferredAlbumTitles, preferredAlbumArtists)) - .ThenBy(item => TrackSortYear(item, context)) - .ThenBy(item => item.title ?? string.Empty) - .Take(limit) - .ToList(); - } - - static string BuildTrackDedupKey(MusicTrack track) - { - if (track == null) - return string.Empty; - - var title = NormalizeText(track.title); - var artist = NormalizeText(track.artist_name); - if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(artist)) - return $"{title}|{artist}"; - - return track.id ?? string.Empty; - } - - static List SelectArtistTrackCandidates(List albums) - { - return albums? - .Where(item => item != null) - .Where(item => string.Equals(item.type, "Single", StringComparison.OrdinalIgnoreCase)) - .Where(item => item.secondary_types.Count == 0) - .Where(item => !LooksLikeEditionNoise(item.title)) - .OrderByDescending(item => item.year ?? 0) - .ThenBy(item => item.title ?? string.Empty) - .GroupBy(item => NormalizeText(item.title)) - .Select(group => - { - var album = group.First(); - return new ArtistTrackCandidate - { - Title = album.title, - NormalizedTitle = NormalizeText(album.title), - Year = album.year - }; - }) - .Where(item => !string.IsNullOrWhiteSpace(item.NormalizedTitle)) - .Take(40) - .ToList() - ?? new List(); - } - - static ArtistTrackCandidate[] MergeArtistTrackCandidates(List browseCandidates, List supplementalCandidates, SearchContext context) - { - var selected = new List(); - var seen = new HashSet(StringComparer.Ordinal); - - void append(IEnumerable source, int limit) - { - foreach (var item in source) - { - if (selected.Count >= 24 || limit <= 0) - break; - - if (item == null || string.IsNullOrWhiteSpace(item.NormalizedTitle)) - continue; - - if (LooksLikeCandidateSingleNoise(item.Title, context?.BestArtist)) - continue; - - if (!seen.Add(item.NormalizedTitle)) - continue; - - selected.Add(item); - limit--; - } - } - - append(supplementalCandidates?.OrderByDescending(item => item.Year ?? 0).ThenBy(item => item.Title ?? string.Empty) ?? Enumerable.Empty(), 14); - append(browseCandidates?.OrderByDescending(item => item.Year ?? 0).ThenBy(item => item.Title ?? string.Empty) ?? Enumerable.Empty(), 12); - append(supplementalCandidates?.OrderByDescending(item => item.Year ?? 0).ThenBy(item => item.Title ?? string.Empty) ?? Enumerable.Empty(), 24); - append(browseCandidates?.OrderByDescending(item => item.Year ?? 0).ThenBy(item => item.Title ?? string.Empty) ?? Enumerable.Empty(), 24); - - return selected.Take(24).ToArray(); - } - - static void ApplyPreferredCandidateRelease(MusicTrack track, JsonArray releases, ArtistTrackCandidate candidate) - { - if (track == null || releases == null || candidate == null || string.IsNullOrWhiteSpace(candidate.NormalizedTitle)) - return; - - var preferred = releases - .OfType() - .Where(item => NormalizeText(item["title"]?.GetValue()) == candidate.NormalizedTitle) - .OrderBy(item => LooksLikeEditionNoise(item["title"]?.GetValue()) ? 1 : 0) - .ThenBy(item => item["date"]?.GetValue() ?? "9999") - .FirstOrDefault(); - - if (preferred == null) - return; - - track.album_id = preferred["id"]?.GetValue() ?? track.album_id; - track.album_title = preferred["title"]?.GetValue() ?? track.album_title; - track.date = preferred["date"]?.GetValue() ?? track.date; - } - - static void CollectFeaturedTracks( - Dictionary bestByTitle, - JsonArray recordings, - SearchContext context, - ArtistTrackCandidate[] candidates, - HashSet preferredAlbumTitles) - { - if (bestByTitle == null || recordings == null || context?.BestArtist == null || candidates == null || candidates.Length == 0) - return; - - var candidateMap = candidates.ToDictionary(item => item.NormalizedTitle, item => item, StringComparer.Ordinal); - - foreach (var item in recordings.OfType()) - { - var title = item["title"]?.GetValue(); - if (string.IsNullOrWhiteSpace(title) || LooksLikeFeaturedTrackNoise(title)) - continue; - - var candidate = MatchArtistTrackCandidate(title, candidateMap); - if (candidate == null) - continue; - - var track = ParseTrack(item); - if (track == null || LooksLikeFeaturedTrackNoise(track.album_title)) - continue; - - ApplyPreferredCandidateRelease(track, item["releases"] as JsonArray, candidate); - - var artistCredit = item["artist-credit"] as JsonArray; - var artistName = JoinArtistCredit(artistCredit); - var artistMatch = ScoreArtistMatch(artistName, context.BestArtist); - if (artistMatch < 55) - continue; - - var score = ScoreArtistTrackCandidate(item, track, candidate, context.BestArtist, preferredAlbumTitles, artistMatch); - if (score < 320) - continue; - - track.search_score = Math.Max(track.search_score ?? 0, 300 + score); - - if (!bestByTitle.TryGetValue(candidate.NormalizedTitle, out var current) || score > current.score) - bestByTitle[candidate.NormalizedTitle] = (track, score); - } - } - - static int ScoreAlbum(MusicAlbum album, SearchContext context) - { - if (album == null) - return int.MinValue; - - var score = (album.search_score ?? 0) * (context?.IsArtistOnly == true ? 1 : 4); - var artistMatch = ScoreArtistMatch(album.artist_name, context?.BestArtist); - if (context?.BestArtist != null) - score += context.IsArtistOnly ? artistMatch * 3 : artistMatch * 2; - - if (context == null || !context.IsArtistOnly) - score += ScoreTitleMatch(album.title, context?.RankingQuery); - - if (context?.BestArtist == null && IsExactQueryTitle(album.title, context)) - score += 90; - - score += album.type switch - { - "Album" => 80, - "EP" => 55, - "Single" => 35, - "Soundtrack" => 20, - _ => 10 - }; - - if (context?.IsArtistOnly == true) - { - if (album.type == "Album" && album.secondary_types.Count == 0) - score += 140; - else if (album.type == "EP" && album.secondary_types.Count == 0) - score += 80; - else if (album.type == "Single" && album.secondary_types.Count == 0) - score += 30; - } - - if (album.year.HasValue) - score += 5; - - if (album.secondary_types.Count > 0) - { - if (album.secondary_types.Contains("Compilation", StringComparer.OrdinalIgnoreCase)) - score -= 90; - if (album.secondary_types.Contains("Live", StringComparer.OrdinalIgnoreCase)) - score -= 85; - if (album.secondary_types.Contains("Remix", StringComparer.OrdinalIgnoreCase)) - score -= 85; - if (album.secondary_types.Contains("DJ-mix", StringComparer.OrdinalIgnoreCase)) - score -= 180; - if (album.secondary_types.Contains("Mixtape/Street", StringComparer.OrdinalIgnoreCase)) - score -= 160; - if (album.secondary_types.Contains("Soundtrack", StringComparer.OrdinalIgnoreCase) && album.type != "Soundtrack") - score -= 35; - } - - if (LooksLikeEditionNoise(album.title) || LooksLikeEditionNoise(album.description)) - score -= 55; - - return score; - } - - static int ScoreTrack(MusicTrack track, SearchContext context, HashSet preferredAlbumTitles, HashSet preferredAlbumArtists) - { - if (track == null) - return int.MinValue; - - var score = (track.search_score ?? 0) * 4; - var artistMatch = ScoreArtistMatch(track.artist_name, context?.BestArtist); - if (context?.BestArtist != null) - score += context.IsArtistOnly ? artistMatch * 3 : artistMatch * 2; - - if (context == null || !context.IsArtistOnly) - score += ScoreTitleMatch(track.title, context?.RankingQuery); - - if (context?.BestArtist == null && IsExactQueryTitle(track.title, context)) - score += 90; - - if (track.duration_ms.HasValue) - score += 8; - - if (!string.IsNullOrWhiteSpace(track.album_title)) - score += 6; - - if (IsExactQueryTitle(track.album_title, context)) - score += 40; - - var normalizedAlbumTitle = NormalizeText(track.album_title); - if (!string.IsNullOrWhiteSpace(normalizedAlbumTitle) && preferredAlbumTitles?.Contains(normalizedAlbumTitle) == true) - score += 70; - - var normalizedArtistName = NormalizeText(track.artist_name); - if (!string.IsNullOrWhiteSpace(normalizedArtistName) && preferredAlbumArtists?.Any(item => normalizedArtistName.Contains(item, StringComparison.Ordinal) || item.Contains(normalizedArtistName, StringComparison.Ordinal)) == true) - score += 55; - - if (LooksLikeEditionNoise(track.album_title)) - score -= 70; - - if (LooksLikeTrackNoise(track.title)) - score -= 65; - - return score; - } - - static int ScoreArtistCandidate(string query, MusicArtist artist) - { - if (artist == null) - return int.MinValue; - - var normalizedQuery = NormalizeText(query); - var normalizedName = NormalizeText(artist.name); - var normalizedSortName = NormalizeText(artist.sort_name); - - var score = 0; - - if (normalizedQuery == normalizedName) - score += 320; - else if (normalizedQuery == normalizedSortName) - score += 280; - else if (!string.IsNullOrWhiteSpace(normalizedName) && normalizedQuery.StartsWith(normalizedName + " ", StringComparison.Ordinal)) - score += 220; - else if (!string.IsNullOrWhiteSpace(normalizedName) && normalizedQuery.Contains(normalizedName, StringComparison.Ordinal)) - score += 170; - - score += ScoreTokenOverlap(normalizedQuery, normalizedName) * 25; - score += ScoreTokenOverlap(normalizedQuery, normalizedSortName) * 12; - - if (!string.IsNullOrWhiteSpace(artist.description)) - score -= 5; - - return score; - } - - static bool ShouldAnchorArtistQuery(string query, MusicArtist artist, int score) - { - if (artist == null) - return false; - - if (IsExactArtistQuery(query, artist)) - return true; - - if (score < 220) - return false; - - if (string.IsNullOrWhiteSpace(artist.country) && string.IsNullOrWhiteSpace(artist.description)) - return false; - - var normalizedQuery = NormalizeText(query); - var normalizedName = NormalizeText(artist.name); - var normalizedSortName = NormalizeText(artist.sort_name); - if (string.IsNullOrWhiteSpace(normalizedQuery)) - return false; - - if (!string.IsNullOrWhiteSpace(normalizedName) && normalizedQuery.StartsWith(normalizedName + " ", StringComparison.Ordinal)) - return true; - - if (!string.IsNullOrWhiteSpace(normalizedSortName) && normalizedQuery.StartsWith(normalizedSortName + " ", StringComparison.Ordinal)) - return true; - - return Regex.IsMatch(query, @"(?i)(^|\s)(feat\.?|ft\.?|featuring|with|vs\.?|x|×)(\s|$)|[-–—:/]"); - } - - static bool ShouldUseAsSecondaryArtist(string query, MusicArtist primaryArtist, MusicArtist secondaryArtist) - { - if (secondaryArtist == null) - return false; - - if (!Regex.IsMatch(query, @"(?i)(^|\s)(feat\.?|ft\.?|featuring|with|and|vs\.?|x|×)(\s|$)|[-–—:/]")) - return false; - - var normalizedPrimary = NormalizeText(primaryArtist?.name); - var normalizedSecondary = NormalizeText(secondaryArtist?.name); - var normalizedQuery = NormalizeText(query); - if (string.IsNullOrWhiteSpace(normalizedSecondary) || normalizedSecondary == normalizedPrimary) - return false; - - if (!normalizedQuery.Contains(normalizedSecondary, StringComparison.Ordinal)) - return false; - - return !string.IsNullOrWhiteSpace(secondaryArtist.country) || !string.IsNullOrWhiteSpace(secondaryArtist.description); - } - - static bool IsExactArtistQuery(string query, MusicArtist artist) - { - var normalizedQuery = NormalizeText(query); - return normalizedQuery == NormalizeText(artist?.name) - || normalizedQuery == NormalizeText(artist?.sort_name); - } - - static string ExtractQueryRemainder(string query, MusicArtist artist) - { - foreach (var candidate in new[] { artist?.name, artist?.sort_name }.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase)) - { - var stripped = Regex.Replace(query, Regex.Escape(candidate), " ", RegexOptions.IgnoreCase); - stripped = Regex.Replace(stripped, @"(?i)\b(feat\.?|ft\.?|featuring|with|and|x|×|vs\.?)\b", " "); - stripped = Regex.Replace(stripped, @"[-–—:;,/()]+", " "); - stripped = Regex.Replace(stripped, @"\s+", " ").Trim(); - - if (!string.IsNullOrWhiteSpace(stripped) && stripped.Length >= 3 && !string.Equals(stripped, query.Trim(), StringComparison.OrdinalIgnoreCase)) - return stripped; - } - - return null; - } - - static int ScoreTitleMatch(string title, string query) - { - var normalizedTitle = NormalizeText(title); - var normalizedQuery = NormalizeText(query); - if (string.IsNullOrWhiteSpace(normalizedTitle) || string.IsNullOrWhiteSpace(normalizedQuery)) - return 0; - - var score = 0; - if (normalizedTitle == normalizedQuery) - score += 220; - else if (normalizedTitle.StartsWith(normalizedQuery + " ", StringComparison.Ordinal) || normalizedTitle.Contains(" " + normalizedQuery + " ", StringComparison.Ordinal)) - score += 140; - else if (normalizedTitle.Contains(normalizedQuery, StringComparison.Ordinal)) - score += 90; - - score += ScoreTokenOverlap(normalizedQuery, normalizedTitle) * 20; - return score; - } - - static ArtistTrackCandidate MatchArtistTrackCandidate(string title, Dictionary candidates) - { - var normalizedTitle = NormalizeText(title); - if (string.IsNullOrWhiteSpace(normalizedTitle)) - return null; - - foreach (var candidate in candidates.Values) - { - var matchScore = ScoreArtistTrackTitleVariant(normalizedTitle, candidate.NormalizedTitle); - if (matchScore > 0) - return candidate; - } - - return null; - } - - static int ScoreArtistTrackTitleVariant(string normalizedTitle, string candidateTitle) - { - if (string.IsNullOrWhiteSpace(normalizedTitle) || string.IsNullOrWhiteSpace(candidateTitle)) - return 0; - - if (normalizedTitle == candidateTitle) - return 220; - - if (!normalizedTitle.StartsWith(candidateTitle + " ", StringComparison.Ordinal)) - return 0; - - var suffix = normalizedTitle[(candidateTitle.Length + 1)..]; - return suffix switch - { - "edited version" => 180, - "single version" => 175, - "album version" => 170, - "radio version" => 165, - _ => 0 - }; - } - - static int ScoreArtistTrackCandidate( - JsonObject item, - MusicTrack track, - ArtistTrackCandidate candidate, - MusicArtist bestArtist, - HashSet preferredAlbumTitles, - int artistMatch) - { - var titleScore = ScoreArtistTrackTitleVariant(NormalizeText(track.title), candidate.NormalizedTitle); - if (titleScore == 0) - return int.MinValue; - - var score = titleScore; - score += artistMatch * 3; - score += (ParseInt(item["score"]) ?? 0) * 2; - - var releases = item["releases"] as JsonArray; - if (HasExactReleaseTitle(releases, candidate.Title)) - score += 120; - - var releaseYearDelta = BestReleaseYearDelta(releases, candidate.Year); - if (releaseYearDelta.HasValue) - score += Math.Max(0, 40 - (releaseYearDelta.Value * 10)); - - if (StartsWithArtist(track.artist_name, bestArtist)) - score += 20; - - if (CountDistinctArtists(item["artist-credit"] as JsonArray) >= 2) - score += 12; - - if (track.duration_ms.HasValue) - score += 8; - - var normalizedAlbumTitle = NormalizeText(track.album_title); - if (!string.IsNullOrWhiteSpace(normalizedAlbumTitle) && preferredAlbumTitles?.Contains(normalizedAlbumTitle) == true) - score += 50; - - if (LooksLikeEditionNoise(track.album_title)) - score -= 50; - - if (LooksLikeFeaturedTrackNoise(track.title) || LooksLikeFeaturedTrackNoise(track.album_title)) - score -= 120; - - return score; - } - - static bool StartsWithArtist(string artistName, MusicArtist bestArtist) - { - var normalizedArtist = NormalizeText(artistName); - var normalizedName = NormalizeText(bestArtist?.name); - var normalizedSortName = NormalizeText(bestArtist?.sort_name); - - return (!string.IsNullOrWhiteSpace(normalizedName) && normalizedArtist.StartsWith(normalizedName, StringComparison.Ordinal)) - || (!string.IsNullOrWhiteSpace(normalizedSortName) && normalizedArtist.StartsWith(normalizedSortName, StringComparison.Ordinal)); - } - - static bool HasExactReleaseTitle(JsonArray releases, string title) - { - var normalizedTitle = NormalizeText(title); - if (string.IsNullOrWhiteSpace(normalizedTitle) || releases == null) - return false; - - return releases - .OfType() - .Select(item => NormalizeText(item["title"]?.GetValue())) - .Any(item => item == normalizedTitle); - } - - static int? BestReleaseYearDelta(JsonArray releases, int? year) - { - if (releases == null || !year.HasValue) - return null; - - var deltas = releases - .OfType() - .Select(item => ParseYear(item["date"]?.GetValue())) - .Where(item => item.HasValue) - .Select(item => Math.Abs(item.Value - year.Value)) - .ToList(); - - return deltas.Count == 0 ? null : deltas.Min(); - } - - static int CountDistinctArtists(JsonArray artistCredit) - { - if (artistCredit == null) - return 0; - - return artistCredit - .OfType() - .Select(item => NormalizeText(item["name"]?.GetValue() ?? item["artist"]?["name"]?.GetValue())) - .Where(item => !string.IsNullOrWhiteSpace(item)) - .Distinct(StringComparer.Ordinal) - .Count(); - } - - static bool IsExactQueryTitle(string value, SearchContext context) - { - return !string.IsNullOrWhiteSpace(value) - && !string.IsNullOrWhiteSpace(context?.NormalizedQuery) - && NormalizeText(value) == context.NormalizedQuery; - } - - static int AlbumSortYear(MusicAlbum album, SearchContext context) - { - if (context?.BestArtist == null && IsExactQueryTitle(album.title, context)) - return album.year ?? 9999; - - return -(album.year ?? 0); - } - - static int TrackSortYear(MusicTrack track, SearchContext context) - { - var year = ParseYear(track.date) ?? 9999; - if (context?.BestArtist == null && IsExactQueryTitle(track.title, context)) - return year; - - return -year; - } - - static int ScoreArtistMatch(string artistName, MusicArtist artist) - { - if (artist == null || string.IsNullOrWhiteSpace(artistName)) - return 0; - - var normalizedArtist = NormalizeText(artistName); - var normalizedName = NormalizeText(artist.name); - var normalizedSortName = NormalizeText(artist.sort_name); - - if (normalizedArtist == normalizedName || normalizedArtist == normalizedSortName) - return 100; - - if (!string.IsNullOrWhiteSpace(normalizedName) && normalizedArtist.Contains(normalizedName, StringComparison.Ordinal)) - return 70; - - if (!string.IsNullOrWhiteSpace(normalizedSortName) && normalizedArtist.Contains(normalizedSortName, StringComparison.Ordinal)) - return 55; - - return ScoreTokenOverlap(normalizedArtist, normalizedName) * 10; - } - - static int ScoreTokenOverlap(string left, string right) - { - var leftTokens = SplitTokens(left); - var rightTokens = SplitTokens(right); - - if (leftTokens.Count == 0 || rightTokens.Count == 0) - return 0; - - return leftTokens.Count(token => rightTokens.Contains(token)); - } - - static HashSet SplitTokens(string value) - { - return NormalizeText(value) - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .ToHashSet(StringComparer.Ordinal); - } - - static string NormalizeText(string value) - { - return Regex.Replace((value ?? string.Empty).ToLowerInvariant(), @"[^a-z0-9а-яёіїєґ]+", " ") - .Trim(); - } - - static string QuoteSearchTerm(string value) - { - value = (value ?? string.Empty).Trim().Replace("\\", "\\\\").Replace("\"", "\\\""); - return $"\"{value}\""; - } - - static List ParseStringList(JsonArray items) - { - if (items == null) - return new List(); - - return items - .Select(item => item?.GetValue()) - .Where(item => !string.IsNullOrWhiteSpace(item)) - .ToList(); - } - - static IEnumerable RankReleaseCandidates(JsonArray releases) - { - return releases? - .OfType() - .OrderByDescending(item => ScoreReleaseCandidate(item)) - .ThenBy(item => item["date"]?.GetValue() ?? "9999") - ?? Enumerable.Empty(); - } - - static int ScoreReleaseCandidate(JsonObject release) - { - if (release == null) - return int.MinValue; - - var score = 0; - if (string.Equals(release["status"]?.GetValue(), "Official", StringComparison.OrdinalIgnoreCase)) - score += 200; - - score += Math.Min(180, ReadTrackCount(release) * 10); - - var title = release["title"]?.GetValue(); - var comment = release["disambiguation"]?.GetValue(); - if (LooksLikeEditionNoise(title) || LooksLikeEditionNoise(comment)) - score -= 80; - - if (!string.IsNullOrWhiteSpace(release["country"]?.GetValue())) - score += 10; - - if (!string.IsNullOrWhiteSpace(release["date"]?.GetValue())) - score += 5; - - return score; - } - - static int ScoreParsedRelease(MusicAlbum album, JsonObject release) - { - if (album == null) - return int.MinValue; - - var score = ScoreReleaseCandidate(release); - score += Math.Min(240, (album.tracks?.Count ?? 0) * 18); - - if (album.type == "Album") - score += 35; - else if (album.type == "EP") - score += 20; - else if (album.type == "Single") - score += 5; - - if (LooksLikeEditionNoise(album.title) || LooksLikeEditionNoise(album.description)) - score -= 60; - - return score; - } - - static int ReadTrackCount(JsonObject release) - { - var media = release?["media"] as JsonArray; - if (media == null) - return 0; - - return media - .OfType() - .Sum(item => ParseInt(item["track-count"]) ?? 0); - } - - static bool LooksLikeEditionNoise(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return false; - - return Regex.IsMatch(value, @"(?i)\b(deluxe|remaster|anniversary|collector|bonus|expanded|instrumental|karaoke|tribute|radio edit|clean|explicit|commentary|acappella|a cappella|remix|mix|edit|edited|video|live|bootleg|mixtape|street)\b"); - } - - static bool LooksLikeTrackNoise(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return false; - - return Regex.IsMatch(value, @"(?i)\b(live|remix|karaoke|instrumental|demo|radio edit|clean|explicit|acappella|a cappella)\b"); - } - - static bool LooksLikeFeaturedTrackNoise(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return false; - - return Regex.IsMatch(value, @"(?i)\b(live|remix|karaoke|instrumental|demo|acappella|a cappella|bootleg|mix)\b"); - } - - static bool LooksLikeCandidateSingleNoise(string value, MusicArtist bestArtist) - { - if (string.IsNullOrWhiteSpace(value)) - return false; - - if (LooksLikeEditionNoise(value)) - return true; - - if (Regex.IsMatch(value, @"(?i)\b(feat\.?|ft\.?|featuring|cypher|bootleg)\b")) - return true; - - if (value.Contains('/') || value.Contains('\\')) - return true; - - if (Regex.IsMatch(value, @"(?i)\bgodzilla\s*\d+\b")) - return true; - - var normalizedTitle = NormalizeText(value); - var normalizedArtist = NormalizeText(bestArtist?.name); - if (!string.IsNullOrWhiteSpace(normalizedArtist) && normalizedTitle.StartsWith(normalizedArtist + " ", StringComparison.Ordinal)) - return true; - - return false; - } - - static List BuildReleaseGroupImages(string id) - { - if (string.IsNullOrWhiteSpace(id)) - return new List(); - - return new List - { - new() { url = $"https://coverartarchive.org/release-group/{id}/front-250", width = 250, height = 250 } - }; - } - - static List BuildReleaseImages(string id) - { - if (string.IsNullOrWhiteSpace(id)) - return new List(); - - return new List - { - new() { url = $"https://coverartarchive.org/release/{id}/front-250", width = 250, height = 250 } - }; - } - - static (string id, string name) FirstArtist(JsonArray artistCredit) - { - var artist = artistCredit? - .OfType() - .Select(item => item["artist"] as JsonObject) - .FirstOrDefault(item => item != null); - - return artist == null - ? default - : (artist["id"]?.GetValue(), artist["name"]?.GetValue()); - } - - static string JoinArtistCredit(JsonArray artistCredit) - { - if (artistCredit == null) - return null; - - return string.Concat(artistCredit.OfType().Select(item => - (item["name"]?.GetValue() ?? item["artist"]?["name"]?.GetValue() ?? string.Empty) + - (item["joinphrase"]?.GetValue() ?? string.Empty))); - } - - static List ParseArtistNames(JsonArray artistCredit) - { - if (artistCredit == null) - return new List(); - - return artistCredit - .OfType() - .Select(item => item["artist"]?["name"]?.GetValue() ?? item["name"]?.GetValue()) - .Where(item => !string.IsNullOrWhiteSpace(item)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - static string ParseIsrc(JsonArray values) - { - if (values == null) - return null; - - foreach (var item in values.OfType()) - { - if (item.TryGetValue(out var value)) - { - string isrc = MusicIsrc.Normalize(value); - if (isrc != null) - return isrc; - } - } - - return null; - } - - static int? ParseYear(string date) - { - if (!string.IsNullOrWhiteSpace(date) && date.Length >= 4 && int.TryParse(date[..4], out var year)) - return year; - - return null; - } - - static int? ParseInt(JsonNode value) - { - if (value == null) - return null; - - if (value.GetValueKind() == System.Text.Json.JsonValueKind.Number) - return value.GetValue(); - - return int.TryParse(value.ToString(), out var number) ? number : null; - } -} diff --git a/mods/Music/Providers/MusicMap/MusicMapSupport.cs b/mods/Music/Providers/MusicMap/MusicMapSupport.cs deleted file mode 100644 index c070c12..0000000 --- a/mods/Music/Providers/MusicMap/MusicMapSupport.cs +++ /dev/null @@ -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(@"
(.*?)
", RegexOptions.Singleline | RegexOptions.Compiled); - static readonly Regex anchorRegex = new("([^<]*)", RegexOptions.Compiled); - - static MusicMapSupport() - { - httpClient.Timeout = TimeSpan.FromSeconds(10); - } - - public static async Task> GetSimilarArtistsAsync(string artist, CancellationToken cancellationToken = default) - { - string normalized = NormalizeName(artist); - if (string.IsNullOrWhiteSpace(normalized)) - return new List(); - - try - { - // пустой ответ кэшируется коротким emptyTtl (20 мин) — разовый - // сбой сайта не залипает на 14 дней - return await MusicMetadataCacheService.GetOrCreateAsync( - "musicmap", - "similar_artists", - $"{cacheVersion}|{normalized}", - similarCacheTtl, - () => FetchSimilarArtistsAsync(artist, cancellationToken), - cancellationToken - ) ?? new List(); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - return new List(); - } - } - - static async Task> FetchSimilarArtistsAsync(string artist, CancellationToken cancellationToken) - { - var result = new List(); - - 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; - } - - /// - /// Фильтр кандидатов карты: выбрасывает алиасы/проекты сид-артистов - /// (containment + fuzzy + потокенно — ловит «Myagi», «Mijagi», - /// «Gruppa Skryptonite»), артистов истории (кросс-скрипт через транслит: - /// «Скриптонит» == «Scriptonite») и дубли по нормализованному имени. - /// Порядок карты (по близости) сохраняется. - /// - public static List FilterCandidates(IEnumerable candidates, IEnumerable aliasReferences, IEnumerable excludeNames) - { - var references = (aliasReferences ?? Enumerable.Empty()).Where(i => !string.IsNullOrWhiteSpace(i)).ToList(); - var excludedRaw = (excludeNames ?? Enumerable.Empty()).Where(i => !string.IsNullOrWhiteSpace(i)).ToList(); - var excluded = excludedRaw.Select(NormalizeName).Where(i => !string.IsNullOrWhiteSpace(i)).ToList(); - - var result = new List(); - var seen = new HashSet(StringComparer.Ordinal); - - foreach (var candidate in candidates ?? Enumerable.Empty()) - { - 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 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 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]; - } -} diff --git a/mods/Music/Providers/SoundCloud/SoundCloudDtos.cs b/mods/Music/Providers/SoundCloud/SoundCloudDtos.cs deleted file mode 100644 index a695eaa..0000000 --- a/mods/Music/Providers/SoundCloud/SoundCloudDtos.cs +++ /dev/null @@ -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 -{ - public List collection { get; set; } = new(); - public string next_href { get; set; } - public string query_urn { get; set; } -} - -public class SoundCloudNestedCollectionDto -{ - public List collection { get; set; } = new(); -} - -public class SoundCloudChartSelectionDto -{ - public string urn { get; set; } - public string title { get; set; } - public SoundCloudNestedCollectionDto 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 tracks { get; set; } = new(); -} - -public class SoundCloudMediaDto -{ - public List 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; } -} diff --git a/mods/Music/Providers/SoundCloud/SoundCloudSupport.Audio.cs b/mods/Music/Providers/SoundCloud/SoundCloudSupport.Audio.cs deleted file mode 100644 index 89a9b6d..0000000 --- a/mods/Music/Providers/SoundCloud/SoundCloudSupport.Audio.cs +++ /dev/null @@ -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> SearchAudioAsync(MusicTrack track, CancellationToken cancellationToken = default) - { - if (track == null) - return Array.Empty(); - - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return Array.Empty(); - - var results = new List(); - var seen = new HashSet(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(); - } - - 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> BuildAudioSourcesAsync(MusicAudioMatch match, CancellationToken cancellationToken = default) - { - if (match == null) - return Array.Empty(); - - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return Array.Empty(); - - var payload = ParseMatchPayload(match); - string trackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id); - if (string.IsNullOrWhiteSpace(trackId)) - return Array.Empty(); - - var track = await LoadTrackElementAsync(trackId, clientId, cancellationToken); - if (!track.HasValue || !track.Value.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object) - return Array.Empty(); - - if (!media.TryGetProperty("transcodings", out var transcodings) || transcodings.ValueKind != JsonValueKind.Array) - return Array.Empty(); - - var sources = new List(); - var seen = new HashSet(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 BuildPreferredAudioSourceAsync(MusicAudioMatch match, CancellationToken cancellationToken = default) - { - if (match == null) - return null; - - string cacheKey = BuildPreferredSourceCacheKey(match); - if (!string.IsNullOrWhiteSpace(cacheKey)) - { - var cached = await MusicMetadataCacheService.GetAsync(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 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 BuildAudioQueries(MusicTrack track) - { - var queries = new List(); - var seen = new HashSet(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 RankAudioMatches(MusicTrack expectedTrack, IEnumerable 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())); - 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 ExtractCandidateArtists(JsonElement track) - { - var artists = new List(); - var seen = new HashSet(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 SplitArtistNames(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return new List(); - - 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 BuildArtistFragments(IEnumerable artists) - { - var fragments = new List(); - var seen = new HashSet(StringComparer.Ordinal); - - foreach (string artist in artists ?? Enumerable.Empty()) - { - 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 OrderPlayableTranscodings(JsonElement transcodings) - { - if (transcodings.ValueKind != JsonValueKind.Array) - return Array.Empty(); - - 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 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 - { - $"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(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}"; - } -} diff --git a/mods/Music/Providers/SoundCloud/SoundCloudSupport.Auth.cs b/mods/Music/Providers/SoundCloud/SoundCloudSupport.Auth.cs deleted file mode 100644 index 5d7bcce..0000000 --- a/mods/Music/Providers/SoundCloud/SoundCloudSupport.Auth.cs +++ /dev/null @@ -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 ParseSavePayload(string payload) - { - var result = new Dictionary(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 - { - "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); - } -} diff --git a/mods/Music/Providers/SoundCloud/SoundCloudSupport.Catalog.cs b/mods/Music/Providers/SoundCloud/SoundCloudSupport.Catalog.cs deleted file mode 100644 index ab94a22..0000000 --- a/mods/Music/Providers/SoundCloud/SoundCloudSupport.Catalog.cs +++ /dev/null @@ -1,1378 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Linq; -using System.Text.Json; -using System.Text.RegularExpressions; - -namespace Music; - -// Discovery/каталог: чарты, страницы артистов и секций, поиск по запросу, -// маппинг JSON api-v2 в Music*-контракты, кодеки id. -public static partial class SoundCloudSupport -{ - public static async Task> GetChartAlbumsAsync(CancellationToken cancellationToken = default) - { - return await MusicMetadataCacheService.GetOrCreateAsync( - DiscoveryProviderId, - "browse", - $"charts:{chartsCacheVersion}:{Country.ToLowerInvariant()}", - chartsCacheTtl, - () => LoadChartAlbumsAsync(cancellationToken), - cancellationToken - ) ?? new List(); - } - - public static async Task GetChartAlbumAsync(string id, CancellationToken cancellationToken = default) - { - string playlistUrl = DecodePlaylistUrl(id); - if (string.IsNullOrWhiteSpace(playlistUrl)) - return null; - - var album = await MusicMetadataCacheService.GetOrCreateAsync( - DiscoveryProviderId, - "album", - $"playlist:{chartsCacheVersion}:{playlistUrl}", - playlistCacheTtl, - () => LoadChartAlbumAsync(playlistUrl, cancellationToken), - cancellationToken - ); - - if (album?.tracks?.Count > 0) - await CacheDiscoveredTracksAsync(album.tracks, cancellationToken); - - return album; - } - - public static async Task GetUserArtistAsync(string id, CancellationToken cancellationToken = default) - { - string userUrl = DecodeUserUrl(id); - if (string.IsNullOrWhiteSpace(userUrl)) - return null; - - return await MusicMetadataCacheService.GetOrCreateAsync( - DiscoveryProviderId, - "artist", - $"user:{chartsCacheVersion}:{userUrl}", - playlistCacheTtl, - () => LoadUserArtistAsync(userUrl, cancellationToken), - cancellationToken - ); - } - - public static async Task GetUserSectionAsync(string id, string page = null, int limit = 20, CancellationToken cancellationToken = default) - { - var decoded = DecodeUserSectionId(id); - if (string.IsNullOrWhiteSpace(decoded.userUrl) || string.IsNullOrWhiteSpace(decoded.section)) - return null; - - string pageKey = string.IsNullOrWhiteSpace(page) ? "first" : page.Trim(); - return await MusicMetadataCacheService.GetOrCreateAsync( - DiscoveryProviderId, - "artistsection", - $"usersection:{chartsCacheVersion}:{decoded.section}:{Math.Max(1, limit)}:{decoded.userUrl}:{pageKey}", - playlistCacheTtl, - () => LoadUserSectionAsync(decoded.userUrl, decoded.section, page, limit, cancellationToken), - cancellationToken - ); - } - - public static async Task GetUserTracksAlbumAsync(string id, CancellationToken cancellationToken = default) - { - string userUrl = DecodeUserTracksUrl(id); - if (string.IsNullOrWhiteSpace(userUrl)) - return null; - - return await MusicMetadataCacheService.GetOrCreateAsync( - DiscoveryProviderId, - "album", - $"usertracks:{chartsCacheVersion}:{userUrl}", - playlistCacheTtl, - () => LoadUserTracksAlbumAsync(userUrl, cancellationToken), - cancellationToken - ); - } - - public static async Task GetTrackAsync(string id, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(id)) - return null; - - var cached = await MusicMetadataCacheService.GetAsync( - DiscoveryProviderId, - "track", - BuildTrackCacheKey(id), - cancellationToken - ); - if (cached != null) - return cached; - - string apiTrackId = ParseTrackApiId(id); - if (string.IsNullOrWhiteSpace(apiTrackId)) - return null; - - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - string url = BuildApiV2Url($"tracks/{apiTrackId}", new Dictionary - { - ["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); - var track = MapStandaloneTrack(document.RootElement); - if (track == null) - return null; - - await MusicMetadataCacheService.SaveAsync( - DiscoveryProviderId, - "track", - BuildTrackCacheKey(track.id), - track, - trackCacheTtl, - cancellationToken); - - return track; - } - - public static string BuildPlaylistAlbumId(string playlistUrl) - { - if (string.IsNullOrWhiteSpace(playlistUrl)) - return null; - - return PlaylistIdPrefix + Uri.EscapeDataString(playlistUrl.Trim()); - } - - public static string DecodePlaylistUrl(string id) - { - if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(PlaylistIdPrefix, StringComparison.OrdinalIgnoreCase)) - return null; - - string encoded = id.Substring(PlaylistIdPrefix.Length); - return string.IsNullOrWhiteSpace(encoded) ? null : Uri.UnescapeDataString(encoded); - } - - public static string BuildUserArtistId(string userUrl) - { - if (string.IsNullOrWhiteSpace(userUrl)) - return null; - - return UserIdPrefix + Uri.EscapeDataString(userUrl.Trim()); - } - - public static string DecodeUserUrl(string id) - { - if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(UserIdPrefix, StringComparison.OrdinalIgnoreCase)) - return null; - - string encoded = id.Substring(UserIdPrefix.Length); - return string.IsNullOrWhiteSpace(encoded) ? null : Uri.UnescapeDataString(encoded); - } - - public static string BuildUserTracksAlbumId(string userUrl) - { - if (string.IsNullOrWhiteSpace(userUrl)) - return null; - - return UserTracksAlbumPrefix + Uri.EscapeDataString(userUrl.Trim()); - } - - static string BuildUserSectionId(string userUrl, string section) - { - if (string.IsNullOrWhiteSpace(userUrl) || string.IsNullOrWhiteSpace(section)) - return null; - - return $"{UserSectionPrefix}{Uri.EscapeDataString(userUrl.Trim())}:{section.Trim().ToLowerInvariant()}"; - } - - static (string userUrl, string section) DecodeUserSectionId(string id) - { - if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(UserSectionPrefix, StringComparison.OrdinalIgnoreCase)) - return (null, null); - - string value = id.Substring(UserSectionPrefix.Length); - int separator = value.LastIndexOf(':'); - if (separator <= 0 || separator >= value.Length - 1) - return (null, null); - - string encodedUserUrl = value.Substring(0, separator); - string section = value.Substring(separator + 1); - - return ( - string.IsNullOrWhiteSpace(encodedUserUrl) ? null : Uri.UnescapeDataString(encodedUserUrl), - NormalizeUserSectionKey(section) - ); - } - - static string NormalizeUserSectionKey(string section) - { - string value = NormalizeValue(section)?.ToLowerInvariant(); - return value is "toptracks" or "albums" or "tracks" or "fansalsolike" ? value : null; - } - - public static string DecodeUserTracksUrl(string id) - { - if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(UserTracksAlbumPrefix, StringComparison.OrdinalIgnoreCase)) - return null; - - string encoded = id.Substring(UserTracksAlbumPrefix.Length); - return string.IsNullOrWhiteSpace(encoded) ? null : Uri.UnescapeDataString(encoded); - } - - public static bool IsUserArtist(string provider, string id) - { - return string.Equals(provider, DiscoveryProviderId, StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrWhiteSpace(DecodeUserUrl(id)); - } - - public static bool IsUserTracksAlbum(string provider, string id) - { - return string.Equals(provider, DiscoveryProviderId, StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrWhiteSpace(DecodeUserTracksUrl(id)); - } - - public static bool IsUserSection(string provider, string id) - { - var decoded = DecodeUserSectionId(id); - return string.Equals(provider, DiscoveryProviderId, StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrWhiteSpace(decoded.userUrl) - && !string.IsNullOrWhiteSpace(decoded.section); - } - - static async Task> LoadChartAlbumsAsync(CancellationToken cancellationToken) - { - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return new List(); - - string url = BuildApiV2Url("charts/selections", new Dictionary - { - ["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 new List(); - } - - string json = await response.Content.ReadAsStringAsync(cancellationToken); - using var document = JsonDocument.Parse(json); - if (!document.RootElement.TryGetProperty("collection", out var selections) || selections.ValueKind != JsonValueKind.Array) - return new List(); - - JsonElement? selection = PickSelection(selections); - if (selection == null || !TryGetNestedCollection(selection.Value, "items", out var playlists)) - return new List(); - - var results = new List(); - int index = 0; - - foreach (var playlist in playlists.EnumerateArray()) - { - if (!ShouldExposePlaylist(playlist)) - continue; - - var album = MapChartPlaylist(selection.Value, playlist, index++); - if (album != null) - results.Add(album); - } - - return results; - } - - static async Task LoadChartAlbumAsync(string playlistUrl, CancellationToken cancellationToken) - { - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - var resolved = await ResolveUrlElementAsync(playlistUrl, clientId, cancellationToken); - if (!resolved.HasValue) - return null; - - var playlist = resolved.Value; - - int? trackCount = GetInt(playlist, "track_count"); - int loadedTracks = GetArrayLength(playlist, "tracks"); - string playlistId = GetString(playlist, "id"); - - if (!string.IsNullOrWhiteSpace(playlistId) && (!trackCount.HasValue || loadedTracks < trackCount.Value)) - { - var hydrated = await LoadPlaylistByIdAsync(playlistId, clientId, cancellationToken); - if (hydrated.HasValue) - playlist = hydrated.Value; - } - - var trackDetails = await LoadTrackDetailsAsync(CollectTrackIdsForHydration(playlist), clientId, cancellationToken); - return MapResolvedPlaylist(playlist, trackDetails.tracks); - } - - static async Task LoadUserArtistAsync(string userUrl, CancellationToken cancellationToken) - { - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - var resolved = await ResolveUrlElementAsync(userUrl, clientId, cancellationToken); - if (!resolved.HasValue) - return null; - - var user = resolved.Value; - var artist = MapUser(user, includeTracksAlbum: false); - if (artist == null) - return null; - - string userId = GetString(user, "id"); - if (string.IsNullOrWhiteSpace(userId)) - return artist; - - var topTracksTask = SafeLoadCollectionElementsAsync($"users/{userId}/toptracks", clientId, 30, cancellationToken); - var albumsTask = SafeLoadCollectionElementsAsync($"users/{userId}/albums", clientId, 20, cancellationToken); - var allTracksTask = SafeLoadCollectionElementsAsync($"users/{userId}/tracks", clientId, 20, cancellationToken); - var relatedArtistsTask = SafeLoadCollectionElementsAsync( - $"users/{userId}/relatedartists", - clientId, - 12, - cancellationToken, - new Dictionary - { - ["creators_only"] = "false", - ["page_size"] = "12" - }); - - await Task.WhenAll(topTracksTask, albumsTask, allTracksTask, relatedArtistsTask); - - var topTracks = await topTracksTask; - var albums = await albumsTask; - var allTracks = await allTracksTask; - var relatedArtists = await relatedArtistsTask; - - AddSectionIfNotEmpty(artist, BuildTrackSection(userUrl, "toptracks", "Top tracks", topTracks.items, topTracks.hasMore, topTracks.nextPage)); - - var albumsSection = BuildAlbumSection(userUrl, "albums", "Albums", albums.items, albums.hasMore, albums.nextPage); - AddSectionIfNotEmpty(artist, albumsSection); - if (albumsSection?.albums?.Count > 0) - artist.albums = albumsSection.albums; - - AddSectionIfNotEmpty(artist, BuildTrackSection(userUrl, "tracks", "All tracks", allTracks.items, allTracks.hasMore, allTracks.nextPage)); - AddSectionIfNotEmpty(artist, BuildArtistSection(userUrl, "fansalsolike", "Fans also like", relatedArtists.items, relatedArtists.hasMore, relatedArtists.nextPage)); - - await CacheDiscoveredTracksAsync( - artist.sections - ?.Where(section => section?.tracks != null) - .SelectMany(section => section.tracks) - ?? Enumerable.Empty(), - cancellationToken); - - return artist; - } - - static async Task LoadUserSectionAsync(string userUrl, string section, string page, int limit, CancellationToken cancellationToken) - { - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - section = NormalizeUserSectionKey(section); - if (string.IsNullOrWhiteSpace(section)) - return null; - - int safeLimit = Math.Max(1, Math.Min(limit <= 0 ? 20 : limit, 50)); - SoundCloudCollectionPage pageResult; - - if (!string.IsNullOrWhiteSpace(page)) - { - pageResult = await SafeLoadCollectionElementsAsync( - null, - clientId, - safeLimit, - cancellationToken, - pageUrl: page); - } - else - { - var resolved = await ResolveUrlElementAsync(userUrl, clientId, cancellationToken); - if (!resolved.HasValue) - return null; - - string userId = GetString(resolved.Value, "id"); - if (string.IsNullOrWhiteSpace(userId)) - return null; - - pageResult = await SafeLoadUserSectionPageAsync(userId, section, clientId, safeLimit, cancellationToken); - } - - return section switch - { - "toptracks" => BuildTrackSection(userUrl, section, "Top tracks", pageResult.items, pageResult.hasMore, pageResult.nextPage), - "albums" => BuildAlbumSection(userUrl, section, "Albums", pageResult.items, pageResult.hasMore, pageResult.nextPage), - "tracks" => BuildTrackSection(userUrl, section, "All tracks", pageResult.items, pageResult.hasMore, pageResult.nextPage), - "fansalsolike" => BuildArtistSection(userUrl, section, "Fans also like", pageResult.items, pageResult.hasMore, pageResult.nextPage), - _ => null - }; - } - - static Task SafeLoadUserSectionPageAsync( - string userId, - string section, - string clientId, - int limit, - CancellationToken cancellationToken) - { - return section switch - { - "toptracks" => SafeLoadCollectionElementsAsync($"users/{userId}/toptracks", clientId, limit, cancellationToken), - "albums" => SafeLoadCollectionElementsAsync($"users/{userId}/albums", clientId, limit, cancellationToken), - "tracks" => SafeLoadCollectionElementsAsync($"users/{userId}/tracks", clientId, limit, cancellationToken), - "fansalsolike" => SafeLoadCollectionElementsAsync( - $"users/{userId}/relatedartists", - clientId, - limit, - cancellationToken, - new Dictionary - { - ["creators_only"] = "false", - ["page_size"] = limit.ToString() - }), - _ => Task.FromResult(SoundCloudCollectionPage.Empty()) - }; - } - - static async Task LoadUserTracksAlbumAsync(string userUrl, CancellationToken cancellationToken) - { - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - var resolved = await ResolveUrlElementAsync(userUrl, clientId, cancellationToken); - if (!resolved.HasValue) - return null; - - var user = resolved.Value; - string userId = GetString(user, "id"); - string username = GetString(user, "username") ?? "SoundCloud"; - var album = BuildUserTracksAlbum(userUrl, username, BuildImages(GetString(user, "avatar_url"))); - - if (string.IsNullOrWhiteSpace(userId)) - return album; - - foreach (var track in await LoadUserTrackElementsAsync(userId, clientId, 50, cancellationToken)) - { - var mapped = MapPlaylistTrack(track, album, album.tracks.Count + 1, SelectFirstImageUrl(album.images)); - if (mapped != null) - album.tracks.Add(mapped); - } - - return album; - } - - static Task CacheDiscoveredTracksAsync(IEnumerable tracks, CancellationToken cancellationToken) - { - if (tracks == null) - return Task.CompletedTask; - - var writes = new List(); - - foreach (var track in tracks) - { - if (track == null || string.IsNullOrWhiteSpace(track.id)) - continue; - - writes.Add(MusicMetadataCacheService.SaveAsync( - DiscoveryProviderId, - "track", - BuildTrackCacheKey(track.id), - track, - trackCacheTtl, - cancellationToken)); - } - - return writes.Count == 0 ? Task.CompletedTask : Task.WhenAll(writes); - } - - static JsonElement? PickSelection(JsonElement selections) - { - if (selections.ValueKind != JsonValueKind.Array || selections.GetArrayLength() == 0) - return null; - - string country = Country; - foreach (var selection in selections.EnumerateArray()) - { - string title = GetString(selection, "title"); - if (!string.IsNullOrWhiteSpace(title) && title.EndsWith($" {country}", StringComparison.OrdinalIgnoreCase)) - return selection; - } - - foreach (var selection in selections.EnumerateArray()) - { - string title = GetString(selection, "title"); - if (!string.IsNullOrWhiteSpace(title) && title.Contains("US", StringComparison.OrdinalIgnoreCase)) - return selection; - } - - foreach (var selection in selections.EnumerateArray()) - return selection; - - return null; - } - - static bool TryGetNestedCollection(JsonElement element, string propertyName, out JsonElement collection) - { - collection = default; - - if (!element.TryGetProperty(propertyName, out var nested) || nested.ValueKind != JsonValueKind.Object) - return false; - - if (!nested.TryGetProperty("collection", out collection) || collection.ValueKind != JsonValueKind.Array) - return false; - - return true; - } - - static bool ShouldExposePlaylist(JsonElement playlist) - { - string url = GetString(playlist, "permalink_url"); - string title = GetString(playlist, "title"); - if (string.IsNullOrWhiteSpace(url) || string.IsNullOrWhiteSpace(title)) - return false; - - return !string.Equals(title.Trim(), "Artist Pro", StringComparison.OrdinalIgnoreCase); - } - - static MusicAlbum MapChartPlaylist(JsonElement selection, JsonElement playlist, int index) - { - string playlistUrl = GetString(playlist, "permalink_url"); - if (string.IsNullOrWhiteSpace(playlistUrl)) - return null; - - string title = NormalizePlaylistTitle(GetString(playlist, "title")); - string artwork = UpgradeArtwork(GetString(playlist, "artwork_url")); - string description = GetString(selection, "title"); - - return new MusicAlbum - { - id = BuildPlaylistAlbumId(playlistUrl), - title = title, - artist_name = "SoundCloud", - date = GetString(playlist, "display_date") ?? GetString(playlist, "published_at"), - type = "Playlist", - description = description, - images = string.IsNullOrWhiteSpace(artwork) - ? new List() - : new List { new() { url = artwork, width = 500, height = 500 } }, - provider_refs = new List - { - new() { provider = DiscoveryProviderId, external_id = playlistUrl } - } - }; - } - - static MusicAlbum MapResolvedPlaylist(JsonElement playlist, Dictionary hydratedTracks = null) - { - string playlistUrl = GetString(playlist, "permalink_url"); - if (string.IsNullOrWhiteSpace(playlistUrl)) - return null; - - string title = NormalizePlaylistTitle(GetString(playlist, "title")); - string artwork = UpgradeArtwork(GetString(playlist, "artwork_url")); - string date = GetString(playlist, "display_date") ?? GetString(playlist, "published_at"); - string artistName = "SoundCloud"; - string avatar = null; - if (playlist.TryGetProperty("user", out var user) && user.ValueKind == JsonValueKind.Object) - { - artistName = NormalizeValue(GetString(user, "username")) ?? artistName; - avatar = GetString(user, "avatar_url"); - } - - var album = new MusicAlbum - { - id = BuildPlaylistAlbumId(playlistUrl), - title = title, - artist_name = artistName, - date = date, - type = IsSoundCloudAlbumSet(playlist) ? "Album" : "Playlist", - description = GetString(playlist, "genre") ?? (IsSoundCloudAlbumSet(playlist) ? "Album" : "Playlist"), - images = BuildImages(artwork, avatar), - provider_refs = new List - { - new() { provider = DiscoveryProviderId, external_id = playlistUrl } - }, - tracks = new List() - }; - - if (playlist.TryGetProperty("tracks", out var tracks) && tracks.ValueKind == JsonValueKind.Array) - { - int position = 1; - foreach (var track in tracks.EnumerateArray()) - { - var mapped = MapPlaylistTrack(track, album, position++, artwork, hydratedTracks); - if (mapped != null) - album.tracks.Add(mapped); - } - } - - return album; - } - - static MusicAlbum MapSearchPlaylist(JsonElement playlist, string type) - { - string playlistUrl = GetString(playlist, "permalink_url"); - string title = NormalizeValue(GetString(playlist, "title")); - if (string.IsNullOrWhiteSpace(playlistUrl) || string.IsNullOrWhiteSpace(title)) - return null; - - string artistName = null; - string avatar = null; - if (playlist.TryGetProperty("user", out var user) && user.ValueKind == JsonValueKind.Object) - { - artistName = NormalizeValue(GetString(user, "username")); - avatar = GetString(user, "avatar_url"); - } - - return new MusicAlbum - { - id = BuildPlaylistAlbumId(playlistUrl), - title = title, - artist_name = string.IsNullOrWhiteSpace(artistName) ? "SoundCloud" : artistName, - date = GetString(playlist, "display_date") ?? GetString(playlist, "published_at"), - type = string.IsNullOrWhiteSpace(type) ? "Playlist" : type, - description = GetString(playlist, "genre") ?? type ?? "SoundCloud", - images = BuildImages(GetString(playlist, "artwork_url"), avatar), - provider_refs = new List - { - new() { provider = DiscoveryProviderId, external_id = playlistUrl } - } - }; - } - - static bool IsSoundCloudAlbumSet(JsonElement playlist) - { - string setType = NormalizeValue(GetString(playlist, "set_type")); - string playlistType = NormalizeValue(GetString(playlist, "playlist_type")); - string kind = NormalizeValue(GetString(playlist, "kind")); - - return string.Equals(setType, "album", StringComparison.OrdinalIgnoreCase) - || string.Equals(playlistType, "album", StringComparison.OrdinalIgnoreCase) - || string.Equals(kind, "album", StringComparison.OrdinalIgnoreCase); - } - - static void AddSectionIfNotEmpty(MusicArtist artist, MusicBrowseSection section) - { - if (artist == null || section == null) - return; - - bool hasEntries = section.tracks?.Count > 0 - || section.albums?.Count > 0 - || section.artists?.Count > 0; - - if (hasEntries) - artist.sections.Add(section); - } - - static MusicBrowseSection BuildTrackSection(string userUrl, string key, string title, List items, bool hasMore, string nextPage) - { - var section = new MusicBrowseSection - { - id = BuildUserSectionId(userUrl, key), - title = title, - type = "tracks", - source_provider = DiscoveryProviderId, - has_more = hasMore, - next_page = nextPage, - tracks = new List() - }; - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var item in items ?? new List()) - { - var track = MapStandaloneTrack(item); - if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id)) - continue; - - section.tracks.Add(track); - } - - return section; - } - - static MusicBrowseSection BuildAlbumSection(string userUrl, string key, string title, List items, bool hasMore, string nextPage) - { - var section = new MusicBrowseSection - { - id = BuildUserSectionId(userUrl, key), - title = title, - type = "albums", - source_provider = DiscoveryProviderId, - has_more = hasMore, - next_page = nextPage, - albums = new List() - }; - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var item in items ?? new List()) - { - var album = MapSearchPlaylist(item, "Album"); - if (album == null || string.IsNullOrWhiteSpace(album.id) || !seen.Add(album.id)) - continue; - - section.albums.Add(album); - } - - return section; - } - - static MusicBrowseSection BuildArtistSection(string userUrl, string key, string title, List items, bool hasMore, string nextPage) - { - var section = new MusicBrowseSection - { - id = BuildUserSectionId(userUrl, key), - title = title, - type = "artists", - source_provider = DiscoveryProviderId, - has_more = hasMore, - next_page = nextPage, - artists = new List() - }; - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var item in items ?? new List()) - { - var artist = MapUser(item, includeTracksAlbum: false); - if (artist == null || string.IsNullOrWhiteSpace(artist.id) || !seen.Add(artist.id)) - continue; - - section.artists.Add(artist); - } - - return section; - } - - static MusicArtist MapUser(JsonElement user, bool includeTracksAlbum) - { - string userUrl = GetString(user, "permalink_url"); - string username = NormalizeValue(GetString(user, "username")); - if (string.IsNullOrWhiteSpace(userUrl) || string.IsNullOrWhiteSpace(username)) - return null; - - var images = BuildImages(GetString(user, "avatar_url")); - var artist = new MusicArtist - { - id = BuildUserArtistId(userUrl), - name = username, - sort_name = username, - description = "SoundCloud", - images = images, - provider_refs = new List - { - new() { provider = DiscoveryProviderId, external_id = userUrl } - } - }; - - if (includeTracksAlbum) - artist.albums.Add(BuildUserTracksAlbum(userUrl, username, images)); - - return artist; - } - - static MusicAlbum BuildUserTracksAlbum(string userUrl, string username, List images) - { - return new MusicAlbum - { - id = BuildUserTracksAlbumId(userUrl), - title = "Треки пользователя", - artist_id = BuildUserArtistId(userUrl), - artist_name = string.IsNullOrWhiteSpace(username) ? "SoundCloud" : username, - type = "Playlist", - description = "SoundCloud", - images = images ?? new List(), - provider_refs = new List - { - new() { provider = DiscoveryProviderId, external_id = userUrl } - } - }; - } - - static MusicTrack MapPlaylistTrack(JsonElement track, MusicAlbum album, int position, string fallbackArtwork, Dictionary hydratedTracks = null) - { - string trackId = GetString(track, "id"); - if ((string.IsNullOrWhiteSpace(GetString(track, "title")) || string.IsNullOrWhiteSpace(GetString(track, "artwork_url"))) - && !string.IsNullOrWhiteSpace(trackId) - && hydratedTracks?.TryGetValue(trackId, out var hydratedTrack) == true) - { - track = hydratedTrack; - } - - string rawTitle = GetString(track, "title"); - var titleParts = SplitArtistTitleFromSoundCloudTitle(rawTitle); - string title = titleParts.title ?? NormalizeValue(rawTitle); - if (string.IsNullOrWhiteSpace(title)) - return null; - - var artists = ExtractCandidateArtists(track); - string artistName = artists.FirstOrDefault(); - - string artwork = UpgradeArtwork(GetString(track, "artwork_url")) ?? fallbackArtwork; - string permalinkUrl = GetString(track, "permalink_url"); - string urn = GetString(track, "urn"); - string externalId = !string.IsNullOrWhiteSpace(permalinkUrl) - ? permalinkUrl - : (!string.IsNullOrWhiteSpace(urn) ? urn : title); - - return new MusicTrack - { - id = !string.IsNullOrWhiteSpace(urn) ? urn : $"soundcloud:track:{Uri.EscapeDataString(externalId)}", - title = title.Trim(), - artist_name = string.IsNullOrWhiteSpace(artistName) ? "SoundCloud" : artistName, - artists = string.IsNullOrWhiteSpace(artistName) ? new List() : new List { artistName }, - album_id = album?.id, - album_title = album?.title, - isrc = GetTrackIsrc(track), - duration_ms = GetInt(track, "full_duration") ?? GetInt(track, "duration"), - track_number = position, - date = GetString(track, "published_at"), - images = string.IsNullOrWhiteSpace(artwork) - ? new List() - : new List { new() { url = artwork, width = 500, height = 500 } }, - provider_refs = new List - { - new() { provider = DiscoveryProviderId, external_id = externalId } - } - }; - } - - static List CollectTrackIdsForHydration(JsonElement playlist) - { - var ids = new List(); - if (!playlist.TryGetProperty("tracks", out var tracks) || tracks.ValueKind != JsonValueKind.Array) - return ids; - - foreach (var track in tracks.EnumerateArray()) - { - string title = GetString(track, "title"); - string id = GetString(track, "id"); - - if (string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(id)) - ids.Add(id); - } - - return ids; - } - - /// - /// Рекомендации SoundCloud по конкретному треку: top-1 поиска как сид → - /// /tracks/{id}/related («Related tracks» сайта, коллаборативная - /// фильтрация), при недоборе — track-station того же сида. Используется - /// радио как основной слой похожести (вместо «топ треков артиста»). - /// - public static Task> FindRelatedTracksByQueryAsync(string query, int limit = 12, CancellationToken cancellationToken = default) - => FindRelatedTracksByQueryAsync(query, null, null, limit, cancellationToken); - - public static async Task> FindRelatedTracksByQueryAsync(string query, string expectedArtist, string expectedTitle, int limit = 12, CancellationToken cancellationToken = default) - { - var tracks = new List(); - if (string.IsNullOrWhiteSpace(query)) - return tracks; - - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - var seedElements = await SearchTrackElementsAsync(query, clientId, 1, cancellationToken); - if (seedElements == null) - return null; - - if (seedElements.Count == 0) - return tracks; - - if (!IsRelevantRelatedSeed(seedElements[0], expectedArtist, expectedTitle)) - return tracks; - - if (!seedElements[0].TryGetProperty("id", out var idProperty) || idProperty.ValueKind != JsonValueKind.Number) - return tracks; - - long seedId = idProperty.GetInt64(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - void AddAll(IEnumerable items) - { - foreach (var item in items ?? Enumerable.Empty()) - { - var element = item; - - // station-элементы бывают обёрнуты в { "track": {...} } - if (element.ValueKind == JsonValueKind.Object - && element.TryGetProperty("track", out var inner) - && inner.ValueKind == JsonValueKind.Object) - element = inner; - - var track = MapStandaloneTrack(element); - if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id)) - continue; - - tracks.Add(track); - - if (tracks.Count >= limit) - return; - } - } - - var related = await SafeLoadCollectionElementsAsync($"tracks/{seedId}/related", clientId, limit, cancellationToken); - AddAll(related.items); - - if (tracks.Count < limit) - { - var station = await SafeLoadCollectionElementsAsync($"stations/soundcloud:track-stations:{seedId}/tracks", clientId, limit, cancellationToken); - AddAll(station.items); - - if (tracks.Count == 0 && !related.complete && !station.complete) - return null; - } - - return tracks; - } - - static bool IsRelevantRelatedSeed(JsonElement seedElement, string expectedArtist, string expectedTitle) - { - if (string.IsNullOrWhiteSpace(expectedTitle)) - return true; - - var seed = MapStandaloneTrack(seedElement); - if (seed == null) - return false; - - string expectedTitleKey = NormalizeRelatedSeedText(expectedTitle); - string foundTitleKey = NormalizeRelatedSeedText(seed.title); - if (string.IsNullOrWhiteSpace(expectedTitleKey) || string.IsNullOrWhiteSpace(foundTitleKey)) - return false; - - int expectedTitleTokens = expectedTitleKey.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Length; - int sharedTitleTokens = CountSharedTokens(expectedTitleKey, foundTitleKey); - int requiredTitleTokens = expectedTitleTokens <= 1 - ? 1 - : Math.Max(1, (int)Math.Ceiling(expectedTitleTokens * 0.6)); - - if (sharedTitleTokens < requiredTitleTokens) - return false; - - string expectedArtistKey = NormalizeRelatedSeedText(expectedArtist); - if (string.IsNullOrWhiteSpace(expectedArtistKey)) - return true; - - string foundArtistKey = NormalizeRelatedSeedText(seed.artist_name); - string foundCombinedKey = NormalizeRelatedSeedText($"{seed.artist_name} {seed.title}"); - - if (CountSharedTokens(expectedArtistKey, foundArtistKey) > 0 || CountSharedTokens(expectedArtistKey, foundCombinedKey) > 0) - return true; - - // SoundCloud uploads often use usernames instead of real artists. For - // non-generic titles, exact title coverage is enough to keep related. - return expectedTitleTokens > 1 && sharedTitleTokens >= expectedTitleTokens; - } - - static string NormalizeRelatedSeedText(string value) - { - value = Regex.Replace(value ?? string.Empty, @"\([^)]*\)|\[[^\]]*\]", " "); - value = Regex.Replace(value, @"\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b", " ", RegexOptions.IgnoreCase); - return NormalizeSearchText(value); - } - - public static async Task> SearchTracksByQueryAsync(string query, int limit = 10, CancellationToken cancellationToken = default) - { - var tracks = new List(); - if (string.IsNullOrWhiteSpace(query)) - return tracks; - - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var elements = await SearchTrackElementsAsync(query, clientId, limit, cancellationToken); - if (elements == null) - return null; - - foreach (var item in elements) - { - var track = MapStandaloneTrack(item); - if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id)) - continue; - - tracks.Add(track); - } - - return tracks; - } - - // доминирующие жанры артиста по его трекам в поиске (сырые display-значения - // поля genre, по убыванию частоты) — для жанрового слоя «Миксов недели» - public static async Task> SearchDominantGenresAsync(string query, CancellationToken cancellationToken = default) - { - var result = new List(); - if (string.IsNullOrWhiteSpace(query)) - return result; - - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return result; - - var elements = await SearchTrackElementsAsync(query, clientId, 10, cancellationToken); - if (elements == null) - return result; - - var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var item in elements) - { - string genre = GetString(item, "genre")?.Trim(); - if (string.IsNullOrWhiteSpace(genre) || genre.Length > 40) - continue; - - counts[genre] = counts.TryGetValue(genre, out var current) - ? (current.display, current.count + 1) - : (genre, 1); - } - - return counts.Values - .Where(i => i.count >= 2) - .OrderByDescending(i => i.count) - .Select(i => i.display) - .Take(3) - .ToList(); - } - - // свободный genre-тег трека -> канонический жанр-фасет SC (фильтр - // поиска регистрозависим и принимает только канон в lower; - // проверено живьём: «Hip Hop» — 0 результатов, «hip-hop & rap» — работает) - public static string MapToCanonicalGenre(string genre) - { - string value = (genre ?? string.Empty).ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(value)) - return null; - - if (value.Contains("hip") || value.Contains("rap") || value.Contains("trap") || value.Contains("phonk")) - return "hip-hop & rap"; - if (value.Contains("edm") || value.Contains("dance")) - return "dance & edm"; - if (value.Contains("r&b") || value.Contains("rnb") || value.Contains("soul")) - return "r&b & soul"; - if (value.Contains("drum") && value.Contains("bass")) - return "drum & bass"; - if (value.Contains("dubstep")) - return "dubstep"; - if (value.Contains("house")) - return "house"; - if (value.Contains("techno")) - return "techno"; - if (value.Contains("trance")) - return "trance"; - if (value.Contains("electro")) - return "electronic"; - if (value.Contains("metal")) - return "metal"; - if (value.Contains("indie")) - return "indie"; - if (value.Contains("rock")) - return "rock"; - if (value.Contains("jazz") || value.Contains("blues")) - return "jazz & blues"; - if (value.Contains("folk") || value.Contains("singer")) - return "folk & singer-songwriter"; - if (value.Contains("reggaeton")) - return "reggaeton"; - if (value.Contains("reggae")) - return "reggae"; - if (value.Contains("dancehall")) - return "dancehall"; - if (value.Contains("latin")) - return "latin"; - if (value.Contains("ambient")) - return "ambient"; - if (value.Contains("classic")) - return "classical"; - if (value.Contains("country")) - return "country"; - if (value.Contains("pop")) - return "pop"; - - return null; - } - - // популярные треки канонического жанра: штатный /charts SC закрыт - // (400/404, проверено живьём 2026-07-16), рабочий путь — search/tracks - // с filter.genre_or_tag; параметр q обязателен, даже пустой - public static async Task> SearchTracksByGenreAsync(string canonicalGenre, int limit = 30, CancellationToken cancellationToken = default) - { - var tracks = new List(); - if (string.IsNullOrWhiteSpace(canonicalGenre)) - return tracks; - - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - try - { - string url = BuildApiV2Url("search/tracks", new Dictionary - { - ["filter.genre_or_tag"] = canonicalGenre.Trim().ToLowerInvariant(), - ["sort"] = "popular", - ["client_id"] = clientId, - ["limit"] = Math.Max(1, limit).ToString(), - ["linked_partitioning"] = "1" - }); - - // BuildApiV2Url выбрасывает пустые значения, а без q эндпоинт - // отвечает 400 — дописываем пустой q вручную - url += "&q="; - - 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; - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var item in collection.EnumerateArray()) - { - var track = MapStandaloneTrack(item); - if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id)) - continue; - - tracks.Add(track); - } - - return tracks; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - return null; - } - } - - public static async Task> SearchPlaylistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default) - { - var playlists = new List(); - if (string.IsNullOrWhiteSpace(query)) - return playlists; - - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var elements = await SearchElementsAsync("search/playlists", query, clientId, limit, cancellationToken); - if (elements == null) - return null; - - foreach (var item in elements) - { - if (IsSoundCloudAlbumSet(item)) - continue; - - var playlist = MapSearchPlaylist(item, "Playlist"); - if (playlist == null || string.IsNullOrWhiteSpace(playlist.id) || !seen.Add(playlist.id)) - continue; - - playlists.Add(playlist); - } - - return playlists; - } - - public static async Task> SearchAlbumsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default) - { - var albums = new List(); - if (string.IsNullOrWhiteSpace(query)) - return albums; - - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - var albumElements = await SearchElementsAsync("search/albums", query, clientId, limit, cancellationToken); - if (albumElements == null) - return null; - - foreach (var item in albumElements) - { - var album = MapSearchPlaylist(item, "Album"); - if (album == null || string.IsNullOrWhiteSpace(album.id) || !seen.Add(album.id)) - continue; - - albums.Add(album); - } - - if (albums.Count >= Math.Max(1, limit)) - return albums; - - var playlistElements = await SearchElementsAsync("search/playlists", query, clientId, limit * 2, cancellationToken); - if (playlistElements == null) - return albums.Count > 0 ? albums : null; - - foreach (var item in playlistElements) - { - if (!IsSoundCloudAlbumSet(item)) - continue; - - var album = MapSearchPlaylist(item, "Album"); - if (album == null || string.IsNullOrWhiteSpace(album.id) || !seen.Add(album.id)) - continue; - - albums.Add(album); - - if (albums.Count >= Math.Max(1, limit)) - break; - } - - return albums; - } - - public static async Task> SearchArtistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default) - { - var artists = new List(); - if (string.IsNullOrWhiteSpace(query)) - return artists; - - string clientId = await GetClientIdAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(clientId)) - return null; - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var elements = await SearchElementsAsync("search/users", query, clientId, limit, cancellationToken); - if (elements == null) - return null; - - foreach (var item in elements) - { - var artist = MapUser(item, includeTracksAlbum: false); - if (artist == null || string.IsNullOrWhiteSpace(artist.id) || !seen.Add(artist.id)) - continue; - - artists.Add(artist); - } - - return artists; - } - - static string NormalizePlaylistTitle(string title) - { - string value = (title ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(value)) - return "SoundCloud"; - - return value.Equals("All music genres", StringComparison.OrdinalIgnoreCase) - ? "Top 50" - : value; - } - - static string UpgradeArtwork(string url) - { - if (string.IsNullOrWhiteSpace(url)) - return null; - - return url - .Replace("-large.", "-t500x500.", StringComparison.OrdinalIgnoreCase) - .Replace("-t300x300.", "-t500x500.", StringComparison.OrdinalIgnoreCase); - } - - static List BuildImages(params string[] urls) - { - var images = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var raw in urls ?? Array.Empty()) - { - string url = UpgradeArtwork(raw); - if (string.IsNullOrWhiteSpace(url) || !seen.Add(url)) - continue; - - images.Add(new MusicImage - { - url = url, - width = 500, - height = 500 - }); - } - - return images; - } - - static string SelectFirstImageUrl(List images) - { - return images?.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i?.url))?.url; - } - - static string BuildTrackCacheKey(string id) => $"track:{chartsCacheVersion}:{id.Trim().ToLowerInvariant()}"; - - static MusicTrack MapStandaloneTrack(JsonElement track) - { - string rawTitle = GetString(track, "title"); - var titleParts = SplitArtistTitleFromSoundCloudTitle(rawTitle); - string title = titleParts.title ?? NormalizeValue(rawTitle); - if (string.IsNullOrWhiteSpace(title)) - return null; - - var artists = ExtractCandidateArtists(track); - string artistName = artists.FirstOrDefault(); - - string artwork = UpgradeArtwork(GetString(track, "artwork_url")); - string permalinkUrl = GetString(track, "permalink_url"); - string urn = GetString(track, "urn"); - string externalId = !string.IsNullOrWhiteSpace(permalinkUrl) - ? permalinkUrl - : (!string.IsNullOrWhiteSpace(urn) ? urn : title); - - return new MusicTrack - { - id = !string.IsNullOrWhiteSpace(urn) ? urn : $"soundcloud:track:{Uri.EscapeDataString(externalId)}", - title = title.Trim(), - artist_name = string.IsNullOrWhiteSpace(artistName) ? "SoundCloud" : artistName, - artists = string.IsNullOrWhiteSpace(artistName) ? new List() : new List { artistName }, - isrc = GetTrackIsrc(track), - duration_ms = GetInt(track, "full_duration") ?? GetInt(track, "duration"), - date = GetString(track, "published_at"), - images = string.IsNullOrWhiteSpace(artwork) - ? new List() - : new List { new() { url = artwork, width = 500, height = 500 } }, - provider_refs = new List - { - new() { provider = DiscoveryProviderId, external_id = externalId } - } - }; - } -} diff --git a/mods/Music/Providers/SoundCloud/SoundCloudSupport.Import.cs b/mods/Music/Providers/SoundCloud/SoundCloudSupport.Import.cs deleted file mode 100644 index b4c6db7..0000000 --- a/mods/Music/Providers/SoundCloud/SoundCloudSupport.Import.cs +++ /dev/null @@ -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 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 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 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 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 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 ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default) - { - if (source == null || string.IsNullOrWhiteSpace(source.url)) - return Task.FromResult(ImportUnavailable("У плейлиста нет SoundCloud источника.")); - - return ImportPlaylistAsync(source.url, cancellationToken); - } -} diff --git a/mods/Music/Providers/SoundCloud/SoundCloudSupport.cs b/mods/Music/Providers/SoundCloud/SoundCloudSupport.cs deleted file mode 100644 index 0cc5fa7..0000000 --- a/mods/Music/Providers/SoundCloud/SoundCloudSupport.cs +++ /dev/null @@ -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*(?.+?)\s+(?:-|–|—|:)\s+(?.+?)\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(); -} diff --git a/mods/Music/Providers/Spotify/SpotifySupport.cs b/mods/Music/Providers/Spotify/SpotifySupport.cs deleted file mode 100644 index eab79f8..0000000 --- a/mods/Music/Providers/Spotify/SpotifySupport.cs +++ /dev/null @@ -1,1395 +0,0 @@ -using System.Net.Http; -using System.Text.Json; -using System.Text.RegularExpressions; -using static Music.MusicPlaylistImportHelpers; - -namespace Music; - -// Импорт публичных плейлистов/альбомов Spotify БЕЗ ключей и логина: -// гостевой accessToken со страницы embed-плеера + pathfinder GraphQL -// (persisted queries) — тот же путь, что у веб-плеера Spotify. -// Стримов Spotify не отдаёт (DRM): треки приезжают чистыми метаданными, -// аудио резолвится обычным конвейером (YouTube-матчер), как у VK-чарта. -public static class SpotifySupport -{ - public sealed class ArtistDiscoveryResult - { - public string artist_id { get; set; } - public string artist_name { get; set; } - public List<MusicTrack> top_tracks { get; set; } = new(); - public List<MusicArtist> related_artists { get; set; } = new(); - } - - public const string ProviderId = "spotify"; - public const string DiscoveryProviderId = "spotifycharts"; - public const string PlaylistSourceType = "spotify_playlist"; - public const string AlbumSourceType = "spotify_album"; - public const string TracksSectionId = "search:spotify:tracks"; - public const string ArtistsSectionId = "search:spotify:artists"; - public const string AlbumsSectionId = "search:spotify:albums"; - public const int PlaylistTrackLimit = 2000; - - public static bool IsSearchEnabled => ModInit.conf?.spotify_search_fallback_enabled == true; - - const string PathfinderUrl = "https://api-partner.spotify.com/pathfinder/v1/query"; - const string PathfinderV2Url = "https://api-partner.spotify.com/pathfinder/v2/query"; - - // persisted-query хэши веб-плеера; Spotify их периодически ротирует. - // Симптом ротации: ошибка PersistedQueryNotFound в ответе pathfinder — - // тогда взять свежие из бандла веб-плеера (или SpotifyScraper/api/pathfinder.py). - const string FetchPlaylistHash = "a65e12194ed5fc443a1cdebed5fabe33ca5b07b987185d63c72483867ad13cb4"; - const string GetAlbumHash = "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10"; - // pathfinder v2 (POST) хэши веб-плеера, разведаны с живого open.spotify.com - // (2026-07-20). Питают отдельную Spotify-вкладку поиска + её экраны - // артиста/альбома. Ротируются как v1-хэши импорта (симптом PersistedQueryNotFound). - const string FindTopResultsHash = "755858df4daab8d212980b02a81dcf8c9a58447de318b59d07c4651a1d0450b9"; - const string QueryArtistHash = "a8226439dffdc02caecf1fc6e693e98be57638c0195f002d7802e4b8e5802760"; - const string QueryAlbumHash = "ce390dbf7ca6b61a23aec210619e1094fe9d23d7f101ff773ce1146f84d4dd10"; - const string QueryArtistDiscographyHash = "5e07d323febb57b4a56a42abbf781490e58764aa45feb6e3dc0591564fc56599"; - const string QueryArtistAppearsOnHash = "9a4bb7a20d6720fe52d7b47bc001cfa91940ddf5e7113761460b4a288d18a4c1"; - // embed-страница для гостевого токена: любой публичный плейлист - const string TokenSeedPlaylistId = "37i9dQZF1DXcBWIGoYBM5M"; - - const int PlaylistPageLimit = 100; - const int AlbumPageLimit = 50; - const int ArtistOverviewShelfLimit = 20; - const int MaxImportTracks = PlaylistTrackLimit; - const string ArtistDiscographyOrder = "DATE_DESC"; - 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("spotify"); - static readonly Regex entityUrlRegex = new(@"^https?://open\.spotify\.com/(?:intl-[a-z\-]+/)?(playlist|album)/([A-Za-z0-9]{22})", RegexOptions.IgnoreCase | RegexOptions.Compiled); - static readonly Regex entityUriRegex = new(@"^spotify:(playlist|album):([A-Za-z0-9]{22})$", RegexOptions.IgnoreCase | RegexOptions.Compiled); - static readonly Regex accessTokenRegex = new("\"accessToken\"\\s*:\\s*\"([^\"]+)\"", RegexOptions.Compiled); - static readonly Regex accessTokenExpiresRegex = new("\"accessTokenExpirationTimestampMs\"\\s*:\\s*(\\d+)", RegexOptions.Compiled); - static readonly SemaphoreSlim anonymousTokenLock = new(1, 1); - static string anonymousToken; - static DateTime anonymousTokenExpiresAt; - - static SpotifySupport() - { - httpClient.Timeout = TimeSpan.FromSeconds(20); - } - - public static bool CanHandleUrl(string url) => ParseEntity(url) != null; - - public static async Task<MusicAlbum> GetPlaylistAlbumAsync(string playlistId, int limit, CancellationToken cancellationToken = default) - { - playlistId = playlistId?.Trim(); - if (string.IsNullOrWhiteSpace(playlistId) || !Regex.IsMatch(playlistId, "^[A-Za-z0-9]{22}$")) - return null; - - int take = Math.Clamp(limit, 1, PlaylistTrackLimit); - - try - { - var tracks = new List<MusicTrack>(); - string title = null; - string ownerName = null; - string description = null; - List<MusicImage> images = null; - int totalCount = -1; - - for (int offset = 0; offset < take && (totalCount < 0 || offset < totalCount);) - { - int pageLimit = Math.Min(PlaylistPageLimit, take - offset); - var root = await QueryAsync("fetchPlaylist", FetchPlaylistHash, new - { - uri = $"spotify:playlist:{playlistId}", - offset, - limit = pageLimit, - enableWatchFeedEntrypoint = false - }, "playlist", playlistId, cancellationToken); - - var playlist = GetProperty(root, "data", "playlistV2"); - if (playlist == null || playlist.Value.ValueKind != JsonValueKind.Object) - return null; - - var content = GetProperty(playlist.Value, "content"); - if (content == null || !content.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return null; - - if (totalCount < 0) - { - totalCount = GetInt(content.Value, "totalCount") ?? items.GetArrayLength(); - var owner = GetProperty(playlist.Value, "ownerV2", "data"); - title = GetString(playlist.Value, "name")?.Trim(); - ownerName = owner != null ? GetString(owner.Value, "name")?.Trim() : null; - description = GetString(playlist.Value, "description")?.Trim(); - images = MapPlaylistImages(GetProperty(playlist.Value, "images")); - } - - int itemCount = items.GetArrayLength(); - if (itemCount == 0) - { - if (offset < Math.Min(totalCount, take)) - return null; - - break; - } - - foreach (var item in items.EnumerateArray()) - { - var track = MapPlaylistItem(item); - if (track != null) - tracks.Add(track); - } - - offset += pageLimit; - } - - tracks = DeduplicateTracks(tracks); - - return new MusicAlbum - { - id = $"spotify:playlist:{playlistId}", - title = string.IsNullOrWhiteSpace(title) ? "Spotify Playlist" : title, - artist_name = string.IsNullOrWhiteSpace(ownerName) ? "Spotify" : ownerName, - type = "Playlist", - description = description, - images = images ?? new List<MusicImage>(), - provider_refs = new List<MusicProviderRef> - { - new() { provider = DiscoveryProviderId, external_id = playlistId } - }, - tracks = tracks - }; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - return null; - } - } - - // Поиск треков для отдельной Spotify-вкладки в результатах поиска. - // Spotify «по-человечески» понимает ввод («рианна», «маданна», опечатки), - // где MusicBrainz спотыкается. Треки чистые (без стрима) — играются обычным - // конвейером (YouTube-матчер), как импортные и VK-чарт. Пустой список при - // любой неудаче (сеть/ротация/пусто) — вкладка просто не появится. - public static async Task<List<MusicTrack>> SearchTracksAsync(string query, int limit, CancellationToken cancellationToken = default) - { - query = query?.Trim(); - if (string.IsNullOrWhiteSpace(query)) - return new List<MusicTrack>(); - - try - { - var root = await QueryV2Async("findTopResults", FindTopResultsHash, new - { - query, - numberOfTopResults = Math.Clamp(limit, 1, 30) - }, cancellationToken); - - var tracksV2 = GetProperty(GetProperty(root, "data", "searchV2") ?? default, "tracksV2"); - if (tracksV2 == null || !tracksV2.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return new List<MusicTrack>(); - - var tracks = new List<MusicTrack>(); - foreach (var item in items.EnumerateArray()) - { - // items[].item.data = Track; альбом/обложка в albumOfTrack - var data = GetProperty(item, "item", "data"); - if (data == null || !string.Equals(GetString(data.Value, "__typename"), "Track", StringComparison.OrdinalIgnoreCase)) - continue; - - var album = GetProperty(data.Value, "albumOfTrack"); - string albumTitle = album != null ? GetString(album.Value, "name") : null; - string albumId = album != null ? GetString(album.Value, "uri") : null; - var images = album != null ? MapCoverArt(GetProperty(album.Value, "coverArt")) : null; - - var mapped = MapTrackElement(data.Value, albumTitle, images, date: null, durationProperty: "duration", albumId: albumId); - if (mapped != null) - tracks.Add(mapped); - - if (tracks.Count >= limit) - break; - } - - return DeduplicateTracks(tracks); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - return new List<MusicTrack>(); - } - } - - public static async Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(string inputUrl, CancellationToken cancellationToken = default) - { - var entity = ParseEntity(inputUrl); - if (entity == null) - return ImportUnavailable("Вставь ссылку на Spotify плейлист или альбом."); - - // прогрев кэша токена + дружелюбная ошибка, если Spotify недоступен - string token = await GetAnonymousTokenAsync(entity.Value.type, entity.Value.id, cancellationToken); - if (string.IsNullOrWhiteSpace(token)) - return ImportUnavailable("Не удалось получить Spotify токен."); - - try - { - return entity.Value.type == "album" - ? await ImportAlbumAsync(entity.Value.id, cancellationToken) - : await ImportPlaylistByIdAsync(entity.Value.id, cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - Console.WriteLine($"[Music] spotify import failed: {ex.Message}"); - return ImportUnavailable("Spotify не ответил, попробуй ещё раз."); - } - } - - public static Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default) - { - if (source == null || string.IsNullOrWhiteSpace(source.url)) - return Task.FromResult(ImportUnavailable("У плейлиста нет Spotify источника.")); - - return ImportPlaylistAsync(source.url, cancellationToken); - } - - static (string type, string id)? ParseEntity(string value) - { - value = value?.Trim(); - if (string.IsNullOrWhiteSpace(value)) - return null; - - var match = entityUriRegex.Match(value); - if (!match.Success) - match = entityUrlRegex.Match(value); - - if (!match.Success) - return null; - - return (match.Groups[1].Value.ToLowerInvariant(), match.Groups[2].Value); - } - - // Гостевой токен живёт на любой публичной embed-странице (~1 час); - // логина/ключей не требует — это тот же токен, что получает браузер инкогнито. - static async Task<string> GetAnonymousTokenAsync(string entityType, string entityId, CancellationToken cancellationToken) - { - if (!string.IsNullOrWhiteSpace(anonymousToken) && anonymousTokenExpiresAt > DateTime.UtcNow) - return anonymousToken; - - await anonymousTokenLock.WaitAsync(cancellationToken); - try - { - if (!string.IsNullOrWhiteSpace(anonymousToken) && anonymousTokenExpiresAt > DateTime.UtcNow) - return anonymousToken; - - using var request = new HttpRequestMessage(HttpMethod.Get, $"https://open.spotify.com/embed/{entityType}/{entityId}"); - 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 null; - - string html = await response.Content.ReadAsStringAsync(cancellationToken); - var tokenMatch = accessTokenRegex.Match(html ?? string.Empty); - if (!tokenMatch.Success) - return null; - - var expiresMatch = accessTokenExpiresRegex.Match(html); - DateTime expiresAt = expiresMatch.Success && long.TryParse(expiresMatch.Groups[1].Value, out long expiresMs) - ? DateTimeOffset.FromUnixTimeMilliseconds(expiresMs).UtcDateTime.AddMinutes(-2) - : DateTime.UtcNow.AddMinutes(30); - - anonymousToken = tokenMatch.Groups[1].Value; - anonymousTokenExpiresAt = expiresAt; - return anonymousToken; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - return null; - } - finally - { - anonymousTokenLock.Release(); - } - } - - static void InvalidateAnonymousToken() - { - anonymousToken = null; - anonymousTokenExpiresAt = DateTime.MinValue; - } - - // сам берёт токен из кэша; на 401 сбрасывает его и повторяет запрос - // один раз со свежим — протухший гостевой токен не роняет импорт/sync - static async Task<JsonElement?> QueryAsync(string operationName, string hash, object variables, string entityType, string entityId, CancellationToken cancellationToken) - { - string url = PathfinderUrl - + "?operationName=" + Uri.EscapeDataString(operationName) - + "&variables=" + Uri.EscapeDataString(MusicJson.Serialize(variables)) - + "&extensions=" + Uri.EscapeDataString($"{{\"persistedQuery\":{{\"version\":1,\"sha256Hash\":\"{hash}\"}}}}"); - - for (int attempt = 0; attempt < 2; attempt++) - { - string token = await GetAnonymousTokenAsync(entityType, entityId, cancellationToken); - if (string.IsNullOrWhiteSpace(token)) - return null; - - using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); - request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent); - request.Headers.TryAddWithoutValidation("Accept", "application/json"); - request.Headers.TryAddWithoutValidation("app-platform", "WebPlayer"); - - using var response = await httpClient.SendAsync(request, cancellationToken); - if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) - { - InvalidateAnonymousToken(); - continue; - } - - if (!response.IsSuccessStatusCode) - return null; - - string json = await response.Content.ReadAsStringAsync(cancellationToken); - using var document = JsonDocument.Parse(json); - var root = document.RootElement.Clone(); - - if (root.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Array && errors.GetArrayLength() > 0) - { - string firstError = errors.EnumerateArray().Select(e => GetString(e, "message")).FirstOrDefault(m => !string.IsNullOrWhiteSpace(m)); - if (firstError?.Contains("PersistedQueryNotFound", StringComparison.OrdinalIgnoreCase) == true) - Console.WriteLine("[Music] spotify pathfinder hash rotated — module update required"); - else - Console.WriteLine($"[Music] spotify pathfinder error: {firstError}"); - return null; - } - - return root; - } - - return null; - } - - public static bool IsSpotifyArtist(string provider, string id) - => string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase) - || (id ?? string.Empty).StartsWith("spotify:artist:", StringComparison.OrdinalIgnoreCase); - - public static bool IsSpotifyAlbum(string provider, string id) - => (string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase) - && (id ?? string.Empty).StartsWith("spotify:album:", StringComparison.OrdinalIgnoreCase)) - || (id ?? string.Empty).StartsWith("spotify:album:", StringComparison.OrdinalIgnoreCase); - - public static bool IsSpotifyArtistSection(string provider, string id) - => string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase) - && ParseSpotifyArtistSectionId(id) != null; - - // Артисты для Spotify-вкладки поиска (findTopResults -> searchV2.artists). - public static async Task<List<MusicArtist>> SearchArtistsAsync(string query, int limit, CancellationToken cancellationToken = default) - { - query = query?.Trim(); - if (string.IsNullOrWhiteSpace(query)) - return new List<MusicArtist>(); - - try - { - var root = await QueryV2Async("findTopResults", FindTopResultsHash, new { query, numberOfTopResults = Math.Clamp(limit, 1, 30) }, cancellationToken); - var artists = GetProperty(GetProperty(root, "data", "searchV2") ?? default, "artists"); - if (artists == null || !artists.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return new List<MusicArtist>(); - - var result = new List<MusicArtist>(); - foreach (var item in items.EnumerateArray()) - { - var mapped = MapArtistNode(GetProperty(item, "data")); - if (mapped != null) - result.Add(mapped); - if (result.Count >= limit) - break; - } - - return result; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch { return new List<MusicArtist>(); } - } - - // Лёгкий discovery-ответ для персональных миксов: один queryArtist - // без загрузки всей дискографии, которую делает GetArtistAsync. - public static async Task<ArtistDiscoveryResult> GetArtistDiscoveryByNameAsync( - string query, - int topTrackLimit = 12, - int relatedArtistLimit = 12, - CancellationToken cancellationToken = default) - { - var candidates = await SearchArtistsAsync(query, 5, cancellationToken); - var artist = candidates.FirstOrDefault(candidate => MusicMapSupport.IsAliasOf(candidate?.name, query)); - if (artist == null) - return null; - - return await GetArtistDiscoveryAsync(artist.id, topTrackLimit, relatedArtistLimit, cancellationToken); - } - - public static async Task<ArtistDiscoveryResult> GetArtistDiscoveryAsync( - string artistId, - int topTrackLimit = 12, - int relatedArtistLimit = 12, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(artistId) - || !artistId.StartsWith("spotify:artist:", StringComparison.OrdinalIgnoreCase)) - return null; - - try - { - var root = await QueryV2Async("queryArtist", QueryArtistHash, new { uri = artistId }, cancellationToken); - var artist = GetProperty(root, "data", "artistUnion"); - if (artist == null || artist.Value.ValueKind != JsonValueKind.Object) - return null; - - string name = GetString(GetProperty(artist.Value, "profile") ?? default, "name")?.Trim(); - if (string.IsNullOrWhiteSpace(name)) - return null; - - var discography = GetProperty(artist.Value, "discography"); - var related = GetProperty(artist.Value, "relatedContent"); - - return new ArtistDiscoveryResult - { - artist_id = artistId, - artist_name = name, - top_tracks = discography == null - ? new List<MusicTrack>() - : MapTopTracks(GetProperty(discography.Value, "topTracks"), Math.Clamp(topTrackLimit, 1, 30)), - related_artists = related == null - ? new List<MusicArtist>() - : MapRelatedArtists(GetProperty(related.Value, "relatedArtists"), Math.Clamp(relatedArtistLimit, 1, 30)) - }; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch { return null; } - } - - // Альбомы для Spotify-вкладки поиска (findTopResults -> searchV2.albumsV2). - public static async Task<List<MusicAlbum>> SearchAlbumsAsync(string query, int limit, CancellationToken cancellationToken = default) - { - query = query?.Trim(); - if (string.IsNullOrWhiteSpace(query)) - return new List<MusicAlbum>(); - - try - { - var root = await QueryV2Async("findTopResults", FindTopResultsHash, new { query, numberOfTopResults = Math.Clamp(limit, 1, 30) }, cancellationToken); - var albums = GetProperty(GetProperty(root, "data", "searchV2") ?? default, "albumsV2"); - if (albums == null || !albums.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return new List<MusicAlbum>(); - - var result = new List<MusicAlbum>(); - foreach (var item in items.EnumerateArray()) - { - var mapped = MapAlbumNode(GetProperty(item, "data")); - if (mapped != null) - result.Add(mapped); - if (result.Count >= limit) - break; - } - - return result; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch { return new List<MusicAlbum>(); } - } - - public static Task<MusicBrowseSection> GetArtistSectionAsync(string sectionId, string page = null, int limit = 20, string artistName = null, CancellationToken cancellationToken = default) - { - var parsed = ParseSpotifyArtistSectionId(sectionId); - if (parsed == null) - return Task.FromResult<MusicBrowseSection>(null); - - int offset = 0; - if (!string.IsNullOrWhiteSpace(page) && int.TryParse(page, out int parsedOffset)) - offset = Math.Max(0, parsedOffset); - - limit = Math.Clamp(limit, 1, 50); - - return parsed.Value.sectionKey switch - { - "albums" => GetDiscographySectionAsync(parsed.Value.artistId, "albums", "Альбомы", artistName, offset, limit, cancellationToken), - "singles" => GetDiscographySectionAsync(parsed.Value.artistId, "singles", "Синглы / EP", artistName, offset, limit, cancellationToken), - "compilations" => GetDiscographySectionAsync(parsed.Value.artistId, "compilations", "Сборники", artistName, offset, limit, cancellationToken), - "releases" => GetDiscographySectionAsync(parsed.Value.artistId, "releases", "Релизы", artistName, offset, limit, cancellationToken), - "appears-on" => GetAppearsOnSectionAsync(parsed.Value.artistId, artistName, offset, limit, cancellationToken), - _ => Task.FromResult<MusicBrowseSection>(null) - }; - } - - // Экран артиста: профиль + дискография (альбомы+синглы). id = spotify:artist:xxx. - public static async Task<MusicArtist> GetArtistAsync(string id, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(id)) - return null; - - try - { - var root = await QueryV2Async("queryArtist", QueryArtistHash, new { uri = id }, cancellationToken); - var au = GetProperty(root, "data", "artistUnion"); - if (au == null || au.Value.ValueKind != JsonValueKind.Object) - return null; - - string name = GetString(GetProperty(au.Value, "profile") ?? default, "name")?.Trim(); - if (string.IsNullOrWhiteSpace(name)) - return null; - - var artist = new MusicArtist - { - id = id, - name = name, - images = MapCoverArt(GetProperty(GetProperty(au.Value, "visuals") ?? default, "avatarImage")), - provider_refs = new List<MusicProviderRef> { new() { provider = ProviderId, external_id = id } } - }; - - var albumsTask = GetDiscographySectionAsync(id, "albums", "Альбомы", name, 0, ArtistOverviewShelfLimit, cancellationToken); - var singlesTask = GetDiscographySectionAsync(id, "singles", "Синглы / EP", name, 0, ArtistOverviewShelfLimit, cancellationToken); - var compilationsTask = GetDiscographySectionAsync(id, "compilations", "Сборники", name, 0, ArtistOverviewShelfLimit, cancellationToken); - var appearsOnTask = GetAppearsOnSectionAsync(id, name, 0, ArtistOverviewShelfLimit, cancellationToken); - - var disco = GetProperty(au.Value, "discography"); - if (disco != null) - { - var topTracks = MapTopTracks(GetProperty(disco.Value, "topTracks"), limit: 12); - AddTrackSection(artist, $"{id}:top-tracks", "Популярные треки", topTracks); - - var popularReleases = MapDirectAlbumItems(GetProperty(disco.Value, "popularReleasesAlbums"), name, limit: 12); - AddAlbumSection(artist, $"{id}:popular-releases", "Популярные релизы", popularReleases); - } - - await Task.WhenAll(albumsTask, singlesTask, compilationsTask, appearsOnTask); - - var albumsSection = await albumsTask; - var singlesSection = await singlesTask; - var compilationsSection = await compilationsTask; - var appearsOnSection = await appearsOnTask; - - AddSectionIfNotEmpty(artist, albumsSection); - AddSectionIfNotEmpty(artist, singlesSection); - AddSectionIfNotEmpty(artist, compilationsSection); - - var seenAlbums = new HashSet<string>(StringComparer.Ordinal); - foreach (var album in (albumsSection?.albums ?? new List<MusicAlbum>()) - .Concat(singlesSection?.albums ?? new List<MusicAlbum>()) - .Concat(compilationsSection?.albums ?? new List<MusicAlbum>())) - { - if (album != null && seenAlbums.Add(album.id)) - artist.albums.Add(album); - } - - var related = GetProperty(au.Value, "relatedContent"); - if (related != null) - { - AddSectionIfNotEmpty(artist, appearsOnSection); - - var relatedArtists = MapRelatedArtists(GetProperty(related.Value, "relatedArtists"), limit: 12); - AddArtistSection(artist, $"{id}:related-artists", "Похожие артисты", relatedArtists); - } - - return artist; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch { return null; } - } - - // Экран альбома: мета + треки. id = spotify:album:xxx. - public static async Task<MusicAlbum> GetAlbumAsync(string id, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(id)) - return null; - - try - { - var root = await QueryV2Async("queryAlbum", QueryAlbumHash, new { uri = id, offset = 0 }, cancellationToken); - var au = GetProperty(root, "data", "albumUnion"); - if (au == null || au.Value.ValueKind != JsonValueKind.Object) - return null; - - string title = GetString(au.Value, "name")?.Trim(); - if (string.IsNullOrWhiteSpace(title)) - return null; - - string artistName = ExtractArtistNames(GetProperty(au.Value, "artists")).FirstOrDefault(); - var images = MapCoverArt(GetProperty(au.Value, "coverArt")); - string date = GetString(GetProperty(au.Value, "date") ?? default, "isoString"); - int? year = GetInt(GetProperty(au.Value, "date") ?? default, "year"); - - var album = new MusicAlbum - { - id = id, - title = title, - artist_name = string.IsNullOrWhiteSpace(artistName) ? "Spotify" : artistName, - year = year, - date = date, - images = images, - provider_refs = new List<MusicProviderRef> { new() { provider = ProviderId, external_id = id } } - }; - - var tracksV2 = GetProperty(au.Value, "tracksV2"); - if (tracksV2 != null && tracksV2.Value.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array) - { - foreach (var item in items.EnumerateArray()) - { - var track = GetProperty(item, "track"); - if (track == null) - continue; - - var mapped = MapTrackElement(track.Value, title, images, date, durationProperty: "duration"); - if (mapped != null) - { - mapped.album_id = id; - mapped.album_title = title; - album.tracks.Add(mapped); - } - } - } - - return album; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch { return null; } - } - - static MusicArtist MapArtistNode(JsonElement? node) - { - if (node == null || node.Value.ValueKind != JsonValueKind.Object) - return null; - - string uri = GetString(node.Value, "uri"); - string name = GetString(GetProperty(node.Value, "profile") ?? default, "name")?.Trim(); - if (string.IsNullOrWhiteSpace(uri) || string.IsNullOrWhiteSpace(name)) - return null; - - return new MusicArtist - { - id = uri, - name = name, - images = MapCoverArt(GetProperty(GetProperty(node.Value, "visuals") ?? default, "avatarImage")), - provider_refs = new List<MusicProviderRef> { new() { provider = ProviderId, external_id = uri } } - }; - } - - static MusicAlbum MapAlbumNode(JsonElement? node) - { - if (node == null || node.Value.ValueKind != JsonValueKind.Object) - return null; - - string uri = GetString(node.Value, "uri"); - string title = GetString(node.Value, "name")?.Trim(); - if (string.IsNullOrWhiteSpace(uri) || string.IsNullOrWhiteSpace(title)) - return null; - - return new MusicAlbum - { - id = uri, - title = title, - artist_name = ExtractArtistNames(GetProperty(node.Value, "artists")).FirstOrDefault() ?? "Spotify", - year = GetInt(GetProperty(node.Value, "date") ?? default, "year"), - images = MapCoverArt(GetProperty(node.Value, "coverArt")), - provider_refs = new List<MusicProviderRef> { new() { provider = ProviderId, external_id = uri } } - }; - } - - static MusicAlbum MapDiscographyRelease(JsonElement release, string artistName) - { - string uri = GetString(release, "uri"); - string title = GetString(release, "name")?.Trim(); - if (string.IsNullOrWhiteSpace(uri) || string.IsNullOrWhiteSpace(title)) - return null; - - return new MusicAlbum - { - id = uri, - title = title, - artist_name = string.IsNullOrWhiteSpace(artistName) ? null : artistName, - year = GetInt(GetProperty(release, "date") ?? default, "year"), - type = GetString(release, "type"), - images = MapCoverArt(GetProperty(release, "coverArt")), - provider_refs = new List<MusicProviderRef> { new() { provider = ProviderId, external_id = uri } } - }; - } - - static async Task<MusicBrowseSection> GetDiscographySectionAsync(string artistId, string sectionKey, string title, string artistName, int offset, int limit, CancellationToken cancellationToken) - { - try - { - string operationName = sectionKey switch - { - "albums" => "queryArtistDiscographyAlbums", - "singles" => "queryArtistDiscographySingles", - "compilations" => "queryArtistDiscographyCompilations", - "releases" => "queryArtistDiscographyAll", - _ => null - }; - - string responseKey = sectionKey == "releases" ? "all" : sectionKey; - if (string.IsNullOrWhiteSpace(operationName)) - return null; - - var root = await QueryV2Async(operationName, QueryArtistDiscographyHash, new - { - uri = artistId, - offset, - limit, - order = ArtistDiscographyOrder - }, cancellationToken); - - var group = GetProperty(root, "data", "artistUnion", "discography", responseKey); - if (group == null) - return null; - - int totalCount = GetInt(group.Value, "totalCount") ?? offset + limit; - - if (sectionKey == "singles") - { - var tracks = MapDiscographySingleTracks(group, artistName, limit); - return BuildTrackSection( - BuildSpotifyArtistSectionId(artistId, sectionKey), - title, - tracks, - totalCount > offset + tracks.Count, - NextOffset(offset, tracks.Count, totalCount) - ); - } - - var albums = MapDiscographyGroup(group, artistName, limit); - - return BuildAlbumSection( - BuildSpotifyArtistSectionId(artistId, sectionKey), - title, - albums, - totalCount > offset + albums.Count, - NextOffset(offset, albums.Count, totalCount) - ); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch { return null; } - } - - static async Task<MusicBrowseSection> GetAppearsOnSectionAsync(string artistId, string artistName, int offset, int limit, CancellationToken cancellationToken) - { - try - { - var root = await QueryV2Async("queryArtistAppearsOn", QueryArtistAppearsOnHash, new { uri = artistId }, cancellationToken); - var group = GetProperty(root, "data", "artistUnion", "relatedContent", "appearsOn"); - if (group == null) - return null; - - var allAlbums = MapDiscographyGroup(group, artistName, 50); - var pageAlbums = allAlbums.Skip(offset).Take(limit).ToList(); - int totalCount = allAlbums.Count; - - return BuildAlbumSection( - BuildSpotifyArtistSectionId(artistId, "appears-on"), - "Релизы с участием", - pageAlbums, - totalCount > offset + pageAlbums.Count, - NextOffset(offset, pageAlbums.Count, totalCount) - ); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch { return null; } - } - - static List<MusicTrack> MapTopTracks(JsonElement? topTracks, int limit) - { - var result = new List<MusicTrack>(); - if (topTracks == null || !topTracks.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return result; - - foreach (var item in items.EnumerateArray()) - { - var track = GetProperty(item, "track"); - if (track == null) - continue; - - var album = GetProperty(track.Value, "albumOfTrack"); - string albumTitle = album != null ? GetString(album.Value, "name") : null; - string albumId = album != null ? GetString(album.Value, "uri") : null; - var images = album != null ? MapCoverArt(GetProperty(album.Value, "coverArt")) : null; - - var mapped = MapTrackElement(track.Value, albumTitle, images, date: null, durationProperty: "duration", albumId: albumId); - if (mapped != null) - result.Add(mapped); - - if (result.Count >= limit) - break; - } - - return DeduplicateTracks(result); - } - - static List<MusicAlbum> MapDirectAlbumItems(JsonElement? container, string artistName, int limit) - { - var result = new List<MusicAlbum>(); - if (container == null || !container.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return result; - - var seen = new HashSet<string>(StringComparer.Ordinal); - foreach (var item in items.EnumerateArray()) - { - var album = MapDiscographyRelease(item, artistName); - if (album != null && seen.Add(album.id)) - result.Add(album); - - if (result.Count >= limit) - break; - } - - return result; - } - - static List<MusicAlbum> MapDiscographyGroup(JsonElement? groupNode, string artistName, int limit) - { - var result = new List<MusicAlbum>(); - if (groupNode == null || !groupNode.Value.TryGetProperty("items", out var relItems) || relItems.ValueKind != JsonValueKind.Array) - return result; - - var seen = new HashSet<string>(StringComparer.Ordinal); - foreach (var relItem in relItems.EnumerateArray()) - { - var releases = GetProperty(GetProperty(relItem, "releases") ?? default, "items"); - if (releases == null || releases.Value.ValueKind != JsonValueKind.Array || releases.Value.GetArrayLength() == 0) - continue; - - var album = MapDiscographyRelease(releases.Value[0], artistName); - if (album != null && seen.Add(album.id)) - result.Add(album); - - if (result.Count >= limit) - break; - } - - return result; - } - - static List<MusicTrack> MapDiscographySingleTracks(JsonElement? groupNode, string artistName, int limit) - { - var result = new List<MusicTrack>(); - if (groupNode == null || !groupNode.Value.TryGetProperty("items", out var relItems) || relItems.ValueKind != JsonValueKind.Array) - return result; - - var seen = new HashSet<string>(StringComparer.Ordinal); - foreach (var relItem in relItems.EnumerateArray()) - { - var releases = GetProperty(GetProperty(relItem, "releases") ?? default, "items"); - if (releases == null || releases.Value.ValueKind != JsonValueKind.Array || releases.Value.GetArrayLength() == 0) - continue; - - var track = MapDiscographySingleTrack(releases.Value[0], artistName); - string key = track?.album_id ?? track?.id; - if (track != null && !string.IsNullOrWhiteSpace(key) && seen.Add(key)) - result.Add(track); - - if (result.Count >= limit) - break; - } - - return result; - } - - static MusicTrack MapDiscographySingleTrack(JsonElement release, string artistName) - { - string albumUri = GetString(release, "uri"); - string albumId = GetString(release, "id"); - string title = GetString(release, "name")?.Trim(); - if (string.IsNullOrWhiteSpace(albumUri) || string.IsNullOrWhiteSpace(title)) - return null; - - string date = GetString(GetProperty(release, "date") ?? default, "isoString"); - var images = MapCoverArt(GetProperty(release, "coverArt")); - string releaseTrackId = !string.IsNullOrWhiteSpace(albumId) - ? $"spotify:release:{albumId}" - : albumUri.Replace("spotify:album:", "spotify:release:", StringComparison.OrdinalIgnoreCase); - - return new MusicTrack - { - id = releaseTrackId, - title = title, - artist_name = string.IsNullOrWhiteSpace(artistName) ? "Spotify" : artistName, - artists = string.IsNullOrWhiteSpace(artistName) ? new List<string>() : new List<string> { artistName }, - album_id = albumUri, - album_title = title, - date = date, - track_number = 1, - images = images, - provider_refs = new List<MusicProviderRef> - { - new() { provider = ProviderId, external_id = albumUri } - } - }; - } - - static List<MusicArtist> MapRelatedArtists(JsonElement? relatedArtists, int limit) - { - var result = new List<MusicArtist>(); - if (relatedArtists == null || !relatedArtists.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return result; - - var seen = new HashSet<string>(StringComparer.Ordinal); - foreach (var item in items.EnumerateArray()) - { - var artist = MapArtistNode(item); - if (artist != null && seen.Add(artist.id)) - result.Add(artist); - - if (result.Count >= limit) - break; - } - - return result; - } - - static void AddSectionIfNotEmpty(MusicArtist artist, MusicBrowseSection section) - { - if (artist == null || section == null) - return; - - bool hasEntries = section.tracks?.Count > 0 - || section.albums?.Count > 0 - || section.artists?.Count > 0; - - if (hasEntries) - artist.sections.Add(section); - } - - static void AddTrackSection(MusicArtist artist, string id, string title, List<MusicTrack> tracks) - { - if (artist == null || tracks == null || tracks.Count == 0) - return; - - artist.sections.Add(new MusicBrowseSection - { - id = id, - title = title, - type = "tracks", - source_provider = ProviderId, - has_more = false, - tracks = tracks - }); - } - - static MusicBrowseSection BuildAlbumSection(string id, string title, List<MusicAlbum> albums, bool hasMore, string nextPage) - { - return new MusicBrowseSection - { - id = id, - title = title, - type = "albums", - source_provider = ProviderId, - has_more = hasMore, - next_page = nextPage, - albums = albums ?? new List<MusicAlbum>() - }; - } - - static MusicBrowseSection BuildTrackSection(string id, string title, List<MusicTrack> tracks, bool hasMore, string nextPage) - { - return new MusicBrowseSection - { - id = id, - title = title, - type = "tracks", - source_provider = ProviderId, - has_more = hasMore, - next_page = nextPage, - tracks = tracks ?? new List<MusicTrack>() - }; - } - - static void AddAlbumSection(MusicArtist artist, string id, string title, List<MusicAlbum> albums) - { - if (artist == null || albums == null || albums.Count == 0) - return; - - artist.sections.Add(new MusicBrowseSection - { - id = id, - title = title, - type = "albums", - source_provider = ProviderId, - has_more = false, - albums = albums - }); - } - - static void AddArtistSection(MusicArtist artist, string id, string title, List<MusicArtist> artists) - { - if (artist == null || artists == null || artists.Count == 0) - return; - - artist.sections.Add(new MusicBrowseSection - { - id = id, - title = title, - type = "artists", - source_provider = ProviderId, - has_more = false, - artists = artists - }); - } - - static string BuildSpotifyArtistSectionId(string artistId, string sectionKey) - => $"{artistId}:{sectionKey}"; - - static (string artistId, string sectionKey)? ParseSpotifyArtistSectionId(string sectionId) - { - if (string.IsNullOrWhiteSpace(sectionId)) - return null; - - int separator = sectionId.LastIndexOf(':'); - if (separator <= 0 || separator >= sectionId.Length - 1) - return null; - - string artistId = sectionId.Substring(0, separator); - string sectionKey = sectionId.Substring(separator + 1); - if (!artistId.StartsWith("spotify:artist:", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(sectionKey)) - return null; - - return (artistId, sectionKey); - } - - static string NextOffset(int offset, int count, int totalCount) - { - int next = offset + Math.Max(0, count); - return count > 0 && totalCount > next ? next.ToString() : null; - } - - // pathfinder v2: POST с телом {variables, operationName, extensions}. - // Отдельный транспорт от v1-QueryAsync (тот GET и для импорта). Токен — - // тот же гостевой, с общей embed-страницы; на 401 сброс+повтор. - static async Task<JsonElement?> QueryV2Async(string operationName, string hash, object variables, CancellationToken cancellationToken) - { - string body = MusicJson.Serialize(new - { - variables, - operationName, - extensions = new { persistedQuery = new { version = 1, sha256Hash = hash } } - }); - - for (int attempt = 0; attempt < 2; attempt++) - { - string token = await GetAnonymousTokenAsync("playlist", TokenSeedPlaylistId, cancellationToken); - if (string.IsNullOrWhiteSpace(token)) - return null; - - using var request = new HttpRequestMessage(HttpMethod.Post, PathfinderV2Url) - { - Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") - }; - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); - request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent); - request.Headers.TryAddWithoutValidation("Accept", "application/json"); - request.Headers.TryAddWithoutValidation("app-platform", "WebPlayer"); - - using var response = await httpClient.SendAsync(request, cancellationToken); - if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) - { - InvalidateAnonymousToken(); - continue; - } - - if (!response.IsSuccessStatusCode) - return null; - - string json = await response.Content.ReadAsStringAsync(cancellationToken); - using var document = JsonDocument.Parse(json); - var root = document.RootElement.Clone(); - - if (root.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Array && errors.GetArrayLength() > 0) - { - string firstError = errors.EnumerateArray().Select(e => GetString(e, "message")).FirstOrDefault(m => !string.IsNullOrWhiteSpace(m)); - if (firstError?.Contains("PersistedQueryNotFound", StringComparison.OrdinalIgnoreCase) == true) - Console.WriteLine("[Music] spotify pathfinder v2 hash rotated — module update required"); - else - Console.WriteLine($"[Music] spotify pathfinder v2 error: {firstError}"); - return null; - } - - return root; - } - - return null; - } - - static async Task<MusicUserPlaylistImportResult> ImportPlaylistByIdAsync(string playlistId, CancellationToken cancellationToken) - { - var tracks = new List<MusicTrack>(); - string title = null; - int totalCount = -1; - - for (int offset = 0; totalCount < 0 || (offset < totalCount && tracks.Count < MaxImportTracks); offset += PlaylistPageLimit) - { - var root = await QueryAsync("fetchPlaylist", FetchPlaylistHash, new - { - uri = $"spotify:playlist:{playlistId}", - offset, - limit = PlaylistPageLimit, - enableWatchFeedEntrypoint = false - }, "playlist", playlistId, cancellationToken); - - var playlist = GetProperty(root, "data", "playlistV2"); - if (playlist == null || playlist.Value.ValueKind != JsonValueKind.Object) - { - // страница не отдалась — импорт атомарный, усечённый список не сохраняем - return offset == 0 - ? ImportUnavailable("Spotify плейлист не найден или приватный.") - : ImportUnavailable("Spotify не отдал плейлист целиком, попробуй ещё раз."); - } - - title ??= GetString(playlist.Value, "name"); - - var content = GetProperty(playlist.Value, "content"); - if (content == null) - return ImportUnavailable("Spotify плейлист не удалось прочитать."); - - if (totalCount < 0) - totalCount = GetInt(content.Value, "totalCount") ?? 0; - - if (!content.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return ImportUnavailable("Spotify не отдал плейлист целиком, попробуй ещё раз."); - - if (items.GetArrayLength() == 0 && offset < totalCount) - return ImportUnavailable("Spotify не отдал плейлист целиком, попробуй ещё раз."); - - foreach (var item in items.EnumerateArray()) - { - var mapped = MapPlaylistItem(item); - if (mapped != null) - tracks.Add(mapped); - } - - if (totalCount <= 0) - break; - } - - tracks = DeduplicateTracks(tracks); - if (tracks.Count == 0) - return ImportUnavailable("В Spotify плейлисте не найдено треков."); - - title = string.IsNullOrWhiteSpace(title) ? "Spotify Playlist" : title.Trim(); - - return new MusicUserPlaylistImportResult - { - available = true, - title = title, - track_count = tracks.Count, - truncated = totalCount > tracks.Count && tracks.Count >= MaxImportTracks, - tracks = tracks, - source = new MusicUserPlaylistSource - { - type = PlaylistSourceType, - url = $"https://open.spotify.com/playlist/{playlistId}", - playlist_id = playlistId, - title = title - } - }; - } - - static async Task<MusicUserPlaylistImportResult> ImportAlbumAsync(string albumId, CancellationToken cancellationToken) - { - var tracks = new List<MusicTrack>(); - string title = null, artistName = null, date = null; - List<MusicImage> albumImages = null; - int totalCount = -1; - - for (int offset = 0; totalCount < 0 || (offset < totalCount && tracks.Count < MaxImportTracks); offset += AlbumPageLimit) - { - var root = await QueryAsync("getAlbum", GetAlbumHash, new - { - uri = $"spotify:album:{albumId}", - locale = "", - offset, - limit = AlbumPageLimit - }, "album", albumId, cancellationToken); - - var album = GetProperty(root, "data", "albumUnion"); - if (album == null || album.Value.ValueKind != JsonValueKind.Object) - { - return offset == 0 - ? ImportUnavailable("Spotify альбом не найден.") - : ImportUnavailable("Spotify не отдал альбом целиком, попробуй ещё раз."); - } - - if (title == null) - { - title = GetString(album.Value, "name"); - artistName = ExtractArtistNames(GetProperty(album.Value, "artists")).FirstOrDefault(); - date = GetString(GetProperty(album.Value, "date") ?? default, "isoString"); - albumImages = MapCoverArt(GetProperty(album.Value, "coverArt")); - } - - var tracksV2 = GetProperty(album.Value, "tracksV2"); - if (tracksV2 == null) - return ImportUnavailable("Spotify альбом не удалось прочитать."); - - if (totalCount < 0) - totalCount = GetInt(tracksV2.Value, "totalCount") ?? 0; - - if (!tracksV2.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return ImportUnavailable("Spotify не отдал альбом целиком, попробуй ещё раз."); - - if (items.GetArrayLength() == 0 && offset < totalCount) - return ImportUnavailable("Spotify не отдал альбом целиком, попробуй ещё раз."); - - foreach (var item in items.EnumerateArray()) - { - var track = GetProperty(item, "track"); - if (track == null) - continue; - - var mapped = MapTrackElement( - track.Value, - title, - albumImages, - date, - durationProperty: "duration", - albumId: $"spotify:album:{albumId}"); - if (mapped != null) - tracks.Add(mapped); - } - - if (totalCount <= 0) - break; - } - - tracks = DeduplicateTracks(tracks); - if (tracks.Count == 0) - return ImportUnavailable("В Spotify альбоме не найдено треков."); - - title = string.IsNullOrWhiteSpace(title) ? "Spotify Album" : title.Trim(); - string playlistTitle = string.IsNullOrWhiteSpace(artistName) ? title : $"{artistName} — {title}"; - - return new MusicUserPlaylistImportResult - { - available = true, - title = playlistTitle, - track_count = tracks.Count, - tracks = tracks, - source = new MusicUserPlaylistSource - { - type = AlbumSourceType, - url = $"https://open.spotify.com/album/{albumId}", - playlist_id = albumId, - title = playlistTitle - } - }; - } - - static MusicTrack MapPlaylistItem(JsonElement item) - { - var data = GetProperty(item, "itemV2", "data"); - if (data == null || !string.Equals(GetString(data.Value, "__typename"), "Track", StringComparison.OrdinalIgnoreCase)) - return null; - - var album = GetProperty(data.Value, "albumOfTrack"); - string albumTitle = album != null ? GetString(album.Value, "name") : null; - string albumId = album != null ? GetString(album.Value, "uri") : null; - var images = album != null ? MapCoverArt(GetProperty(album.Value, "coverArt")) : null; - - return MapTrackElement(data.Value, albumTitle, images, date: null, durationProperty: "trackDuration", albumId: albumId); - } - - static MusicTrack MapTrackElement(JsonElement track, string albumTitle, List<MusicImage> images, string date, string durationProperty, string albumId = null) - { - string title = GetString(track, "name")?.Trim(); - string uri = GetString(track, "uri"); - if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(uri)) - return null; - - var artists = ExtractArtistNames(GetProperty(track, "artists")); - var duration = GetProperty(track, durationProperty); - - return new MusicTrack - { - id = uri, - title = title, - artist_name = artists.Count > 0 ? string.Join(", ", artists) : "Spotify", - artists = artists, - album_id = string.IsNullOrWhiteSpace(albumId) ? null : albumId.Trim(), - album_title = string.IsNullOrWhiteSpace(albumTitle) ? null : albumTitle.Trim(), - duration_ms = duration != null ? GetInt(duration.Value, "totalMilliseconds") : null, - track_number = GetInt(track, "trackNumber"), - disc_number = GetInt(track, "discNumber"), - date = date, - images = images?.ToList() ?? new List<MusicImage>(), - provider_refs = new List<MusicProviderRef> - { - new() { provider = ProviderId, external_id = uri } - } - }; - } - - static List<string> ExtractArtistNames(JsonElement? artists) - { - var result = new List<string>(); - if (artists == null || !artists.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return result; - - foreach (var item in items.EnumerateArray()) - { - string name = GetString(GetProperty(item, "profile") ?? default, "name")?.Trim(); - if (!string.IsNullOrWhiteSpace(name) && !result.Contains(name, StringComparer.OrdinalIgnoreCase)) - result.Add(name); - } - - return result; - } - - static List<MusicImage> MapCoverArt(JsonElement? coverArt) - { - var result = new List<MusicImage>(); - if (coverArt == null || !coverArt.Value.TryGetProperty("sources", out var sources) || sources.ValueKind != JsonValueKind.Array) - return result; - - foreach (var source in sources.EnumerateArray()) - { - string url = GetString(source, "url"); - if (string.IsNullOrWhiteSpace(url)) - continue; - - result.Add(new MusicImage - { - url = url, - width = GetInt(source, "width"), - height = GetInt(source, "height") - }); - } - - // крупные первыми — как отдаёт SoundCloud-маппер - return result.OrderByDescending(i => i.width ?? 0).ToList(); - } - - static List<MusicImage> MapPlaylistImages(JsonElement? images) - { - var result = new List<MusicImage>(); - if (images == null || !images.Value.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) - return result; - - foreach (var item in items.EnumerateArray()) - { - foreach (var image in MapCoverArt(item)) - { - if (!result.Any(i => string.Equals(i.url, image.url, StringComparison.OrdinalIgnoreCase))) - result.Add(image); - } - } - - return result.OrderByDescending(i => i.width ?? 0).ToList(); - } - -} diff --git a/mods/Music/Providers/Yandex/YandexMusicProviders.cs b/mods/Music/Providers/Yandex/YandexMusicProviders.cs deleted file mode 100644 index 552fd95..0000000 --- a/mods/Music/Providers/Yandex/YandexMusicProviders.cs +++ /dev/null @@ -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); -} diff --git a/mods/Music/Providers/Yandex/YandexMusicSupport.cs b/mods/Music/Providers/Yandex/YandexMusicSupport.cs deleted file mode 100644 index e22a43a..0000000 --- a/mods/Music/Providers/Yandex/YandexMusicSupport.cs +++ /dev/null @@ -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; - } -} diff --git a/mods/Music/README.md b/mods/Music/README.md deleted file mode 100644 index 696d8d2..0000000 --- a/mods/Music/README.md +++ /dev/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 в публичный плейлист и импортировать по ссылке); прямой доступ к библиотеке требует логина и не реализован. diff --git a/mods/Music/SQL/MusicContext.cs b/mods/Music/SQL/MusicContext.cs deleted file mode 100644 index c9770b2..0000000 --- a/mods/Music/SQL/MusicContext.cs +++ /dev/null @@ -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; } -} diff --git a/mods/Music/Services/Auth/MusicAuthService.cs b/mods/Music/Services/Auth/MusicAuthService.cs deleted file mode 100644 index 5555a7a..0000000 --- a/mods/Music/Services/Auth/MusicAuthService.cs +++ /dev/null @@ -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); - } -} diff --git a/mods/Music/Services/Auth/MusicAuthStorageService.cs b/mods/Music/Services/Auth/MusicAuthStorageService.cs deleted file mode 100644 index 58a62f0..0000000 --- a/mods/Music/Services/Auth/MusicAuthStorageService.cs +++ /dev/null @@ -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(); -} diff --git a/mods/Music/Services/Auth/MusicProfileIdentity.cs b/mods/Music/Services/Auth/MusicProfileIdentity.cs deleted file mode 100644 index 3596737..0000000 --- a/mods/Music/Services/Auth/MusicProfileIdentity.cs +++ /dev/null @@ -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"; - } -} diff --git a/mods/Music/Services/Cache/MusicMetadataCacheService.cs b/mods/Music/Services/Cache/MusicMetadataCacheService.cs deleted file mode 100644 index 32a1bc5..0000000 --- a/mods/Music/Services/Cache/MusicMetadataCacheService.cs +++ /dev/null @@ -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; -} diff --git a/mods/Music/Services/Cache/MusicSourceMatchService.cs b/mods/Music/Services/Cache/MusicSourceMatchService.cs deleted file mode 100644 index 119eecb..0000000 --- a/mods/Music/Services/Cache/MusicSourceMatchService.cs +++ /dev/null @@ -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; -} diff --git a/mods/Music/Services/Catalog/MusicCatalogService.cs b/mods/Music/Services/Catalog/MusicCatalogService.cs deleted file mode 100644 index 6c20168..0000000 --- a/mods/Music/Services/Catalog/MusicCatalogService.cs +++ /dev/null @@ -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}"; -} diff --git a/mods/Music/Services/Catalog/MusicProviderRegistry.cs b/mods/Music/Services/Catalog/MusicProviderRegistry.cs deleted file mode 100644 index 00a7a36..0000000 --- a/mods/Music/Services/Catalog/MusicProviderRegistry.cs +++ /dev/null @@ -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; - } -} diff --git a/mods/Music/Services/Http/MusicHttp.cs b/mods/Music/Services/Http/MusicHttp.cs deleted file mode 100644 index 54a0e99..0000000 --- a/mods/Music/Services/Http/MusicHttp.cs +++ /dev/null @@ -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; - } - } -} diff --git a/mods/Music/Services/Http/MusicProxyService.cs b/mods/Music/Services/Http/MusicProxyService.cs deleted file mode 100644 index 9c85762..0000000 --- a/mods/Music/Services/Http/MusicProxyService.cs +++ /dev/null @@ -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; - } -} diff --git a/mods/Music/Services/Images/DiscogsArtistImageService.cs b/mods/Music/Services/Images/DiscogsArtistImageService.cs deleted file mode 100644 index f357fa0..0000000 --- a/mods/Music/Services/Images/DiscogsArtistImageService.cs +++ /dev/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()}"; -} diff --git a/mods/Music/Services/Images/MusicImageProxyService.cs b/mods/Music/Services/Images/MusicImageProxyService.cs deleted file mode 100644 index fafd7d2..0000000 --- a/mods/Music/Services/Images/MusicImageProxyService.cs +++ /dev/null @@ -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); - } -} diff --git a/mods/Music/Services/Lyrics/MusicLyricsService.cs b/mods/Music/Services/Lyrics/MusicLyricsService.cs deleted file mode 100644 index e9f6ea0..0000000 --- a/mods/Music/Services/Lyrics/MusicLyricsService.cs +++ /dev/null @@ -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; - } -} diff --git a/mods/Music/Services/Lyrics/YouTubeMusicLyricsFallback.cs b/mods/Music/Services/Lyrics/YouTubeMusicLyricsFallback.cs deleted file mode 100644 index b56b977..0000000 --- a/mods/Music/Services/Lyrics/YouTubeMusicLyricsFallback.cs +++ /dev/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; } - } -} diff --git a/mods/Music/Services/Playback/MusicDailyMixService.cs b/mods/Music/Services/Playback/MusicDailyMixService.cs deleted file mode 100644 index 0bf2bca..0000000 --- a/mods/Music/Services/Playback/MusicDailyMixService.cs +++ /dev/null @@ -1,1598 +0,0 @@ -using System.Globalization; - -namespace Music; - -/// <summary> -/// «Миксы недели»: каждому дню недели детерминированно назначаются два -/// артиста из истории прослушиваний профиля + до двух похожих (доминирующие -/// чужие артисты из SoundCloud related-пулов) — это основа микса. Поверх — -/// Поверх — additive discovery-слой: Music-Map, SoundCloud-жанры и -/// Spotify related artists делят до 8 слотов с адаптивными квотами; -/// открытия вплетаются в микс равномерно. Seed = -/// profile + ISO-неделя, поэтому внутри дня и недели состав стабилен, а на -/// следующей неделе раскладка меняется. Сводки по дням — чистый SQL без -/// внешних HTTP; треки грузятся лениво при открытии микса. -/// </summary> -public static class MusicDailyMixService -{ - static readonly TimeSpan mixTimeout = TimeSpan.FromSeconds(8); - static readonly TimeSpan mixCacheTtl = TimeSpan.FromHours(12); - static readonly TimeSpan shortMixCacheTtl = TimeSpan.FromMinutes(30); - const int historyDepth = 150; - const int ownArtistsPerDay = 2; - const int minHistoryArtists = 3; - const int maxAssignableArtists = 28; - // квота артистов из плейлистов пользователя (импорт Spotify/SC или свои): - // «декларированный вкус» участвует в неделе даже при богатой истории, - // а пользователю без истории плейлист сам открывает полку - const int maxPlaylistArtists = 8; - const int mixTrackLimit = 24; - const int minMixTracks = 8; - const int minSimilarPoolTracks = 3; - // рецепт разнообразия (просьба пользователя 2026-07-16: «чтобы клиент - // получал что новенькое»): 2 своих + до 2 похожих из SC related + до 2 - // внешних с Music-Map = до 6 артистов на микс; round-robin выравнивает - // по ~4 трека на артиста, свои артисты идут первыми пулами - const int maxSimilarArtists = 2; - const int maxExternalArtists = 2; - const int maxRefillExternalArtists = 6; - const int externalPickWindow = 12; - // Music-Map + жанр сохраняют свои прежние 5 слотов. Spotify related - // получает до 4 слотов, но общий discovery-слой ограничен 8: - // источники адаптивно забирают незанятые места, но не вытесняют основу. - const int coreDiscoveryTrackBudget = 5; - const int discoveryTrackBudget = 8; - const int spotifyTrackQuota = 4; - const int desiredMixTrackCount = 20; - const int maxMainTracksPerArtist = 4; - const int maxDiscoveryTracksPerArtist = 2; - const int externalPoolCandidateLimit = 10; - const int genreTrackQuota = 4; - const int genrePoolFetchLimit = 30; - const int spotifyFallbackTarget = 16; - const int spotifyRelatedArtistLimit = 2; - static readonly TimeSpan genreCacheTtl = TimeSpan.FromHours(6); - static readonly TimeSpan artistGenresCacheTtl = TimeSpan.FromDays(14); - static readonly TimeSpan spotifyDiscoveryCacheTtl = TimeSpan.FromDays(7); - // кэш собранных кандидатов недели (история + парс payload всех плейлистов — - // самая дорогая часть /music/home). Сбор НЕ зависит от недели и salt (они - // входят только в шаффл), поэтому ключ — профиль; сброс миксов и смена - // недели получают новую раскладку мгновенно из кэшированного сбора. - // Пустой/ошибочный сбор живёт 45s (ревью Codex: новый пользователь после - // импорта плейлиста не должен ждать TTL, чтобы полка появилась) - static readonly TimeSpan seedCacheTtl = TimeSpan.FromMinutes(12); - static readonly TimeSpan seedCacheEmptyTtl = TimeSpan.FromSeconds(45); - const int seedArtistsCacheCapacity = 256; - static readonly System.Collections.Concurrent.ConcurrentDictionary<string, (DateTime expiresAt, List<HistoryArtist> artists)> seedArtistsCache = new(StringComparer.Ordinal); - // кап жёсткий: чтение lock-free, trim + вставка под общим lock — иначе - // параллельные сборы одновременно пройдут проверку Count и вместе её пробьют - static readonly object seedArtistsCacheWriteLock = new(); - // v19-target-20: расширенный discovery-слой не раздувает итог выше целевых 20 - // v18-spotify-peer: Spotify related участвует во всех миксах с адаптивной квотой 2–4 - // v17-spotify-buckets: фиты Spotify считаются по primary artist и не обходят cap - // v16-spotify-fallback: короткие миксы добираются чистой Spotify - // metadata сидов, затем максимум двух related artists, только до 16 - // v15-short-ttl: миксы короче 16 треков живут в кэше 30m, а не 12h - // v14-secondary-genre: один дополнительный жанр добирает только - // короткие (<16) миксы; обычные миксы и HTTP-бюджет не меняются - // v13-metadata: неразрушающая нормализация + подтверждение Music-Map - // external через YouTube author-match либо SoundCloud ISRC - // v12: основной artist-cap снижен до 4 треков, чтобы якорь не доминировал - // в 20-трековом миксе; v11: короткие миксы после artist-cap добираются дополнительными - // Music-Map/genre артистами до ~20 треков без снятия лимитов на артиста; - // v10: финальный artist-cap в миксе, чтобы один исполнитель/related-пул - // не забивал весь день и у discovery/genre оставалось место; - // v9: косметика title strip для дуэтов "Artist & Other - Title"; - // v8: artist-pool теперь гейтит кандидатов по запрошенному артисту и - // канонизирует uploader/channel-имена; v7 — title-prefix нормализация сидов - // применяется и к истории (YouTube/SC channel/uploader -> реальный Artist - Title) - // v20: пересобираем миксы с album_id из Spotify, чтобы playback мог - // быстро получить точную длительность без обращения к MusicBrainz. - const string cacheVersion = "dailymix-v20-spotify-album-id"; - - static readonly string[] dayTitles = - { - "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье" - }; - - sealed class HistoryArtist - { - public string Name; - public string NormalizedName; - public MusicTrack RecentTrack; - } - - sealed class PlaylistArtistCandidate - { - public string Name; - public string NormalizedName; - public MusicTrack RecentTrack; - public int Count; - public int FirstIndex; - public int NoisyCount; - } - - sealed class GenrePoolPlan - { - public List<List<MusicTrack>> PrimaryPools { get; init; } = new(); - public string SecondaryGenre { get; init; } - } - - sealed class SpotifyDiscoveryPlan - { - public List<SpotifySupport.ArtistDiscoveryResult> SeedDiscoveries { get; init; } = new(); - public List<List<MusicTrack>> RelatedPools { get; init; } = new(); - } - - // кэшируемый payload микса; null из фабрики (слабый результат) не кэшируется - sealed class DailyMixPayload - { - public List<string> artists { get; set; } = new(); - public List<MusicTrack> tracks { get; set; } = new(); - } - - public static async Task<List<MusicDailyMixSummary>> GetSummariesAsync(string profileId, int salt = 0, CancellationToken cancellationToken = default) - { - var (assignment, historyArtists) = await BuildWeekAssignmentAsync(profileId, salt, cancellationToken); - if (assignment == null) - return new List<MusicDailyMixSummary>(); - - // при бедной истории показываем столько дней, сколько закрывается - // РАЗНЫМИ парами артистов, — иначе полка выглядит натянуто. - // Полное покрытие (14+ артистов) — вся неделя с переносом через - // Вс→Пн; частичное — остаток текущей недели от сегодня БЕЗ переноса: - // шаг пар по модулю n на границе недели коллизится (7 простое), - // а «полка живёт до воскресенья» честно матчится с «обновится в - // понедельник» - int coverage = Math.Min(7, historyArtists.Count / ownArtistsPerDay); - int today = IsoDayOfWeek(DateTime.UtcNow); - var result = new List<MusicDailyMixSummary>(coverage); - - for (int offset = 0; offset < coverage; offset++) - { - int day = coverage >= 7 - ? (today - 1 + offset) % 7 + 1 - : today + offset; - - if (day > 7) - break; - - result.Add(new MusicDailyMixSummary - { - day = day, - title = dayTitles[day - 1], - today = day == today, - artists = assignment[day - 1].Select(i => i.Name).ToList() - }); - } - - return result; - } - - public static async Task<MusicDailyMixResponse> GetMixAsync(string profileId, int day, int salt = 0, CancellationToken cancellationToken = default) - { - if (day is < 1 or > 7) - return Unavailable(day, "Некорректный день недели."); - - var (assignment, historyArtists) = await BuildWeekAssignmentAsync(profileId, salt, cancellationToken); - if (assignment == null) - return Unavailable(day, "Слишком мало истории — послушай больше музыки, миксы соберутся сами."); - - var seeds = assignment[day - 1]; - string cacheKey = $"{cacheVersion}|{NormalizeProfileKey(profileId)}|{SeedKey(salt)}|{day}|{string.Join(",", seeds.Select(i => i.NormalizedName))}"; - - DailyMixPayload payload = null; - - try - { - payload = await MusicMetadataCacheService.GetOrCreateAsync( - "dailymix", - "daily_mix", - cacheKey, - mixCacheTtl, - () => BuildMixAsync(profileId, day, salt, seeds, historyArtists, cancellationToken), - cancellationToken, - created => created.tracks.Count < 16 ? shortMixCacheTtl : mixCacheTtl); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - } - - if (payload == null || payload.tracks.Count < minMixTracks) - return Unavailable(day, "Микс не собрался — попробуй чуть позже."); - - return new MusicDailyMixResponse - { - available = true, - day = day, - title = dayTitles[day - 1], - artists = payload.artists, - tracks = payload.tracks - }; - } - - // раскладка артистов истории по 7 дням; null = истории мало, полку не показываем. - // Детерминирована в пределах ISO-недели: seed-перемешивание + шаг по 2 артиста - // на день, с wrap'ом при коротком списке. Сколько дней из раскладки реально - // показывать (уникальные пары), решает GetSummariesAsync по artistCount - static async Task<(List<HistoryArtist>[] assignment, List<HistoryArtist> artists)> BuildWeekAssignmentAsync(string profileId, int salt, CancellationToken cancellationToken) - { - var artists = await CollectSeedArtistsCachedAsync(profileId, cancellationToken); - if (artists.Count < minHistoryArtists) - return (null, artists); - - // вход шаффла сортируем по стабильному ключу: без этого любое - // прослушивание меняло recency-порядок истории и пересобирало ВСЮ - // раскладку недели (живой промах: миксы уехали после теста на - // телефоне). Теперь раскладка сдвигается только при смене САМОГО - // состава top-28 (дебют/выпадение артиста), а не порядка - artists.Sort((a, b) => string.CompareOrdinal(a.NormalizedName, b.NormalizedName)); - - ShuffleDeterministic(artists, Fnv1aHash($"{NormalizeProfileKey(profileId)}|{SeedKey(salt)}")); - - var assignment = new List<HistoryArtist>[7]; - - for (int day = 1; day <= 7; day++) - { - var picks = new List<HistoryArtist>(ownArtistsPerDay); - - for (int slot = 0; slot < ownArtistsPerDay; slot++) - { - var candidate = artists[((day - 1) * ownArtistsPerDay + slot) % artists.Count]; - if (!picks.Contains(candidate)) - picks.Add(candidate); - } - - assignment[day - 1] = picks; - } - - return (assignment, artists); - } - - static async Task<List<HistoryArtist>> CollectSeedArtistsCachedAsync(string profileId, CancellationToken cancellationToken) - { - string key = NormalizeProfileKey(profileId); - - // возвращаем КОПИЮ списка: BuildWeekAssignmentAsync сортирует и - // шаффлит его на месте — общий инстанс между запросами дал бы гонку - if (seedArtistsCache.TryGetValue(key, out var cached) && cached.expiresAt > DateTime.UtcNow) - return new List<HistoryArtist>(cached.artists); - - var artists = await CollectSeedArtistsAsync(profileId, cancellationToken); - - var ttl = artists.Count >= minHistoryArtists ? seedCacheTtl : seedCacheEmptyTtl; - - lock (seedArtistsCacheWriteLock) - { - if (!seedArtistsCache.ContainsKey(key) && seedArtistsCache.Count >= seedArtistsCacheCapacity) - { - // сначала протухшие; если их нет — вытесняем с ближайшим истечением, - // иначе при равномерном трафике свежие профили переполнят кап - foreach (var stale in seedArtistsCache.Where(i => i.Value.expiresAt <= DateTime.UtcNow).ToList()) - seedArtistsCache.TryRemove(stale.Key, out _); - - while (seedArtistsCache.Count >= seedArtistsCacheCapacity) - { - var oldest = seedArtistsCache.OrderBy(i => i.Value.expiresAt).FirstOrDefault(); - if (oldest.Key == null || !seedArtistsCache.TryRemove(oldest.Key, out _)) - break; - } - } - - seedArtistsCache[key] = (DateTime.UtcNow.Add(ttl), new List<HistoryArtist>(artists)); - } - - return artists; - } - - // кандидаты недели: история прослушиваний (реальное поведение, до 28) + - // артисты из плейлистов пользователя (декларированный вкус, квота 8, - // гарантирована — история при переполнении ужимается с наименее свежего - // края). Пользователь без истории, но с плейлистом ≥3 артистов получает - // полку целиком из плейлиста - static async Task<List<HistoryArtist>> CollectSeedArtistsAsync(string profileId, CancellationToken cancellationToken) - { - var artists = await CollectHistoryArtistsAsync(profileId, cancellationToken); - var playlistArtists = new List<HistoryArtist>(); - - try - { - var playlistTracks = await MusicUserPlaylistService.GetAllTracksAsync(profileId, cancellationToken); - playlistArtists = RankPlaylistArtists(playlistTracks, artists) - .Take(maxPlaylistArtists) - .ToList(); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - } - - if (playlistArtists.Count == 0) - return artists; - - int historyKeep = Math.Min(artists.Count, maxAssignableArtists - playlistArtists.Count); - return artists.Take(historyKeep).Concat(playlistArtists).ToList(); - } - - static List<HistoryArtist> RankPlaylistArtists(List<MusicTrack> playlistTracks, List<HistoryArtist> historyArtists) - { - var groups = new List<PlaylistArtistCandidate>(); - var byNormalized = new Dictionary<string, PlaylistArtistCandidate>(StringComparer.OrdinalIgnoreCase); - var history = historyArtists ?? new List<HistoryArtist>(); - int index = 0; - - foreach (var rawTrack in playlistTracks ?? new List<MusicTrack>()) - { - var track = NormalizeSeedTrack(rawTrack); - string name = MusicRadioService.CleanupArtistName(track?.artist_name); - if (string.IsNullOrWhiteSpace(name)) - continue; - - string normalized = MusicRadioService.NormalizeText(name); - if (string.IsNullOrWhiteSpace(normalized)) - continue; - - if (history.Any(existing => MusicMapSupport.IsAliasOf(name, existing.Name))) - continue; - - if (!byNormalized.TryGetValue(normalized, out var group)) - { - group = groups.FirstOrDefault(existing => MusicMapSupport.IsAliasOf(name, existing.Name)); - - if (group == null) - { - group = new PlaylistArtistCandidate - { - Name = name, - NormalizedName = normalized, - RecentTrack = track, - FirstIndex = index - }; - groups.Add(group); - } - - byNormalized[normalized] = group; - } - - group.Count++; - if (IsNoisyDiscoveryTrack(track)) - group.NoisyCount++; - - // Для related-пула лучше держать первый чистый трек артиста: он - // идёт сидом в SoundCloud related. Если первый был slowed/reverb, а - // позже встретился обычный трек — заменяем только сид, не порядок. - if (IsNoisyDiscoveryTrack(group.RecentTrack) && !IsNoisyDiscoveryTrack(track)) - group.RecentTrack = track; - - index++; - } - - return groups - .OrderByDescending(group => PlaylistArtistScore(group)) - .ThenBy(group => group.FirstIndex) - .Select(group => new HistoryArtist - { - Name = group.Name, - NormalizedName = group.NormalizedName, - RecentTrack = group.RecentTrack - }) - .ToList(); - } - - static int PlaylistArtistScore(PlaylistArtistCandidate group) - { - // Повторы в плейлистах — главный сигнал намерения. Одноразовые SC- - // uploader'ы остаются fallback'ом для холодного старта, но не перебивают - // артистов, реально часто встречающихся в сохранённой музыке. - return group.Count * 100 - group.NoisyCount * 30; - } - - // канонизация сида рекомендаций (история И плейлисты): из uploader-имён - // вида «Artist - Title» вытаскивается реальный артист - static MusicTrack NormalizeSeedTrack(MusicTrack track) - { - if (track == null) - return null; - - var result = CloneTrack(track); - - if ((IsProviderUploadTrack(result) || string.IsNullOrWhiteSpace(result.artist_name)) - && TrySplitLeadingArtistTitle(result.title, out var artist, out var title)) - { - result.artist_name = artist; - result.artists = new List<string> { artist }; - result.title = title; - } - - return result; - } - - static bool TrySplitLeadingArtistTitle(string rawTitle, out string artist, out string title) - { - artist = null; - title = null; - - var match = System.Text.RegularExpressions.Regex.Match(rawTitle ?? string.Empty, @"^\s*(?<artist>.{2,80}?)\s+[-–—]{1,2}\s+(?<title>.{2,180})\s*$"); - if (!match.Success) - return false; - - artist = MusicRadioService.CleanupArtistName(match.Groups["artist"].Value.Trim()); - title = match.Groups["title"].Value.Trim(); - - if (string.IsNullOrWhiteSpace(artist) || string.IsNullOrWhiteSpace(title)) - return false; - - return MusicRadioService.NormalizeText(artist).Length >= 2 - && MusicRadioService.NormalizeText(title).Length >= 2; - } - - static bool IsProviderUploadTrack(MusicTrack track) - { - if (track == null) - return false; - - if ((track.id ?? string.Empty).StartsWith("soundcloud:", StringComparison.OrdinalIgnoreCase)) - return true; - - if ((track.id ?? string.Empty).StartsWith("youtube:", StringComparison.OrdinalIgnoreCase)) - return true; - - return (track.provider_refs ?? new List<MusicProviderRef>()) - .Any(i => (i.provider ?? string.Empty).Contains("soundcloud", StringComparison.OrdinalIgnoreCase)); - } - - static bool IsNoisyDiscoveryTrack(MusicTrack track) - { - string value = MusicRadioService.NormalizeText(track?.title); - if (string.IsNullOrWhiteSpace(value)) - return true; - - string padded = $" {value} "; - string[] noisy = - { - " slowed ", " reverb ", " sped up ", " nightcore ", " remix ", - " cover ", " karaoke ", " type beat ", " freestyle " - }; - - return noisy.Any(word => padded.Contains(word, StringComparison.OrdinalIgnoreCase)); - } - - 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 async Task<List<HistoryArtist>> CollectHistoryArtistsAsync(string profileId, CancellationToken cancellationToken) - { - var result = new List<HistoryArtist>(); - var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - - List<MusicRecentlyPlayedItem> history; - try - { - history = await MusicPlaybackHistoryService.GetRecentAsync(profileId, historyDepth, cancellationToken); - } - catch - { - return result; - } - - // история отсортирована по свежести — первый встреченный трек артиста - // и есть его самый свежий (идёт сид-треком в related-пул) - foreach (var item in history) - { - var track = NormalizeSeedTrack(item?.track); - if (track == null) - continue; - - string name = MusicRadioService.CleanupArtistName(track.artist_name); - if (string.IsNullOrWhiteSpace(name)) - continue; - - string normalized = MusicRadioService.NormalizeText(name); - if (string.IsNullOrWhiteSpace(normalized) || !seen.Add(normalized)) - continue; - - // кластер-дедуп: варианты одного артиста («Cкриптoнит»/«Скриптонит», - // «Basta, GUF»/«Basta & Guf», «Miyagi & Эндшпиль»/«MiyaGi & Эндшпиль») - // не должны занимать несколько слотов недели — живой промах: день - // из двух алиасов одного кластера вырождается в моно-артист микс - if (result.Any(existing => MusicMapSupport.IsAliasOf(name, existing.Name))) - continue; - - result.Add(new HistoryArtist - { - Name = name, - NormalizedName = normalized, - RecentTrack = track - }); - - if (result.Count >= maxAssignableArtists) - break; - } - - return result; - } - - static async Task<DailyMixPayload> BuildMixAsync(string profileId, int day, int salt, List<HistoryArtist> seeds, List<HistoryArtist> historyArtists, CancellationToken cancellationToken) - { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(mixTimeout); - - var artistPoolTasks = seeds - .Select(seed => MusicRadioService.LoadArtistPoolAsync(seed.Name, timeoutCts.Token)) - .ToList(); - - var relatedTasks = SoundCloudSupport.IsDiscoveryEnabled - ? seeds.Select(seed => MusicRadioService.LoadRelatedPoolAsync(seed.RecentTrack, timeoutCts.Token)).ToList() - : new List<Task<List<MusicTrack>>>(); - - try - { - await Task.WhenAll(artistPoolTasks.Concat(relatedTasks)); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - } - - static List<MusicTrack> PoolOf(Task<List<MusicTrack>> task) => task.IsCompletedSuccessfully - ? task.Result ?? new List<MusicTrack>() - : new List<MusicTrack>(); - - var pools = artistPoolTasks.Select(PoolOf).Where(pool => pool.Count > 0).ToList(); - var mixArtists = seeds.Select(i => i.Name).ToList(); - - // похожие — доминирующие чужие артисты из related-пулов сид-треков: - // их треки уже на руках, дополнительных HTTP не нужно - var relatedTracks = relatedTasks.SelectMany(PoolOf).ToList(); - var similars = PickSimilarArtistPools(relatedTracks, seeds, maxSimilarArtists); - - foreach (var similar in similars) - { - pools.Add(similar.pool); - mixArtists.Add(similar.name); - } - - if (pools.Count == 0) - return null; - - // сэмплирование внутри дня тоже детерминировано: пул может обновиться - // (6h TTL radio-кэша), а микс дня не должен скакать между заходами - uint sampleSeed = Fnv1aHash($"{NormalizeProfileKey(profileId)}|{SeedKey(salt)}|{day}|tracks"); - - // Music-Map — только additive-слой. Запускаем его после проверки, что - // базовый микс уже есть: так при пустых artist/related-пулах не остаются - // фоновые HTTP-задачи, а общий 8-секундный бюджет всё равно сохраняется. - var musicMapTasks = seeds - .Select(seed => MusicMapSupport.GetSimilarArtistsAsync(seed.Name, timeoutCts.Token)) - .ToList(); - - // жанровые пулы — тоже additive-слой, стартуют параллельно с картами - // (жанры артистов кэшируются 14d, пул жанра — 6h) - var genrePoolsTask = SoundCloudSupport.IsDiscoveryEnabled - ? LoadGenrePoolsAsync(seeds, timeoutCts.Token) - : Task.FromResult(new GenrePoolPlan()); - - // Spotify стартует параллельно с другим discovery. На холодном кэше - // это не добавляет последовательную задержку к сборке микса. - var spotifyDiscoveryTask = ModInit.conf?.spotify_discovery_enabled == true - ? LoadSpotifyDiscoveryPlanAsync(seeds, mixArtists, sampleSeed, timeoutCts.Token) - : Task.FromResult(new SpotifyDiscoveryPlan()); - - for (int i = 0; i < pools.Count; i++) - { - var pool = pools[i].ToList(); - ShuffleDeterministic(pool, sampleSeed + (uint)i); - pools[i] = pool; - } - - var seedTracks = seeds.Select(i => i.RecentTrack).ToList(); - var seedNoisyWords = MusicRadioService.CollectSeedNoisyWords(seedTracks); - bool requireCyrillic = seedTracks.Count > 0 - && seedTracks.All(seed => MusicRadioService.ContainsCyrillic(seed?.title) || MusicRadioService.ContainsCyrillic(seed?.artist_name)); - - // дедуп-сеты общие для слоя открытий и основного заполнения - var excludedIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - var excludedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - var excludedTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - var outputIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - var outputKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - var outputTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - - // ЕДИНЫЙ СЛОЙ ОТКРЫТИЙ (общий бюджет discoveryTrackBudget, ревью - // Codex): сперва Music-Map externals (похожесть на твоих), остаток - // добирает жанр дня; Music-Map пуст — жанр берёт весь бюджет. - // Строго additive, в слотах карточки НЕ анонсируется — сюрприз. - // Для «специй» шумовые слова не прощаются, даже если были в сидах: - // discovery должен удивлять артистом, а не slowed/remix-версией - var discoveryOutput = new List<MusicTrack>(); - var strictNoisyWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - var discoveryArtistCounts = new Dictionary<string, int>(StringComparer.Ordinal); - var externalArtists = new List<string>(); - List<List<MusicTrack>> genrePools = null; - - try - { - externalArtists = await PickExternalArtistsAsync(musicMapTasks, seeds, historyArtists, similars.Select(i => i.name), sampleSeed); - - var externalPools = await LoadExternalArtistPoolsAsync(externalArtists, timeoutCts.Token); - - if (externalPools.Count > 0) - MusicRadioService.FillRoundRobin( - externalPools, discoveryOutput, coreDiscoveryTrackBudget, - strictNoisyWords, requireCyrillic, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - discoveryArtistCounts, maxDiscoveryTracksPerArtist); - - int remaining = Math.Min(coreDiscoveryTrackBudget - discoveryOutput.Count, genreTrackQuota); - - if (remaining > 0) - { - var knownNames = historyArtists.Select(i => i.Name) - .Concat(mixArtists) - .Concat(externalArtists) - .ToList(); - - genrePools = await PrepareGenrePoolsAsync(genrePoolsTask, knownNames, sampleSeed, timeoutCts.Token); - - if (genrePools.Count > 0) - FillDiscoveryFromGenrePools( - genrePools, discoveryOutput, discoveryOutput.Count + remaining, - strictNoisyWords, requireCyrillic, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - discoveryArtistCounts); - } - - var spotifyPlan = await spotifyDiscoveryTask; - int spotifyLimit = Math.Min( - discoveryTrackBudget, - discoveryOutput.Count + spotifyTrackQuota); - - FillSpotifyRelatedPools( - spotifyPlan.RelatedPools, - discoveryOutput, - spotifyLimit, - strictNoisyWords, - requireCyrillic, - discoveryArtistCounts, - excludedIds, - excludedKeys, - excludedTitles, - outputIds, - outputKeys, - outputTitles); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - } - - var output = new List<MusicTrack>(); - var mainArtistCounts = new Dictionary<string, int>(StringComparer.Ordinal); - MusicRadioService.FillRoundRobin( - pools, - output, - Math.Min( - mixTrackLimit - discoveryOutput.Count, - desiredMixTrackCount - discoveryOutput.Count), - seedNoisyWords, - requireCyrillic, - excludedIds, - excludedKeys, - excludedTitles, - outputIds, - outputKeys, - outputTitles, - mainArtistCounts, - maxMainTracksPerArtist); - - int desiredDiscoveryLimit = Math.Min( - mixTrackLimit - output.Count, - desiredMixTrackCount - output.Count); - - if (desiredDiscoveryLimit > discoveryOutput.Count) - { - try - { - await RefillShortMixAsync( - musicMapTasks, - genrePoolsTask, - seeds, - historyArtists, - mixArtists, - similars.Select(i => i.name), - externalArtists, - sampleSeed, - strictNoisyWords, - requireCyrillic, - discoveryOutput, - desiredDiscoveryLimit, - output.Count, - discoveryArtistCounts, - excludedIds, - excludedKeys, - excludedTitles, - outputIds, - outputKeys, - outputTitles, - genrePools, - timeoutCts.Token); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - // The discovery budget expired; keep the tracks already collected. - } - } - - if (ModInit.conf?.spotify_discovery_enabled == true - && output.Count + discoveryOutput.Count < spotifyFallbackTarget - && desiredDiscoveryLimit > discoveryOutput.Count) - { - await FillSpotifyFallbackAsync( - spotifyDiscoveryTask, - sampleSeed, - seedNoisyWords, - strictNoisyWords, - requireCyrillic, - discoveryOutput, - desiredDiscoveryLimit, - output.Count, - mainArtistCounts, - discoveryArtistCounts, - excludedIds, - excludedKeys, - excludedTitles, - outputIds, - outputKeys, - outputTitles, - timeoutCts.Token, - cancellationToken); - } - - cancellationToken.ThrowIfCancellationRequested(); - - // открытия вплетаются равномерно, а не хвостом: - // при 19+5 — позиции 4, 9, 14, 19, 24 - if (discoveryOutput.Count > 0) - { - int step = (output.Count + discoveryOutput.Count) / (discoveryOutput.Count + 1); - - for (int i = 0; i < discoveryOutput.Count; i++) - output.Insert(Math.Min(output.Count, (i + 1) * step + i), discoveryOutput[i]); - } - - // auto_radio — маркер автопродолжения очереди; для микса дня это - // обычные треки плейлиста, флаг с общего FillRoundRobin снимаем - foreach (var track in output) - track.auto_radio = false; - - return output.Count >= minMixTracks - ? new DailyMixPayload { artists = mixArtists, tracks = output } - : null; - } - - // Artist-cap защищает от доминирования одного исполнителя, но у бедного дня - // после этого может остаться 10-15 треков. Добор включается только тогда: - // берём ещё внешних Music-Map артистов и, если надо, жанровые пулы. Лимиты на - // артиста остаются теми же, поэтому микс растёт шириной, а не повтором. - static async Task RefillShortMixAsync( - List<Task<List<string>>> musicMapTasks, - Task<GenrePoolPlan> genrePoolsTask, - List<HistoryArtist> seeds, - List<HistoryArtist> historyArtists, - List<string> mixArtists, - IEnumerable<string> similarArtists, - List<string> selectedExternalArtists, - uint sampleSeed, - HashSet<string> strictNoisyWords, - bool requireCyrillic, - List<MusicTrack> discoveryOutput, - int desiredDiscoveryLimit, - int mainTrackCount, - Dictionary<string, int> discoveryArtistCounts, - HashSet<string> excludedIds, - HashSet<string> excludedKeys, - HashSet<string> excludedTitles, - HashSet<string> outputIds, - HashSet<string> outputKeys, - HashSet<string> outputTitles, - List<List<MusicTrack>> preparedGenrePools, - CancellationToken cancellationToken) - { - if (desiredDiscoveryLimit <= discoveryOutput.Count) - return; - - try - { - var extraExternalArtists = await PickExternalArtistsAsync( - musicMapTasks, - seeds, - historyArtists, - (similarArtists ?? Enumerable.Empty<string>()).Concat(selectedExternalArtists), - sampleSeed + 0x51F15EED, - maxRefillExternalArtists); - - if (extraExternalArtists.Count > 0) - { - selectedExternalArtists.AddRange(extraExternalArtists); - - var extraPools = await LoadExternalArtistPoolsAsync(extraExternalArtists, cancellationToken); - - if (extraPools.Count > 0) - MusicRadioService.FillRoundRobin( - extraPools, discoveryOutput, desiredDiscoveryLimit, - strictNoisyWords, requireCyrillic, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - discoveryArtistCounts, maxDiscoveryTracksPerArtist); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - } - - if (desiredDiscoveryLimit <= discoveryOutput.Count) - return; - - try - { - var genrePools = preparedGenrePools; - if (genrePools == null) - { - var knownNames = historyArtists.Select(i => i.Name) - .Concat(mixArtists) - .Concat(selectedExternalArtists) - .ToList(); - - genrePools = await PrepareGenrePoolsAsync(genrePoolsTask, knownNames, sampleSeed, cancellationToken); - } - - if (genrePools.Count > 0) - FillDiscoveryFromGenrePools( - genrePools, discoveryOutput, desiredDiscoveryLimit, - strictNoisyWords, requireCyrillic, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - discoveryArtistCounts); - - if (mainTrackCount + discoveryOutput.Count >= 16 - || desiredDiscoveryLimit <= discoveryOutput.Count) - return; - - var secondaryGenrePools = await PrepareGenrePoolsAsync( - genrePoolsTask, - historyArtists.Select(i => i.Name) - .Concat(mixArtists) - .Concat(selectedExternalArtists), - sampleSeed + 0x6A09E667, - cancellationToken, - secondary: true); - - if (secondaryGenrePools.Count > 0) - FillDiscoveryFromGenrePools( - secondaryGenrePools, discoveryOutput, desiredDiscoveryLimit, - strictNoisyWords, requireCyrillic, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - discoveryArtistCounts); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - } - } - - // общий для main-заполнения и refill: пулы внешних Music-Map артистов. - // Топ выдачи поиска = популярные треки артиста — правильное знакомство - // с незнакомым именем. Окно шире лимита на артиста: если первые результаты - // шумные/дубли, FillRoundRobin дойдёт до нормального трека, а artist-cap - // не даст внешнему артисту доминировать - static async Task<List<List<MusicTrack>>> LoadExternalArtistPoolsAsync(List<string> artists, CancellationToken cancellationToken) - { - var poolTasks = artists - .Select(artist => MusicRadioService.LoadArtistPoolAsync(artist, cancellationToken, requireConfirmedArtist: true)) - .ToList(); - - try - { - await Task.WhenAll(poolTasks); - } - catch - { - } - - return poolTasks - .Where(task => task.IsCompletedSuccessfully) - .Select(task => task.Result ?? new List<MusicTrack>()) - .Where(pool => pool.Count > 0) - .Select(pool => pool.Take(externalPoolCandidateLimit).ToList()) - .ToList(); - } - - static async Task FillSpotifyFallbackAsync( - Task<SpotifyDiscoveryPlan> spotifyDiscoveryTask, - uint sampleSeed, - HashSet<string> seedNoisyWords, - HashSet<string> strictNoisyWords, - bool requireCyrillic, - List<MusicTrack> discoveryOutput, - int desiredDiscoveryLimit, - int mainTrackCount, - Dictionary<string, int> mainArtistCounts, - Dictionary<string, int> discoveryArtistCounts, - HashSet<string> excludedIds, - HashSet<string> excludedKeys, - HashSet<string> excludedTitles, - HashSet<string> outputIds, - HashSet<string> outputKeys, - HashSet<string> outputTitles, - CancellationToken budgetToken, - CancellationToken cancellationToken) - { - int missing = spotifyFallbackTarget - mainTrackCount - discoveryOutput.Count; - if (missing <= 0) - return; - - int target = Math.Min(desiredDiscoveryLimit, discoveryOutput.Count + missing); - - try - { - cancellationToken.ThrowIfCancellationRequested(); - // A completed plan is still useful after expiry, but no new discovery - // request or extra timeout is allowed for the fallback. - var plan = spotifyDiscoveryTask.IsCompletedSuccessfully - ? spotifyDiscoveryTask.Result - : await spotifyDiscoveryTask.WaitAsync(budgetToken); - cancellationToken.ThrowIfCancellationRequested(); - if (plan == null) - return; - - var seedDiscoveries = plan.SeedDiscoveries; - - var seedPools = seedDiscoveries - .Select(item => item.top_tracks - .Select(MusicRadioService.NormalizeCandidateMetadata) - .Where(track => track != null) - .ToList()) - .Where(pool => pool.Count > 0) - .ToList(); - - for (int i = 0; i < seedPools.Count; i++) - ShuffleDeterministic(seedPools[i], sampleSeed + 0x3C6EF372 + (uint)i); - - if (seedPools.Count > 0) - { - MusicRadioService.FillRoundRobin( - seedPools, discoveryOutput, target, - seedNoisyWords, requireCyrillic, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - mainArtistCounts, maxMainTracksPerArtist); - - if (mainTrackCount + discoveryOutput.Count < spotifyFallbackTarget && requireCyrillic) - MusicRadioService.FillRoundRobin( - seedPools, discoveryOutput, target, - seedNoisyWords, false, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - mainArtistCounts, maxMainTracksPerArtist); - } - - if (mainTrackCount + discoveryOutput.Count >= spotifyFallbackTarget - || discoveryOutput.Count >= target) - return; - - FillSpotifyRelatedPools( - plan.RelatedPools, - discoveryOutput, - target, - strictNoisyWords, - requireCyrillic, - discoveryArtistCounts, - excludedIds, - excludedKeys, - excludedTitles, - outputIds, - outputKeys, - outputTitles); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - } - } - - static async Task<SpotifyDiscoveryPlan> LoadSpotifyDiscoveryPlanAsync( - List<HistoryArtist> seeds, - IEnumerable<string> mixArtists, - uint sampleSeed, - CancellationToken cancellationToken) - { - var seedTasks = seeds.Select(seed => LoadSpotifyArtistDiscoveryByNameAsync(seed.Name, cancellationToken)).ToList(); - - try - { - await Task.WhenAll(seedTasks); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - } - - var seedDiscoveries = seedTasks - .Where(task => task.IsCompletedSuccessfully && task.Result != null) - .Select(task => task.Result) - .ToList(); - - if (seedDiscoveries.Count == 0) - return new SpotifyDiscoveryPlan(); - - var excludedNames = seeds.Select(seed => seed.Name) - .Concat(mixArtists ?? Enumerable.Empty<string>()) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .ToList(); - - var relatedCandidates = seedDiscoveries - .SelectMany(discovery => discovery.related_artists.Select((artist, rank) => new { artist, rank })) - .Where(item => item.artist != null - && !string.IsNullOrWhiteSpace(item.artist.id) - && !string.IsNullOrWhiteSpace(item.artist.name) - && !excludedNames.Any(name => MusicMapSupport.IsAliasOf(item.artist.name, name))) - .GroupBy(item => MusicRadioService.NormalizeText(item.artist.name), StringComparer.Ordinal) - .Where(group => !string.IsNullOrWhiteSpace(group.Key)) - .Select(group => new - { - artist = group.First().artist, - sources = group.Count(), - rank = group.Min(item => item.rank) - }) - .OrderByDescending(item => item.sources) - .ThenBy(item => item.rank) - .Take(8) - .ToList(); - - // Совпадение related от обоих сидов важнее позиции в одном списке. - // Внутри одинаковой силы порядок зависит от seed дня. - ShuffleDeterministic(relatedCandidates, sampleSeed + 0xBB67AE85); - var selectedRelated = relatedCandidates - .OrderByDescending(item => item.sources) - .Take(spotifyRelatedArtistLimit) - .Select(item => item.artist) - .ToList(); - - var relatedTasks = selectedRelated - .Select(artist => LoadSpotifyArtistDiscoveryByIdAsync(artist.id, cancellationToken)) - .ToList(); - - try - { - await Task.WhenAll(relatedTasks); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - } - - var relatedPools = relatedTasks - .Where(task => task.IsCompletedSuccessfully && task.Result != null) - .Select(task => task.Result.top_tracks - .Select(MusicRadioService.NormalizeCandidateMetadata) - .Where(track => track != null) - .ToList()) - .Where(pool => pool.Count > 0) - .ToList(); - - for (int i = 0; i < relatedPools.Count; i++) - ShuffleDeterministic(relatedPools[i], sampleSeed + 0xA54FF53A + (uint)i); - - return new SpotifyDiscoveryPlan - { - SeedDiscoveries = seedDiscoveries, - RelatedPools = relatedPools - }; - } - - static void FillSpotifyRelatedPools( - List<List<MusicTrack>> relatedPools, - List<MusicTrack> discoveryOutput, - int limit, - HashSet<string> strictNoisyWords, - bool requireCyrillic, - Dictionary<string, int> discoveryArtistCounts, - HashSet<string> excludedIds, - HashSet<string> excludedKeys, - HashSet<string> excludedTitles, - HashSet<string> outputIds, - HashSet<string> outputKeys, - HashSet<string> outputTitles) - { - if (relatedPools == null || relatedPools.Count == 0 || discoveryOutput.Count >= limit) - return; - - MusicRadioService.FillRoundRobin( - relatedPools, discoveryOutput, limit, - strictNoisyWords, requireCyrillic, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - discoveryArtistCounts, maxDiscoveryTracksPerArtist); - - if (discoveryOutput.Count < limit && requireCyrillic) - MusicRadioService.FillRoundRobin( - relatedPools, discoveryOutput, limit, - strictNoisyWords, false, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - discoveryArtistCounts, maxDiscoveryTracksPerArtist); - } - - static Task<SpotifySupport.ArtistDiscoveryResult> LoadSpotifyArtistDiscoveryByNameAsync(string artist, CancellationToken cancellationToken) - { - return MusicMetadataCacheService.GetOrCreateAsync( - SpotifySupport.ProviderId, - "weekly_artist_discovery", - $"artist-name-v2|{MusicRadioService.NormalizeText(artist)}", - spotifyDiscoveryCacheTtl, - () => SpotifySupport.GetArtistDiscoveryByNameAsync(artist, cancellationToken: cancellationToken), - cancellationToken); - } - - static Task<SpotifySupport.ArtistDiscoveryResult> LoadSpotifyArtistDiscoveryByIdAsync(string artistId, CancellationToken cancellationToken) - { - return MusicMetadataCacheService.GetOrCreateAsync( - SpotifySupport.ProviderId, - "weekly_artist_discovery", - $"artist-id-v2|{artistId}", - spotifyDiscoveryCacheTtl, - () => SpotifySupport.GetArtistDiscoveryAsync(artistId, cancellationToken: cancellationToken), - cancellationToken); - } - - // общий для main-заполнения и refill: жанровый слой в два прохода — - // сперва в родном скрипт-режиме микса; если кириллический фильтр съел всё - // (глобальный жанровый топ чаще латиница) — второй проход без него: - // жанровый слой и задуман как сознательный выход за рамки привычного - static void FillDiscoveryFromGenrePools( - List<List<MusicTrack>> genrePools, - List<MusicTrack> discoveryOutput, - int limit, - HashSet<string> strictNoisyWords, - bool requireCyrillic, - HashSet<string> excludedIds, - HashSet<string> excludedKeys, - HashSet<string> excludedTitles, - HashSet<string> outputIds, - HashSet<string> outputKeys, - HashSet<string> outputTitles, - Dictionary<string, int> discoveryArtistCounts) - { - int beforeGenre = discoveryOutput.Count; - - MusicRadioService.FillRoundRobin( - genrePools, discoveryOutput, limit, - strictNoisyWords, requireCyrillic, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - discoveryArtistCounts, maxDiscoveryTracksPerArtist); - - if (discoveryOutput.Count == beforeGenre && requireCyrillic) - MusicRadioService.FillRoundRobin( - genrePools, discoveryOutput, limit, - strictNoisyWords, false, - excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles, - discoveryArtistCounts, maxDiscoveryTracksPerArtist); - } - - static async Task<List<List<MusicTrack>>> PrepareGenrePoolsAsync( - Task<GenrePoolPlan> genrePoolsTask, - IEnumerable<string> knownNames, - uint sampleSeed, - CancellationToken cancellationToken, - bool secondary = false) - { - List<List<MusicTrack>> source; - - try - { - var plan = await genrePoolsTask ?? new GenrePoolPlan(); - - if (!secondary) - { - source = plan.PrimaryPools; - } - else if (!string.IsNullOrWhiteSpace(plan.SecondaryGenre)) - { - var secondaryPool = await LoadGenrePoolAsync(plan.SecondaryGenre, cancellationToken); - source = secondaryPool.Count > 0 - ? new List<List<MusicTrack>> { secondaryPool } - : new List<List<MusicTrack>>(); - } - else - { - source = new List<List<MusicTrack>>(); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - return new List<List<MusicTrack>>(); - } - - var known = (knownNames ?? Enumerable.Empty<string>()) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .ToList(); - - var genrePools = source - .Select(genreTracks => genreTracks - .Select(MusicRadioService.NormalizeCandidateMetadata) - .Where(track => !string.IsNullOrWhiteSpace(track?.artist_name) - && !known.Any(name => MusicMapSupport.IsAliasOf(track.artist_name, name))) - .ToList()) - .Where(pool => pool.Count > 0) - .ToList(); - - for (int i = 0; i < genrePools.Count; i++) - ShuffleDeterministic(genrePools[i], sampleSeed + 777 + (uint)i); - - return genrePools; - } - - // жанр дня: доминирующие genre-теги из SC-поиска по обоим сид-артистам - // (кэш 14d на артиста) -> канонический жанр-фасет SC -> популярные треки - // жанра (кэш 6h). Жанр берётся только ПРИ УВЕРЕННОСТИ (ревью Codex): - // явный победитель — один пул; ничья двух сильных жанров (артисты дня - // из разных миров) — слой делится между обоими; слабый сигнал — слоя нет - static async Task<GenrePoolPlan> LoadGenrePoolsAsync(List<HistoryArtist> seeds, CancellationToken cancellationToken) - { - var genreTasks = seeds.Select(seed => MusicMetadataCacheService.GetOrCreateAsync( - SoundCloudSupport.DiscoveryProviderId, - "artist_genres", - $"genres-v1|{MusicRadioService.NormalizeText(seed.Name)}", - artistGenresCacheTtl, - () => SoundCloudSupport.SearchDominantGenresAsync(seed.Name, cancellationToken), - cancellationToken)).ToList(); - - try - { - await Task.WhenAll(genreTasks); - } - catch - { - } - - // голоса по убыванию ранга: топ-жанр артиста весит больше - var votes = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); - - foreach (var task in genreTasks.Where(t => t.IsCompletedSuccessfully)) - { - var genres = task.Result ?? new List<string>(); - - for (int rank = 0; rank < genres.Count; rank++) - { - string canonical = SoundCloudSupport.MapToCanonicalGenre(genres[rank]); - if (string.IsNullOrWhiteSpace(canonical)) - continue; - - votes[canonical] = votes.TryGetValue(canonical, out int current) - ? current + (3 - rank) - : 3 - rank; - } - } - - var ranked = votes.OrderByDescending(i => i.Value).ToList(); - - // слабый сигнал: жанр не был топ-тегом ни одного артиста (score < 3) - if (ranked.Count == 0 || ranked[0].Value < 3) - return new GenrePoolPlan(); - - var chosenGenres = new List<string> { ranked[0].Key }; - - // ничья двух сильных жанров — артисты дня из разных миров: - // делим жанровый слой между обоими вместо случайного победителя - if (ranked.Count > 1 && ranked[1].Value >= 3 && ranked[1].Value * 2 > ranked[0].Value) - chosenGenres.Add(ranked[1].Key); - - // Пул не грузим заранее: он нужен только если после обычного - // добора микс остался короче 16 треков. Берём ровно один следующий - // жанр и только при реальном сигнале от сидов (score >= 2). - string secondaryGenre = ranked - .Where(item => item.Value >= 2 && !chosenGenres.Contains(item.Key, StringComparer.OrdinalIgnoreCase)) - .Select(item => item.Key) - .FirstOrDefault(); - - var poolTasks = chosenGenres.Select(genre => LoadGenrePoolAsync(genre, cancellationToken)).ToList(); - - try - { - await Task.WhenAll(poolTasks); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - } - - return new GenrePoolPlan - { - PrimaryPools = poolTasks - .Where(task => task.IsCompletedSuccessfully) - .Select(task => task.Result ?? new List<MusicTrack>()) - .Where(pool => pool.Count > 0) - .ToList(), - SecondaryGenre = secondaryGenre - }; - } - - static Task<List<MusicTrack>> LoadGenrePoolAsync(string genre, CancellationToken cancellationToken) - { - return MusicMetadataCacheService.GetOrCreateAsync( - SoundCloudSupport.DiscoveryProviderId, - "genre_tracks", - $"genrepool-v1|{genre}", - genreCacheTtl, - () => SoundCloudSupport.SearchTracksByGenreAsync(genre, genrePoolFetchLimit, cancellationToken), - cancellationToken); - } - - // внешние похожие артисты с Music-Map: пересечение карт обоих сид-артистов - // (похож на обоих = сильный сигнал вайба дня), при пустом пересечении — - // union в порядке близости; алиасы сидов, вся top-28 история и уже - // выбранные SC-similar исключены; seeded-выбор из ближайшего окна — - // стабилен в пределах недели, разный между неделями - static async Task<List<string>> PickExternalArtistsAsync( - List<Task<List<string>>> musicMapTasks, - List<HistoryArtist> seeds, - List<HistoryArtist> historyArtists, - IEnumerable<string> similarArtists, - uint seed, - int maxArtists = maxExternalArtists) - { - try - { - await Task.WhenAll(musicMapTasks); - } - catch - { - } - - var maps = musicMapTasks - .Where(task => task.IsCompletedSuccessfully) - .Select(task => task.Result ?? new List<string>()) - .Where(map => map.Count > 0) - .ToList(); - - if (maps.Count == 0) - return new List<string>(); - - var aliasReferences = seeds.Select(i => i.Name).ToList(); - var excludeNames = historyArtists.Select(i => i.Name) - .Concat(similarArtists ?? Enumerable.Empty<string>()) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .ToList(); - - var filtered = maps - .Select(map => MusicMapSupport.FilterCandidates(map, aliasReferences, excludeNames)) - .Where(map => map.Count > 0) - .ToList(); - - if (filtered.Count == 0) - return new List<string>(); - - List<string> candidates; - - if (filtered.Count > 1) - { - var otherKeys = filtered.Skip(1) - .Select(map => map.Select(MusicMapSupport.NormalizeName).ToHashSet(StringComparer.Ordinal)) - .ToList(); - - candidates = filtered[0] - .Where(name => otherKeys.All(keys => keys.Contains(MusicMapSupport.NormalizeName(name)))) - .ToList(); - - if (candidates.Count == 0) - { - candidates = new List<string>(); - var seen = new HashSet<string>(StringComparer.Ordinal); - - foreach (var name in filtered.SelectMany(map => map)) - { - if (seen.Add(MusicMapSupport.NormalizeName(name))) - candidates.Add(name); - } - } - } - else - { - candidates = filtered[0]; - } - - if (candidates.Count == 0) - return new List<string>(); - - // несколько seeded-выборов из ближайшего окна без повторов/алиасов - var window = candidates.Take(externalPickWindow).ToList(); - var picks = new List<string>(); - uint state = seed ^ 0x9E3779B9; - - while (picks.Count < maxArtists && window.Count > 0) - { - state = NextXorshift(state); - string pick = window[(int)(state % (uint)window.Count)]; - window.Remove(pick); - - if (!picks.Any(chosen => MusicMapSupport.IsAliasOf(pick, chosen))) - picks.Add(pick); - } - - return picks; - } - - static uint NextXorshift(uint seed) - { - uint state = seed == 0 ? 2463534242 : seed; - state ^= state << 13; - state ^= state >> 17; - state ^= state << 5; - return state; - } - - static List<(string name, List<MusicTrack> pool)> PickSimilarArtistPools(List<MusicTrack> relatedTracks, List<HistoryArtist> seeds, int maxArtists) - { - var seedNames = new HashSet<string>(seeds.Select(i => i.NormalizedName), StringComparer.OrdinalIgnoreCase); - var groups = new Dictionary<string, (string name, List<MusicTrack> pool)>(StringComparer.OrdinalIgnoreCase); - - foreach (var raw in relatedTracks) - { - var track = MusicRadioService.NormalizeCandidateMetadata(raw); - string name = MusicRadioService.CleanupArtistName(track?.artist_name); - if (string.IsNullOrWhiteSpace(name)) - continue; - - string normalized = MusicRadioService.NormalizeText(name); - if (string.IsNullOrWhiteSpace(normalized) || seedNames.Contains(normalized)) - continue; - - // кросс-скрипт/опечаточные дубли сида NormalizeText не ловит - // (живой кейс: «Cкриптoнит» с латинскими C/o в истории vs - // «Скриптонит» из related) — добиваем транслит+fuzzy Music-Map - if (seeds.Any(seed => MusicMapSupport.IsAliasOf(name, seed.Name))) - continue; - - if (!groups.TryGetValue(normalized, out var group)) - groups[normalized] = group = (name, new List<MusicTrack>()); - - group.pool.Add(track); - } - - var result = new List<(string name, List<MusicTrack> pool)>(); - - foreach (var group in groups.Values.OrderByDescending(i => i.pool.Count)) - { - // группы отсортированы по убыванию — дальше только мельче - if (group.pool.Count < minSimilarPoolTracks) - break; - - if (result.Any(chosen => MusicMapSupport.IsAliasOf(group.name, chosen.name))) - continue; - - result.Add(group); - - if (result.Count >= maxArtists) - break; - } - - return result; - } - - static MusicDailyMixResponse Unavailable(int day, string message) => new() - { - available = false, - day = day, - title = day is >= 1 and <= 7 ? dayTitles[day - 1] : null, - message = message - }; - - static string NormalizeProfileKey(string profileId) - => (profileId ?? string.Empty).Trim().ToLowerInvariant(); - - // Неделя СОЗНАТЕЛЬНО считается по UTC, а не по локальному времени - // (для UTC+3 полка обновляется ~03:00 ночи понедельника — приемлемо): - // граница недели не зависит от таймзоны контейнера и переводов часов, - // а «локальный понедельник» потребовал бы таймзону профиля — новое - // состояние ради сдвига на пару часов. Это решение, не недосмотр - static string WeekKey() - { - var now = DateTime.UtcNow; - return $"{ISOWeek.GetYear(now)}-w{ISOWeek.GetWeekOfYear(now):D2}"; - } - - // salt — «сброс миксов недели» по кнопке: клиент хранит счётчик в своём - // Storage (скоуп совпадает с per-browser профилем) и шлёт параметром; - // salt=0 сохраняет исходный формат seed'а — дефолтная раскладка не - // меняется от самого факта появления параметра - static string SeedKey(int salt) - => salt == 0 ? WeekKey() : $"{WeekKey()}|{salt}"; - - static int IsoDayOfWeek(DateTime date) - => ((int)date.DayOfWeek + 6) % 7 + 1; - - // string.GetHashCode рандомизирован per-process — для стабильного seed - // между рестартами контейнера нужен собственный хэш - static uint Fnv1aHash(string value) - { - uint hash = 2166136261; - - foreach (char c in value ?? string.Empty) - { - hash ^= c; - hash *= 16777619; - } - - return hash; - } - - static void ShuffleDeterministic<T>(List<T> list, uint seed) - { - uint state = seed == 0 ? 2463534242 : seed; - - uint next() - { - // xorshift32 — дешёвый детерминированный PRNG, качества хватает - state ^= state << 13; - state ^= state >> 17; - state ^= state << 5; - return state; - } - - for (int i = list.Count - 1; i > 0; i--) - { - int j = (int)(next() % (uint)(i + 1)); - (list[i], list[j]) = (list[j], list[i]); - } - } -} diff --git a/mods/Music/Services/Playback/MusicPlaybackHistoryService.cs b/mods/Music/Services/Playback/MusicPlaybackHistoryService.cs deleted file mode 100644 index 6f418f0..0000000 --- a/mods/Music/Services/Playback/MusicPlaybackHistoryService.cs +++ /dev/null @@ -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(); -} diff --git a/mods/Music/Services/Playback/MusicPlaybackModeService.cs b/mods/Music/Services/Playback/MusicPlaybackModeService.cs deleted file mode 100644 index 74e169a..0000000 --- a/mods/Music/Services/Playback/MusicPlaybackModeService.cs +++ /dev/null @@ -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; -} diff --git a/mods/Music/Services/Playback/MusicPlaybackService.cs b/mods/Music/Services/Playback/MusicPlaybackService.cs deleted file mode 100644 index 56caa7f..0000000 --- a/mods/Music/Services/Playback/MusicPlaybackService.cs +++ /dev/null @@ -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(); - } -} diff --git a/mods/Music/Services/Playback/MusicPlaylistService.cs b/mods/Music/Services/Playback/MusicPlaylistService.cs deleted file mode 100644 index ea5f394..0000000 --- a/mods/Music/Services/Playback/MusicPlaylistService.cs +++ /dev/null @@ -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; } - } -} diff --git a/mods/Music/Services/Playback/MusicRadioService.cs b/mods/Music/Services/Playback/MusicRadioService.cs deleted file mode 100644 index 2f828fd..0000000 --- a/mods/Music/Services/Playback/MusicRadioService.cs +++ /dev/null @@ -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); - } -} diff --git a/mods/Music/Services/Playback/MusicResolver.cs b/mods/Music/Services/Playback/MusicResolver.cs deleted file mode 100644 index c4bdc53..0000000 --- a/mods/Music/Services/Playback/MusicResolver.cs +++ /dev/null @@ -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; - } -} diff --git a/mods/Music/Services/Playback/MusicStatsService.cs b/mods/Music/Services/Playback/MusicStatsService.cs deleted file mode 100644 index 37b5154..0000000 --- a/mods/Music/Services/Playback/MusicStatsService.cs +++ /dev/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; } -} diff --git a/mods/Music/Services/Playback/MusicStreamModeService.cs b/mods/Music/Services/Playback/MusicStreamModeService.cs deleted file mode 100644 index 932cc9c..0000000 --- a/mods/Music/Services/Playback/MusicStreamModeService.cs +++ /dev/null @@ -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"); - } -} diff --git a/mods/Music/Services/Playback/MusicStreamRelayService.cs b/mods/Music/Services/Playback/MusicStreamRelayService.cs deleted file mode 100644 index 9c81b27..0000000 --- a/mods/Music/Services/Playback/MusicStreamRelayService.cs +++ /dev/null @@ -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); - } -} diff --git a/mods/Music/Services/Playback/MusicStreamTicketService.cs b/mods/Music/Services/Playback/MusicStreamTicketService.cs deleted file mode 100644 index d35e1f2..0000000 --- a/mods/Music/Services/Playback/MusicStreamTicketService.cs +++ /dev/null @@ -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 - }; - } -} diff --git a/mods/Music/Services/Playlists/MusicPlaylistImportHelpers.cs b/mods/Music/Services/Playlists/MusicPlaylistImportHelpers.cs deleted file mode 100644 index 87d1889..0000000 --- a/mods/Music/Services/Playlists/MusicPlaylistImportHelpers.cs +++ /dev/null @@ -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; - } -} diff --git a/mods/Music/Services/Playlists/MusicUserPlaylistService.cs b/mods/Music/Services/Playlists/MusicUserPlaylistService.cs deleted file mode 100644 index aa42569..0000000 --- a/mods/Music/Services/Playlists/MusicUserPlaylistService.cs +++ /dev/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(); -} diff --git a/mods/Music/Services/Utils/MusicJson.cs b/mods/Music/Services/Utils/MusicJson.cs deleted file mode 100644 index 3b64cfb..0000000 --- a/mods/Music/Services/Utils/MusicJson.cs +++ /dev/null @@ -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); -} diff --git a/mods/Music/docs/client-architecture.md b/mods/Music/docs/client-architecture.md deleted file mode 100644 index b5f4019..0000000 --- a/mods/Music/docs/client-architecture.md +++ /dev/null @@ -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). diff --git a/mods/Music/docs/ios-embedded-lockscreen-seek.md b/mods/Music/docs/ios-embedded-lockscreen-seek.md deleted file mode 100644 index f70b741..0000000 --- a/mods/Music/docs/ios-embedded-lockscreen-seek.md +++ /dev/null @@ -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-плеера. diff --git a/mods/Music/manifest.json b/mods/Music/manifest.json deleted file mode 100644 index be20c26..0000000 --- a/mods/Music/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "enable": true, - "dynamic": true, - "references": [ - "YoutubeExplode" - ], - "tree": [ - "Controllers", - "Models", - "Providers", - "SQL", - "Services", - "GlobalUsings.cs", - "ModInit.cs", - "ModuleConf.cs" - ] -} diff --git a/mods/Music/plugin.js b/mods/Music/plugin.js deleted file mode 100644 index 137e8f0..0000000 --- a/mods/Music/plugin.js +++ /dev/null @@ -1,17934 +0,0 @@ -(function () { - 'use strict'; - - /* - * ====================================================================== - * Music-модуль Lampac — весь клиент для Lampa одним файлом. - * Сервер отдаёт его как /music.js (шаблоны {localhost}/{client_debug_enabled}/{stats_clear_enabled}/{daily_reset_enabled}/{spotify_search_fallback_enabled} - * подставляются при отдаче). Серверная часть: Modules/Music (C#). - * Обзор архитектуры: README.md, docs/client-architecture.md. - * - * КАРТА СЕКЦИЙ — навигация по маркерам "// ===== ИМЯ =====": - * CORE / CONFIG endpoints, storage-ключи, глобальное состояние, SVG-иконки - * TRANSPORT / COMMON HELPERS request/requestPost, uid, трейсы и heat-метрики, кэш-хелперы - * PLAYBACK SETTINGS / PROVIDERS режим качества/воспроизведения, выбор audio-провайдера - * ARTWORK / LOCAL STATE генеративные SVG-обложки, recent-полки, закладки - * AUTH вход во внешние сервисы (SoundCloud OAuth) - * ENTITY NAVIGATION переходы на экраны артиста/альбома/секции - * QUEUE / IOS PLAYBACK очередь, снапшот/restore, standalone <audio>, - * keep-alive, Media Session, watchdog - * IOS FULL PLAYER фулл-плеер: UI, свайпы, шиты, лирика, sleep-таймер - * SEARCH DATA поиск, история запросов, догрузка «Ещё» - * LAMPA PLAYER BRIDGE embedded-режим (player=inner) поверх Lampa.PlayerVideo - * USER PLAYLISTS плейлисты, импорт по ссылке, контекст-меню - * HOME / CARD UI главный экран, карточки, MusicComponent - * FULL SCREEN COMPONENTS экраны entries/section/artist/album/bookmarks - * REGISTRATION / STYLES add()/createMusic(): пункт меню, компоненты, CSS - * - * ТОЧКИ ВХОДА (откуда Lampa вызывает этот код): - * 1. createMusic() в самом низу файла — регистрирует шаблон стилей и - * Lampa-компоненты, после app ready вызывает add(). - * 2. add() — добавляет пункт меню «Музыка», подписывается на - * Lampa.Player start/destroy (мост embedded-режима). - * 3. Зарегистрированные компоненты (lampac_music_home/search/section/ - * artist/album/entries/bookmarks) — Lampa создаёт их через Activity.push. - * 4. Обработчики Media Session (play/pause/seekto/prev/next) — iOS - * дергает их с lock screen / из пункта управления. - * - * ДВА РЕЖИМА ВОСПРОИЗВЕДЕНИЯ — ДВЕ РАЗНЫЕ РЕАЛЬНОСТИ, НЕ СМЕШИВАТЬ: - * player=inner — музыка играет в штатном Lampa.PlayerVideo - * (секция LAMPA PLAYER BRIDGE). Lock screen iOS: - * play/pause/next/prev, перемотки НЕТ намеренно — - * см. docs/ios-embedded-lockscreen-seek.md. - * player=ios — собственный <audio>-элемент и свой UI (секции - * QUEUE / IOS PLAYBACK + IOS FULL PLAYER). Полный - * lock screen, включая скраббер перемотки. Весь слой - * iOS-хаков (warmup/keep-alive/lock-kick) — только здесь. - * - * ПРИФЕТЧ-СЛОЙ (три независимых механизма, все через requestPlay: - * общий кэш PLAY_PREFETCH_CACHE 10min + single-flight; греют ТОЛЬКО кэш - * /music/play, ничего не запускают; таймеры НЕ переиспользовать между ними): - * schedulePlayPrefetch hover:focus по карточке (ТВ) — общий 180ms - * таймер «грей последнее под фокусом» - * scheduleNextTrackPrefetch следующий трек очереди ~3s после реального - * старта звука; гейт поколением очереди; - * зеркалит арифметику embedded-next - * scheduleFirstTrackPrefetch открытие экрана альбома/плейлиста; на - * standalone iOS — стартовое окно из - * getStandaloneIosInitialResolveCount треков - * Инварианты тайминга: cache-hit requestPlay отдаёт done СТРОГО асинхронно - * (standalone-запуск рассчитан на резолв вне жеста); session-settle ~700ms - * между silent-warmup и реальным src (iOS фиксирует capabilities Now - * Playing в первые сотни ms сессии). - * - * ЗАЩИТЫ ОЧЕРЕДИ: - * MUSIC_PLAY_LAUNCH_TOKEN last-tap-wins для playTrack: стейловые - * async-флоу не доезжают до плеера - * auto-skip мёртвый трек (честный available=false) не - * тупик — очередь шагает дальше; сеть НЕ - * скипается, кап 3 подряд, repeat-one свят - * - * ЛЕГЕНДА ИМЕНОВАНИЙ: - * MUSIC_* модульное глобальное состояние (паспорта — у объявлений) - * standaloneIos* standalone iOS-плеер (player=ios) - * MUSIC_EMBEDDED_IOS / embedded-функции — embedded-режим (player=inner) - * mapXxxCard DTO сервера -> карточка полки (entry) - * buildXxx конструкторы DOM/URL/DTO - * openXxx навигация (Activity.push, шиты, меню) - * traceStandaloneIosAudio / bumpMusicHeatMetric — отладка; шлют на - * /music/clientlog ТОЛЬКО при client_debug_enabled - * - * ОСНОВНЫЕ DTO (источник правды — Models/Contracts.cs на сервере): - * - * @typedef {Object} MusicTrack трек каталога - * id {string} "провайдер-скоуп" ид: mb:..., sc:..., spotify:track:... - * title {string} - * artist_id / artist_name {string} - * artists {string[]} - * album_id / album_title {string} - * isrc {?string} ISO 3901, если его вернул каталог - * duration_ms {?number} может отсутствовать (напр. VK-чарт) — - * тогда бэкфиллится из selected_match при резолве - * images {Array} [{url, ...}]; url уже проксирован сервером - * - * @typedef {Object} MusicAudioMatch кандидат audio-источника - * provider_id {string} youtubeaudio | sefonaudio | soundcloudaudio | z3fmaudio - * id {string} - * duration_ms {?number} - * pinned {boolean} выбран пользователем вручную: обходит эвристики - * релевантности и не перезаписывается авто-подбором - * - * Ответ /music/play: { available, track, selected_match: MusicAudioMatch, - * sources: [{ url, external_url, headers, ... }] } — url ведёт на - * серверный relay /music/stream, напрямую на CDN клиент не ходит. - * - * entry (карточка полки, см. mapTrackCard и соседей): - * { type: 'track'|'album'|'artist'|'playlist'|'query', - * id, title, subtitle, badge, image, background, raw } - * ====================================================================== - */ - - // ===== CORE / CONFIG ===== - - if (window.plugin_lampac_music_ready) return; - window.plugin_lampac_music_ready = true; - - var MUSIC = { - version: '0.3.0', - title: 'Музыка', - endpoints: { - home: '{localhost}/music/home', - section: '{localhost}/music/section', - search: '{localhost}/music/search', - providers: '{localhost}/music/providers', - artist: '{localhost}/music/artist', - artistSection: '{localhost}/music/artistsection', - artistImage: '{localhost}/music/artistimg', - album: '{localhost}/music/album', - play: '{localhost}/music/play', - playlist: '{localhost}/music/playlist.m3u', - matches: '{localhost}/music/matches', - selectMatch: '{localhost}/music/match/select', - resetMatch: '{localhost}/music/match/reset', - lyrics: '{localhost}/music/lyrics', - playlists: '{localhost}/music/playlists', - playlistTracks: '{localhost}/music/playlists/tracks', - playlistCreate: '{localhost}/music/playlists/create', - playlistDelete: '{localhost}/music/playlists/delete', - playlistImport: '{localhost}/music/playlists/import', - playlistSync: '{localhost}/music/playlists/sync', - playlistTrackAdd: '{localhost}/music/playlists/track/add', - playlistTrackRemove: '{localhost}/music/playlists/track/remove', - playlistTrackMove: '{localhost}/music/playlists/track/move', - daily: '{localhost}/music/daily', - statsTop: '{localhost}/music/stats/top', - statsClear: '{localhost}/music/stats/clear', - radio: '{localhost}/music/radio', - markHistory: '{localhost}/music/history/mark', - removeHistory: '{localhost}/music/history/remove', - authState: '{localhost}/music/auth/state', - authSave: '{localhost}/music/auth/save', - authLogout: '{localhost}/music/auth/logout', - clientLog: '{localhost}/music/clientlog' - }, - storage: { - last_query: 'lampac_music_last_query', - recent_queries: 'lampac_music_recent_queries', - recent_albums: 'lampac_music_recent_albums', - recent_artists: 'lampac_music_recent_artists', - bookmarked_tracks: 'lampac_music_bookmarked_tracks', - bookmarked_albums: 'lampac_music_bookmarked_albums', - repeat_mode: 'lampac_music_repeat_mode', - shuffle: 'lampac_music_shuffle', - bookmarked_artists: 'lampac_music_bookmarked_artists', - quality_mode: 'lampac_music_quality_mode', - playback_mode: 'lampac_music_playback_mode', - audio_provider: 'lampac_music_audio_provider', - player: 'lampac_music_player', - radio_autoplay_enabled: 'lampac_music_radio_autoplay_enabled', - daily_salt: 'lampac_music_daily_salt', - queue_restore_enabled: 'lampac_music_queue_restore_enabled', - queue_snapshot: 'lampac_music_queue_snapshot', // legacy v1: только чтение при миграции - queue_blob_v2: 'lampac_music_queue_blob_v2', - queue_position_v2: 'lampac_music_queue_position_v2' - }, - art: { - cover: 'https://coverartarchive.org/release-group/' - }, - features: { - auth: false - } - }; - - var MUSIC_QUEUE = { - tracks: [], - currentIndex: 0, - currentTrackId: null - }; - var MUSIC_RADIO_STATE = { - pending: false, - request: null, - lastGeneration: '', - lastRequestAt: 0 - }; - var MUSIC_HISTORY_MARK_DELAY = 10000; - var MUSIC_CLIENT_TRACE_ENABLED = '{client_debug_enabled}' === 'true'; - var MUSIC_STATS_CLEAR_ENABLED = '{stats_clear_enabled}' !== 'false'; - var MUSIC_DAILY_RESET_ENABLED = '{daily_reset_enabled}' !== 'false'; - var MUSIC_SPOTIFY_SEARCH_ENABLED = '{spotify_search_fallback_enabled}' === 'true'; - var MUSIC_HEAT_PROBE = { - timer: 0, - interval: 10000, - seq: 0, - startedAt: 0, - lastFlushAt: 0, - counters: {} - }; - var MUSIC_LAST_FOCUS_CONTEXT = null; - var MUSIC_HOME_REFRESH_RESTORE_BLOCK_UNTIL = 0; - var MUSIC_DEFERRED_HOME_REFRESH = false; - var MUSIC_LAST_PLAYER_CONTROLLER_RESTORE_AT = 0; - var MUSIC_PLAYBACK_LOADING_ACTIVE = false; - var MUSIC_PLAYBACK_LOADING_TIMER = 0; - var MUSIC_ALBUM_DETAILS_CACHE = {}; - var MUSIC_ALBUM_DETAILS_TTL = 1000 * 60 * 5; - var MUSIC_SPOTIFY_RELEASE_TOKEN = 0; - // generation-токен запуска playTrack: каждый новый вызов инвалидирует - // незавершённые async-флоу предыдущего (requestPlay может резолвиться - // секунды — повторный тап в это окно раньше давал два параллельных плеера) - var MUSIC_PLAY_LAUNCH_TOKEN = 0; - /* - * ПАСПОРТ: состояние standalone iOS-плеера (player=ios). Главный неявный - * автомат файла — почти все iOS-хаки читают/пишут эти поля. - * - * Жизненный цикл: startStandaloneIosAudioPlayback (жест пользователя!) -> - * active=true, играем очередь -> stopStandaloneIosAudio -> active=false. - * - * audio сам <audio>-элемент; живёт от старта до stop - * active плеер владеет воспроизведением (бар/фулл-плеер видимы) - * playlist/tracks очередь: playlist — исходный порядок, tracks — рабочий - * currentIndex индекс текущего трека в tracks - * switching true на время смены трека: глушит watchdog/synthetic-ended, - * чтобы переходное состояние не приняли за зависание - * playing наше представление "звук идёт" (не то же, что !audio.paused: - * iOS умеет замораживать currentTime при играющем флаге) - * shuffleOrder ленивая пермутация индексов при shuffle (null = выкл) - * - * Media Session / lock screen: - * mediaSessionArmed обработчики play/pause/seekto/prev/next навешены. - * Регистрируются ОДИН раз ДО первого play() — iOS - * фиксирует возможности Now Playing при создании сессии - * mediaSessionTrackId для какого трека выставлены metadata/artwork - * lastPositionSync троттлинг setPositionState - * - * Keep-alive (удержание аудиосессии на паузе, см. секцию keep-alive): - * keepAlive <audio>-луп тишины (legacy-путь, флаг WEBAUDIO=false) - * keepAliveCtx AudioContext (основной путь, WEBAUDIO=true) - * keepAliveActive луп/контекст сейчас держит сессию - * keepAliveGestureArmed gesture-unlock слушатели навешены - * - * Watchdog / восстановление: - * prepareToken / playWatchToken токены-инварианты против гонок: асинхронный - * колбэк сверяет токен и молча выходит, если началась - * новая операция (смена трека, новый watch) - * resumePosition позиция для восстановления после recovery/restore - * lastPlayingAt метка последнего реального прогресса (для watchdog) - * - * UI-троттлинг (не перерисовывать бар/фулл-плеер без изменений): - * lastUiUpdate, barUiKey, barProgressKey, fullUiKey, fullPlaybackKey, - * fullProgressKey — ключи "что уже нарисовано" - * - * Отладка: traceSeq, lastTraceTimeupdate (только при client_debug_enabled). - * Служебное: timeupdateHandler/timeupdateAttached (attach/detach ровно одного - * слушателя), lifecycleBound (visibility/pagehide-трейсы навешены один раз). - */ - var MUSIC_IOS_AUDIO = { - audio: null, - active: false, - playlist: [], - tracks: [], - currentIndex: -1, - switching: false, - playing: false, - mediaSessionTrackId: null, - lastPositionSync: 0, - prepareToken: 0, - playWatchToken: 0, - keepAlive: null, - keepAliveCtx: null, - keepAliveActive: false, - shuffleOrder: null, - keepAliveGestureArmed: false, - mediaSessionArmed: false, - resumePosition: 0, - lastPlayingAt: 0, - traceSeq: 0, - lastTraceTimeupdate: 0, - lastUiUpdate: 0, - barUiKey: '', - barProgressKey: '', - fullUiKey: '', - fullPlaybackKey: '', - fullProgressKey: '', - timeupdateHandler: null, - timeupdateAttached: false, - lifecycleBound: false - }; - var MUSIC_IOS_BAR = null; - var MUSIC_IOS_FULL_PLAYER = null; - var MUSIC_IOS_FULL_PLAYER_OPEN = false; - var MUSIC_IOS_FULL_PLAYER_RETURN_CONTROLLER = 'content'; - var MUSIC_IOS_SLEEP_TIMER = { - timer: 0, - endAt: 0 - }; - /* - * ПАСПОРТ: embedded-режим (player=inner) — музыка в штатном Lampa.PlayerVideo, - * мы только дополняем его Media Session-метаданными и next/prev. - * НЕ управляет audio-элементом: media owner — сам Lampa-плеер. - * active embedded-мост включён (играет музыкальный контент) - * mediaSessionArmed play/pause/prev/next навешены (seekto тут НЕ вешаем — - * см. docs/ios-embedded-lockscreen-seek.md) - * lastData последний data от Lampa.Player start (метаданные трека) - * playWatchToken / switchToken анти-гонки, как в MUSIC_IOS_AUDIO - * navigationIndex последняя выбранная позиция, даже если её источник - * ещё не разрешился или оказался недоступным - */ - var MUSIC_EMBEDDED_IOS = { - active: false, - lifecycleBound: false, - mediaSessionArmed: false, - playWatchToken: 0, - lastData: null, - traceSeq: 0, - switchToken: 0, - navigationIndex: -1 - }; - var MUSIC_CLIENT_DEBUG_ERRORS_BOUND = false; - var MUSIC_LAMPA_PLAYER_DEBUG_BOUND = false; - var PLAY_PREFETCH_CACHE = {}; - var PLAY_PREFETCH_PENDING = {}; - var PLAY_PREFETCH_TIMER = 0; - var PLAY_PREFETCH_TTL = 1000 * 60 * 10; - var MUSIC_NEXT_PREFETCH_TIMER = 0; - var MUSIC_NEXT_PREFETCH_FOR = ''; - var MUSIC_QUEUE_SNAPSHOT_VERSION = 1; // legacy-формат, принимается только при чтении - var MUSIC_QUEUE_BLOB_VERSION = 2; - var MUSIC_QUEUE_SNAPSHOT_LIMIT = 500; - var MUSIC_QUEUE_SNAPSHOT_TTL = 1000 * 60 * 60 * 24 * 14; - var MUSIC_QUEUE_SNAPSHOT_SAVE_DELAY = 4000; - // за сколько позиций до края сохранённого окна переписываем blob целиком - var MUSIC_QUEUE_WINDOW_EDGE_MARGIN = 50; - /* - * Снапшот v2 разнесён на два ключа, чтобы частый 4-секундный тик не - * пересобирал и не сериализовывал всю очередь (до 500 треков): - * queue_blob_v2 — окно треков + режимы; пишется ТОЛЬКО по событиям - * (смена трека/состава, toggles, край окна) - * queue_position_v2 — крошечная запись позиции; пишется каждые 4s - * Связка через snapshotId: позиция от чужого поколения блоба игнорируется. - * MUSIC_QUEUE_BLOB_STATE — что лежит в записанном блобе (для тика позиции). - */ - var MUSIC_QUEUE_BLOB_STATE = { - snapshotId: '', - windowStart: 0, - windowLength: 0, - truncated: false, - seq: 0, - legacyCleared: false - }; - /* - * ПАСПОРТ: восстановление очереди после перезапуска клиента. - * Формат v2 — два ключа Storage (см. комментарий у MUSIC_QUEUE_BLOB_STATE): - * blob окна очереди пишется по событиям (force), позиция — раз в 4s. - * Читается при старте (v2, fallback на legacy v1); TTL 14 дней по свежести - * позиции. Плейлисты >500 треков усечены окном вокруг текущего. - * available валидный снапшот прочитан, restore-путь доступен - * position сохранённый currentTime трека - * timer отложенная запись позиции (0 = нет ожидающей) - */ - var MUSIC_QUEUE_RESTORE = { - available: false, - position: 0, - updatedAt: 0, - timer: 0 - }; - - var MUSIC_HOME_CACHE = {}; - var MUSIC_HOME_SECTION_TITLES = {}; - var MUSIC_HOME_SECTION_META = {}; - var SEARCH_EXPANDED_CACHE = {}; - var SEARCH_EXPANDED_PENDING = {}; - var SEARCH_EXPANDED_WAITERS = {}; - var HOME_SECTION_LIMIT = 20; - var RECENT_SECTION_STORAGE_LIMIT = 100; - var RECENT_QUERY_QUICK_LIMIT = 8; - var MUSIC_RECENT_EVENT = 'lampac_music_recent_changed'; - var ARTIST_IMAGE_CACHE = {}; - var ARTIST_IMAGE_PENDING = {}; - var ARTIST_IMAGE_QUEUE = []; - var ARTIST_IMAGE_ACTIVE = 0; - var ARTIST_IMAGE_CONCURRENCY = 1; - var MUSIC_AUDIO_PROVIDERS = null; - var MUSIC_AUDIO_PROVIDERS_PENDING = false; - var MUSIC_AUDIO_PROVIDER_WAITERS = []; - // сводка «Твоего топа»: checkedAt — троттлинг проверки unlock (60s), - // summary — последний ответ /music/stats/top (для hero-меты экрана) - var MUSIC_STATS_TOP = { checkedAt: 0, summary: null }; - - var IMG_BG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAADUlEQVR42gECAP3/AAAAAgABUyucMAAAAABJRU5ErkJggg=='; - var MENU_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 4V14.5C11.4134 14.1819 10.7413 14 10 14C7.79086 14 6 15.7909 6 18C6 20.2091 7.79086 22 10 22C12.2091 22 14 20.2091 14 18V8H18V4H12Z" fill="currentColor"/></svg>'; - var SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.5 4a6.5 6.5 0 1 0 4.06 11.58l3.43 3.43 1.41-1.41-3.43-3.43A6.5 6.5 0 0 0 10.5 4Zm0 2a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Z" fill="currentColor"/></svg>'; - var FILTER_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 7h16v2H4V7Zm0 5h16v2H4v-2Zm0 5h16v2H4v-2Z" fill="currentColor"/></svg>'; - var BOOKMARK_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 4h10a1 1 0 0 1 1 1v15l-6-3.4L6 20V5a1 1 0 0 1 1-1Z" fill="currentColor"/></svg>'; - var HOME_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 4 4 10.2V20h5v-5h6v5h5v-9.8L12 4Z" fill="currentColor"/></svg>'; - var IOS_PLAYER_PREV_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6h2v12H7V6Zm10.5 1.2v9.6c0 .8-.9 1.28-1.56.82L10 13.4a1 1 0 0 1 0-1.64l5.94-4.18c.66-.46 1.56.02 1.56.82Z" fill="currentColor"/></svg>'; - var IOS_PLAYER_PLAY_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.8 6.9v10.2c0 .8.87 1.3 1.56.87l7.23-5.1a1.05 1.05 0 0 0 0-1.78l-7.23-5.1c-.69-.43-1.56.06-1.56.91Z" fill="currentColor"/></svg>'; - var IOS_PLAYER_PAUSE_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 6.5h3V17.5H8V6.5Zm5 0h3V17.5H13V6.5Z" fill="currentColor"/></svg>'; - var IOS_PLAYER_NEXT_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15 6h2v12h-2V6ZM6.5 7.2v9.6c0 .8.9 1.28 1.56.82L14 13.4a1 1 0 0 0 0-1.64L8.06 7.58c-.66-.46-1.56.02-1.56.82Z" fill="currentColor"/></svg>'; - var IOS_PLAYER_QUEUE_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 7.5h10M5 12h10M5 16.5h7M17.5 15v-5m0 0-2 2m2-2 2 2" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>'; - var IOS_PLAYER_CLOSE_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m8.46 8.47 7.07 7.06m0-7.06-7.07 7.06" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>'; - var IOS_PLAYER_DOWN_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m7 10 5 5 5-5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>'; - var IOS_PLAYER_SOURCE_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 7h10l-2.5 3M17 17H7l2.5-3M5.5 9.5A6.5 6.5 0 0 1 17 7m1.5 7.5A6.5 6.5 0 0 1 7 17" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>'; - var IOS_PLAYER_TIMER_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 7v5l3 2M9 3h6M12 21a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>'; - var IOS_PLAYER_LYRICS_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M19 10v1a7 7 0 0 1-14 0v-1M12 18v3" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>'; - var IOS_PLAYER_SHUFFLE_ICON ='<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>'; - var IOS_PLAYER_RADIO_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.4 8.7C4.5 8.7 3 10.2 3 12s1.5 3.3 3.4 3.3c3.3 0 7.9-6.6 11.2-6.6 1.9 0 3.4 1.5 3.4 3.3s-1.5 3.3-3.4 3.3c-3.3 0-7.9-6.6-11.2-6.6Z" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>'; - var IOS_PLAYER_REPEAT_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m17 1 4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>'; - var IOS_PLAYER_REPEAT_ONE_ICON = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m17 1 4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/><text x="12" y="15" text-anchor="middle" font-size="8.5" font-weight="800" fill="currentColor" stroke="none" font-family="Arial">1</text></svg>'; - - window.lampac_music = MUSIC; - - var musicUid = Lampa.Storage.get('lampac_unic_id', ''); - if (!musicUid) { - musicUid = Lampa.Utils.uid(8).toLowerCase(); - Lampa.Storage.set('lampac_unic_id', musicUid); - } - - // ===== TRANSPORT / COMMON HELPERS ===== - - function withIdentity(url) { - url = String(url || ''); - - if (url.indexOf('account_email=') === -1) { - var email = Lampa.Storage.get('account_email', ''); - if (email) url = Lampa.Utils.addUrlComponent(url, 'account_email=' + encodeURIComponent(email)); - } - - if (url.indexOf('uid=') === -1) { - var uid = Lampa.Storage.get('lampac_unic_id', ''); - if (uid) url = Lampa.Utils.addUrlComponent(url, 'uid=' + encodeURIComponent(uid)); - } - - return url; - } - - function withIdentityPayload(payload) { - payload = String(payload || ''); - - if (payload.indexOf('account_email=') === -1) { - var email = Lampa.Storage.get('account_email', ''); - if (email) payload += (payload ? '&' : '') + 'account_email=' + encodeURIComponent(email); - } - - if (payload.indexOf('uid=') === -1) { - var uid = Lampa.Storage.get('lampac_unic_id', ''); - if (uid) payload += (payload ? '&' : '') + 'uid=' + encodeURIComponent(uid); - } - - return payload; - } - - function setTextIfChanged(element, value) { - if (!element || !element.length) return; - - var text = value == null ? '' : String(value); - if (element.text() !== text) element.text(text); - } - - function setCappedCacheEntry(cache, key, value, limit) { - if (!cache || !key) return; - - if (Object.prototype.hasOwnProperty.call(cache, key)) - delete cache[key]; - - cache[key] = value; - - var keys = Object.keys(cache); - while (keys.length > limit) { - delete cache[keys.shift()]; - } - } - - function request(url, success, error, timeoutMs) { - var network = new Lampa.Reguest(); - network.timeout(Math.max(1000, Number(timeoutMs || 20000))); - network.silent(withIdentity(url), success, error || function () {}); - } - - function requestPost(url, data, success, error) { - var network = new Lampa.Reguest(); - network.timeout(20000); - network.native(withIdentity(url), function (response) { - success(parseJson(response)); - }, error || function () {}, withIdentityPayload(data), { - dataType: 'text', - type: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' - } - }); - } - - function startMusicPlaybackLoading(message) { - MUSIC_PLAYBACK_LOADING_ACTIVE = true; - if (MUSIC_PLAYBACK_LOADING_TIMER) clearTimeout(MUSIC_PLAYBACK_LOADING_TIMER); - MUSIC_PLAYBACK_LOADING_TIMER = setTimeout(function () { - stopMusicPlaybackLoading(); - }, 30000); - - try { - Lampa.Loading.start(function () {}, message || 'Подготавливаю плейлист'); - } catch (e) {} - } - - function stopMusicPlaybackLoading() { - if (!MUSIC_PLAYBACK_LOADING_ACTIVE) return; - - MUSIC_PLAYBACK_LOADING_ACTIVE = false; - if (MUSIC_PLAYBACK_LOADING_TIMER) { - clearTimeout(MUSIC_PLAYBACK_LOADING_TIMER); - MUSIC_PLAYBACK_LOADING_TIMER = 0; - } - try { - Lampa.Loading.stop(); - } catch (e) {} - } - - function traceStandaloneIosAudio(eventName, message, force) { - if (!MUSIC_CLIENT_TRACE_ENABLED) return; - if (!isStandaloneIosAudioActive() && eventName !== 'audio-create') return; - - var now = Date.now(); - if (!force && eventName === 'timeupdate') { - if (now - (MUSIC_IOS_AUDIO.lastTraceTimeupdate || 0) < 10000) return; - MUSIC_IOS_AUDIO.lastTraceTimeupdate = now; - } - - var audio = MUSIC_IOS_AUDIO.audio; - var track = MUSIC_IOS_AUDIO.tracks[MUSIC_IOS_AUDIO.currentIndex]; - var session = mediaSessionObject(); - var state = { - seq: ++MUSIC_IOS_AUDIO.traceSeq, - at: now, - hidden: !!document.hidden, - visibility: document.visibilityState || '', - focus: typeof document.hasFocus === 'function' ? document.hasFocus() : null, - active: !!MUSIC_IOS_AUDIO.active, - switching: !!MUSIC_IOS_AUDIO.switching, - realPlaying: !!MUSIC_IOS_AUDIO.playing, - paused: audio ? !!audio.paused : null, - ended: audio ? !!audio.ended : null, - currentTime: audio && isFinite(audio.currentTime) ? Math.round(audio.currentTime * 100) / 100 : null, - duration: audio && isFinite(audio.duration) ? Math.round(audio.duration * 100) / 100 : null, - playbackRate: audio && isFinite(audio.playbackRate) ? audio.playbackRate : null, - muted: audio ? !!audio.muted : null, - readyState: audio ? audio.readyState : null, - networkState: audio ? audio.networkState : null, - src: audio ? (audio.currentSrc || audio.src || '').slice(0, 140) : '', - mediaSessionState: session && 'playbackState' in session ? session.playbackState : '', - queueLength: queueTracks().length, - queueIndex: queueCurrentIndex(), - playlistLength: Array.isArray(MUSIC_IOS_AUDIO.playlist) ? MUSIC_IOS_AUDIO.playlist.length : 0, - trackIndex: MUSIC_IOS_AUDIO.currentIndex, - track: track ? { - id: track.id || '', - title: track.title || '', - artist: track.artist_name || '' - } : null - }; - var payload = 'event_name=' + encodeURIComponent(eventName || '') - + '&track_id=' + encodeURIComponent(track && track.id ? track.id : '') - + '&state=' + encodeURIComponent(JSON.stringify(state)) - + '&message=' + encodeURIComponent(message || ''); - - try { - var url = withIdentity(MUSIC.endpoints.clientLog); - if (navigator.sendBeacon) { - navigator.sendBeacon(url, new Blob([withIdentityPayload(payload)], { - type: 'application/x-www-form-urlencoded; charset=UTF-8' - })); - return; - } - } catch (e) {} - - try { - requestPost(MUSIC.endpoints.clientLog, payload, function () {}, function () {}); - } catch (e) {} - } - - function describeMusicValue(value) { - if (typeof value === 'function') return '[function]'; - if (value == null) return ''; - - value = String(value || ''); - if (!value) return ''; - - var output = value; - - try { - var parsed = new URL(value, window.location.href); - output = parsed.protocol + '//' + parsed.host + parsed.pathname; - } catch (e) {} - - return output.slice(0, 160); - } - - function describeMediaRanges(ranges) { - if (!ranges || typeof ranges.length !== 'number' || !ranges.length) return ''; - - try { - var last = ranges.length - 1; - return ranges.length + ':' + Math.round(ranges.start(0) * 100) / 100 + '-' + Math.round(ranges.end(last) * 100) / 100; - } catch (e) { - return String(ranges.length || ''); - } - } - - function describeError(error) { - if (!error) return ''; - - try { - if (error.stack) return String(error.stack).slice(0, 800); - if (error.message) return ((error.name || 'Error') + ': ' + error.message).slice(0, 800); - } catch (e) {} - - return String(error || '').slice(0, 800); - } - - function describeMediaError(media) { - if (!media || !media.error) return ''; - - try { - return [ - 'code=' + (media.error.code || ''), - media.error.message || '' - ].join(' ').trim().slice(0, 300); - } catch (e) { - return 'media-error'; - } - } - - function sendMusicClientDebug(eventName, state, message, trackId) { - if (!MUSIC_CLIENT_TRACE_ENABLED) return; - - var payload = 'event_name=' + encodeURIComponent(eventName || '') - + '&track_id=' + encodeURIComponent(trackId || '') - + '&state=' + encodeURIComponent(JSON.stringify(state || {})) - + '&message=' + encodeURIComponent(message || ''); - - try { - var url = withIdentity(MUSIC.endpoints.clientLog); - if (navigator.sendBeacon) { - navigator.sendBeacon(url, new Blob([withIdentityPayload(payload)], { - type: 'application/x-www-form-urlencoded; charset=UTF-8' - })); - return; - } - } catch (e) {} - - try { - requestPost(MUSIC.endpoints.clientLog, payload, function () {}, function () {}); - } catch (e) {} - } - - function bindMusicClientDebugErrors() { - if (!MUSIC_CLIENT_TRACE_ENABLED || MUSIC_CLIENT_DEBUG_ERRORS_BOUND) return; - MUSIC_CLIENT_DEBUG_ERRORS_BOUND = true; - - window.addEventListener('error', function (event) { - var data = activePlayerData() || MUSIC_EMBEDDED_IOS.lastData || {}; - - sendMusicClientDebug('music-js-error', { - at: Date.now(), - hidden: !!document.hidden, - message: event && event.message ? event.message : '', - source: event && event.filename ? event.filename : '', - line: event && event.lineno ? event.lineno : 0, - column: event && event.colno ? event.colno : 0, - error: describeError(event && event.error), - data: { - id: data.music_track_id || '', - title: data.title || '', - artist: data.artist || '', - url: describeMusicValue(data.url) - } - }, event && event.message ? event.message : '', data.music_track_id || ''); - }); - - window.addEventListener('unhandledrejection', function (event) { - var data = activePlayerData() || MUSIC_EMBEDDED_IOS.lastData || {}; - - sendMusicClientDebug('music-js-unhandledrejection', { - at: Date.now(), - hidden: !!document.hidden, - reason: describeError(event && event.reason), - data: { - id: data.music_track_id || '', - title: data.title || '', - artist: data.artist || '', - url: describeMusicValue(data.url) - } - }, describeError(event && event.reason), data.music_track_id || ''); - }); - } - - function describeLampaPlayerDebugPayload(event) { - var parts = []; - event = event || {}; - - if (typeof event.position === 'number') parts.push('position=' + event.position); - if (event.percent != null) parts.push('percent=' + Math.round(Number(event.percent || 0) * 10000) / 100 + '%'); - if (event.method) parts.push('method=' + event.method); - if (event.size) parts.push('size=' + event.size); - if (event.url) parts.push('url=' + describeMusicValue(event.url)); - if (event.error) parts.push('error=' + describeMusicValue(event.error)); - if (event.current != null || event.duration != null) - parts.push('time=' + Math.round(Number(event.current || 0) * 100) / 100 + '/' + Math.round(Number(event.duration || 0) * 100) / 100); - - if (event.item) { - parts.push('item=' + [ - event.item.music_track_id || '', - event.item.title || '', - describeMusicValue(event.item.url || '') - ].join('|')); - } - - if (Array.isArray(event.playlist)) { - var sample = event.playlist.slice(0, 5).map(function (item, index) { - return index + ':' + (item && (item.music_track_id || item.title || describeMusicValue(item.url || '')) || ''); - }).join(','); - parts.push('playlist=' + event.playlist.length + '[' + sample + ']'); - } - - return parts.join(' '); - } - - function traceLampaPlayerDebug(source, name, event) { - if (!MUSIC_CLIENT_TRACE_ENABLED) return; - traceEmbeddedIos('lampa-' + source + '-' + name, describeLampaPlayerDebugPayload(event), true); - } - - function bindLampaPlayerDebug() { - if (!MUSIC_CLIENT_TRACE_ENABLED || MUSIC_LAMPA_PLAYER_DEBUG_BOUND) return; - MUSIC_LAMPA_PLAYER_DEBUG_BOUND = true; - - function follow(listener, source, events) { - if (!listener || typeof listener.follow !== 'function') return; - - events.forEach(function (name) { - try { - listener.follow(name, function (event) { - traceLampaPlayerDebug(source, name, event || {}); - }); - } catch (e) {} - }); - } - - function wrap(owner, method, source) { - if (!owner || typeof owner[method] !== 'function') return; - - var flag = '__musicDebugWrapped_' + method; - if (owner[flag]) return; - owner[flag] = true; - - var original = owner[method]; - owner[method] = function () { - var event = {}; - - if (arguments.length === 1) { - if (typeof arguments[0] === 'object') event = arguments[0] || {}; - else event = { value: arguments[0], url: method === 'url' ? arguments[0] : '' }; - } else if (arguments.length) { - event = { - args: Array.prototype.slice.call(arguments).map(function (arg) { - return describeMusicValue(arg); - }).join('|') - }; - } - - if (event.value != null && !event.url) event.url = method === 'url' ? event.value : ''; - traceLampaPlayerDebug(source, method + '-call', event); - return original.apply(this, arguments); - }; - } - - follow(Lampa.PlayerVideo && Lampa.PlayerVideo.listener, 'video', [ - 'play', 'pause', 'rewind', 'ended', 'error', 'reset_continue', 'canplay' - ]); - - follow(Lampa.PlayerPanel && Lampa.PlayerPanel.listener, 'panel', [ - 'playpause', 'playlist', 'prev', 'next', 'rprev', 'rnext', 'to_start', 'to_end', 'quality', 'mouse_rewind' - ]); - - follow(Lampa.PlayerPlaylist && Lampa.PlayerPlaylist.listener, 'playlist', [ - 'select', 'set' - ]); - - wrap(Lampa.Player, 'play', 'player'); - wrap(Lampa.Player, 'playlist', 'player'); - wrap(Lampa.Player, 'close', 'player'); - wrap(Lampa.PlayerVideo, 'url', 'video'); - wrap(Lampa.PlayerVideo, 'destroy', 'video'); - wrap(Lampa.PlayerVideo, 'to', 'video'); - wrap(Lampa.PlayerVideo, 'play', 'video'); - wrap(Lampa.PlayerVideo, 'pause', 'video'); - wrap(Lampa.PlayerPlaylist, 'url', 'playlist'); - wrap(Lampa.PlayerPlaylist, 'set', 'playlist'); - wrap(Lampa.PlayerPlaylist, 'next', 'playlist'); - wrap(Lampa.PlayerPlaylist, 'prev', 'playlist'); - } - - function traceEmbeddedIos(eventName, message, force) { - if (!MUSIC_CLIENT_TRACE_ENABLED) return; - if (!force && !MUSIC_EMBEDDED_IOS.active) return; - - var now = Date.now(); - var media = activeMusicMediaElement(); - var data = activePlayerData() || MUSIC_EMBEDDED_IOS.lastData || {}; - var track = queueCurrentTrack(); - var session = mediaSessionObject(); - var playlist = activePlaylist(); - var queueIndex = queueCurrentIndex(); - var playlistItem = playlist && queueIndex >= 0 ? playlist[queueIndex] : null; - var mediaCount = 0; - - try { - mediaCount = document.querySelectorAll('.player-video video, .player-video audio').length; - } catch (e) {} - - var state = { - seq: ++MUSIC_EMBEDDED_IOS.traceSeq, - at: now, - hidden: !!document.hidden, - visibility: document.visibilityState || '', - focus: typeof document.hasFocus === 'function' ? document.hasFocus() : null, - controller: Lampa.Controller && Lampa.Controller.enabled ? ((Lampa.Controller.enabled() || {}).name || '') : '', - player: currentExternalPlayer(), - playbackMode: getPlaybackMode(), - provider: getAudioProviderId(), - active: !!MUSIC_EMBEDDED_IOS.active, - keepAlive: !!MUSIC_IOS_AUDIO.keepAliveActive, - paused: media ? !!media.paused : null, - ended: media ? !!media.ended : null, - currentTime: media && isFinite(media.currentTime) ? Math.round(media.currentTime * 100) / 100 : null, - duration: embeddedIosEffectiveDuration(media, data) || null, - readyState: media ? media.readyState : null, - networkState: media ? media.networkState : null, - mediaError: describeMediaError(media), - mediaTag: media && media.tagName ? media.tagName.toLowerCase() : '', - seekable: media ? describeMediaRanges(media.seekable) : '', - buffered: media ? describeMediaRanges(media.buffered) : '', - playsInline: media ? !!(media.playsInline || media.getAttribute('playsinline') != null || media.getAttribute('webkit-playsinline') != null) : null, - mediaSrc: media ? describeMusicValue(media.currentSrc || media.src || '') : '', - mediaCount: mediaCount, - mediaSessionState: session && 'playbackState' in session ? session.playbackState : '', - visual: { - exists: !!$('.lm-player-visual').length, - mode: musicPlayerVisualMode || '', - key: musicPlayerVisualKey || '', - lyricsRequest: musicPlayerVisualLyricsRequest || '' - }, - queueLength: queueTracks().length, - queueIndex: queueIndex, - playlistLength: Array.isArray(playlist) ? playlist.length : 0, - data: { - id: data.music_track_id || '', - title: data.title || '', - artist: data.artist || '', - url: describeMusicValue(data.url) - }, - track: track ? { - id: track.id || '', - title: track.title || '', - artist: track.artist_name || '' - } : null, - playlistItem: playlistItem ? { - id: playlistItem.music_track_id || '', - title: playlistItem.title || '', - artist: playlistItem.artist || '', - url: describeMusicValue(playlistItem.url) - } : null - }; - sendMusicClientDebug('embedded-' + (eventName || ''), state, message || '', (data && data.music_track_id) || (track && track.id) || ''); - } - - function musicHeatHasCounters(counters) { - counters = counters || {}; - - for (var key in counters) { - if (Object.prototype.hasOwnProperty.call(counters, key)) return true; - } - - return false; - } - - function isMusicHeatProbeActive() { - return isStandaloneIosAudioActive() - || !!MUSIC_EMBEDDED_IOS.active - || !!MUSIC_IOS_AUDIO.keepAliveActive - || !!MUSIC_IOS_FULL_PLAYER_OPEN; - } - - function scheduleMusicHeatProbe() { - if (!MUSIC_CLIENT_TRACE_ENABLED || MUSIC_HEAT_PROBE.timer) return; - if (!isMusicHeatProbeActive()) return; - - if (!MUSIC_HEAT_PROBE.startedAt) MUSIC_HEAT_PROBE.startedAt = Date.now(); - MUSIC_HEAT_PROBE.timer = setTimeout(flushMusicHeatProbe, MUSIC_HEAT_PROBE.interval); - } - - function bumpMusicHeatMetric(name, amount) { - if (!MUSIC_CLIENT_TRACE_ENABLED || !name) return; - - MUSIC_HEAT_PROBE.counters[name] = (MUSIC_HEAT_PROBE.counters[name] || 0) + (amount || 1); - scheduleMusicHeatProbe(); - } - - function musicHeatNow() { - try { - if (window.performance && typeof window.performance.now === 'function') - return window.performance.now(); - } catch (e) {} - - return Date.now(); - } - - function bumpMusicHeatDuration(name, startedAt) { - if (!MUSIC_CLIENT_TRACE_ENABLED || !name || !startedAt) return; - - var elapsed = Math.max(0, musicHeatNow() - startedAt); - bumpMusicHeatMetric(name + 'Count'); - bumpMusicHeatMetric(name + 'Ms', Math.round(elapsed * 100) / 100); - } - - function numberForHeat(value) { - return isFinite(value) ? Math.round(value * 100) / 100 : null; - } - - function standaloneIosFullHeatState(now) { - if (!MUSIC_CLIENT_TRACE_ENABLED) return null; - - var player = MUSIC_IOS_FULL_PLAYER; - if (!player || !player.length) { - return { - open: !!MUSIC_IOS_FULL_PLAYER_OPEN, - dom: false - }; - } - - var shell = player.find('.lm-ios-full-player__shell').get(0); - var queueList = player.find('.lm-ios-full-player__queue-list').get(0); - var sheetBody = player.find('.lm-ios-full-player__sheet-body').get(0); - var lyricsMeta = player.data('lyricsLineMeta'); - var manualAt = Number(player.attr('data-lyrics-manual') || 0); - - return { - open: !!MUSIC_IOS_FULL_PLAYER_OPEN, - visible: player.hasClass('lm-ios-full-player--visible'), - sheet: player.hasClass('lm-ios-full-player--sheet-open'), - sheetKind: player.attr('data-sheet-kind') || '', - seeking: player.attr('data-seeking') === 'true', - pausedClass: player.hasClass('lm-ios-full-player--paused'), - queueKeyLen: String(player.attr('data-queue-key') || '').length, - progressKey: MUSIC_IOS_AUDIO.fullProgressKey || '', - playbackKeyLen: String(MUSIC_IOS_AUDIO.fullPlaybackKey || '').length, - uiKeyLen: String(MUSIC_IOS_AUDIO.fullUiKey || '').length, - dom: { - quicks: player.find('.lm-ios-full-player__quick').length, - queueItems: player.find('.lm-ios-full-player__queue-item').length, - queueDividers: player.find('.lm-ios-full-player__queue-divider').length, - sheetRows: player.find('.lm-ios-full-player__sheet-row').length, - lyricLines: player.find('.lm-ios-full-player__lyrics-line').length - }, - shell: shell ? { - h: shell.clientHeight || 0, - scrollH: shell.scrollHeight || 0 - } : null, - sheetBody: sheetBody ? { - h: sheetBody.clientHeight || 0, - scrollH: sheetBody.scrollHeight || 0, - top: sheetBody.scrollTop || 0 - } : null, - queue: queueList ? { - h: queueList.clientHeight || 0, - scrollH: queueList.scrollHeight || 0, - top: queueList.scrollTop || 0 - } : null, - lyrics: lyricsMeta ? { - lines: lyricsMeta.elements ? lyricsMeta.elements.length : 0, - active: lyricsMeta.activeIndex, - manualAge: manualAt ? now - manualAt : 0 - } : null - }; - } - - function logStandaloneIosFullEvent(eventName, message) { - if (!MUSIC_CLIENT_TRACE_ENABLED || !eventName) return; - - var track = MUSIC_IOS_AUDIO.tracks[MUSIC_IOS_AUDIO.currentIndex] || queueCurrentTrack(); - sendMusicClientDebug( - 'full-player-' + eventName, - standaloneIosFullHeatState(Date.now()), - message || '', - track && track.id ? track.id : '' - ); - } - - function postMusicHeatProbe(counters, now) { - var audio = MUSIC_IOS_AUDIO.audio; - var media = activeMusicMediaElement(); - var data = activePlayerData() || MUSIC_EMBEDDED_IOS.lastData || {}; - var track = MUSIC_IOS_AUDIO.tracks[MUSIC_IOS_AUDIO.currentIndex] || queueCurrentTrack(); - var session = mediaSessionObject(); - var ctx = MUSIC_IOS_AUDIO.keepAliveCtx; - var state = { - seq: ++MUSIC_HEAT_PROBE.seq, - at: now, - span: MUSIC_HEAT_PROBE.lastFlushAt ? now - MUSIC_HEAT_PROBE.lastFlushAt : 0, - uptime: MUSIC_HEAT_PROBE.startedAt ? now - MUSIC_HEAT_PROBE.startedAt : 0, - hidden: !!document.hidden, - vis: document.visibilityState || '', - focus: typeof document.hasFocus === 'function' ? document.hasFocus() : null, - ctrl: Lampa.Controller && Lampa.Controller.enabled ? ((Lampa.Controller.enabled() || {}).name || '') : '', - player: currentExternalPlayer(), - mode: getPlaybackMode(), - provider: getAudioProviderId(), - ms: session && 'playbackState' in session ? session.playbackState : '', - counters: counters || {}, - sa: { - active: !!MUSIC_IOS_AUDIO.active, - playing: !!MUSIC_IOS_AUDIO.playing, - switching: !!MUSIC_IOS_AUDIO.switching, - paused: audio ? !!audio.paused : null, - ct: audio ? numberForHeat(audio.currentTime) : null, - dur: audio ? numberForHeat(audio.duration) : null, - rs: audio ? audio.readyState : null, - ns: audio ? audio.networkState : null, - posAge: MUSIC_IOS_AUDIO.lastPositionSync ? now - MUSIC_IOS_AUDIO.lastPositionSync : null, - uiAge: MUSIC_IOS_AUDIO.lastUiUpdate ? now - MUSIC_IOS_AUDIO.lastUiUpdate : null - }, - em: { - active: !!MUSIC_EMBEDDED_IOS.active, - paused: media ? !!media.paused : null, - ct: media ? numberForHeat(media.currentTime) : null, - dur: media ? numberForHeat(media.duration) : null, - rs: media ? media.readyState : null, - ns: media ? media.networkState : null, - tag: media && media.tagName ? media.tagName.toLowerCase() : '' - }, - ka: { - active: !!MUSIC_IOS_AUDIO.keepAliveActive, - webaudio: !!MUSIC_IOS_KEEPALIVE_WEBAUDIO, - ctx: ctx ? (ctx.state || '') : '', - loop: !!MUSIC_IOS_AUDIO.keepAlive - }, - full: standaloneIosFullHeatState(now), - track: track ? { - id: track.id || data.music_track_id || '', - title: track.title || data.title || '', - artist: track.artist_name || data.artist || '' - } : null - }; - var payload = 'event_name=' + encodeURIComponent('heat-probe') - + '&track_id=' + encodeURIComponent((track && track.id) || data.music_track_id || '') - + '&state=' + encodeURIComponent(JSON.stringify(state)) - + '&message='; - - MUSIC_HEAT_PROBE.lastFlushAt = now; - - try { - var url = withIdentity(MUSIC.endpoints.clientLog); - if (navigator.sendBeacon) { - navigator.sendBeacon(url, new Blob([withIdentityPayload(payload)], { - type: 'application/x-www-form-urlencoded; charset=UTF-8' - })); - return; - } - } catch (e) {} - - try { - requestPost(MUSIC.endpoints.clientLog, payload, function () {}, function () {}); - } catch (e) {} - } - - function flushMusicHeatProbe() { - var now = Date.now(); - var counters = MUSIC_HEAT_PROBE.counters || {}; - var active = isMusicHeatProbeActive(); - - MUSIC_HEAT_PROBE.timer = 0; - MUSIC_HEAT_PROBE.counters = {}; - - if (!MUSIC_CLIENT_TRACE_ENABLED) return; - if (!active && !musicHeatHasCounters(counters)) { - MUSIC_HEAT_PROBE.startedAt = 0; - return; - } - - postMusicHeatProbe(counters, now); - - if (active) scheduleMusicHeatProbe(); - else MUSIC_HEAT_PROBE.startedAt = 0; - } - - function parseJson(value) { - if (!value) return null; - if (typeof value === 'object') return value; - - try { - return JSON.parse(value); - } catch (e) { - return null; - } - } - - function encodeSvg(svg) { - return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg); - } - - function escapeXml(value) { - return String(value || '') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function initials(value) { - var words = String(value || '') - .replace(/[^A-Za-z0-9А-Яа-яЁёІіЇїЄєҐґ\s]/g, ' ') - .trim() - .split(/\s+/) - .filter(Boolean); - - if (!words.length) return 'MU'; - if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); - return (words[0][0] + words[1][0]).toUpperCase(); - } - - function formatYear(value) { - var match = String(value || '').match(/\d{4}/); - return match ? match[0] : ''; - } - - function albumBadge(album, sectionKey) { - var type = String(album && album.type || '').toUpperCase(); - var key = String(sectionKey || ''); - - if (key.indexOf(':appears-on') >= 0) return 'FEAT'; - if (type.indexOf('PLAYLIST') >= 0) return 'PLAYLIST'; - if (type.indexOf('SINGLE') >= 0) return 'SINGLE'; - if (type === 'EP' || type.indexOf(' EP') >= 0) return 'EP'; - if (type.indexOf('COMPIL') >= 0) return 'COMP'; - if (type.indexOf('ALBUM') >= 0) return 'ALBUM'; - - return formatYear(album && (album.date || album.year)) || 'ALBUM'; - } - - function formatDuration(ms) { - if (!ms) return '-:--'; - - var total = Math.floor(ms / 1000); - var minutes = Math.floor(total / 60); - var seconds = total % 60; - - return minutes + ':' + (seconds < 10 ? '0' : '') + seconds; - } - - function translate(key, fallback) { - if (window.Lampa && Lampa.Lang && typeof Lampa.Lang.translate === 'function') - return Lampa.Lang.translate(key); - - return fallback || key; - } - - function showContextMenu(title, items, onSelect, focusContext) { - focusContext = focusContext || captureMusicFocusContext('content'); - var list = Array.isArray(items) ? items.filter(Boolean) : []; - - if (!list.length) return; - - Lampa.Select.show({ - title: title || translate('title_action', 'Действия'), - items: list, - onBack: function () { - restoreMusicFocusContext(focusContext, 0); - }, - onBeforeClose: function () { - return true; - }, - onSelect: function (selected) { - if (selected && (selected.separator || selected.disabled || selected.noop)) { - restoreMusicFocusContext(focusContext); - return; - } - - if (!selected || !onSelect) { - restoreMusicFocusContext(focusContext); - return; - } - onSelect(selected, focusContext); - } - }); - } - - function safeControllerName(fallback) { - try { - var enabled = Lampa.Controller && Lampa.Controller.enabled ? Lampa.Controller.enabled() : null; - return enabled && enabled.name ? enabled.name : (fallback || 'content'); - } catch (e) { - return fallback || 'content'; - } - } - - function restoreController(name) { - name = name || 'content'; - - setTimeout(function () { - if (isStandaloneIosPlayerForeground()) return; - try { - if (Lampa.Controller && Lampa.Controller.toggle) - Lampa.Controller.toggle(name); - } catch (e) { - try { - if (name !== 'content' && Lampa.Controller && Lampa.Controller.toggle) - Lampa.Controller.toggle('content'); - } catch (_) {} - } - }, 0); - } - - function blurActiveMusicInput() { - try { - var active = document.activeElement; - if (!active) return; - - var tag = String(active.tagName || '').toLowerCase(); - if (tag === 'input' || tag === 'textarea' || active.isContentEditable) - active.blur(); - } catch (e) {} - } - - function buildMusicFocusKey(item) { - if (!item) return ''; - - return [ - item.section_key || '', - item.type || '', - item.id || item.title || '' - ].join('|'); - } - - function captureMusicFocusContext(fallbackController) { - var focused = $('.lm-card.selector.focus,.lm-track.selector.focus,.selector.focus').get(0); - var container = focused - ? ($(focused).closest('.lm-home-line,.lm-search-line,.lm-search-actions,.lm-search-sources,.lm-search-history,.lm-search-bar,.lm-search-screen,.lm-full,.items-line')[0] || null) - : null; - var selectorIndex = -1; - - if (focused && container) - selectorIndex = $(container).find('.selector').index(focused); - - var context = { - controller: safeControllerName(fallbackController || 'content'), - element: focused || null, - focusKey: focused ? (focused.getAttribute('data-music-focus-key') || '') : '', - container: container, - selectorIndex: selectorIndex - }; - - if (focused && container) - MUSIC_LAST_FOCUS_CONTEXT = context; - else if (MUSIC_LAST_FOCUS_CONTEXT) - return MUSIC_LAST_FOCUS_CONTEXT; - - return context; - } - - function findMusicFocusElement(context) { - if (!context) return null; - - if (context.element && document.documentElement.contains(context.element)) - return context.element; - - if (context.focusKey) { - var matched = $('.selector[data-music-focus-key]').filter(function () { - return this.getAttribute('data-music-focus-key') === context.focusKey; - }).get(0); - - if (matched) return matched; - } - - if (context.container && document.documentElement.contains(context.container)) { - var selectors = $(context.container).find('.selector'); - if (selectors.length) { - var index = Math.max(0, Math.min(context.selectorIndex || 0, selectors.length - 1)); - return selectors.eq(index).get(0); - } - } - - return null; - } - - function restoreMusicFocusContext(context, delay) { - if (!context) return; - - setTimeout(function () { - if (isStandaloneIosPlayerForeground()) return; - if (Date.now() < MUSIC_HOME_REFRESH_RESTORE_BLOCK_UNTIL && context.container && $(context.container).hasClass('lm-home-line')) - return; - - restoreController(context.controller || 'content'); - - setTimeout(function () { - if (isStandaloneIosPlayerForeground()) return; - var target = findMusicFocusElement(context); - if (!target) { - var fallbackContainer = context.container && document.documentElement.contains(context.container) - ? context.container - : null; - - if (fallbackContainer && Lampa.Controller && Lampa.Controller.collectionSet) - Lampa.Controller.collectionSet(fallbackContainer); - - return; - } - - var container = $(target).closest('.lm-home-line,.lm-search-line,.lm-full,.items-line')[0] - || context.container - || target.parentNode - || document.body; - - try { - if (Lampa.Controller && Lampa.Controller.collectionSet) - Lampa.Controller.collectionSet(container); - if (Lampa.Controller && Lampa.Controller.collectionFocus) - Lampa.Controller.collectionFocus(target, container); - } catch (e) {} - - try { - target.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - } catch (_) {} - }, 60); - }, typeof delay === 'number' ? delay : 60); - } - - function restoreMusicFocusContextAfterRefresh(context) { - restoreMusicFocusContext(context, 120); - restoreMusicFocusContext(context, 500); - } - - function captureMusicNeighborFocusContext(context, fallbackController) { - context = context || captureMusicFocusContext(fallbackController); - - var target = context && context.element; - if (!target) return context; - - var container = context.container && document.documentElement.contains(context.container) - ? context.container - : ($(target).closest('.lm-home-line,.lm-search-line,.lm-search-actions,.lm-search-sources,.lm-search-history,.lm-search-bar,.lm-search-screen,.lm-full,.items-line')[0] || null); - - if (!container) return context; - - var selectors = $(container).find('.selector').filter(function () { - return this !== target && !$(this).closest(target).length; - }); - - if (!selectors.length) return context; - - var allSelectors = $(container).find('.selector'); - var targetIndex = allSelectors.index(target); - var neighbor = null; - - if (targetIndex >= 0) { - for (var i = targetIndex + 1; i < allSelectors.length; i++) { - if (allSelectors[i] !== target) { - neighbor = allSelectors[i]; - break; - } - } - - if (!neighbor) { - for (var j = targetIndex - 1; j >= 0; j--) { - if (allSelectors[j] !== target) { - neighbor = allSelectors[j]; - break; - } - } - } - } - - neighbor = neighbor || selectors.get(0); - - return { - controller: safeControllerName((context && context.controller) || fallbackController || 'content'), - element: neighbor, - focusKey: neighbor.getAttribute('data-music-focus-key') || '', - container: container, - selectorIndex: allSelectors.index(neighbor) - }; - } - - function buildEntryRestoreState(entry, index) { - var sectionKey = entry && entry.section_key ? String(entry.section_key) : ''; - if (!sectionKey) return null; - - return { - sectionKey: sectionKey, - entryIndex: Math.max(0, Number(index) || 0) - }; - } - - function refreshListWithFocus(refresh, restoreState, fallbackContext) { - if (!refresh) { - restoreMusicFocusContext(fallbackContext, 120); - return; - } - - if (refresh.length > 0) { - refresh(restoreState || null); - return; - } - - refresh(); - restoreMusicFocusContextAfterRefresh(fallbackContext); - } - - function formatSeconds(seconds) { - seconds = Math.max(0, Math.floor(Number(seconds) || 0)); - - var hours = Math.floor(seconds / 3600); - var minutes = Math.floor((seconds % 3600) / 60); - var secs = seconds % 60; - - if (hours > 0) - return hours + ':' + (minutes < 10 ? '0' : '') + minutes + ':' + (secs < 10 ? '0' : '') + secs; - - return minutes + ':' + (secs < 10 ? '0' : '') + secs; - } - - function formatDate(value) { - if (!value) return 'Неизвестно'; - - var string = String(value); - if (/^\d{4}$/.test(string)) return string; - if (/^\d{4}-\d{2}-\d{2}$/.test(string)) { - var parts = string.split('-'); - return parts[2] + '.' + parts[1] + '.' + parts[0]; - } - - return string; - } - - var STREAM_MODE_ITEMS = [ - { mode: 'auto', title: 'Авто' }, - { mode: 'best', title: 'Лучшее качество' }, - { mode: 'low', title: 'Экономия' }, - { mode: 'm4a', title: 'Предпочитать m4a' }, - { mode: 'webm', title: 'Предпочитать webm' } - ]; - - var PLAYBACK_MODE_ITEMS = [ - { mode: 'audio', title: 'Без видео' }, - { mode: 'video', title: 'С видео' } - ]; - var AUDIO_PROVIDER_FALLBACKS = [ - { id: 'youtubeaudio', name: 'YouTube Audio', capabilities: ['match', 'streams', 'playback', 'audio', 'video'] }, - { id: 'sefonaudio', name: 'Sefon', capabilities: ['match', 'streams', 'playback', 'audio'] }, - { id: 'soundcloudaudio', name: 'SoundCloud', capabilities: ['match', 'streams', 'playback', 'audio'] } - ]; - - // ===== PLAYBACK SETTINGS / PROVIDERS ===== - - function getStreamMode() { - var mode = Lampa.Storage.get(MUSIC.storage.quality_mode, 'auto'); - mode = String(mode || 'auto').toLowerCase(); - - return STREAM_MODE_ITEMS.some(function (item) { - return item.mode === mode; - }) ? mode : 'auto'; - } - - function setStreamMode(mode) { - Lampa.Storage.set(MUSIC.storage.quality_mode, mode || 'auto'); - } - - function getPlaybackMode() { - var mode = Lampa.Storage.get(MUSIC.storage.playback_mode, 'audio'); - mode = String(mode || 'audio').toLowerCase(); - - return PLAYBACK_MODE_ITEMS.some(function (item) { - return item.mode === mode; - }) ? mode : 'audio'; - } - - function setPlaybackMode(mode) { - Lampa.Storage.set(MUSIC.storage.playback_mode, mode || 'audio'); - } - - function buildMusicPlayerValues() { - var values = { - inner: 'Встроенный' - }; - - if (Lampa.Platform.is('apple') && !Lampa.Platform.macOS()) { - values.ios = 'iOS'; - values.vlc = 'VLC'; - values.outplayer = 'Outplayer'; - values.nplayer = 'nPlayer'; - values.infuse = 'Infuse'; - } else if (!Lampa.Platform.macOS()) { - values.ios = 'Music Player'; - } - - if (Lampa.Platform.macOS()) { - values.mpv = 'MPV'; - values.iina = 'IINA'; - values.nplayer = 'nPlayer'; - values.infuse = 'Infuse'; - } - - if (Lampa.Platform.desktop()) - values.other = 'Другой внешний'; - - return values; - } - - // пока пользователь не выбирал плеер для музыки, наследуем глобальный - // плеер Lampa (если он есть в списке платформы), иначе встроенный - function defaultMusicPlayerId(values) { - var globalPlayer = Lampa.Storage.field('player'); - return Object.prototype.hasOwnProperty.call(values, globalPlayer) ? globalPlayer : 'inner'; - } - - function getMusicPlayerId() { - var values = buildMusicPlayerValues(); - var player = Lampa.Storage.get(MUSIC.storage.player, '') || ''; - - return Object.prototype.hasOwnProperty.call(values, player) ? player : defaultMusicPlayerId(values); - } - - function getLaunchMusicPlayerId() { - var player = getMusicPlayerId(); - - // Наш iOS-плеер — audio-only <audio>. Когда пользователь включает - // YouTube «С видео», запуск должен идти через встроенный видеоплеер - // Lampa, иначе ядро не создаёт нормальную video-очередь. - if (player === 'ios' && getPlaybackMode() === 'video') - return 'inner'; - - return player; - } - - // Lampa.Player.play выбирает ВНЕШНЕЕ приложение по глобальной настройке - // player (externalPlayer в ядре читает Storage.field), data.launch_player - // влияет только на развилку inner/внешний. Поэтому выбранный для музыки - // внешний плеер запускаем сами — схемы те же, что в ядре Lampa - function musicExternalSchemeUrl(player, url) { - if (!url) return ''; - - var encoded = encodeURIComponent(url); - - switch (player) { - case 'vlc': return 'vlc://' + url; - case 'nplayer': return 'nplayer-' + url; - case 'infuse': return 'infuse://x-callback-url/play?url=' + encoded; - case 'outplayer': return 'outplayer://x-callback-url/play?url=' + encoded; - case 'mpv': return 'mpv://' + encodeURI(url); - case 'iina': return 'iina://weblink?url=' + encoded; - default: return ''; - } - } - - // ядро Lampa решает playsinline по ГЛОБАЛЬНОЙ настройке player В МОМЕНТ - // создания video-элемента (data.launch_player на это не влияет): при - // глобальном ios видео уходит в НАТИВНЫЙ фулскрин, где панель Lampa с - // плейлистом недоступна. Единственная ручка — временно подменить глобальное - // поле на время запуска; восстанавливаем на start/destroy и по таймауту - // single-flight состояние подмены: при двух быстрых запусках второй НЕ - // должен записать 'inner' первого как «оригинал», а restore устаревшего - // поколения не должен дёргать Storage под ногами у нового - var MUSIC_TEMP_PLAYER = { generation: 0, active: false, original: '' }; - - function playWithTemporaryGlobalPlayer(data, temporaryPlayer) { - var generation = ++MUSIC_TEMP_PLAYER.generation; - - if (!MUSIC_TEMP_PLAYER.active) { - MUSIC_TEMP_PLAYER.original = Lampa.Storage.get('player', ''); - MUSIC_TEMP_PLAYER.active = true; - } - - function restore() { - // свой листенер снимаем всегда, даже если поколение устарело - try { - Lampa.Player.listener.remove('start', restore); - Lampa.Player.listener.remove('destroy', restore); - } catch (e) {} - - if (!MUSIC_TEMP_PLAYER.active || MUSIC_TEMP_PLAYER.generation !== generation) return; - - MUSIC_TEMP_PLAYER.active = false; - - try { - Lampa.Storage.set('player', MUSIC_TEMP_PLAYER.original); - } catch (e) {} - } - - try { - Lampa.Storage.set('player', temporaryPlayer); - } catch (e) { - Lampa.Player.play(data); - return; - } - - try { - Lampa.Player.listener.follow('start', restore); - Lampa.Player.listener.follow('destroy', restore); - } catch (e) {} - - setTimeout(restore, 3000); - Lampa.Player.play(data); - } - - function launchMusicPlayback(data) { - var player = getLaunchMusicPlayerId(); - - // музыкальный inner должен открываться встроенным даже при внешнем - // глобальном плеере — это ядро умеет через data.launch_player - if (player === 'inner') { - data.launch_player = 'inner'; - - // музыкальное видео во встроенном при ГЛОБАЛЬНОМ ios-плеере: - // без подмены ядро создаст video-элемент без playsinline → - // iOS попробует нативный фулскрин, а webkitEnterFullscreen без - // живого жеста запрещён (подготовка плейлиста жест «съедает») → - // «Видео не найдено или повреждено». Гейт НЕ зависит от - // музыкальной настройки плеера — только от глобальной - if (Lampa.Platform.is('apple') - && getPlaybackMode() === 'video' - && Lampa.Storage.field('player') === 'ios') { - playWithTemporaryGlobalPlayer(data, 'inner'); - return; - } - - Lampa.Player.play(data); - return; - } - - var scheme = musicExternalSchemeUrl(player, data && data.url); - - if (scheme) { - try { - window.location.assign(scheme); - return; - } catch (e) {} - } - - Lampa.Player.play(data); - } - - function moveMusicPlayerSettingInPlayer(body) { - if (!body || !body.length) return; - - var musicPlayer = body.find('[data-name="' + MUSIC.storage.player + '"]'); - var mainPlayer = body.find('[data-name="player"]'); - - if (musicPlayer.length && mainPlayer.length) - mainPlayer.after(musicPlayer); - } - - function registerMusicPlayerSetting() { - if (!Lampa.SettingsApi || typeof Lampa.SettingsApi.addParam !== 'function') return; - - Lampa.SettingsApi.addParam({ - component: 'player', - param: { - name: MUSIC.storage.player, - // валидные типы SettingsApi: select/trigger/input/title/static/button; - // 'toggle' не существует — поле молча не рендерится - type: 'select', - values: buildMusicPlayerValues(), - default: defaultMusicPlayerId(buildMusicPlayerValues()) - }, - field: { - name: 'Тип плеера для музыки', - description: 'Каким плеером воспроизводить музыку' - } - }); - - if (Lampa.Settings && Lampa.Settings.listener && typeof Lampa.Settings.listener.follow === 'function') { - Lampa.Settings.listener.follow('open', function (event) { - if (event && event.name === 'player') - moveMusicPlayerSettingInPlayer(event.body); - }); - } - } - - function normalizeAudioProviderId(id) { - return String(id || '').trim().toLowerCase(); - } - - function normalizeAudioProviders(list) { - return (Array.isArray(list) ? list : []).map(function (item) { - if (!item || !item.id) return null; - - return { - id: normalizeAudioProviderId(item.id), - name: item.name || item.id, - capabilities: Array.isArray(item.capabilities) ? item.capabilities.slice() : [] - }; - }).filter(Boolean); - } - - function getCachedAudioProviders() { - return MUSIC_AUDIO_PROVIDERS && MUSIC_AUDIO_PROVIDERS.length - ? MUSIC_AUDIO_PROVIDERS - : AUDIO_PROVIDER_FALLBACKS; - } - - function providerHasCapability(provider, capability) { - if (!provider || !Array.isArray(provider.capabilities) || !capability) return false; - return provider.capabilities.some(function (item) { - return String(item || '').toLowerCase() === String(capability).toLowerCase(); - }); - } - - function providerSupportsPlaybackMode(provider, playbackMode) { - if (!provider) return false; - - if (playbackMode === 'video') - return providerHasCapability(provider, 'video'); - - return providerHasCapability(provider, 'audio') - || !provider.capabilities - || !provider.capabilities.length; - } - - function findAudioProviderById(id, providers) { - id = normalizeAudioProviderId(id); - providers = normalizeAudioProviders(providers || getCachedAudioProviders()); - - return providers.filter(function (item) { - return item.id === id; - })[0] || null; - } - - function getDefaultAudioProviderId(playbackMode, providers) { - playbackMode = playbackMode || getPlaybackMode(); - providers = normalizeAudioProviders(providers || getCachedAudioProviders()); - - var compatible = providers.filter(function (item) { - return providerSupportsPlaybackMode(item, playbackMode); - })[0]; - - if (compatible && compatible.id) return compatible.id; - if (playbackMode === 'video') return 'youtubeaudio'; - return 'youtubeaudio'; - } - - function ensureAudioProviderCompatibility(playbackMode, providers) { - playbackMode = playbackMode || getPlaybackMode(); - - // авторитетен только список с сервера (или явно переданный) — до его - // загрузки работает неполный AUDIO_PROVIDER_FALLBACKS - var authoritative = !!providers || !!(MUSIC_AUDIO_PROVIDERS && MUSIC_AUDIO_PROVIDERS.length); - providers = normalizeAudioProviders(providers || getCachedAudioProviders()); - - var currentId = normalizeAudioProviderId(Lampa.Storage.get(MUSIC.storage.audio_provider, '')); - var current = findAudioProviderById(currentId, providers); - - if (current && providerSupportsPlaybackMode(current, playbackMode)) - return current.id; - - // пока список не авторитетен, сохранённый выбор НЕ затираем: раньше - // холодный старт сбрасывал soundcloudaudio на дефолт ещё до ответа - // /music/providers, и выбор пользователя «не сохранялся» - if (!authoritative && currentId) - return currentId; - - var nextId = getDefaultAudioProviderId(playbackMode, providers); - setAudioProviderId(nextId); - return nextId; - } - - function getAudioProviderId(providers) { - return ensureAudioProviderCompatibility(getPlaybackMode(), providers); - } - - function setAudioProviderId(id) { - Lampa.Storage.set(MUSIC.storage.audio_provider, normalizeAudioProviderId(id || 'youtubeaudio')); - } - - function isQueueRestoreEnabled() { - return Lampa.Storage.get(MUSIC.storage.queue_restore_enabled, false) === true; - } - - function hasActiveMusicPlayback() { - var data = activePlayerData(); - return isStandaloneIosAudioActive() - || !!(data && data.from_music_cluster); - } - - function setQueueRestoreEnabled(enabled) { - enabled = enabled !== false; - Lampa.Storage.set(MUSIC.storage.queue_restore_enabled, enabled); - - if (enabled) { - scheduleQueueSnapshotSave(true); - return; - } - - clearQueueSnapshot(); - - if (!hasActiveMusicPlayback()) { - MUSIC_QUEUE.tracks = []; - MUSIC_QUEUE.currentIndex = 0; - MUSIC_QUEUE.currentTrackId = null; - MUSIC_IOS_AUDIO.tracks = []; - MUSIC_IOS_AUDIO.playlist = []; - MUSIC_IOS_AUDIO.currentIndex = -1; - MUSIC_IOS_AUDIO.active = false; - MUSIC_IOS_AUDIO.switching = false; - MUSIC_IOS_AUDIO.playing = false; - updateStandaloneIosPlayerBar(); - } - } - - function getAudioProviderTitle(id, providers) { - var provider = findAudioProviderById(id || getAudioProviderId(providers), providers); - if (provider && provider.name) return provider.name; - - id = normalizeAudioProviderId(id); - if (id === 'sefonaudio') return 'Sefon'; - if (id === 'youtubeaudio') return 'YouTube Audio'; - if (id === 'yandexmusic') return 'Yandex Music'; - return id || 'YouTube Audio'; - } - - function getAudioProviderAvailabilitySubtitle(provider, playbackMode) { - if (!provider) return ''; - if (providerSupportsPlaybackMode(provider, playbackMode)) return ''; - - if (playbackMode === 'video') - return 'Доступно только без видео'; - - return 'Недоступно в этом режиме'; - } - - function supportsPlayPrefetch(providerId) { - providerId = normalizeAudioProviderId(providerId || getAudioProviderId()); - return providerId === 'youtubeaudio'; - } - - function requiresPreparedInternalPlaylist(providerId) { - providerId = normalizeAudioProviderId(providerId || getAudioProviderId()); - if (shouldPreferTrackNavigationControls()) return true; - return providerId === 'sefonaudio'; - } - - function getPreparedPlaylistConcurrency(providerId) { - providerId = normalizeAudioProviderId(providerId || getAudioProviderId()); - if (shouldPreferTrackNavigationControls()) return 2; - if (providerId === 'soundcloudaudio') return 6; - return providerId === 'sefonaudio' ? 4 : 2; - } - - function shouldUseStagedStandaloneIosPreparation(providerId) { - providerId = normalizeAudioProviderId(providerId || getAudioProviderId()); - return providerId === 'soundcloudaudio' - || providerId === 'sefonaudio' - || providerId === 'youtubeaudio'; - } - - function getStandaloneIosInitialResolveCount(providerId) { - providerId = normalizeAudioProviderId(providerId || getAudioProviderId()); - if (providerId === 'soundcloudaudio') return 2; - if (providerId === 'sefonaudio') return 2; - if (providerId === 'youtubeaudio') return 3; - return 0; - } - - function fetchAudioProviders(done) { - if (MUSIC_AUDIO_PROVIDERS && MUSIC_AUDIO_PROVIDERS.length) { - done(MUSIC_AUDIO_PROVIDERS); - return; - } - - if (MUSIC_AUDIO_PROVIDERS_PENDING) { - MUSIC_AUDIO_PROVIDER_WAITERS.push(done); - return; - } - - MUSIC_AUDIO_PROVIDERS_PENDING = true; - MUSIC_AUDIO_PROVIDER_WAITERS.push(done); - - request(MUSIC.endpoints.providers, function (json) { - var parsed = parseJson(json) || {}; - MUSIC_AUDIO_PROVIDERS = normalizeAudioProviders(parsed.audio); - if (!MUSIC_AUDIO_PROVIDERS.length) - MUSIC_AUDIO_PROVIDERS = normalizeAudioProviders(AUDIO_PROVIDER_FALLBACKS); - - var waiters = MUSIC_AUDIO_PROVIDER_WAITERS.slice(); - MUSIC_AUDIO_PROVIDER_WAITERS = []; - MUSIC_AUDIO_PROVIDERS_PENDING = false; - - waiters.forEach(function (callback) { - if (callback) callback(MUSIC_AUDIO_PROVIDERS); - }); - }, function () { - MUSIC_AUDIO_PROVIDERS = normalizeAudioProviders(AUDIO_PROVIDER_FALLBACKS); - - var waiters = MUSIC_AUDIO_PROVIDER_WAITERS.slice(); - MUSIC_AUDIO_PROVIDER_WAITERS = []; - MUSIC_AUDIO_PROVIDERS_PENDING = false; - - waiters.forEach(function (callback) { - if (callback) callback(MUSIC_AUDIO_PROVIDERS); - }); - }); - } - - function getPlaybackModeTitle(mode) { - mode = mode || getPlaybackMode(); - var found = PLAYBACK_MODE_ITEMS.filter(function (item) { - return item.mode === mode; - })[0]; - - return found ? found.title : 'Без видео'; - } - - function getStreamModeTitle(mode) { - mode = mode || getStreamMode(); - var found = STREAM_MODE_ITEMS.filter(function (item) { - return item.mode === mode; - })[0]; - - return found ? found.title : 'Авто'; - } - - function getQualityModeTitle(mode, playbackMode) { - mode = mode || getStreamMode(); - playbackMode = playbackMode || getPlaybackMode(); - - var found = getQualityMenuItems(playbackMode).filter(function (item) { - return item.mode === mode; - })[0]; - - return found ? found.title : getStreamModeTitle(mode); - } - - function updateFilterButton(button) { - if (!button || !button.length) return; - button.attr('data-mode', getStreamMode()); - button.attr('data-playback-mode', getPlaybackMode()); - button.attr('data-audio-provider', getAudioProviderId()); - button.attr('title', 'Фильтр · ' + getPlaybackModeTitle() + ' · ' + getAudioProviderTitle(getAudioProviderId()) + ' · ' + getQualityModeTitle()); - } - - function buildPlayUrl(track) { - return withIdentity(MUSIC.endpoints.play - + '?' + buildTrackRequestParams(track) - + '&audio_provider=' + encodeURIComponent(getAudioProviderId()) - + '&stream_mode=' + encodeURIComponent(getStreamMode()) - + '&playback_mode=' + encodeURIComponent(getPlaybackMode())); - } - - function buildStreamUrl(track) { - return withIdentity(MUSIC.endpoints.play.replace('/play', '/stream') - + '?' + buildTrackRequestParams(track) - + '&audio_provider=' + encodeURIComponent(getAudioProviderId()) - + '&stream_mode=' + encodeURIComponent(getStreamMode()) - + '&playback_mode=' + encodeURIComponent(getPlaybackMode())); - } - - function getEntityProviderId(entity) { - if (!entity) return ''; - if (entity.provider) return String(entity.provider).trim(); - - if (Array.isArray(entity.provider_refs)) { - for (var i = 0; i < entity.provider_refs.length; i++) { - var providerRef = entity.provider_refs[i]; - if (providerRef && providerRef.provider) return String(providerRef.provider).trim(); - } - } - - return ''; - } - - function getTrackProviderId(track) { - return getEntityProviderId(track); - } - - function extractYouTubeTrackId(track) { - if (!track) return ''; - - var id = normalizeYouTubeVideoId(track.id); - if (id) return id; - - if (Array.isArray(track.provider_refs)) { - for (var i = 0; i < track.provider_refs.length; i++) { - var providerRef = track.provider_refs[i]; - if (!providerRef || String(providerRef.provider || '').toLowerCase() !== 'youtubeaudio') continue; - - var externalId = normalizeYouTubeVideoId(providerRef.external_id); - if (externalId) return externalId; - } - } - - return ''; - } - - function normalizeYouTubeVideoId(id) { - id = String(id || '').trim(); - if (!id) return ''; - - if (/^youtube:/i.test(id)) - id = id.replace(/^youtube:/i, '').trim(); - - return /^[A-Za-z0-9_-]{6,32}$/.test(id) ? id : ''; - } - - function extractYouTubeMatchId(json) { - var match = json && json.selected_match; - if (!match || String(match.provider_id || '').toLowerCase() !== 'youtubeaudio') return ''; - - var id = normalizeYouTubeVideoId(match.id); - if (id) return id; - - if (match.payload) { - try { - var payload = typeof match.payload === 'string' ? JSON.parse(match.payload) : match.payload; - return normalizeYouTubeVideoId(payload && payload.video_id); - } catch (e) {} - } - - return ''; - } - - function extractResolvedYouTubeTrackId(track, json) { - return extractYouTubeMatchId(json) || extractYouTubeTrackId(track); - } - - function isDirectArtistEntity(artist) { - if (!artist) return false; - - var id = String(artist.id || ''); - var provider = getEntityProviderId(artist); - - return provider === 'youtubeaudio' - || provider === 'soundcloudcharts' - || provider === 'spotify' - || /^youtube:channel:/i.test(id) - || /^soundcloud:user:/i.test(id) - || /^spotify:artist:/i.test(id); - } - - function buildTrackRequestParams(track) { - var parts = []; - - if (track && track.id) parts.push('id=' + encodeURIComponent(track.id)); - if (track && getTrackProviderId(track)) parts.push('provider=' + encodeURIComponent(getTrackProviderId(track))); - if (track && track.title) parts.push('title=' + encodeURIComponent(track.title)); - if (track && track.artist_name) parts.push('artist_name=' + encodeURIComponent(track.artist_name)); - if (track && track.album_id) parts.push('album_id=' + encodeURIComponent(track.album_id)); - if (track && track.album_title) parts.push('album_title=' + encodeURIComponent(track.album_title)); - if (track && track.isrc) parts.push('isrc=' + encodeURIComponent(track.isrc)); - if (track && (track.duration_ms || track.duration_ms === 0)) parts.push('duration_ms=' + encodeURIComponent(track.duration_ms)); - if (track && track.date) parts.push('date=' + encodeURIComponent(track.date)); - - return parts.join('&'); - } - - function buildPlayCacheKey(track) { - return [ - buildTrackRequestParams(track), - 'audio_provider=' + getAudioProviderId(), - 'stream_mode=' + getStreamMode(), - 'playback_mode=' + getPlaybackMode() - ].join('&'); - } - - function getCachedPlayResponse(track) { - var key = buildPlayCacheKey(track); - var cached = PLAY_PREFETCH_CACHE[key]; - - if (!cached) return null; - if (cached.expires <= Date.now()) { - delete PLAY_PREFETCH_CACHE[key]; - return null; - } - - return cached.response || null; - } - - function saveCachedPlayResponse(track, response, cacheKey) { - if (!track || !response || !response.available || !response.sources || !response.sources.length) return; - - PLAY_PREFETCH_CACHE[cacheKey || buildPlayCacheKey(track)] = { - response: response, - expires: Date.now() + PLAY_PREFETCH_TTL - }; - } - - function invalidateTrackPlayCache(track) { - if (!track) return; - - var matchId = track.id ? String(track.id) : ''; - Object.keys(PLAY_PREFETCH_CACHE).forEach(function (key) { - if (matchId && ('&' + key + '&').indexOf('&id=' + encodeURIComponent(matchId) + '&') >= 0) - delete PLAY_PREFETCH_CACHE[key]; - }); - Object.keys(PLAY_PREFETCH_PENDING).forEach(function (key) { - if (matchId && ('&' + key + '&').indexOf('&id=' + encodeURIComponent(matchId) + '&') >= 0) { - var callbacks = PLAY_PREFETCH_PENDING[key]; - delete PLAY_PREFETCH_PENDING[key]; - setTimeout(function () { - callbacks.forEach(function (cb) { - if (cb.fail) cb.fail(null); - }); - }, 0); - } - }); - } - - function schedulePlayPrefetch(track) { - if (!track || !track.title) return; - if (!supportsPlayPrefetch()) return; - - if (PLAY_PREFETCH_TIMER) { - clearTimeout(PLAY_PREFETCH_TIMER); - PLAY_PREFETCH_TIMER = 0; - } - - PLAY_PREFETCH_TIMER = setTimeout(function () { - PLAY_PREFETCH_TIMER = 0; - requestPlay(track, function () {}, function () {}); - }, 180); - } - - // Прифетч резолва СЛЕДУЮЩЕГО трека очереди во время воспроизведения текущего: - // к моменту next ответ /music/play уже лежит в PLAY_PREFETCH_CACHE, и - // переключение не ждёт холодный YouTube-матч. Таймер свой, НЕ - // schedulePlayPrefetch — у того hover-семантика с общим 180ms-таймером - // («грей последнее, на чём фокус»), два потребителя молча отменяли бы друг - // друга. Гейт поколения: очередь перечитывается в момент срабатывания, - // устаревший таймер выходит по несовпадению currentTrackId. - function scheduleNextTrackPrefetch(trackId) { - if (!trackId || !supportsPlayPrefetch()) return; - if (MUSIC_NEXT_PREFETCH_FOR === trackId) return; - - MUSIC_NEXT_PREFETCH_FOR = trackId; - - if (MUSIC_NEXT_PREFETCH_TIMER) { - clearTimeout(MUSIC_NEXT_PREFETCH_TIMER); - MUSIC_NEXT_PREFETCH_TIMER = 0; - } - - MUSIC_NEXT_PREFETCH_TIMER = setTimeout(function () { - MUSIC_NEXT_PREFETCH_TIMER = 0; - - // у standalone iOS свой background warm всей очереди - if (isStandaloneIosAudioActive()) return; - if (MUSIC_QUEUE.currentTrackId !== trackId) return; - if (getStandaloneIosRepeatMode() === 'one') return; - - var tracks = queueTracks(); - if (tracks.length < 2) return; - - var index = queueCurrentIndex(); - if (index < 0) return; - - // порядок MUSIC_QUEUE.tracks — уже порядок воспроизведения - // (shuffle запечён при сборке очереди), index+1 корректен - var next = index + 1 < tracks.length - ? tracks[index + 1] - : (getStandaloneIosRepeatMode() === 'all' ? tracks[0] : null); - - if (!next || !next.title) return; - - requestPlay(next, function () {}, function () {}); - }, 3000); - } - - // Прифетч стартового окна при открытии экрана альбома/плейлиста: пока - // пользователь разглядывает hero-шапку, холодный /music/play (5-7s даже - // после batch-parallel поиска) греется в фоне — «Слушать» стартует с - // тёплым резолвом. Ничего не запускает; isStale — гейт активности экрана - // (destroyed компонента). Обычные плееры: только первый трек. Standalone - // iOS: первые getStandaloneIosInitialResolveCount треков — запуск там - // ЖДЁТ жадный резолв этого окна (лок-скрин должен уметь next без сети - // из hidden-страницы), греем ровно его; треки идут ПОСЛЕДОВАТЕЛЬНО - // (без бёрста), жадная фаза запуска бьёт в кэш или single-flight - function scheduleFirstTrackPrefetch(tracks, isStale) { - if (!Array.isArray(tracks) || !tracks.length || !supportsPlayPrefetch()) return; - - var count = shouldUseStandaloneIosAudio() - ? Math.min(tracks.length, Math.max(1, getStandaloneIosInitialResolveCount(getAudioProviderId()))) - : 1; - var index = 0; - - function warmNext() { - if (isStale && isStale()) return; - if (index >= count) return; - - var track = tracks[index++]; - if (!track || !track.title) { - warmNext(); - return; - } - - requestPlay(track, function () { - setTimeout(warmNext, 400); - }, function () { - setTimeout(warmNext, 400); - }); - } - - setTimeout(warmNext, 1000); - } - - function getQualityMenuItems(playbackMode) { - playbackMode = playbackMode || getPlaybackMode(); - - return STREAM_MODE_ITEMS.map(function (item) { - if (playbackMode === 'video' && item.mode === 'm4a') - return { - mode: item.mode, - title: 'Предпочитать mp4' - }; - - return item; - }); - } - - function openPlaybackModeMenu(button, focusContext) { - var currentMode = getPlaybackMode(); - Lampa.Select.show({ - title: 'Режим воспроизведения', - items: PLAYBACK_MODE_ITEMS.map(function (item) { - return { - title: item.title, - mode: item.mode, - selected: item.mode === currentMode - }; - }), - onBack: function () { - openFilterMenu(button, focusContext); - }, - onSelect: function (selected) { - if (!selected || !selected.mode) return; - setPlaybackMode(selected.mode); - ensureAudioProviderCompatibility(selected.mode); - updateFilterButton(button); - Lampa.Noty.show('Режим: ' + getPlaybackModeTitle(selected.mode)); - restoreMusicFocusContext(focusContext, 120); - } - }); - } - - function openAudioProviderMenu(button, focusContext) { - fetchAudioProviders(function (providers) { - var playbackMode = getPlaybackMode(); - var items = normalizeAudioProviders(providers); - var currentId = getAudioProviderId(items); - - if (!items.length) { - Lampa.Noty.show('Подходящих провайдеров нет.'); - restoreMusicFocusContext(focusContext, 120); - return; - } - - Lampa.Select.show({ - title: 'Источник звука', - items: items.map(function (item) { - return { - title: item.name, - subtitle: getAudioProviderAvailabilitySubtitle(item, playbackMode), - provider_id: item.id, - selected: item.id === currentId, - disabled: !providerSupportsPlaybackMode(item, playbackMode) - }; - }), - onBack: function () { - openFilterMenu(button, focusContext); - }, - onSelect: function (selected) { - if (!selected || !selected.provider_id) return; - if (selected.disabled) { - Lampa.Noty.show(selected.subtitle || 'Провайдер недоступен в этом режиме.'); - restoreMusicFocusContext(focusContext, 120); - return; - } - setAudioProviderId(selected.provider_id); - updateFilterButton(button); - Lampa.Noty.show('Источник: ' + getAudioProviderTitle(selected.provider_id, items)); - restoreMusicFocusContext(focusContext, 120); - } - }); - }); - } - - function openStreamModeMenu(button, focusContext) { - var currentMode = getStreamMode(); - var playbackMode = getPlaybackMode(); - Lampa.Select.show({ - title: 'Качество', - items: getQualityMenuItems(playbackMode).map(function (item) { - return { - title: item.title, - mode: item.mode, - selected: item.mode === currentMode - }; - }), - onBack: function () { - openFilterMenu(button, focusContext); - }, - onSelect: function (selected) { - if (!selected || !selected.mode) return; - setStreamMode(selected.mode); - updateFilterButton(button); - Lampa.Noty.show('Качество: ' + getQualityModeTitle(selected.mode)); - restoreMusicFocusContext(focusContext, 120); - } - }); - } - - function resetStatsTopClientState() { - MUSIC_STATS_TOP.summary = null; - MUSIC_STATS_TOP.checkedAt = 0; - } - - // salt «Миксов недели»: счётчик в Storage, уходит параметром на сервер и - // меняет seed раскладки — «сброс» пересобирает неделю без серверного state - function getDailyMixSalt() { - return Number(Lampa.Storage.get(MUSIC.storage.daily_salt, 0)) || 0; - } - - function withDailyMixSalt(url) { - var salt = getDailyMixSalt(); - if (!salt) return url; - return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'daily_salt=' + salt; - } - - function resetDailyMixesWithConfirm(focusContext) { - focusContext = focusContext || captureMusicFocusContext('content'); - - showContextMenu('Обновить миксы недели?', [ - { - title: 'Собрать заново', - subtitle: 'Новые артисты и треки до следующего понедельника', - action: 'confirm_reset_daily' - }, - { title: 'Отмена', action: 'cancel' } - ], function (selected) { - if (!selected || selected.action !== 'confirm_reset_daily') { - restoreMusicFocusContext(focusContext, 120); - return; - } - - Lampa.Storage.set(MUSIC.storage.daily_salt, getDailyMixSalt() + 1); - Lampa.Noty.show('Миксы недели пересобраны.'); - emitRecentChanged('daily_mixes', { - restoreState: { sectionKey: 'daily_mixes', entryIndex: 0 } - }); - restoreMusicFocusContext(focusContext, 180); - }, focusContext); - } - - function clearStatsTopWithConfirm(focusContext) { - focusContext = focusContext || captureMusicFocusContext('content'); - - showContextMenu('Очистить статистику?', [ - { - title: 'Очистить «Твой топ»', - subtitle: 'Недавно слушали и плейлисты останутся', - action: 'confirm_clear_stats' - }, - { title: 'Отмена', action: 'cancel' } - ], function (selected) { - if (!selected || selected.action !== 'confirm_clear_stats') { - restoreMusicFocusContext(focusContext, 120); - return; - } - - requestPost(MUSIC.endpoints.statsClear, '', function (json) { - if (!json || !json.cleared) { - Lampa.Noty.show('Не удалось очистить статистику.'); - restoreMusicFocusContext(focusContext, 120); - return; - } - - resetStatsTopClientState(); - Lampa.Noty.show('Статистика очищена.'); - emitRecentChanged('user_playlists', { - restoreState: { sectionKey: 'user_playlists', entryIndex: 0 } - }); - restoreMusicFocusContext(focusContext, 180); - }, function () { - Lampa.Noty.show('Не удалось очистить статистику.'); - restoreMusicFocusContext(focusContext, 120); - }); - }, focusContext); - } - - function openFilterMenu(button, focusContext) { - focusContext = focusContext || captureMusicFocusContext('content'); - - var currentMode = getStreamMode(); - var playbackMode = getPlaybackMode(); - var items = [ - { - title: 'Режим воспроизведения', - subtitle: getPlaybackModeTitle(playbackMode), - action: 'playback_mode' - }, - { - title: 'Источник звука', - subtitle: getAudioProviderTitle(getAudioProviderId()), - action: 'audio_provider' - }, - { - title: 'Качество', - subtitle: getQualityModeTitle(currentMode, playbackMode), - action: 'stream_mode' - }, - { - title: 'Восстановление очереди', - subtitle: isQueueRestoreEnabled() ? 'Включено' : 'Выключено', - action: 'queue_restore' - }, - { - title: 'Автоподборка треков', - subtitle: isRadioAutoplayEnabled() ? 'Включено' : 'Выключено', - action: 'radio_autoplay' - } - ]; - - if (MUSIC_DAILY_RESET_ENABLED) { - items.push({ - title: 'Обновить миксы недели', - subtitle: 'Собрать новую подборку', - action: 'reset_daily' - }); - } - - if (MUSIC_STATS_CLEAR_ENABLED) { - items.push({ - title: 'Очистить статистику', - subtitle: 'Сбросить «Твой топ»', - action: 'clear_stats' - }); - } - - Lampa.Select.show({ - title: 'Фильтр', - items: items, - onBack: function () { - restoreMusicFocusContext(focusContext, 0); - }, - onSelect: function (selected) { - if (!selected || !selected.action) { - restoreMusicFocusContext(focusContext); - return; - } - - if (selected.action === 'playback_mode') { - openPlaybackModeMenu(button, focusContext); - return; - } - - if (selected.action === 'audio_provider') { - openAudioProviderMenu(button, focusContext); - return; - } - - if (selected.action === 'stream_mode') { - openStreamModeMenu(button, focusContext); - return; - } - - if (selected.action === 'queue_restore') { - var enabled = !isQueueRestoreEnabled(); - setQueueRestoreEnabled(enabled); - Lampa.Noty.show(enabled ? 'Восстановление очереди включено.' : 'Восстановление очереди выключено.'); - restoreMusicFocusContext(focusContext, 120); - return; - } - - if (selected.action === 'radio_autoplay') { - var radioEnabled = !isRadioAutoplayEnabled(); - setRadioAutoplayEnabled(radioEnabled); - Lampa.Noty.show(radioEnabled ? 'Автоподборка треков включена.' : 'Автоподборка треков выключена.'); - restoreMusicFocusContext(focusContext, 120); - return; - } - - if (selected.action === 'reset_daily') { - resetDailyMixesWithConfirm(focusContext); - return; - } - - if (selected.action === 'clear_stats') { - clearStatsTopWithConfirm(focusContext); - } - } - }); - } - - // ===== ARTWORK / LOCAL STATE ===== - - function artworkHash(value) { - var hash = 2166136261; - value = String(value || ''); - - for (var i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); - } - - return hash >>> 0; - } - - function artworkPalette(kind, title, subtitle, colors) { - var palettes = [ - { base: '#11161d', deep: '#2f4651', accent: '#83c7bd', accent2: '#e4b86e', line: '#f0f4ef' }, - { base: '#161822', deep: '#4b3448', accent: '#d9a56b', accent2: '#88c7d0', line: '#f8efe5' }, - { base: '#101820', deep: '#3a574b', accent: '#bdd27b', accent2: '#e4a56f', line: '#eef3df' }, - { base: '#17151c', deep: '#35435e', accent: '#9eb7e5', accent2: '#e2c278', line: '#edf1fb' }, - { base: '#151a18', deep: '#533d37', accent: '#e0a77c', accent2: '#83c5c0', line: '#f5eee8' }, - { base: '#121922', deep: '#3c4f3f', accent: '#d4d889', accent2: '#8fc3de', line: '#f2f3e7' } - ]; - var selected = palettes[artworkHash(kind + '|' + title + '|' + subtitle) % palettes.length]; - - return { - base: colors && colors[0] ? colors[0] : selected.base, - deep: colors && colors[1] ? colors[1] : selected.deep, - accent: selected.accent, - accent2: selected.accent2, - line: selected.line - }; - } - - function artworkSymbol(kind, palette) { - if (kind === 'artist') { - return [ - '<g transform="translate(250 250)">', - '<circle cx="0" cy="0" r="122" fill="' + palette.line + '" opacity="0.10"/>', - '<circle cx="0" cy="-36" r="54" fill="' + palette.line + '" opacity="0.76"/>', - '<path d="M-96 110c15-54 52-82 96-82s81 28 96 82" fill="' + palette.line + '" opacity="0.54"/>', - '</g>' - ].join(''); - } - - if (kind === 'track') { - return [ - '<g transform="translate(250 250)">', - '<circle cx="0" cy="0" r="126" fill="' + palette.line + '" opacity="0.08"/>', - '<path d="M36-100v142c-16-10-38-12-58-5-34 11-52 40-42 65s45 35 79 24c28-9 45-31 45-57V-38l92-18v-58L36-100Z" fill="' + palette.line + '" opacity="0.82"/>', - '</g>' - ].join(''); - } - - if (kind === 'playlist' || kind === 'playlist_add') { - return [ - '<g transform="translate(250 250)">', - '<rect x="-108" y="-112" width="216" height="224" rx="30" fill="' + palette.line + '" opacity="0.10" stroke="' + palette.line + '" stroke-opacity="0.16" stroke-width="2"/>', - kind === 'playlist_add' - ? '<path d="M0-58v116m-58-58h116" stroke="' + palette.line + '" stroke-width="20" stroke-linecap="round" opacity="0.82"/>' - : '<path d="M-46-58h112M-46 0h112M-46 58h78" stroke="' + palette.line + '" stroke-width="18" stroke-linecap="round" opacity="0.76"/><circle cx="-74" cy="-58" r="9" fill="' + palette.accent2 + '" opacity="0.82"/><circle cx="-74" cy="0" r="9" fill="' + palette.accent2 + '" opacity="0.64"/><circle cx="-74" cy="58" r="9" fill="' + palette.accent2 + '" opacity="0.48"/>', - '</g>' - ].join(''); - } - - return [ - '<g transform="translate(250 252)">', - '<circle cx="58" cy="-2" r="116" fill="#05070a" opacity="0.34"/>', - '<circle cx="58" cy="-2" r="40" fill="' + palette.accent2 + '" opacity="0.54"/>', - '<rect x="-136" y="-132" width="212" height="212" rx="28" fill="' + palette.line + '" opacity="0.16" stroke="' + palette.line + '" stroke-opacity="0.16" stroke-width="2"/>', - '<path d="M-30-70v100c-14-8-32-10-49-4-27 9-42 33-34 53 9 20 38 28 65 19 23-8 37-26 37-46V-14l70-14v-48L-30-70Z" fill="' + palette.line + '" opacity="0.74"/>', - '</g>' - ].join(''); - } - - function artwork(kind, title, subtitle, colors) { - kind = kind || 'album'; - var palette = artworkPalette(kind, title, subtitle, colors); - var svg = [ - '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">', - '<defs>', - '<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">', - '<stop offset="0%" stop-color="' + palette.base + '"/>', - '<stop offset="100%" stop-color="' + palette.deep + '"/>', - '</linearGradient>', - '</defs>', - '<rect width="500" height="500" rx="28" fill="url(#bg)"/>', - '<circle cx="410" cy="86" r="150" fill="' + palette.accent + '" opacity="0.16"/>', - '<circle cx="76" cy="424" r="166" fill="' + palette.accent2 + '" opacity="0.10"/>', - '<rect x="18" y="18" width="464" height="464" rx="24" fill="none" stroke="' + palette.line + '" stroke-opacity="0.12" stroke-width="2"/>', - artworkSymbol(kind, palette), - '</svg>' - ].join(''); - - return encodeSvg(svg); - } - - function selectSizedImageEntry(images, desiredSize) { - if (!images || !images.length) return null; - - if (!desiredSize) - return images[0] && images[0].url ? images[0] : null; - - var filtered = images.filter(function (item) { - return item && item.url; - }); - - if (!filtered.length) return null; - - filtered.sort(function (a, b) { - var aw = a.width || 0; - var bw = b.width || 0; - return aw - bw; - }); - - for (var i = 0; i < filtered.length; i++) { - var width = filtered[i].width || 0; - if (width >= desiredSize) return filtered[i]; - } - - return filtered[filtered.length - 1]; - } - - function selectSizedImage(images, desiredSize) { - var entry = selectSizedImageEntry(images, desiredSize); - return entry && entry.url ? entry.url : ''; - } - - function artistImage(artist, desiredSize) { - var remote = ''; - - if (artist && artist.image) { - remote = artist.image; - } - else if (artist && artist.images) { - var selected = selectSizedImageEntry(artist.images, desiredSize); - - if (selected && (selected.width || 0) === 250) { - var fallback = artist.images - .filter(function (item) { - return item && item.url && (item.width || 0) > 250; - }) - .sort(function (a, b) { - return (a.width || 0) - (b.width || 0); - })[0]; - - if (fallback) selected = fallback; - } - - remote = selected && selected.url ? selected.url : ''; - } - - return remote || artwork('artist', artist.name, artist.country || artist.sort_name || 'Artist', ['#172432', '#356181']); - } - - function artistHasRemoteImage(artist) { - if (!artist || !artist.images || !artist.images.length) return false; - - var url = selectSizedImage(artist.images, 150); - return !!(url && !/^data:/i.test(url)); - } - - function artistImageKey(artist) { - return [ - artist && artist.id ? artist.id : '', - artist && artist.name ? artist.name : '', - artist && artist.country ? artist.country : '' - ].join('|').trim().toLowerCase(); - } - - function buildArtistImageUrl(artist) { - var url = MUSIC.endpoints.artistImage; - - if (artist && artist.id) - url = Lampa.Utils.addUrlComponent(url, 'id=' + encodeURIComponent(artist.id)); - - if (artist && artist.name) - url = Lampa.Utils.addUrlComponent(url, 'name=' + encodeURIComponent(artist.name)); - - if (artist && artist.country) - url = Lampa.Utils.addUrlComponent(url, 'country=' + encodeURIComponent(artist.country)); - - return url; - } - - function flushArtistImageQueue(key, images) { - ARTIST_IMAGE_CACHE[key] = images || []; - - var state = ARTIST_IMAGE_PENDING[key]; - delete ARTIST_IMAGE_PENDING[key]; - - if (!state || !state.callbacks) return; - - state.callbacks.forEach(function (callback) { - if (callback) callback(images || []); - }); - } - - function pumpArtistImageQueue() { - while (ARTIST_IMAGE_ACTIVE < ARTIST_IMAGE_CONCURRENCY && ARTIST_IMAGE_QUEUE.length) { - var key = ARTIST_IMAGE_QUEUE.shift(); - var state = ARTIST_IMAGE_PENDING[key]; - - if (!state || !state.artist) continue; - - ARTIST_IMAGE_ACTIVE++; - - request(buildArtistImageUrl(state.artist), function (json) { - var parsed = parseJson(json) || {}; - var images = Array.isArray(parsed.images) ? parsed.images : []; - - flushArtistImageQueue(key, images); - ARTIST_IMAGE_ACTIVE = Math.max(0, ARTIST_IMAGE_ACTIVE - 1); - pumpArtistImageQueue(); - }, function () { - flushArtistImageQueue(key, []); - ARTIST_IMAGE_ACTIVE = Math.max(0, ARTIST_IMAGE_ACTIVE - 1); - pumpArtistImageQueue(); - }); - } - } - - function requestArtistImage(artist, callback) { - if (!artist || !artist.name) { - if (callback) callback([]); - return; - } - - if (artistHasRemoteImage(artist)) { - if (callback) callback(artist.images || []); - return; - } - - var key = artistImageKey(artist); - if (!key) { - if (callback) callback([]); - return; - } - - if (Object.prototype.hasOwnProperty.call(ARTIST_IMAGE_CACHE, key)) { - if (callback) callback(ARTIST_IMAGE_CACHE[key] || []); - return; - } - - if (ARTIST_IMAGE_PENDING[key]) { - if (callback) ARTIST_IMAGE_PENDING[key].callbacks.push(callback); - return; - } - - ARTIST_IMAGE_PENDING[key] = { - artist: artist, - callbacks: callback ? [callback] : [] - }; - - ARTIST_IMAGE_QUEUE.push(key); - pumpArtistImageQueue(); - } - - function albumImage(album, size) { - var remote = album && album.image - ? album.image - : album && album.images ? selectSizedImage(album.images, size || 250) : ''; - - return remote || artwork('album', album.title, album.artist_name || 'Album', ['#20263a', '#475a7b']); - } - - function trackImage(track) { - var remote = track && track.image - ? track.image - : track && track.images ? selectSizedImage(track.images, 250) : ''; - - return remote || artwork('track', track.title, track.artist_name || track.album_title || 'Track', ['#1d2128', '#46505e']); - } - - // Дебаунс фона: setBackground зовётся с каждого hover:focus карточек и - // строк — при листании пультом это десятки смен большого фонового - // изображения подряд (главный подозреваемый в микрофризах навигации на - // Android TV). Схема: первый вызов после покоя применяется сразу (leading), - // частые последующие схлопываются в один trailing-апдейт ~180ms - // (последний запрошенный URL выигрывает), повторный URL не применяется. - var MUSIC_BACKGROUND_URL = ''; - var MUSIC_BACKGROUND_TIMER = 0; - var MUSIC_BACKGROUND_APPLIED_AT = 0; - - function setBackground(url) { - url = url || IMG_BG; - if (url === MUSIC_BACKGROUND_URL) return; - - MUSIC_BACKGROUND_URL = url; - - var now = Date.now(); - if (!MUSIC_BACKGROUND_TIMER && now - MUSIC_BACKGROUND_APPLIED_AT >= 180) { - MUSIC_BACKGROUND_APPLIED_AT = now; - Lampa.Background.change(url); - return; - } - - if (MUSIC_BACKGROUND_TIMER) return; - - MUSIC_BACKGROUND_TIMER = setTimeout(function () { - MUSIC_BACKGROUND_TIMER = 0; - MUSIC_BACKGROUND_APPLIED_AT = Date.now(); - Lampa.Background.change(MUSIC_BACKGROUND_URL); - }, 180); - } - - function emitRecentChanged(sectionKey, payload) { - // History is saved during playback; rebuilding shelves must wait until the player yields focus. - if (isStandaloneIosPlayerForeground()) { - MUSIC_DEFERRED_HOME_REFRESH = true; - return; - } - - if (isLampaPlayerOverlayOpen()) { - MUSIC_DEFERRED_HOME_REFRESH = true; - traceEmbeddedIos('recent-event-deferred', sectionKey || '', true); - ensureLampaPlayerController('recent-event-deferred'); - return; - } - - window.dispatchEvent(new CustomEvent(MUSIC_RECENT_EVENT, { - detail: { - section_key: sectionKey, - payload: payload || null - } - })); - } - - function isStandaloneIosPlayerForeground() { - return MUSIC_IOS_FULL_PLAYER_OPEN - || !!(MUSIC_IOS_BAR && MUSIC_IOS_BAR.hasClass('lm-ios-player--visible') - && safeControllerName() === 'lampac_music_player_bar'); - } - - function flushStandaloneIosDeferredHomeRefresh() { - if (!MUSIC_DEFERRED_HOME_REFRESH || isStandaloneIosPlayerForeground() || isLampaPlayerOverlayOpen()) return; - - var controller = safeControllerName(); - if (controller !== 'content' && controller !== 'items_line') return; - - var active = Lampa.Activity.active(); - if (!active || active.component !== 'lampac_music_home') return; - - // Controller.toggle restores navigation only; Activity.toggle also consumes the pending home refresh. - if (active.activity && typeof active.activity.toggle === 'function') active.activity.toggle(); - } - - function isLampaPlayerOverlayOpen() { - try { - return !!(Lampa.Player && typeof Lampa.Player.opened === 'function' && Lampa.Player.opened()); - } catch (e) { - return false; - } - } - - function getLampaControllerName() { - try { - var enabled = Lampa.Controller && Lampa.Controller.enabled ? Lampa.Controller.enabled() : null; - return enabled && enabled.name ? String(enabled.name) : ''; - } catch (e) { - return ''; - } - } - - function isLampaPlayerControllerName(name) { - name = String(name || ''); - return name.indexOf('player') === 0; - } - - function shouldSkipPlayerControllerRestore(name) { - name = String(name || ''); - return name === 'select' || name === 'modal' || name === 'keyboard' || name.indexOf('settings') === 0; - } - - function ensureLampaPlayerController(origin) { - if (!isLampaPlayerOverlayOpen()) return false; - - var data = activePlayerData(); - if (!data || !data.from_music_cluster) return false; - - var name = getLampaControllerName(); - if (isLampaPlayerControllerName(name) || shouldSkipPlayerControllerRestore(name)) - return false; - - var now = Date.now(); - if (now - MUSIC_LAST_PLAYER_CONTROLLER_RESTORE_AT < 500) - return false; - - MUSIC_LAST_PLAYER_CONTROLLER_RESTORE_AT = now; - - try { - if (Lampa.Controller && typeof Lampa.Controller.toggle === 'function') { - Lampa.Controller.toggle('player'); - traceEmbeddedIos('controller-restored', (origin || '') + ' from=' + name, true); - return true; - } - } catch (e) { - traceEmbeddedIos('controller-restore-error', describeError(e), true); - } - - return false; - } - - function bindRecentListener(handler) { - if (!handler) return function () {}; - window.addEventListener(MUSIC_RECENT_EVENT, handler); - return function () { - window.removeEventListener(MUSIC_RECENT_EVENT, handler); - }; - } - - function notifyPlaylistChanged(playlistId, options) { - options = options || {}; - playlistId = String(playlistId || '').trim(); - - if (!options.preserveHomeCache) { - MUSIC_HOME_CACHE.user_playlists = []; - MUSIC_HOME_SECTION_META.user_playlists = { has_more: false }; - } - - if (!options.skipHomeSection) { - emitRecentChanged('user_playlists', { - playlist_id: playlistId, - restoreState: options.restoreState || null, - skipActiveSectionRefresh: options.skipActiveSectionRefresh === true - }); - } - - if (playlistId && !options.skipPlaylistSection) - emitRecentChanged('playlist:' + playlistId, { playlist_id: playlistId }); - } - - function cleanRecentAlbumArtist(value) { - value = String(value || '').trim(); - if (!value) return ''; - - return value - .replace(/\s*[·•]\s*\d{4}.*$/i, '') - .replace(/\s*[·•]\s*album.*$/i, '') - .trim(); - } - - function recentAlbumIdentity(album) { - if (!album) return ''; - - var title = normalizeText(album.title || album.album_title || ''); - var artist = normalizeText(album.artist_name || album.artist || album.album_artist || ''); - - if (!artist && album.subtitle) - artist = normalizeText(cleanRecentAlbumArtist(album.subtitle)); - - if (title && artist) - return 'album:' + artist + '|' + title; - - return album.id ? 'id:' + String(album.id) : ''; - } - - function recentArtistIdentity(artist) { - if (!artist) return ''; - - var name = normalizeText(artist.name || artist.title || artist.artist_name || artist.sort_name || ''); - - if (name) - return 'artist:' + name; - - return artist.id ? 'id:' + String(artist.id) : ''; - } - - function homeCacheEntryIdentity(sectionKey, entry) { - if (!entry) return ''; - - if (sectionKey === 'recent_albums') - return recentAlbumIdentity(entry.raw || entry); - - if (sectionKey === 'recent_artists') - return recentArtistIdentity(entry.raw || entry); - - return entry.id ? 'id:' + String(entry.id) : ''; - } - - function recentEntityIdentity(key, entity) { - if (!entity) return ''; - - if (key === MUSIC.storage.recent_albums) - return recentAlbumIdentity(entity); - - if (key === MUSIC.storage.recent_artists) - return recentArtistIdentity(entity); - - return entity.id ? 'id:' + String(entity.id) : ''; - } - - function dedupeRecentEntities(key, list, limit) { - var seen = {}; - var result = []; - - (Array.isArray(list) ? list : []).forEach(function (item) { - var identity = recentEntityIdentity(key, item); - if (!identity || seen[identity]) return; - - seen[identity] = true; - result.push(item); - }); - - return result.slice(0, limit || RECENT_SECTION_STORAGE_LIMIT); - } - - function touchHomeCacheEntry(sectionKey, entry, limit) { - if (!sectionKey || !entry || !entry.id) return; - - var identity = homeCacheEntryIdentity(sectionKey, entry); - if (!identity) return; - - var list = Array.isArray(MUSIC_HOME_CACHE[sectionKey]) ? MUSIC_HOME_CACHE[sectionKey].slice() : []; - list = list.filter(function (item) { - return item && homeCacheEntryIdentity(sectionKey, item) !== identity; - }); - list.unshift(entry); - MUSIC_HOME_CACHE[sectionKey] = list.slice(0, limit || RECENT_SECTION_STORAGE_LIMIT); - } - - function updateHomeSectionMetaFromCache(sectionKey) { - if (!sectionKey) return; - - var entries = Array.isArray(MUSIC_HOME_CACHE[sectionKey]) ? MUSIC_HOME_CACHE[sectionKey] : []; - MUSIC_HOME_SECTION_META[sectionKey] = { - has_more: entries.length > HOME_SECTION_LIMIT - }; - } - - function hasHomeCacheEntry(sectionKey, entryId) { - if (!sectionKey || !entryId || !Array.isArray(MUSIC_HOME_CACHE[sectionKey])) return false; - - return MUSIC_HOME_CACHE[sectionKey].some(function (item) { - return item && item.id === entryId; - }); - } - - function removeHomeCacheEntry(sectionKey, entityOrId) { - if (!sectionKey || !entityOrId || !Array.isArray(MUSIC_HOME_CACHE[sectionKey])) return; - - var targetId = typeof entityOrId === 'object' ? entityOrId.id : entityOrId; - var targetIdentity = typeof entityOrId === 'object' ? homeCacheEntryIdentity(sectionKey, entityOrId) : ''; - - MUSIC_HOME_CACHE[sectionKey] = MUSIC_HOME_CACHE[sectionKey].filter(function (item) { - if (!item) return false; - if (targetIdentity && homeCacheEntryIdentity(sectionKey, item) === targetIdentity) return false; - return item.id !== targetId; - }); - } - - function normalizeRecentQueryItem(item) { - if (!item) return null; - - if (typeof item === 'string') { - var value = String(item || '').trim(); - return value ? { query: value, artist: null } : null; - } - - if (typeof item === 'object') { - var query = String(item.query || item.title || '').trim(); - if (!query) return null; - - return { - query: query, - artist: item.artist || null - }; - } - - return null; - } - - function snapshotRecentQueryArtist(artist) { - if (!artist || !artist.id) return null; - - return { - id: artist.id, - name: artist.name || '', - sort_name: artist.sort_name || '', - country: artist.country || '', - images: Array.isArray(artist.images) ? artist.images.slice() : [] - }; - } - - function updateRecentQueriesCache(emitPayload) { - var queries = getRecentQueries(); - Lampa.Storage.set(MUSIC.storage.recent_queries, queries.slice(0, RECENT_SECTION_STORAGE_LIMIT)); - MUSIC_HOME_CACHE.recent_queries = applySectionKey(queries.map(mapQueryCard), 'recent_queries'); - MUSIC_HOME_SECTION_META.recent_queries = { has_more: queries.length > HOME_SECTION_LIMIT }; - - if (typeof emitPayload !== 'undefined') - emitRecentChanged('recent_queries', emitPayload); - } - - function saveLastQuery(query) { - query = String(query || '').trim(); - if (!query) return; - - Lampa.Storage.set(MUSIC.storage.last_query, query); - - var existing = null; - var list = getRecentQueries().filter(function (item) { - if (!item) return false; - - if (item.query.toLowerCase() === String(query).toLowerCase()) { - existing = item; - return false; - } - - return true; - }); - - list.unshift({ - query: query, - artist: existing && existing.artist ? existing.artist : null - }); - - Lampa.Storage.set(MUSIC.storage.recent_queries, list.slice(0, RECENT_SECTION_STORAGE_LIMIT)); - updateRecentQueriesCache(query); - } - - function clearRecentQueries() { - Lampa.Storage.set(MUSIC.storage.recent_queries, []); - MUSIC_HOME_CACHE.recent_queries = []; - MUSIC_HOME_SECTION_META.recent_queries = { has_more: false }; - emitRecentChanged('recent_queries', null); - } - - function removeRecentQuery(query, options) { - options = options || {}; - query = String(query || '').trim(); - if (!query) return false; - - var list = getRecentQueries(); - var next = list.filter(function (item) { - return item && item.query.toLowerCase() !== query.toLowerCase(); - }); - - if (next.length === list.length) return false; - - Lampa.Storage.set(MUSIC.storage.recent_queries, next); - MUSIC_HOME_CACHE.recent_queries = applySectionKey(next.map(mapQueryCard), 'recent_queries'); - MUSIC_HOME_SECTION_META.recent_queries = { has_more: next.length > HOME_SECTION_LIMIT }; - if (!options.skipEvent) - emitRecentChanged('recent_queries', query); - return true; - } - - function removeRecentEntity(key, entityOrId, options) { - options = options || {}; - var entityId = typeof entityOrId === 'object' ? entityOrId.id : entityOrId; - if (!key || !entityId) return false; - - var list = Lampa.Storage.get(key, []); - list = Array.isArray(list) ? list : []; - var targetIdentity = typeof entityOrId === 'object' ? recentEntityIdentity(key, entityOrId) : ''; - - var next = list.filter(function (item) { - if (!item) return false; - if (targetIdentity && recentEntityIdentity(key, item) === targetIdentity) return false; - return item.id !== entityId; - }); - - Lampa.Storage.set(key, next); - var sectionKey = key === MUSIC.storage.recent_albums - ? 'recent_albums' - : key === MUSIC.storage.recent_artists - ? 'recent_artists' - : ''; - - if (sectionKey) { - removeHomeCacheEntry(sectionKey, entityOrId); - MUSIC_HOME_SECTION_META[sectionKey] = { - has_more: next.length > HOME_SECTION_LIMIT - }; - if (!options.skipEvent) - emitRecentChanged(sectionKey, entityId); - } - - return next.length !== list.length; - } - - function getLastQuery() { - return Lampa.Storage.get(MUSIC.storage.last_query, 'Nirvana'); - } - - function getRecentQueries() { - var list = Lampa.Storage.get(MUSIC.storage.recent_queries, []); - return Array.isArray(list) ? list.map(normalizeRecentQueryItem).filter(Boolean).slice(0, RECENT_SECTION_STORAGE_LIMIT) : []; - } - - function updateRecentQueryArtist(query, artist, emitChange) { - query = String(query || '').trim(); - if (!query || !artist || !artist.id) return false; - - var changed = false; - var next = getRecentQueries().map(function (item) { - if (!item || item.query.toLowerCase() !== query.toLowerCase()) return item; - - var snapshot = snapshotRecentQueryArtist(artist); - if (!snapshot) return item; - - var prevKey = JSON.stringify(item.artist || null); - var nextKey = JSON.stringify(snapshot); - if (prevKey === nextKey) return item; - - changed = true; - - return { - query: item.query, - artist: snapshot - }; - }); - - if (!changed) return false; - - Lampa.Storage.set(MUSIC.storage.recent_queries, next.slice(0, RECENT_SECTION_STORAGE_LIMIT)); - updateRecentQueriesCache(emitChange === false ? undefined : query); - return true; - } - - function openSearchQuery(query) { - query = String(query || '').trim(); - if (!query) return; - - saveLastQuery(query); - openSearchScreen(query); - } - - function openSearchScreen(query) { - query = String(query || '').trim(); - var payload = { - title: 'Поиск', - component: 'lampac_music_search', - search_mode: true, - query: query, - page: 1, - noinfo: true - }; - var active = Lampa.Activity.active(); - - if (active && active.component === 'lampac_music_search') { - Lampa.Activity.replace(payload); - return; - } - - Lampa.Activity.push(payload); - } - - function saveRecentEntity(key, entity) { - if (!entity || !entity.id) return; - - var list = Lampa.Storage.get(key, []); - list = Array.isArray(list) ? list : []; - var identity = recentEntityIdentity(key, entity); - list = list.filter(function (item) { - return item && recentEntityIdentity(key, item) !== identity; - }); - list.unshift(entity); - list = dedupeRecentEntities(key, list, RECENT_SECTION_STORAGE_LIMIT); - Lampa.Storage.set(key, list); - - if (key === MUSIC.storage.recent_albums) { - touchHomeCacheEntry('recent_albums', mapAlbumCard(entity), RECENT_SECTION_STORAGE_LIMIT); - MUSIC_HOME_SECTION_META.recent_albums = { has_more: list.length > HOME_SECTION_LIMIT }; - emitRecentChanged('recent_albums', entity); - } - - if (key === MUSIC.storage.recent_artists) { - touchHomeCacheEntry('recent_artists', mapArtistCard(entity), RECENT_SECTION_STORAGE_LIMIT); - MUSIC_HOME_SECTION_META.recent_artists = { has_more: list.length > HOME_SECTION_LIMIT }; - emitRecentChanged('recent_artists', entity); - } - } - - function getRecentEntities(key) { - var list = Lampa.Storage.get(key, []); - list = Array.isArray(list) ? list.filter(function (item) { - return item && item.id; - }) : []; - - if (key === MUSIC.storage.recent_albums || key === MUSIC.storage.recent_artists) { - var deduped = dedupeRecentEntities(key, list, RECENT_SECTION_STORAGE_LIMIT); - if (deduped.length !== list.length) - Lampa.Storage.set(key, deduped); - return deduped; - } - - return list; - } - - // мемоизация закладок: isBookmarkedEntity дёргается тиком фулл-плеера - // 2–4 раза в секунду (heat-probe: fullPlayerActionsUpdate), и каждый вызов - // парсил массив из Storage заново. Все записи идут через save/remove ниже — - // там инвалидация; читатели не мутируют возвращённый массив (проверено) - var MUSIC_BOOKMARK_CACHE = {}; - - function getBookmarkedEntities(key) { - if (MUSIC_BOOKMARK_CACHE[key]) return MUSIC_BOOKMARK_CACHE[key]; - - var list = Lampa.Storage.get(key, []); - list = Array.isArray(list) ? list.filter(function (item) { - return item && item.id; - }) : []; - - MUSIC_BOOKMARK_CACHE[key] = list; - return list; - } - - function isBookmarkedEntity(key, entityId) { - if (!key || !entityId) return false; - - return getBookmarkedEntities(key).some(function (item) { - return item && item.id === entityId; - }); - } - - function saveBookmarkedEntity(key, entity) { - if (!key || !entity || !entity.id) return false; - - var list = getBookmarkedEntities(key).filter(function (item) { - return item && item.id !== entity.id; - }); - - list.unshift(entity); - Lampa.Storage.set(key, list.slice(0, 100)); - delete MUSIC_BOOKMARK_CACHE[key]; - return true; - } - - function removeBookmarkedEntity(key, entityId) { - if (!key || !entityId) return false; - - var list = getBookmarkedEntities(key); - var next = list.filter(function (item) { - return item && item.id !== entityId; - }); - - Lampa.Storage.set(key, next); - delete MUSIC_BOOKMARK_CACHE[key]; - return next.length !== list.length; - } - - function toggleBookmarkedEntity(key, entity) { - if (!key || !entity || !entity.id) return false; - - if (isBookmarkedEntity(key, entity.id)) { - removeBookmarkedEntity(key, entity.id); - return false; - } - - saveBookmarkedEntity(key, entity); - return true; - } - - function removeHistoryTrack(trackId, done, options) { - options = options || {}; - requestPost(MUSIC.endpoints.removeHistory, 'id=' + encodeURIComponent(trackId || ''), function (json) { - var removed = !!(json && json.removed); - if (removed) { - removeHomeCacheEntry('recently_played', trackId); - updateHomeSectionMetaFromCache('recently_played'); - if (!options.skipEvent) - emitRecentChanged('recently_played', trackId); - } - done(removed); - }, function () { - done(false); - }); - } - - function normalizeText(value) { - return String(value || '') - .toLowerCase() - .replace(/[^a-z0-9а-яёіїєґ]+/gi, ' ') - .replace(/\s+/g, ' ') - .trim(); - } - - function albumTextMatches(left, right) { - if (!left || !right) return false; - - return left === right - || left.indexOf(right) !== -1 - || right.indexOf(left) !== -1; - } - - function scoreAlbumCandidate(source, candidate) { - if (!candidate) return -1; - - var score = 0; - var sourceTitle = normalizeText(source.title); - var sourceBaseTitle = normalizeText(stripAlbumEditionSuffix(source.title)); - var sourceArtist = normalizeText(source.artist_name); - var candidateTitle = normalizeText(candidate.title); - var candidateBaseTitle = normalizeText(stripAlbumEditionSuffix(candidate.title)); - var candidateArtist = normalizeText(candidate.artist_name); - var titleMatches = albumTextMatches(sourceTitle, candidateTitle) - || albumTextMatches(sourceBaseTitle, candidateBaseTitle); - var artistMatches = albumTextMatches(sourceArtist, candidateArtist); - - if (!titleMatches || !artistMatches) return -1; - - if (sourceTitle && candidateTitle === sourceTitle) score += 60; - else if (sourceBaseTitle && candidateBaseTitle === sourceBaseTitle) score += 45; - else score += 30; - - if (sourceArtist && candidateArtist === sourceArtist) score += 50; - else score += 20; - - if (source.year && candidate.year && String(source.year) === String(candidate.year)) score += 15; - else if (source.date && candidate.date && String(candidate.date).slice(0, 4) === String(source.date).slice(0, 4)) score += 10; - - if (candidateTitle && /deluxe|edition|remaster|anniversary|karaoke|tribute|cover/i.test(candidateTitle)) - score -= 25; - - return score; - } - - // Apple-чарты полны изданий вида «ANTI (Deluxe)» / «Dangerous: The Double - // Album» — по полному названию MusicBrainz-поиск не находит ничего, - // по базовому — находит (проверено живьём, июль 2026) - function stripAlbumEditionSuffix(query) { - return String(query || '') - .replace(/\s*\([^)]*(?:deluxe|edition|version|anniversary|expanded|remaster)[^)]*\)\s*$/i, '') - .replace(/\s*[-–—:]\s*(?:the\s+)?(?:deluxe|anthology|double\s+album|expanded|complete)(?:\s+(?:edition|version|album))?\s*$/i, '') - .replace(/\s*[-–—]\s*EP\s*$/i, '') - .trim(); - } - - function isAppleMusicDiscoveryAlbum(album) { - if (!album) return false; - - return getAlbumProviderId(album) === 'applemusiccharts' - || /^applecharts:/i.test(String(album.id || '')); - } - - function normalizeAlbumLookupProvider(value) { - value = String(value || '').trim().toLowerCase(); - return value === 'applemusic' || value === 'spotify' || value === 'soundcloud' || value === 'musicbrainz' ? value : 'auto'; - } - - function getSearchAlbumCandidates(parsed, provider) { - if (provider === 'musicbrainz') - return Array.isArray(parsed && parsed.albums) ? parsed.albums : []; - - if (provider !== 'spotify' && provider !== 'soundcloud') return []; - - var albums = []; - var sections = Array.isArray(parsed && parsed.search_sections) ? parsed.search_sections : []; - - sections.forEach(function (section) { - if (!section) return; - - var source = String(section.source_provider || '').toLowerCase(); - var id = String(section.id || '').toLowerCase(); - var isSpotifyAlbums = provider === 'spotify' - && (source === 'spotify' || id.indexOf('search:spotify') === 0); - var isSoundCloudAlbums = provider === 'soundcloud' - && source === 'soundcloudcharts' - && id === 'search:soundcloud:albums'; - - if (!isSpotifyAlbums && !isSoundCloudAlbums) return; - - if (Array.isArray(section.albums)) { - albums = albums.concat(section.albums.filter(function (candidate) { - return provider !== 'soundcloud' - || String(candidate && candidate.type || '').toLowerCase() === 'album'; - })); - } - }); - - return albums; - } - - function findSearchAlbumCandidate(album, parsed, providers) { - for (var providerIndex = 0; providerIndex < providers.length; providerIndex++) { - var provider = providers[providerIndex]; - var candidates = getSearchAlbumCandidates(parsed, provider); - var best = null; - var bestScore = -1; - - candidates.forEach(function (candidate) { - var score = scoreAlbumCandidate(album, candidate); - if (score > bestScore) { - bestScore = score; - best = candidate; - } - }); - - if (best && bestScore > 0) - return best; - } - - return null; - } - - function resolveAlbumBySearch(album, providers, allowDirectFallback, done, fail) { - function finishWithoutMatch() { - if (allowDirectFallback && album && album.id) { - done(album); - return; - } - - if (fail) fail(); - } - - // у /music/search бюджет ответа 2s: холодный запрос отдаёт пустоту, - // а фоновый прогрев дозаполняет кэш — поэтому пустой ответ повторяем - // через паузу, и только потом пробуем запрос без edition-суффикса. - var fullQuery = String(album.lookup_query).trim(); - var strippedQuery = stripAlbumEditionSuffix(fullQuery); - var attempts = [ - { query: fullQuery, delay: 0 }, - { query: fullQuery, delay: 2500 } - ]; - - if (strippedQuery && strippedQuery !== fullQuery) { - attempts.push({ query: strippedQuery, delay: 0 }); - attempts.push({ query: strippedQuery, delay: 2500 }); - } - - function runAttempt(step) { - if (step >= attempts.length) { - finishWithoutMatch(); - return; - } - - var attempt = attempts[step]; - - function fire() { - request(MUSIC.endpoints.search + '?q=' + encodeURIComponent(attempt.query), function (json) { - var parsed = parseJson(json) || {}; - var best = findSearchAlbumCandidate(album, parsed, providers); - - if (best) { - done(best); - return; - } - - // MusicBrainz ещё прогревается (2s-бюджет сервера) — - // повторяем тот же запрос, а не следующий по каскаду. - if (providers.indexOf('musicbrainz') !== -1 && parsed.metadata_pending && (attempt.pendingRetries || 0) < 2) { - attempt.pendingRetries = (attempt.pendingRetries || 0) + 1; - setTimeout(fire, 3000); - return; - } - - runAttempt(step + 1); - }, function () { - runAttempt(step + 1); - }); - } - - if (attempt.delay) setTimeout(fire, attempt.delay); - else fire(); - } - - runAttempt(0); - } - - function resolveAlbum(album, done, fail) { - if (!album || !album.lookup_query) { - done(album); - return; - } - - if (!isAppleMusicDiscoveryAlbum(album)) { - resolveAlbumBySearch(album, ['musicbrainz'], true, done, fail); - return; - } - - var resolver = normalizeAlbumLookupProvider(album.lookup_provider); - var searchProviders = resolver === 'musicbrainz' || resolver === 'applemusic' - ? ['musicbrainz'] - : resolver === 'spotify' - ? ['spotify', 'musicbrainz'] - : resolver === 'soundcloud' - ? ['soundcloud', 'musicbrainz'] - : ['spotify', 'soundcloud', 'musicbrainz']; - - function searchFallback() { - resolveAlbumBySearch(album, searchProviders, false, done, fail); - } - - if (resolver !== 'auto' && resolver !== 'applemusic') { - searchFallback(); - return; - } - - requestAlbumDetails(album, 'applemusiccharts', function (resolvedAlbum) { - if (resolvedAlbum && resolvedAlbum.id && Array.isArray(resolvedAlbum.tracks) && resolvedAlbum.tracks.length) { - done(resolvedAlbum); - return; - } - - searchFallback(); - }, searchFallback); - } - - function openSearchInput(initialValue, options) { - var value = typeof initialValue === 'string' ? initialValue : ''; - Lampa.Input.edit({ - value: value, - title: 'Поиск музыки', - free: true, - nosave: false, - nomic: true - }, function (newValue) { - newValue = String(newValue || '').trim(); - - if (!newValue) { - if (options && options.onCancel) options.onCancel(); - return; - } - - if (options && options.ignoreSameValue && newValue === value.trim()) { - if (options.onCancel) options.onCancel(); - return; - } - - openSearchQuery(newValue); - }); - } - - // ===== AUTH ===== - - function loadAuthStates(done, fail) { - request(MUSIC.endpoints.authState, function (json) { - var parsed = parseJson(json) || []; - done(Array.isArray(parsed) ? parsed : []); - }, fail || function () { - done([]); - }); - } - - function getYandexAuthState(states) { - var list = Array.isArray(states) ? states : []; - - for (var i = 0; i < list.length; i++) { - if (list[i] && list[i].provider_id === 'yandexmusic') return list[i]; - } - - return { - provider_id: 'yandexmusic', - provider_name: 'Yandex Music', - authenticated: false, - token_ready: false, - web_ready: false, - message: 'Token and cookies are required for full functionality.' - }; - } - - function updateAuthStatus(target, state) { - if (!target) return; - target.text(state && state.message ? state.message : 'Yandex auth не задана'); - } - - function refreshAuthStatus(target, callback) { - loadAuthStates(function (states) { - var state = getYandexAuthState(states); - updateAuthStatus(target, state); - if (callback) callback(state); - }, function () { - updateAuthStatus(target, null); - if (callback) callback(null); - }); - } - - function saveAuthPayload(payload, authStatus, onDone, restoreContext) { - requestPost(MUSIC.endpoints.authSave, 'provider=yandexmusic&payload=' + encodeURIComponent(payload || ''), function (json) { - if (!json || json.saved !== true) { - Lampa.Noty.show('Не удалось сохранить авторизацию.'); - restoreMusicFocusContext(restoreContext); - return; - } - - refreshAuthStatus(authStatus, function (state) { - Lampa.Noty.show(state && state.message ? state.message : 'Авторизация сохранена.'); - if (onDone) onDone(state); - restoreMusicFocusContext(restoreContext, 120); - }); - }, function () { - Lampa.Noty.show('Не удалось сохранить авторизацию.'); - restoreMusicFocusContext(restoreContext); - }); - } - - function logoutAuth(authStatus, restoreContext) { - requestPost(MUSIC.endpoints.authLogout, 'provider=yandexmusic', function () { - refreshAuthStatus(authStatus, function () { - Lampa.Noty.show('Авторизация очищена.'); - restoreMusicFocusContext(restoreContext, 120); - }); - }, function () { - Lampa.Noty.show('Не удалось очистить авторизацию.'); - restoreMusicFocusContext(restoreContext); - }); - } - - function openAuthMenu(authStatus) { - var restoreContext = captureMusicFocusContext('content'); - - loadAuthStates(function (states) { - var state = getYandexAuthState(states); - var items = [ - { title: state.message || 'Состояние неизвестно', action: 'status' }, - { title: 'Ввести OAuth token', action: 'token' }, - { title: 'Вставить web cookies', action: 'cookies' }, - { title: 'Очистить авторизацию', action: 'logout' } - ]; - - Lampa.Select.show({ - title: 'Yandex Music', - items: items, - onBack: function () { - restoreMusicFocusContext(restoreContext); - }, - onSelect: function (selected) { - if (!selected || !selected.action || selected.action === 'status') { - Lampa.Noty.show(state.message || 'Состояние неизвестно.'); - restoreMusicFocusContext(restoreContext, 120); - return; - } - - if (selected.action === 'token') { - Lampa.Input.edit({ - value: '', - title: 'Yandex OAuth token', - free: true, - nosave: true, - nomic: true - }, function (value) { - value = (value || '').trim(); - if (!value) { - restoreMusicFocusContext(restoreContext); - return; - } - saveAuthPayload('ym_token=' + encodeURIComponent(value), authStatus, null, restoreContext); - }); - return; - } - - if (selected.action === 'cookies') { - Lampa.Input.edit({ - value: '', - title: 'Yandex cookies', - free: true, - nosave: true, - nomic: true - }, function (value) { - value = (value || '').trim(); - if (!value) { - restoreMusicFocusContext(restoreContext); - return; - } - saveAuthPayload(value, authStatus, null, restoreContext); - }); - return; - } - - if (selected.action === 'logout') { - logoutAuth(authStatus, restoreContext); - } - } - }); - }, function () { - Lampa.Noty.show('Не удалось получить состояние авторизации.'); - restoreMusicFocusContext(restoreContext); - }); - } - - // ===== ENTITY NAVIGATION ===== - - function openArtistCatalog(artist) { - if (!artist || !artist.id || /^related:/i.test(String(artist.id))) - return openSearchQuery(artist && artist.name ? artist.name : ''); - - var provider = getEntityProviderId(artist); - - saveRecentEntity(MUSIC.storage.recent_artists, artist); - blurActiveMusicInput(); - Lampa.Activity.push({ - title: artist.name || 'Артист', - component: 'lampac_music_artist', - id: artist.id, - provider: provider, - page: 1, - noinfo: true - }); - } - - function getActiveMusicQuery() { - try { - var active = Lampa.Activity.active(); - if (!active || (active.component !== 'lampac_music_home' && active.component !== 'lampac_music_search')) return ''; - return String(active.query || '').trim(); - } catch (e) { - return ''; - } - } - - function openArtist(artist) { - if (!artist) return; - - saveRecentEntity(MUSIC.storage.recent_artists, artist); - - var artistName = String(artist.name || '').trim(); - var activeQuery = getActiveMusicQuery(); - - if (isDirectArtistEntity(artist)) { - openArtistCatalog(artist); - return; - } - - if (artistName && normalizeText(activeQuery) === normalizeText(artistName)) { - openArtistCatalog(artist); - return; - } - - openSearchQuery(artistName); - } - - // фоллбек «Открыть альбом» для треков без album-меты (SoundCloud-аплоады, - // часть YouTube): ищем сам ТРЕК в поиске — MusicBrainz отдаёт треки с - // album_id — и открываем альбом найденной записи. Гейт по совпадению - // артиста и названия: лучше честный отказ, чем чужой альбом - function splitTrackArtistTitle(value) { - value = String(value || '').trim(); - if (!value) return null; - - var match = value.match(/^(.{2,120}?)\s*[-–—]\s+(.{2,180})$/) - || value.match(/^(.{2,120}?)\s+[-–—]\s*(.{2,180})$/); - if (!match) return null; - - var artist = match[1].trim(); - var title = match[2].trim(); - if (!artist || !title) return null; - - return { artist: artist, title: title }; - } - - function normalizeAlbumLookupUnicode(value) { - value = String(value || ''); - - try { - return value.normalize('NFC'); - } catch (e) { - return value; - } - } - - function addAlbumLookupQuery(list, seen, artist, title) { - var query = normalizeAlbumLookupUnicode([artist, title].filter(Boolean).join(' ').trim()); - - function addVariant(value) { - value = normalizeAlbumLookupUnicode(value); - var key = normalizeText(value); - if (!key || seen[key]) return; - - seen[key] = true; - list.push(value); - } - - addVariant(query); - addVariant(query.replace(/эй/gi, 'ей')); - } - - function stripAlbumLookupTitleNoise(value) { - return normalizeAlbumLookupUnicode(value) - .replace(/&/gi, '&') - .replace(/\s*[\[(][^\])]*(?:feat\.?|ft\.?|featuring|при\s*уч)[^\])]*[\])]\s*/gi, ' ') - .replace(/\s*[\[(][^\])]*(?:official|audio|video|lyrics?|lyric|clip|mv|hd|4k|remix|sped|slowed|nightcore|bass|boosted|screwed|reverb|amateur|премьера|текст|клип)[^\])]*[\])]\s*/gi, ' ') - .replace(/\s+[\/|]\s+.*$/i, ' ') - .replace(/\s+#[^\s]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - } - - function normalizeAlbumLookupText(value) { - return normalizeText(normalizeAlbumLookupUnicode(stripAlbumLookupTitleNoise(value))) - .replace(/эи/g, 'еи') - .replace(/эй/g, 'ей') - .replace(/\s+/g, ' ') - .trim(); - } - - function albumLookupTokens(value) { - var stop = { - feat: true, - ft: true, - featuring: true, - official: true, - audio: true, - video: true, - lyrics: true, - lyric: true, - clip: true, - remix: true - }; - - return normalizeAlbumLookupText(value) - .split(' ') - .filter(function (token) { - return token && token.length > 1 && !stop[token]; - }); - } - - function countTokenOverlap(left, right) { - var seen = {}; - var matched = {}; - var count = 0; - - left.forEach(function (token) { - seen[token] = true; - }); - - right.forEach(function (token) { - if (seen[token] && !matched[token]) { - matched[token] = true; - count++; - } - }); - - return count; - } - - function pushAlbumLookupExpectation(list, seen, artist, title, titleOnly) { - title = stripAlbumLookupTitleNoise(title); - if (!title) return; - - var titleTokens = albumLookupTokens(title); - if (!titleTokens.length) return; - - artist = String(artist || '').trim(); - var artistTokens = albumLookupTokens(artist); - - if (!artistTokens.length && !titleOnly) return; - if (titleOnly && titleTokens.length < 3) return; - - var key = normalizeAlbumLookupText(artist) + '|' + normalizeAlbumLookupText(title) + '|' + (titleOnly ? '1' : '0'); - if (seen[key]) return; - - seen[key] = true; - list.push({ - artist: artist, - title: title, - titleOnly: !!titleOnly, - artistTokens: artistTokens, - titleTokens: titleTokens, - normalizedArtist: normalizeAlbumLookupText(artist), - normalizedTitle: normalizeAlbumLookupText(title) - }); - } - - function buildAlbumLookupExpectations(track, artist, title) { - var list = []; - var seen = {}; - var cleanTitle = stripAlbumLookupTitleNoise(title); - var split = splitTrackArtistTitle(title); - - pushAlbumLookupExpectation(list, seen, artist, cleanTitle, false); - - if (split) { - pushAlbumLookupExpectation(list, seen, split.artist, split.title, false); - if (artist) pushAlbumLookupExpectation(list, seen, artist, split.title, false); - } - - // Случай без явного дефиса, но с артистом внутри названия: - // "Eminem Till i collapse", "Мияги&Эндшпиль Люби меня". - // Это слабый вариант: он не открывает альбом сам по себе, если кандидат - // не совпал хотя бы частью artist-токенов из строки. - pushAlbumLookupExpectation(list, seen, null, cleanTitle, true); - - return list; - } - - function scoreAlbumLookupCandidateForExpectation(candidate, expectation) { - var cTitle = normalizeAlbumLookupText(candidate.title); - var cArtist = normalizeAlbumLookupText(candidate.artist_name); - var cTitleTokens = albumLookupTokens(candidate.title); - var cArtistTokens = albumLookupTokens(candidate.artist_name); - var titleExact = cTitle && expectation.normalizedTitle - && (cTitle === expectation.normalizedTitle - || cTitle.indexOf(expectation.normalizedTitle) !== -1 - || (expectation.normalizedTitle.indexOf(cTitle) !== -1 && cTitleTokens.length >= 2)); - var titleOverlap = countTokenOverlap(expectation.titleTokens, cTitleTokens); - var artistExact = !!expectation.normalizedArtist && !!cArtist - && (cArtist.indexOf(expectation.normalizedArtist) !== -1 - || expectation.normalizedArtist.indexOf(cArtist) !== -1); - var artistOverlap = countTokenOverlap(expectation.artistTokens, cArtistTokens); - var titleMentionsArtist = countTokenOverlap(expectation.titleTokens, cArtistTokens); - var titleOk = titleExact || (titleOverlap >= Math.min(2, expectation.titleTokens.length)); - var artistOk = false; - var score = 0; - - if (!titleOk) return 0; - - if (expectation.titleOnly) { - // Не даём title-only варианту открыть чужой одноимённый трек: - // в строке должен быть хотя бы один токен артиста кандидата. - artistOk = titleMentionsArtist > 0; - } else { - artistOk = !expectation.artistTokens.length || artistExact || artistOverlap > 0; - } - - if (!artistOk) return 0; - - if (titleExact) score += 40; - score += Math.min(titleOverlap, 4) * 12; - if (artistExact) score += 28; - score += Math.min(artistOverlap, 3) * 10; - if (titleMentionsArtist) score += Math.min(titleMentionsArtist, 2) * 8; - if (expectation.titleOnly) score -= 10; - - return score; - } - - function scoreAlbumLookupCandidate(candidate, expectations) { - if (!candidate || !candidate.album_id) return 0; - - var best = 0; - for (var i = 0; i < expectations.length; i++) - best = Math.max(best, scoreAlbumLookupCandidateForExpectation(candidate, expectations[i])); - - return best; - } - - function resolveAlbumViaTrackSearch(track, done, fail) { - var artist = getTrackArtistSearchQuery(track) || String(track.artist_name || '').trim(); - var title = String(track.title || '').trim(); - - if (!title) { - if (fail) fail(); - return; - } - - var expectations = buildAlbumLookupExpectations(track, artist, title); - var queryList = []; - var querySeen = {}; - - expectations.forEach(function (expectation) { - if (!expectation.titleOnly) { - addAlbumLookupQuery(queryList, querySeen, expectation.artist, expectation.title); - addAlbumLookupQuery(queryList, querySeen, null, expectation.title); - } else { - addAlbumLookupQuery(queryList, querySeen, null, expectation.title); - } - }); - - if (!queryList.length || !expectations.length) { - if (fail) fail(); - return; - } - - // холодный поиск отдаёт пустоту из-за 2s-бюджета сервера, а прогрев - // треков (MusicBrainz, rate-limit) занимает 5-10s — поэтому ретраим - // ТОТ ЖЕ запрос по флагу metadata_pending, а не следующий по списку; - // слепой повтор через 2.5s приходил раньше конца прогрева - var attempts = []; - - queryList.forEach(function (query) { - attempts.push({ query: query, delay: 0, pendingRetries: 0 }); - }); - - function runAttempt(step) { - if (step >= attempts.length) { - if (fail) fail(); - return; - } - - function fire() { - var attempt = attempts[step]; - - request(MUSIC.endpoints.search + '?q=' + encodeURIComponent(attempt.query), function (json) { - var parsed = parseJson(json) || {}; - var candidates = Array.isArray(parsed.tracks) ? parsed.tracks : []; - var best = null; - var bestScore = 0; - - for (var i = 0; i < candidates.length; i++) { - var score = scoreAlbumLookupCandidate(candidates[i], expectations); - if (score > bestScore) { - best = candidates[i]; - bestScore = score; - } - } - - if (best) { - done(best); - return; - } - - // сервер прямо сообщает, что метадата ещё прогревается — - // повторяем ТОТ ЖЕ запрос, следующий по каскаду бессмыслен - if (parsed.metadata_pending && attempt.pendingRetries < 2) { - attempt.pendingRetries++; - setTimeout(fire, 3000); - return; - } - - runAttempt(step + 1); - }, function () { - runAttempt(step + 1); - }); - } - - if (attempts[step].delay) setTimeout(fire, attempts[step].delay); - else fire(); - } - - runAttempt(0); - } - - function openAlbumFromTrackSearch(track, restoreContext) { - startMusicPlaybackLoading('Открываю альбом'); - - resolveAlbumViaTrackSearch(track, function (found) { - stopMusicPlaybackLoading(); - openAlbum({ - id: found.album_id, - title: found.album_title || 'Альбом', - artist_name: found.artist_name || '', - images: Array.isArray(found.images) ? found.images : [] - }, { - onFail: function () { - restoreMusicFocusContext(restoreContext); - } - }); - }, function () { - stopMusicPlaybackLoading(); - Lampa.Noty.show('Не удалось определить альбом трека.'); - restoreMusicFocusContext(restoreContext); - }); - } - - function cloneJsonObject(value) { - if (!value || typeof value !== 'object') return value; - - try { - return JSON.parse(JSON.stringify(value)); - } catch (e) { - return value; - } - } - - function getAlbumProviderId(album) { - var provider = getEntityProviderId(album); - var id = String(album && album.id || ''); - - if (!provider && /^spotify:album:/i.test(id)) provider = 'spotify'; - return provider || null; - } - - function requestAlbumDetails(album, provider, done, fail) { - var albumId = album && album.id; - if (!albumId) { - if (fail) fail(); - return; - } - - provider = provider || getAlbumProviderId(album); - - var cacheKey = (provider || '') + '|' + albumId; - var cached = MUSIC_ALBUM_DETAILS_CACHE[cacheKey]; - var now = Date.now(); - - if (cached && cached.expires > now && cached.album) { - setTimeout(function () { - done(cloneJsonObject(cached.album)); - }, 0); - return; - } - - var albumUrl = MUSIC.endpoints.album + '?id=' + encodeURIComponent(albumId); - if (provider) - albumUrl += '&provider=' + encodeURIComponent(provider); - - request(albumUrl, function (json) { - var parsed = parseJson(json); - if (parsed && parsed.id) { - MUSIC_ALBUM_DETAILS_CACHE[cacheKey] = { - expires: Date.now() + MUSIC_ALBUM_DETAILS_TTL, - album: cloneJsonObject(parsed) - }; - } - - done(parsed); - }, fail); - } - - function pushAlbumActivity(resolvedAlbum, provider) { - if (String(resolvedAlbum && resolvedAlbum.type || '').toUpperCase().indexOf('PLAYLIST') < 0) - saveRecentEntity(MUSIC.storage.recent_albums, resolvedAlbum); - Lampa.Activity.push({ - title: resolvedAlbum.title || 'Альбом', - component: 'lampac_music_album', - id: resolvedAlbum.id, - provider: provider || getAlbumProviderId(resolvedAlbum), - page: 1, - noinfo: true - }); - } - - function normalizeAlbumTracksForPlayback(album) { - var tracks = Array.isArray(album && album.tracks) ? album.tracks : []; - - return tracks.map(function (track) { - track.artist_name = track.artist_name || album.artist_name; - track.album_id = track.album_id || album.id; - track.album_title = track.album_title || album.title; - if (!track.images || !track.images.length) - track.images = Array.isArray(album.images) ? album.images : []; - return track; - }); - } - - function isSpotifyReleaseTrack(track) { - if (!track) return false; - - return /^spotify:release:/i.test(String(track.id || '')) - && /^spotify:album:/i.test(String(track.album_id || '')) - && getTrackProviderId(track) === 'spotify'; - } - - function playSpotifyReleaseTrack(track, playlistTracks, startIndex, options) { - if (!isSpotifyReleaseTrack(track)) return false; - - var token = ++MUSIC_SPOTIFY_RELEASE_TOKEN; - startMusicPlaybackLoading('Открываю релиз'); - - requestAlbumDetails({ id: track.album_id }, 'spotify', function (loadedAlbum) { - if (token !== MUSIC_SPOTIFY_RELEASE_TOKEN) return; - - stopMusicPlaybackLoading(); - - if (!loadedAlbum || loadedAlbum.available === false || !loadedAlbum.id) { - Lampa.Noty.show('Не удалось открыть релиз.'); - return; - } - - var tracks = normalizeAlbumTracksForPlayback(loadedAlbum); - if (tracks.length === 1) { - var queue = playlistTracks && playlistTracks.length ? playlistTracks.slice() : [track]; - var index = typeof startIndex === 'number' ? startIndex : 0; - index = Math.max(0, Math.min(index, queue.length - 1)); - queue[index] = tracks[0]; - playTrack(tracks[0], queue, index, options); - return; - } - - pushAlbumActivity(loadedAlbum, 'spotify'); - }, function () { - if (token !== MUSIC_SPOTIFY_RELEASE_TOKEN) return; - - stopMusicPlaybackLoading(); - Lampa.Noty.show('Не удалось открыть релиз.'); - }); - - return true; - } - - function openAlbum(album, options) { - options = options || {}; - - // резолв с retry может занять несколько секунд — показываем лоадер - if (album && album.lookup_query) startMusicPlaybackLoading('Открываю альбом'); - - resolveAlbum(album, function (resolvedAlbum) { - stopMusicPlaybackLoading(); - if (!resolvedAlbum || !resolvedAlbum.id) { - Lampa.Noty.show('Не удалось подобрать альбом.'); - if (options.onFail) options.onFail(); - return; - } - - pushAlbumActivity(resolvedAlbum, getAlbumProviderId(resolvedAlbum)); - }, function () { - stopMusicPlaybackLoading(); - Lampa.Noty.show('Не удалось подобрать альбом.'); - if (options.onFail) options.onFail(); - }); - } - - // ===== QUEUE / IOS PLAYBACK ===== - - // --- очередь: треки и текущий индекс --- - - function queueTracks() { - return Array.isArray(MUSIC_QUEUE.tracks) ? MUSIC_QUEUE.tracks : []; - } - - function queueCurrentIndex() { - var tracks = queueTracks(); - if (!tracks.length) return -1; - - if (MUSIC_QUEUE.currentTrackId) { - for (var i = 0; i < tracks.length; i++) { - if (tracks[i] && tracks[i].id === MUSIC_QUEUE.currentTrackId) { - MUSIC_QUEUE.currentIndex = i; - return i; - } - } - } - - return Math.max(0, Math.min(MUSIC_QUEUE.currentIndex || 0, tracks.length - 1)); - } - - function queueCurrentTrack() { - var index = queueCurrentIndex(); - var tracks = queueTracks(); - return index >= 0 && tracks[index] ? tracks[index] : null; - } - - function rememberQueue(tracks, startIndex) { - MUSIC_EMBEDDED_IOS.switchToken++; - MUSIC_QUEUE.tracks = (tracks || []).slice(); - MUSIC_QUEUE.currentIndex = typeof startIndex === 'number' ? startIndex : 0; - MUSIC_QUEUE.currentTrackId = MUSIC_QUEUE.tracks[MUSIC_QUEUE.currentIndex] - ? MUSIC_QUEUE.tracks[MUSIC_QUEUE.currentIndex].id - : null; - MUSIC_EMBEDDED_IOS.navigationIndex = MUSIC_QUEUE.currentIndex; - - MUSIC_QUEUE_RESTORE.available = false; - scheduleQueueSnapshotSave(true); - } - - function updateQueueCurrent(trackId) { - if (!trackId) return; - MUSIC_QUEUE.currentTrackId = trackId; - - var tracks = queueTracks(); - for (var i = 0; i < tracks.length; i++) { - if (tracks[i] && tracks[i].id === trackId) { - MUSIC_QUEUE.currentIndex = i; - MUSIC_EMBEDDED_IOS.navigationIndex = i; - if (MUSIC_QUEUE_RESTORE.available && MUSIC_QUEUE_RESTORE.trackId !== trackId) - MUSIC_QUEUE_RESTORE.position = 0; - MUSIC_QUEUE_RESTORE.trackId = trackId; - scheduleQueueSnapshotSave(true); - return; - } - } - - scheduleQueueSnapshotSave(true); - } - - // --- снапшот очереди / restore после перезапуска клиента --- - - function compactQueueProviderRefs(refs) { - if (!Array.isArray(refs)) return []; - - return refs.map(function (item) { - if (!item) return null; - - return { - provider: item.provider || '', - external_id: item.external_id || '', - url: item.url || '' - }; - }).filter(function (item) { - return !!(item && (item.provider || item.external_id || item.url)); - }).slice(0, 6); - } - - function compactQueueImages(images) { - if (!Array.isArray(images)) return []; - - return images.map(function (item) { - if (!item) return null; - - return { - url: item.url || '', - width: item.width || 0, - height: item.height || 0 - }; - }).filter(function (item) { - return !!(item && item.url); - }).slice(0, 4); - } - - function compactQueueTrack(track) { - if (!track || !track.id) return null; - - var copy = { - id: track.id, - title: track.title || '', - artist_name: track.artist_name || '', - album_title: track.album_title || '', - isrc: track.isrc || '', - duration_ms: track.duration_ms || 0, - date: track.date || '', - provider: track.provider || '', - image: track.image || '' - }; - var images = compactQueueImages(track.images); - var providerRefs = compactQueueProviderRefs(track.provider_refs); - - if (images.length) copy.images = images; - if (providerRefs.length) copy.provider_refs = providerRefs; - if (track.auto_radio) copy.auto_radio = true; - - return copy; - } - - function isRadioAutoplayEnabled() { - return Lampa.Storage.get(MUSIC.storage.radio_autoplay_enabled, false) === true; - } - - function setRadioAutoplayEnabled(enabled) { - Lampa.Storage.set(MUSIC.storage.radio_autoplay_enabled, enabled === true); - - if (!enabled) { - resetRadioAutoplayRequest(); - MUSIC_RADIO_STATE.lastGeneration = ''; - } - } - - function resetRadioAutoplayRequest() { - var request = MUSIC_RADIO_STATE.request; - if (request) { - ['play', 'seeking', 'error'].forEach(function (type) { - request.media.removeEventListener(type, request.onIntent); - }); - } - MUSIC_RADIO_STATE.request = null; - MUSIC_RADIO_STATE.pending = false; - } - - function cancelRadioAutoplayResume() { - var request = MUSIC_RADIO_STATE.request; - if (request && request.waiting) { - request.waiting = null; - request.resumeCancelled = true; - } - } - - function radioQueueIdentity(tracks) { - // Snapshot IDs change on saves, even when the playback queue is unchanged. - return JSON.stringify((tracks || []).map(function (track) { return track && track.id || ''; })); - } - - function isRadioAutoplayRequestCurrent(request) { - if (!request || MUSIC_RADIO_STATE.request !== request || !isRadioAutoplayEnabled() - || getPlaybackMode() !== 'audio' || getStandaloneIosRepeatMode() === 'all' - || request.player !== currentExternalPlayer() || request.launch !== MUSIC_PLAY_LAUNCH_TOKEN - || request.queue !== radioQueueIdentity(queueTracks())) return false; - - if (shouldUseStandaloneIosAudio()) - return isStandaloneIosAudioActive() && request.media === MUSIC_IOS_AUDIO.audio; - - var data = activePlayerData(); - return !!(data && data.from_music_cluster && request.media === activeMusicMediaElement() - && canReuseActiveQueue(queueTracks())); - } - - function waitForRadioAutoplay(media) { - var request = MUSIC_RADIO_STATE.request; - if (!isRadioAutoplayRequestCurrent(request) || request.media !== media - || request.resumeCancelled || getStandaloneIosRepeatMode() !== 'off' || media.error) return false; - - var standalone = shouldUseStandaloneIosAudio(); - var index = queueCurrentIndex(); - if (index < 0 || (standalone ? standaloneIosNeighborIndex(1) >= 0 : index < queueTracks().length - 1)) - return false; - - request.waiting = { - index: index, - time: Number(media.currentTime || 0), - source: media.currentSrc || media.src, - switchToken: standalone ? MUSIC_IOS_AUDIO.playWatchToken : MUSIC_EMBEDDED_IOS.switchToken - }; - return true; - } - - function canResumeRadioAutoplay(request) { - var waiting = request.waiting; - var media = request.media; - var switchToken = shouldUseStandaloneIosAudio() ? MUSIC_IOS_AUDIO.playWatchToken : MUSIC_EMBEDDED_IOS.switchToken; - return !!(waiting && getStandaloneIosRepeatMode() === 'off' && !media.error - && (media.paused || media.ended) && queueCurrentIndex() === waiting.index - && (!shouldUseStandaloneIosAudio() || standaloneIosNeighborIndex(1) < 0) - && switchToken === waiting.switchToken && (media.currentSrc || media.src) === waiting.source - && Math.abs(Number(media.currentTime || 0) - waiting.time) < 0.5); - } - - function normalizeRadioDedupeText(value) { - return normalizeText(value) - .replace(/\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - } - - function radioTrackDedupeKey(track) { - if (!track) return ''; - - var title = normalizeRadioDedupeText(track.title || ''); - var artist = normalizeRadioDedupeText(track.artist_name || ''); - - return title ? (artist + '|' + title) : ''; - } - - function compactRadioTrack(track) { - var compact = compactQueueTrack(track); - if (!compact) return null; - if (track && track.auto_radio) compact.auto_radio = true; - return compact; - } - - function radioQueueGenerationKey(tracks, currentIndex) { - return [ - MUSIC_QUEUE_BLOB_STATE.snapshotId || '', - currentIndex, - (tracks || []).map(function (track) { - return track && track.id ? track.id : ''; - }).join('|') - ].join('::'); - } - - function radioSeedTracks(tracks, currentIndex) { - var result = []; - var seen = {}; - - for (var i = currentIndex; i >= 0 && result.length < 5; i--) { - var track = tracks[i]; - if (!track || !track.id) continue; - - var artistKey = normalizeRadioDedupeText(track.artist_name || ''); - var key = artistKey || track.id; - if (seen[key]) continue; - seen[key] = true; - result.push(compactRadioTrack(track)); - } - - return result.filter(Boolean); - } - - function radioExcludeTracks(tracks) { - return (tracks || []).map(compactRadioTrack).filter(Boolean); - } - - function shouldTriggerRadioAutoplay(origin) { - if (!isRadioAutoplayEnabled()) return false; - if (getPlaybackMode() !== 'audio') return false; - if (getStandaloneIosRepeatMode() === 'all') return false; - - var player = currentExternalPlayer(); - var managedPlayer = player === 'ios' || player === 'inner' || player === 'lampa'; - if (!managedPlayer) return false; - - var tracks = queueTracks(); - var currentIndex = queueCurrentIndex(); - if (!tracks.length || currentIndex < 0) return false; - - var remaining = tracks.length - currentIndex - 1; - - // при shuffle физический индекс текущего трека случаен — остаток - // очереди считаем по shuffle-порядку, иначе радио дольёт треки, - // когда пол-очереди ещё не слушано - if (shouldUseStandaloneIosAudio() && isStandaloneIosShuffle()) { - var order = standaloneIosOrder(); - var orderPos = order ? order.indexOf(currentIndex) : -1; - if (orderPos > -1) remaining = order.length - orderPos - 1; - } - - if (remaining > 1) return false; - - if (shouldUseStandaloneIosAudio()) - return isStandaloneIosAudioActive(); - - var data = activePlayerData(); - if (!data || !data.from_music_cluster) return false; - if (!usesInternalPlaybackFlow()) return false; - - return canReuseActiveQueue(tracks); - } - - function appendRadioTracksToManagedQueue(radioTracks) { - var currentTracks = queueTracks(); - var currentIndex = queueCurrentIndex(); - if (!currentTracks.length || currentIndex < 0) return 0; - - var seenIds = {}; - var seenKeys = {}; - - currentTracks.forEach(function (track) { - if (track && track.id) seenIds[track.id] = true; - var key = radioTrackDedupeKey(track); - if (key) seenKeys[key] = true; - }); - - var additions = []; - (radioTracks || []).forEach(function (track) { - if (!track || !track.id || seenIds[track.id]) return; - - var key = radioTrackDedupeKey(track); - if (key && seenKeys[key]) return; - - track.auto_radio = true; - seenIds[track.id] = true; - if (key) seenKeys[key] = true; - additions.push(track); - }); - - if (!additions.length) return 0; - - if (shouldUseStandaloneIosAudio()) { - if (!isStandaloneIosAudioActive()) return 0; - - var order = isStandaloneIosShuffle() ? standaloneIosOrder().slice() : null; - additions.forEach(function (track) { - if (order) order.push(MUSIC_IOS_AUDIO.tracks.length); - MUSIC_IOS_AUDIO.tracks.push(track); - MUSIC_IOS_AUDIO.playlist.push(buildPlayback(track)); - }); - - MUSIC_IOS_AUDIO.shuffleOrder = order; - MUSIC_QUEUE.tracks = MUSIC_IOS_AUDIO.tracks.slice(); - MUSIC_QUEUE.currentIndex = MUSIC_IOS_AUDIO.currentIndex; - MUSIC_QUEUE.currentTrackId = MUSIC_IOS_AUDIO.tracks[MUSIC_IOS_AUDIO.currentIndex] - ? MUSIC_IOS_AUDIO.tracks[MUSIC_IOS_AUDIO.currentIndex].id - : MUSIC_QUEUE.currentTrackId; - } else { - var playlist = activePlaylist(); - if (!playlist.length || !canReuseActiveQueue(currentTracks)) return 0; - - additions.forEach(function (track) { - currentTracks.push(track); - playlist.push(buildPlayback(track)); - }); - - MUSIC_QUEUE.tracks = currentTracks.slice(); - MUSIC_QUEUE.currentIndex = currentIndex; - MUSIC_QUEUE.currentTrackId = currentTracks[currentIndex] ? currentTracks[currentIndex].id : MUSIC_QUEUE.currentTrackId; - - try { - Lampa.Player.playlist(playlist); - } catch (e) {} - - syncMusicMediaSession(activePlayerData()); - } - - scheduleQueueSnapshotSave(true); - updateStandaloneIosPlayerBar(); - if (MUSIC_IOS_FULL_PLAYER_OPEN) updateStandaloneIosFullPlayer(); - - return additions.length; - } - - // «Радио от трека»: сид = выбранный трек, ответ радио становится НОВОЙ - // очередью — сид первым (нажал радио от песни → она и стартует, дальше - // волна). При любой неудаче текущая очередь не трогается - function startRadioFromTrack(track, restoreContext) { - if (!track || !track.id) return; - - var launchToken = ++MUSIC_PLAY_LAUNCH_TOKEN; - MUSIC_SPOTIFY_RELEASE_TOKEN++; - var prepareToken = MUSIC_IOS_AUDIO.prepareToken; - var player = currentExternalPlayer(); - function stale() { - return launchToken !== MUSIC_PLAY_LAUNCH_TOKEN - || prepareToken !== MUSIC_IOS_AUDIO.prepareToken - || player !== currentExternalPlayer(); - } - - startMusicPlaybackLoading('Подбираю волну'); - - var seed = compactRadioTrack(track); - var payload = 'seeds=' + encodeURIComponent(JSON.stringify(seed ? [seed] : [])) - + '&exclude=' + encodeURIComponent(JSON.stringify([])) - + '&limit=20'; - - requestPost(MUSIC.endpoints.radio, payload, function (json) { - if (stale()) return; - stopMusicPlaybackLoading(); - - var radioTracks = json && json.available && Array.isArray(json.tracks) ? json.tracks : []; - radioTracks = radioTracks.filter(function (item) { - return item && item.id && item.id !== track.id; - }); - - if (!radioTracks.length) { - Lampa.Noty.show('Не удалось подобрать треки.'); - restoreMusicFocusContext(restoreContext); - return; - } - - playTrack(track, [track].concat(radioTracks), 0, { forceFresh: true }); - }, function () { - if (stale()) return; - stopMusicPlaybackLoading(); - Lampa.Noty.show('Не удалось подобрать треки.'); - restoreMusicFocusContext(restoreContext); - }); - } - - function maybeRequestRadioAutoplay(origin) { - if (!shouldTriggerRadioAutoplay(origin)) return; - - var tracks = queueTracks(); - var currentIndex = queueCurrentIndex(); - var generation = radioQueueGenerationKey(tracks, currentIndex); - - if (!generation || MUSIC_RADIO_STATE.pending || MUSIC_RADIO_STATE.lastGeneration === generation) - return; - - var media = shouldUseStandaloneIosAudio() ? MUSIC_IOS_AUDIO.audio : activeMusicMediaElement(); - if (!media) return; - - var request = { - player: currentExternalPlayer(), - launch: MUSIC_PLAY_LAUNCH_TOKEN, - queue: radioQueueIdentity(tracks), - media: media, - waiting: null - }; - request.onIntent = function (event) { - if (!request.waiting) return; - // A synthetic end can emit a queued seeking event at the same position. - if (event.type === 'seeking' && Math.abs(Number(media.currentTime || 0) - request.waiting.time) < 0.5) - return; - request.waiting = null; - request.resumeCancelled = true; - }; - ['play', 'seeking', 'error'].forEach(function (type) { - media.addEventListener(type, request.onIntent); - }); - MUSIC_RADIO_STATE.request = request; - MUSIC_RADIO_STATE.pending = true; - MUSIC_RADIO_STATE.lastGeneration = generation; - MUSIC_RADIO_STATE.lastRequestAt = Date.now(); - - var seeds = radioSeedTracks(tracks, currentIndex); - var exclude = radioExcludeTracks(tracks); - - if (!seeds.length) { - resetRadioAutoplayRequest(); - return; - } - - var payload = 'seeds=' + encodeURIComponent(JSON.stringify(seeds)) - + '&exclude=' + encodeURIComponent(JSON.stringify(exclude)) - + '&limit=20'; - - requestPost(MUSIC.endpoints.radio, payload, function (json) { - if (MUSIC_RADIO_STATE.request !== request) return; - var current = isRadioAutoplayRequestCurrent(request); - var resume = current && canResumeRadioAutoplay(request); - resetRadioAutoplayRequest(); - - if (!current || !json || !json.available || !Array.isArray(json.tracks) || !json.tracks.length) - return; - - var firstAdded = queueTracks().length; - var added = appendRadioTracksToManagedQueue(json.tracks); - if (added > 0) { - Lampa.Noty.show('Радио добавило треки в очередь.'); - if (resume) { - if (shouldUseStandaloneIosAudio()) { - var nextIndex = standaloneIosNeighborIndex(1); - if (nextIndex >= 0) standaloneIosPlayIndex(nextIndex); - } else { - scheduleTrackPlayed(queueTracks()[firstAdded]); - playEmbeddedQueueIndexInPlace(firstAdded, 'radio-resume'); - } - } - } - }, function () { - if (MUSIC_RADIO_STATE.request === request) resetRadioAutoplayRequest(); - }); - } - - function currentQueueSnapshotPosition() { - var audio = MUSIC_IOS_AUDIO.audio; - - if (audio && isFinite(audio.currentTime) && audio.currentTime > 0) - return Math.max(0, audio.currentTime); - - var data = activePlayerData(); - var media = data && data.from_music_cluster ? activeMusicMediaElement() : null; - - if (media && isFinite(media.currentTime) && media.currentTime > 0) - return Math.max(0, media.currentTime); - - if (data && data.from_music_cluster && data.timeline && isFinite(data.timeline.time)) - return Math.max(0, Number(data.timeline.time || 0)); - - return Math.max(0, Number(MUSIC_QUEUE_RESTORE.position || 0)); - } - - function saveQueueSnapshotNow() { - if (MUSIC_QUEUE_RESTORE.timer) { - clearTimeout(MUSIC_QUEUE_RESTORE.timer); - MUSIC_QUEUE_RESTORE.timer = 0; - } - - if (!isQueueRestoreEnabled()) return; - - var tracks = queueTracks(); - var currentIndex = queueCurrentIndex(); - - if (!tracks.length || currentIndex < 0) return; - - var compact = []; - var currentCompactIndex = -1; - - tracks.forEach(function (track, index) { - var item = compactQueueTrack(track); - if (!item) return; - - if (index === currentIndex || (MUSIC_QUEUE.currentTrackId && item.id === MUSIC_QUEUE.currentTrackId)) - currentCompactIndex = compact.length; - - compact.push(item); - }); - - if (!compact.length) return; - if (currentCompactIndex < 0) currentCompactIndex = Math.max(0, Math.min(currentIndex, compact.length - 1)); - - var offset = 0; - var truncated = false; - - if (compact.length > MUSIC_QUEUE_SNAPSHOT_LIMIT) { - truncated = true; - offset = Math.max(0, Math.min( - currentCompactIndex - Math.floor(MUSIC_QUEUE_SNAPSHOT_LIMIT / 3), - compact.length - MUSIC_QUEUE_SNAPSHOT_LIMIT - )); - compact = compact.slice(offset, offset + MUSIC_QUEUE_SNAPSHOT_LIMIT); - currentCompactIndex = Math.max(0, Math.min(compact.length - 1, currentCompactIndex - offset)); - } - - var currentTrack = compact[currentCompactIndex] || null; - var position = currentQueueSnapshotPosition(); - var snapshotId = Date.now() + '-' + (++MUSIC_QUEUE_BLOB_STATE.seq); - var now = Date.now(); - - try { - Lampa.Storage.set(MUSIC.storage.queue_blob_v2, { - version: MUSIC_QUEUE_BLOB_VERSION, - snapshotId: snapshotId, - updatedAt: now, - tracks: compact, - currentIndex: currentCompactIndex, - currentTrackId: currentTrack ? currentTrack.id : '', - currentTime: position, - repeatMode: getStandaloneIosRepeatMode(), - shuffle: isStandaloneIosShuffle(), - playbackMode: getPlaybackMode(), - audioProvider: getAudioProviderId(), - truncated: truncated, - windowStart: offset, - windowLength: compact.length - }); - - MUSIC_QUEUE_BLOB_STATE.snapshotId = snapshotId; - MUSIC_QUEUE_BLOB_STATE.windowStart = offset; - MUSIC_QUEUE_BLOB_STATE.windowLength = compact.length; - MUSIC_QUEUE_BLOB_STATE.truncated = truncated; - - writeQueuePositionRecord(snapshotId, currentIndex, currentTrack ? currentTrack.id : '', position, now); - - // legacy-ключ v1 чистим один раз после первой успешной записи v2 - if (!MUSIC_QUEUE_BLOB_STATE.legacyCleared) { - MUSIC_QUEUE_BLOB_STATE.legacyCleared = true; - Lampa.Storage.set(MUSIC.storage.queue_snapshot, null); - } - } catch (e) {} - } - - function writeQueuePositionRecord(snapshotId, currentIndex, trackId, position, now) { - Lampa.Storage.set(MUSIC.storage.queue_position_v2, { - snapshotId: snapshotId, - trackId: trackId || '', - currentIndex: currentIndex, - currentTime: position, - updatedAt: now - }); - } - - // частый путь (тик timeupdate каждые 4s): пишем только позицию, blob не - // пересобираем. Если блоба ещё нет или текущий трек подошёл к краю - // усечённого окна — эскалация до полной записи - function saveQueuePositionNow() { - if (!isQueueRestoreEnabled()) return; - - var tracks = queueTracks(); - var currentIndex = queueCurrentIndex(); - if (!tracks.length || currentIndex < 0) return; - - var blob = MUSIC_QUEUE_BLOB_STATE; - - if (!blob.snapshotId) { - saveQueueSnapshotNow(); - return; - } - - if (blob.truncated) { - var windowEnd = blob.windowStart + blob.windowLength; - var outsideWindow = currentIndex < blob.windowStart || currentIndex >= windowEnd; - // у края эскалируем только если окну есть куда сдвинуться в эту - // сторону — иначе хвост длинной очереди переписывал бы blob каждый тик - var nearMovableStart = currentIndex < blob.windowStart + MUSIC_QUEUE_WINDOW_EDGE_MARGIN && blob.windowStart > 0; - var nearMovableEnd = currentIndex >= windowEnd - MUSIC_QUEUE_WINDOW_EDGE_MARGIN && windowEnd < tracks.length; - - if (outsideWindow || nearMovableStart || nearMovableEnd) { - saveQueueSnapshotNow(); - return; - } - } - - bumpMusicHeatMetric('queuePositionSave'); - - try { - writeQueuePositionRecord( - blob.snapshotId, - currentIndex, - MUSIC_QUEUE.currentTrackId || '', - currentQueueSnapshotPosition(), - Date.now() - ); - } catch (e) {} - } - - function scheduleQueueSnapshotSave(force) { - if (!isQueueRestoreEnabled()) return; - - bumpMusicHeatMetric(force ? 'queueSnapshotForce' : 'queueSnapshotSchedule'); - - if (force) { - saveQueueSnapshotNow(); - return; - } - - if (MUSIC_QUEUE_RESTORE.timer) { - bumpMusicHeatMetric('queueSnapshotAlreadyPending'); - return; - } - - MUSIC_QUEUE_RESTORE.timer = setTimeout(function () { - MUSIC_QUEUE_RESTORE.timer = 0; - saveQueuePositionNow(); - }, MUSIC_QUEUE_SNAPSHOT_SAVE_DELAY); - } - - function clearQueueSnapshot() { - if (MUSIC_QUEUE_RESTORE.timer) { - clearTimeout(MUSIC_QUEUE_RESTORE.timer); - MUSIC_QUEUE_RESTORE.timer = 0; - } - - MUSIC_QUEUE_RESTORE.available = false; - MUSIC_QUEUE_RESTORE.position = 0; - MUSIC_QUEUE_RESTORE.updatedAt = 0; - MUSIC_QUEUE_RESTORE.trackId = ''; - - MUSIC_QUEUE_BLOB_STATE.snapshotId = ''; - MUSIC_QUEUE_BLOB_STATE.windowStart = 0; - MUSIC_QUEUE_BLOB_STATE.windowLength = 0; - MUSIC_QUEUE_BLOB_STATE.truncated = false; - - try { - Lampa.Storage.set(MUSIC.storage.queue_snapshot, null); - Lampa.Storage.set(MUSIC.storage.queue_blob_v2, null); - Lampa.Storage.set(MUSIC.storage.queue_position_v2, null); - } catch (e) {} - } - - function hasRestoredQueueSnapshot() { - return !!(MUSIC_QUEUE_RESTORE.available && queueTracks().length && queueCurrentIndex() >= 0); - } - - function hasStandaloneIosRestoredQueue() { - return shouldUseStandaloneIosAudio() && hasRestoredQueueSnapshot() && !isStandaloneIosAudioActive(); - } - - function selectRestoredQueueIndex(index) { - var tracks = queueTracks(); - if (!tracks.length) return false; - - index = Math.max(0, Math.min(index, tracks.length - 1)); - MUSIC_QUEUE.currentIndex = index; - MUSIC_QUEUE.currentTrackId = tracks[index] ? tracks[index].id : null; - MUSIC_QUEUE_RESTORE.trackId = MUSIC_QUEUE.currentTrackId; - MUSIC_QUEUE_RESTORE.position = 0; - - if (shouldUseStandaloneIosAudio()) { - MUSIC_IOS_AUDIO.tracks = tracks.slice(); - MUSIC_IOS_AUDIO.playlist = tracks.map(function (track) { return buildPlayback(track); }); - MUSIC_IOS_AUDIO.currentIndex = index; - MUSIC_IOS_AUDIO.shuffleOrder = null; - } - - scheduleQueueSnapshotSave(true); - updateStandaloneIosPlayerBar(); - return true; - } - - function resumeRestoredQueueSnapshot() { - var tracks = queueTracks(); - var index = queueCurrentIndex(); - var position = Math.max(0, Number(MUSIC_QUEUE_RESTORE.position || 0)); - - if (!tracks.length || index < 0 || !tracks[index]) return false; - - MUSIC_QUEUE_RESTORE.available = false; - MUSIC_QUEUE_RESTORE.position = 0; - playTrack(tracks[index], tracks, index, { - forceFresh: true, - resumePosition: position - }); - return true; - } - - function readStoredQueueObject(key) { - try { - var value = Lampa.Storage.get(key, null); - if (typeof value === 'string' && value) - value = JSON.parse(value); - return value && typeof value === 'object' ? value : null; - } catch (e) { - return null; - } - } - - // v2: blob (окно очереди) + отдельная запись позиции, связанные snapshotId. - // Позиция от другого поколения блоба игнорируется — берём данные из блоба. - // Возвращает объект в форме legacy-снапшота, чтобы код восстановления был общим - function readQueueSnapshotV2() { - var blob = readStoredQueueObject(MUSIC.storage.queue_blob_v2); - if (!blob || blob.version !== MUSIC_QUEUE_BLOB_VERSION || !Array.isArray(blob.tracks)) return null; - - var position = readStoredQueueObject(MUSIC.storage.queue_position_v2); - var positionValid = !!(position && position.snapshotId && position.snapshotId === blob.snapshotId); - - // свежесть определяет позиционная запись (blob пишется редко), - // без валидной позиции — метка самого блоба - var liveAt = positionValid ? Number(position.updatedAt || 0) : Number(blob.updatedAt || 0); - - return { - tracks: blob.tracks, - currentIndex: blob.currentIndex, - currentTrackId: positionValid && position.trackId ? position.trackId : (blob.currentTrackId || ''), - currentTime: positionValid ? Math.max(0, Number(position.currentTime || 0)) : Math.max(0, Number(blob.currentTime || 0)), - updatedAt: liveAt, - repeatMode: blob.repeatMode, - shuffle: blob.shuffle - }; - } - - function readLegacyQueueSnapshot() { - var snapshot = readStoredQueueObject(MUSIC.storage.queue_snapshot); - if (!snapshot || snapshot.version !== MUSIC_QUEUE_SNAPSHOT_VERSION || !Array.isArray(snapshot.tracks)) return null; - return snapshot; - } - - function restoreQueueSnapshot() { - if (!isQueueRestoreEnabled()) return false; - - var snapshot = readQueueSnapshotV2() || readLegacyQueueSnapshot(); - - if (!snapshot) return false; - if (snapshot.updatedAt && Date.now() - snapshot.updatedAt > MUSIC_QUEUE_SNAPSHOT_TTL) { - clearQueueSnapshot(); - return false; - } - - var tracks = snapshot.tracks.map(compactQueueTrack).filter(Boolean); - if (!tracks.length) { - clearQueueSnapshot(); - return false; - } - - var index = Math.max(0, Math.min(Number(snapshot.currentIndex || 0), tracks.length - 1)); - if (snapshot.currentTrackId) { - for (var i = 0; i < tracks.length; i++) { - if (tracks[i] && tracks[i].id === snapshot.currentTrackId) { - index = i; - break; - } - } - } - - MUSIC_QUEUE.tracks = tracks; - MUSIC_QUEUE.currentIndex = index; - MUSIC_QUEUE.currentTrackId = tracks[index] ? tracks[index].id : null; - MUSIC_QUEUE_RESTORE.available = true; - MUSIC_QUEUE_RESTORE.position = Math.max(0, Number(snapshot.currentTime || 0)); - MUSIC_QUEUE_RESTORE.updatedAt = Number(snapshot.updatedAt || 0); - MUSIC_QUEUE_RESTORE.trackId = MUSIC_QUEUE.currentTrackId || ''; - - if (snapshot.repeatMode === 'off' || snapshot.repeatMode === 'all' || snapshot.repeatMode === 'one') - Lampa.Storage.set(MUSIC.storage.repeat_mode, snapshot.repeatMode); - if (typeof snapshot.shuffle === 'boolean') - Lampa.Storage.set(MUSIC.storage.shuffle, snapshot.shuffle); - - if (shouldUseStandaloneIosAudio()) { - MUSIC_IOS_AUDIO.tracks = tracks.slice(); - MUSIC_IOS_AUDIO.playlist = tracks.map(function (track) { return buildPlayback(track); }); - MUSIC_IOS_AUDIO.currentIndex = index; - MUSIC_IOS_AUDIO.active = false; - MUSIC_IOS_AUDIO.switching = false; - MUSIC_IOS_AUDIO.playing = false; - MUSIC_IOS_AUDIO.shuffleOrder = null; - updateStandaloneIosPlayerBar(); - } - - return true; - } - - // --- standalone <audio>: элемент, timeupdate, lock-kick --- - - function shouldUseStandaloneIosAudio() { - return currentExternalPlayer() === 'ios' - && getPlaybackMode() === 'audio'; - } - - function isStandaloneIosAudioActive() { - return !!(MUSIC_IOS_AUDIO.active && MUSIC_IOS_AUDIO.audio); - } - - // по трейсам: скраббер локскрина включается только после rate-перехода, - // случившегося ПОСЛЕ построения Now Playing UI (kick при старте не помог, - // ручная пауза уже под блокировкой — помогала). Делаем переход сами - // через 400ms после ухода в фон — на слух незаметно, буфер полон - function scheduleStandaloneIosLockKick() { - var audio = MUSIC_IOS_AUDIO.audio; - - if (!audio || audio.paused || MUSIC_IOS_AUDIO.switching) return; - if (audio.getAttribute('data-source-url') === 'silent-warmup') return; - - setTimeout(function () { - if (MUSIC_IOS_AUDIO.audio !== audio || audio.paused || MUSIC_IOS_AUDIO.switching) return; - if (!document.hidden) return; - - traceStandaloneIosAudio('lock-kick', '', true); - - try { - audio.pause(); - var promise = audio.play(); - if (promise && typeof promise.catch === 'function') promise.catch(function () {}); - } catch (e) {} - }, 400); - } - - function standaloneIosAudioTimeupdate() { - bumpMusicHeatMetric('standaloneTimeupdate'); - if (document.hidden) bumpMusicHeatMetric('standaloneHiddenTimeupdate'); - if (maybeSyntheticStandaloneIosEnded('timeupdate')) return; - if (expireStandaloneIosSleepTimer('timeupdate', false)) return; - - updatePendingTrackPlayedProgress(MUSIC_IOS_AUDIO.audio); - traceStandaloneIosAudio('timeupdate', '', false); - scheduleQueueSnapshotSave(false); - maybeRequestRadioAutoplay('standalone-timeupdate'); - updateStandaloneIosPositionState(false); - - // Во время lock-screen playback страница скрыта, но audio timeupdate - // продолжает будить JS. DOM-бар пользователю не виден, поэтому не - // перерисовываем его в фоне: оставляем только Media Session position. - if (!document.hidden) { - var now = Date.now(); - if (now - (MUSIC_IOS_AUDIO.lastUiUpdate || 0) >= 500) { - MUSIC_IOS_AUDIO.lastUiUpdate = now; - bumpMusicHeatMetric('standaloneUiUpdate'); - updateStandaloneIosPlayerBar(); - } - } - } - - function setStandaloneIosTimeupdateListener(active) { - var audio = MUSIC_IOS_AUDIO.audio; - if (!audio) return; - - if (!MUSIC_IOS_AUDIO.timeupdateHandler) - MUSIC_IOS_AUDIO.timeupdateHandler = standaloneIosAudioTimeupdate; - - if (active) { - if (MUSIC_IOS_AUDIO.timeupdateAttached) return; - - audio.addEventListener('timeupdate', MUSIC_IOS_AUDIO.timeupdateHandler); - MUSIC_IOS_AUDIO.timeupdateAttached = true; - bumpMusicHeatMetric('standaloneTimeupdateAttach'); - return; - } - - if (!MUSIC_IOS_AUDIO.timeupdateAttached) return; - - try { - audio.removeEventListener('timeupdate', MUSIC_IOS_AUDIO.timeupdateHandler); - } catch (e) {} - - MUSIC_IOS_AUDIO.timeupdateAttached = false; - bumpMusicHeatMetric('standaloneTimeupdateDetach'); - } - - function bindStandaloneIosAudioLifecycleTrace() { - if (MUSIC_IOS_AUDIO.lifecycleBound) return; - MUSIC_IOS_AUDIO.lifecycleBound = true; - - ['visibilitychange', 'pagehide', 'pageshow', 'focus', 'blur'].forEach(function (eventName) { - var target = eventName === 'visibilitychange' ? document : window; - target.addEventListener(eventName, function () { - traceStandaloneIosAudio('lifecycle-' + eventName, '', true); - updateStandaloneIosPlaybackState(); - - if (eventName === 'visibilitychange' && !document.hidden) { - setStandaloneIosTimeupdateListener(true); - reviveStandaloneIosKeepAlive(); - scheduleStandaloneIosVisibleUnfreeze(); - updateStandaloneIosPositionState(true); - updateStandaloneIosPlayerBar(); - } - - // при блокировке переутверждаем сессию: свежие handlers + - // positionState в момент, когда iOS строит локскрин-контролы - if (eventName === 'visibilitychange' && document.hidden && isStandaloneIosAudioActive()) { - syncStandaloneIosMediaSession(); - setStandaloneIosTimeupdateListener(false); - scheduleStandaloneIosLockKick(); - } - }); - }); - } - - function standaloneIosAudioElement() { - if (MUSIC_IOS_AUDIO.audio) return MUSIC_IOS_AUDIO.audio; - - MUSIC_IOS_AUDIO.timeupdateAttached = false; - var audio = document.createElement('audio'); - audio.preload = 'auto'; - audio.crossOrigin = 'anonymous'; - audio.playsInline = true; - audio.setAttribute('playsinline', 'playsinline'); - audio.setAttribute('webkit-playsinline', 'webkit-playsinline'); - audio.setAttribute('x-webkit-airplay', 'allow'); - audio.style.position = 'fixed'; - audio.style.width = '0'; - audio.style.height = '0'; - audio.style.opacity = '0'; - audio.style.pointerEvents = 'none'; - audio.style.left = '-9999px'; - audio.style.top = '-9999px'; - - audio.addEventListener('play', function () { - traceStandaloneIosAudio('audio-play', '', true); - }); - - audio.addEventListener('playing', function () { - stopMusicPlaybackLoading(); - - MUSIC_IOS_AUDIO.active = true; - MUSIC_IOS_AUDIO.switching = false; - MUSIC_IOS_AUDIO.playing = true; - MUSIC_IOS_AUDIO.lastPlayingAt = Date.now(); - - // реальный звук пошёл — цепочка авто-скипов мёртвых треков прервана - if (audio.getAttribute('data-source-url') !== 'silent-warmup') - resetAutoSkipCounter(); - - if (!MUSIC_IOS_AUDIO.keepAliveActive || (MUSIC_IOS_KEEPALIVE_WEBAUDIO && MUSIC_IOS_AUDIO.keepAliveCtx && MUSIC_IOS_AUDIO.keepAliveCtx.state !== 'running')) - startStandaloneIosKeepAlive('audio-playing'); - - var track = MUSIC_IOS_AUDIO.tracks[MUSIC_IOS_AUDIO.currentIndex]; - if (track && track.id) { - updateQueueCurrent(track.id); - flushPendingTrackPlayed({ - from_music_cluster: true, - music_track_id: track.id - }, audio); - } - - traceStandaloneIosAudio('audio-playing', '', true); - scheduleQueueSnapshotSave(true); - maybeRequestRadioAutoplay('standalone-playing'); - syncStandaloneIosMediaSession(); - updateStandaloneIosPlayerBar(); - - }); - - audio.addEventListener('pause', function () { - MUSIC_IOS_AUDIO.playing = false; - traceStandaloneIosAudio('audio-pause', '', true); - scheduleQueueSnapshotSave(true); - updateStandaloneIosPlaybackState(); - updateStandaloneIosPlayerBar(); - }); - - audio.addEventListener('loadedmetadata', function () { - traceStandaloneIosAudio('audio-loadedmetadata', '', true); - updateStandaloneIosPositionState(true); - updateStandaloneIosPlayerBar(); - }); - - audio.addEventListener('durationchange', function () { - traceStandaloneIosAudio('audio-durationchange', '', true); - updateStandaloneIosPositionState(true); - updateStandaloneIosPlayerBar(); - }); - - audio.addEventListener('ended', function () { - MUSIC_IOS_AUDIO.switching = false; - MUSIC_IOS_AUDIO.playing = false; - traceStandaloneIosAudio('audio-ended', '', true); - updateStandaloneIosPlayerBar(); - - if (!standaloneIosHandleTrackEnd()) { - stopStandaloneIosKeepAlive('ended'); - syncStandaloneIosMediaSession(); - } - }); - - audio.addEventListener('error', function () { - MUSIC_IOS_AUDIO.switching = false; - MUSIC_IOS_AUDIO.playing = false; - traceStandaloneIosAudio('audio-error', audio.error ? String(audio.error.code || '') : '', true); - Lampa.Noty.show('Не удалось воспроизвести трек.'); - updateStandaloneIosPlaybackState(); - updateStandaloneIosPlayerBar(); - }); - - ['waiting', 'stalled', 'suspend', 'abort', 'emptied', 'canplay', 'canplaythrough', 'seeking', 'seeked'].forEach(function (eventName) { - audio.addEventListener(eventName, function () { - // зацикленная warmup-тишина эмитит seeking/seeked каждые ~400ms — - // не спамим трейсами и обновлениями, пока резолвится реальный src - if (audio.getAttribute('data-source-url') === 'silent-warmup') return; - - if (eventName === 'waiting' || eventName === 'stalled' || eventName === 'suspend') { - MUSIC_IOS_AUDIO.playing = !!(!audio.paused && !audio.ended); - - // зависание у хвоста при завышенной длительности = конец данных - if (maybeSyntheticStandaloneIosEnded(eventName)) return; - } else if (eventName === 'abort' || eventName === 'emptied') { - MUSIC_IOS_AUDIO.playing = false; - } - - traceStandaloneIosAudio('audio-' + eventName, '', true); - updateStandaloneIosPlaybackState(); - - // после реального завершения seek позиция локскрин-скраббера - // должна обновиться сразу, не дожидаясь timeupdate - if (eventName === 'seeked') - updateStandaloneIosPositionState(true); - }); - }); - - if (document.body && !audio.parentNode) - document.body.appendChild(audio); - - MUSIC_IOS_AUDIO.audio = audio; - MUSIC_IOS_AUDIO.timeupdateHandler = standaloneIosAudioTimeupdate; - setStandaloneIosTimeupdateListener(!document.hidden); - bindStandaloneIosAudioLifecycleTrace(); - traceStandaloneIosAudio('audio-create', '', true); - return audio; - } - - // --- keep-alive аудио-сессии (resume на заблокированном экране) --- - - // 0.3s тишины, генерится в JS как data: URI — src доступен синхронно - // прямо в обработчике жеста (сеть бы съела user-gesture кредит) и не - // зависит от сервера. Используется gesture-warmup'ом и legacy keep-alive - function buildSilentWavDataUri() { - var rate = 8000; - var samples = Math.floor(rate * 0.3); - var bytes = new Uint8Array(44 + samples); - var pos = 0; - - function str(value) { for (var i = 0; i < value.length; i++) bytes[pos++] = value.charCodeAt(i); } - function u32(value) { bytes[pos++] = value & 255; bytes[pos++] = (value >> 8) & 255; bytes[pos++] = (value >> 16) & 255; bytes[pos++] = (value >> 24) & 255; } - function u16(value) { bytes[pos++] = value & 255; bytes[pos++] = (value >> 8) & 255; } - - str('RIFF'); u32(36 + samples); str('WAVE'); - str('fmt '); u32(16); u16(1); u16(1); u32(rate); u32(rate); u16(1); u16(8); - str('data'); u32(samples); - for (var i = 0; i < samples; i++) bytes[pos++] = 128; - - var binary = ''; - for (var j = 0; j < bytes.length; j++) binary += String.fromCharCode(bytes[j]); - return 'data:audio/wav;base64,' + btoa(binary); - } - - function standaloneIosKeepAliveElement() { - if (MUSIC_IOS_AUDIO.keepAlive) return MUSIC_IOS_AUDIO.keepAlive; - - var el = document.createElement('audio'); - el.src = buildSilentWavDataUri(); - el.loop = true; - el.preload = 'auto'; - el.playsInline = true; - el.setAttribute('playsinline', 'playsinline'); - el.setAttribute('webkit-playsinline', 'webkit-playsinline'); - el.style.position = 'fixed'; - el.style.width = '0'; - el.style.height = '0'; - el.style.opacity = '0'; - el.style.pointerEvents = 'none'; - el.style.left = '-9999px'; - el.style.top = '-9999px'; - - if (document.body) document.body.appendChild(el); - - MUSIC_IOS_AUDIO.keepAlive = el; - return el; - } - - // Web Audio держит аудио-сессию так же, как <audio>-луп, но НЕ участвует в - // Now Playing: иконка play/pause на локскрине следует за состоянием основного - // трека. false → откат на старый <audio>-луп (иконка будет врать на паузе) - var MUSIC_IOS_KEEPALIVE_WEBAUDIO = true; - - function standaloneIosKeepAliveContext() { - if (MUSIC_IOS_AUDIO.keepAliveCtx) return MUSIC_IOS_AUDIO.keepAliveCtx; - - var Ctor = window.AudioContext || window.webkitAudioContext; - if (!Ctor) return null; - - var ctx; - try { - ctx = new Ctor(); - } catch (e) { - return null; - } - - try { - var gain = ctx.createGain(); - gain.gain.value = 0; - gain.connect(ctx.destination); - - var source; - if (typeof ctx.createConstantSource === 'function') { - source = ctx.createConstantSource(); - } else { - source = ctx.createOscillator(); - source.frequency.value = 20; - } - source.connect(gain); - source.start(); - } catch (e) {} - - ctx.onstatechange = function () { - traceStandaloneIosAudio('keepalive-ctx-state', ctx.state || '', true); - - if (MUSIC_IOS_AUDIO.keepAliveActive && ctx.state !== 'running') { - try { ctx.resume(); } catch (e) {} - } - }; - - MUSIC_IOS_AUDIO.keepAliveCtx = ctx; - armStandaloneIosKeepAliveGestureUnlock(); - return ctx; - } - - // iOS переводит AudioContext в running только из реального жеста на видимой - // странице: если плейлист поднялся через restore без тапа, контекст висит - // interrupted и keep-alive ничего не держит (resume() из фона даёт - // InvalidStateError). Любой тап по странице поднимает контекст - function armStandaloneIosKeepAliveGestureUnlock() { - if (MUSIC_IOS_AUDIO.keepAliveGestureArmed) return; - MUSIC_IOS_AUDIO.keepAliveGestureArmed = true; - - var unlock = function () { - var ctx = MUSIC_IOS_AUDIO.keepAliveCtx; - - if (!ctx || !MUSIC_IOS_AUDIO.keepAliveActive) return; - if (ctx.state === 'running') return; - - traceStandaloneIosAudio('keepalive-gesture-resume', ctx.state || '', true); - - try { - var promise = ctx.resume(); - if (promise && typeof promise.catch === 'function') promise.catch(function () {}); - } catch (e) {} - }; - - ['touchend', 'mousedown', 'keydown'].forEach(function (eventName) { - document.addEventListener(eventName, unlock, true); - }); - } - - // бесшумный источник работает ПОСТОЯННО, пока standalone-плеер активен: - // стартовать его в момент паузы под блокировкой поздно — запуск из фоновой - // страницы iOS замораживает; запущенный на видимой странице в жестовом - // контексте, он переживает блокировку и не даёт деактивировать аудио-сессию - function startStandaloneIosKeepAlive(origin) { - bumpMusicHeatMetric('keepAliveStartCall'); - MUSIC_IOS_AUDIO.keepAliveActive = true; - - if (MUSIC_IOS_KEEPALIVE_WEBAUDIO) { - var ctx = standaloneIosKeepAliveContext(); - - if (!ctx) { - MUSIC_IOS_AUDIO.keepAliveActive = false; - traceStandaloneIosAudio('keepalive-error', origin + ' no-webaudio', true); - return; - } - - try { - var resumePromise = ctx.resume(); - if (resumePromise && typeof resumePromise.catch === 'function') resumePromise.catch(function (error) { - traceStandaloneIosAudio('keepalive-rejected', origin + ' ' + (error && error.name ? error.name : String(error || '')), true); - }); - bumpMusicHeatMetric('keepAliveStartWebAudio'); - traceStandaloneIosAudio('keepalive-start', origin + ' webaudio ' + (ctx.state || ''), true); - } catch (e) { - MUSIC_IOS_AUDIO.keepAliveActive = false; - traceStandaloneIosAudio('keepalive-error', origin + ' ' + (e && e.name ? e.name : String(e || '')), true); - } - - return; - } - - var el = standaloneIosKeepAliveElement(); - - try { - var promise = el.play(); - if (promise && typeof promise.catch === 'function') promise.catch(function (error) { - MUSIC_IOS_AUDIO.keepAliveActive = false; - traceStandaloneIosAudio('keepalive-rejected', origin + ' ' + (error && error.name ? error.name : String(error || '')), true); - }); - bumpMusicHeatMetric('keepAliveStartLoop'); - traceStandaloneIosAudio('keepalive-start', origin || '', true); - } catch (e) { - MUSIC_IOS_AUDIO.keepAliveActive = false; - traceStandaloneIosAudio('keepalive-error', origin + ' ' + (e && e.name ? e.name : String(e || '')), true); - } - } - - // если iOS остановила/заморозила keep-alive пока страница была скрыта, - // на видимой странице его можно перезапустить - function reviveStandaloneIosKeepAlive() { - if (!MUSIC_IOS_AUDIO.keepAliveActive) return; - - if (MUSIC_IOS_KEEPALIVE_WEBAUDIO) { - var ctx = MUSIC_IOS_AUDIO.keepAliveCtx; - if (!ctx || ctx.state === 'running') return; - - bumpMusicHeatMetric('keepAliveReviveWebAudio'); - traceStandaloneIosAudio('keepalive-revive', 'webaudio ' + (ctx.state || ''), true); - - try { - var resumePromise = ctx.resume(); - if (resumePromise && typeof resumePromise.catch === 'function') resumePromise.catch(function () {}); - } catch (e) {} - - return; - } - - var el = MUSIC_IOS_AUDIO.keepAlive; - if (!el || !el.paused) return; - - bumpMusicHeatMetric('keepAliveReviveLoop'); - traceStandaloneIosAudio('keepalive-revive', '', true); - - try { - var promise = el.play(); - if (promise && typeof promise.catch === 'function') promise.catch(function () {}); - } catch (e) {} - } - - function stopStandaloneIosKeepAlive(origin) { - if (MUSIC_IOS_AUDIO.keepAliveActive) - traceStandaloneIosAudio('keepalive-stop', origin || '', true); - - bumpMusicHeatMetric('keepAliveStop'); - MUSIC_IOS_AUDIO.keepAliveActive = false; - - if (MUSIC_IOS_AUDIO.keepAliveCtx) { - try { - MUSIC_IOS_AUDIO.keepAliveCtx.suspend(); - } catch (e) {} - } - - if (MUSIC_IOS_AUDIO.keepAlive) { - try { - MUSIC_IOS_AUDIO.keepAlive.pause(); - } catch (e) {} - } - } - - function clearStandaloneIosMediaSession() { - var session = mediaSessionObject(); - if (!session) return; - - ['play', 'pause', 'seekto', 'seekforward', 'seekbackward', 'previoustrack', 'nexttrack'].forEach(function (action) { - setMediaSessionHandler(action, null); - }); - - try { - session.metadata = null; - } catch (e) {} - - try { - if ('playbackState' in session) session.playbackState = 'none'; - } catch (e) {} - - MUSIC_IOS_AUDIO.mediaSessionTrackId = null; - MUSIC_IOS_AUDIO.mediaSessionArmed = false; - MUSIC_IOS_AUDIO.lastPositionSync = 0; - } - - // --- shuffle / repeat / порядок воспроизведения --- - - function getStandaloneIosRepeatMode() { - var mode = String(Lampa.Storage.get(MUSIC.storage.repeat_mode, 'off') || 'off'); - return mode === 'one' || mode === 'all' ? mode : 'off'; - } - - function isStandaloneIosShuffle() { - return Lampa.Storage.get(MUSIC.storage.shuffle, false) === true; - } - - function rebuildStandaloneIosShuffleOrder() { - var length = hasStandaloneIosRestoredQueue() - ? queueTracks().length - : MUSIC_IOS_AUDIO.playlist.length; - var currentIndex = hasStandaloneIosRestoredQueue() - ? queueCurrentIndex() - : MUSIC_IOS_AUDIO.currentIndex; - var order = []; - - for (var i = 0; i < length; i++) order.push(i); - - for (var j = order.length - 1; j > 0; j--) { - var k = Math.floor(Math.random() * (j + 1)); - var swap = order[j]; - order[j] = order[k]; - order[k] = swap; - } - - // текущий трек — в начало порядка: «дальше» предсказуемо продолжает игру - var currentPos = order.indexOf(currentIndex); - if (currentPos > 0) { - order.splice(currentPos, 1); - order.unshift(currentIndex); - } - - MUSIC_IOS_AUDIO.shuffleOrder = order; - } - - function standaloneIosOrder() { - if (!isStandaloneIosShuffle()) return null; - - var length = hasStandaloneIosRestoredQueue() - ? queueTracks().length - : MUSIC_IOS_AUDIO.playlist.length; - if (!length) return null; - - if (!Array.isArray(MUSIC_IOS_AUDIO.shuffleOrder) || MUSIC_IOS_AUDIO.shuffleOrder.length !== length) - rebuildStandaloneIosShuffleOrder(); - - return MUSIC_IOS_AUDIO.shuffleOrder; - } - - // соседний индекс воспроизведения с учётом shuffle-порядка и repeat=all - function standaloneIosNeighborIndex(offset) { - var length = hasStandaloneIosRestoredQueue() - ? queueTracks().length - : MUSIC_IOS_AUDIO.playlist.length; - var current = hasStandaloneIosRestoredQueue() - ? queueCurrentIndex() - : MUSIC_IOS_AUDIO.currentIndex; - - if (!length || current < 0) return -1; - - var order = standaloneIosOrder(); - var pos = order ? order.indexOf(current) : current; - if (pos < 0) pos = 0; - - var nextPos = pos + offset; - - if (getStandaloneIosRepeatMode() === 'all' && length > 1) - nextPos = ((nextPos % length) + length) % length; - - if (nextPos < 0 || nextPos >= length) return -1; - - return order ? order[nextPos] : nextPos; - } - - // конец трека: repeat one → сыграть заново, иначе — следующий по порядку - function standaloneIosHandleTrackEnd() { - if (getStandaloneIosRepeatMode() === 'one') { - var audio = MUSIC_IOS_AUDIO.audio; - if (!audio) return false; - - try { - audio.currentTime = 0; - } catch (e) {} - - try { - var promise = audio.play(); - if (promise && typeof promise.catch === 'function') promise.catch(function () {}); - } catch (e) { - return false; - } - - updateStandaloneIosPlaybackState(); - updateStandaloneIosPositionState(true); - return true; - } - - var nextIndex = standaloneIosNeighborIndex(1); - if (nextIndex < 0) waitForRadioAutoplay(MUSIC_IOS_AUDIO.audio); - return nextIndex >= 0 && standaloneIosPlayIndex(nextIndex); - } - - function toggleStandaloneIosShuffle() { - var enabled = !isStandaloneIosShuffle(); - - Lampa.Storage.set(MUSIC.storage.shuffle, enabled); - MUSIC_IOS_AUDIO.shuffleOrder = null; - - Lampa.Noty.show(enabled ? 'Перемешивание включено.' : 'Перемешивание выключено.'); - updateStandaloneIosPlayerBar(); - syncStandaloneIosMediaSession(); - } - - function cycleStandaloneIosRepeatMode() { - var mode = getStandaloneIosRepeatMode(); - var next = mode === 'off' ? 'all' : (mode === 'all' ? 'one' : 'off'); - - Lampa.Storage.set(MUSIC.storage.repeat_mode, next); - - Lampa.Noty.show(next === 'all' ? 'Повтор очереди.' : (next === 'one' ? 'Повтор трека.' : 'Повтор выключен.')); - updateStandaloneIosPlayerBar(); - syncStandaloneIosMediaSession(); - } - - var MUSIC_IOS_ART_TINT_CACHE = {}; - - // средний цвет обложки → верх фонового градиента (обложки идут через - // image-proxy того же origin, поэтому canvas не tainted; чужой хост - // упадёт в catch и оставит дефолтный тинт) - // --- тинт обложки, состояние плеера и общие действия (бар + фулл-плеер) --- - - function computeStandaloneIosArtTint(url, callback) { - if (!url) { callback(''); return; } - if (Object.prototype.hasOwnProperty.call(MUSIC_IOS_ART_TINT_CACHE, url)) { - callback(MUSIC_IOS_ART_TINT_CACHE[url]); - return; - } - - var img = new Image(); - - img.onload = function () { - var tint = ''; - - try { - var size = 12; - var canvas = document.createElement('canvas'); - canvas.width = size; - canvas.height = size; - - var ctx = canvas.getContext('2d'); - ctx.drawImage(img, 0, 0, size, size); - - var data = ctx.getImageData(0, 0, size, size).data; - var r = 0, g = 0, b = 0, count = data.length / 4; - - for (var i = 0; i < data.length; i += 4) { - r += data[i]; - g += data[i + 1]; - b += data[i + 2]; - } - - r /= count; g /= count; b /= count; - - // подтянуть к комфортной яркости фона, сохранив оттенок - var peak = Math.max(r, g, b, 1); - var scale = Math.min(2.2, 118 / peak); - r = Math.round(Math.min(255, r * scale)); - g = Math.round(Math.min(255, g * scale)); - b = Math.round(Math.min(255, b * scale)); - - tint = 'rgba(' + r + ',' + g + ',' + b + ',0.5)'; - } catch (e) { - tint = ''; - } - - setCappedCacheEntry(MUSIC_IOS_ART_TINT_CACHE, url, tint, 80); - callback(tint); - }; - - img.onerror = function () { - setCappedCacheEntry(MUSIC_IOS_ART_TINT_CACHE, url, '', 80); - callback(''); - }; - - img.src = url; - } - - function standaloneIosPlayerState() { - var audio = MUSIC_IOS_AUDIO.audio; - var restored = hasStandaloneIosRestoredQueue(); - var tracks = restored ? queueTracks() : (MUSIC_IOS_AUDIO.tracks || []); - var currentIndex = restored ? queueCurrentIndex() : MUSIC_IOS_AUDIO.currentIndex; - var active = (isStandaloneIosAudioActive() || restored) && tracks.length > 0; - var track = active ? tracks[currentIndex] : null; - var duration = active && track ? standaloneIosEffectiveDuration(restored ? null : audio, track) : 0; - var current = restored - ? Math.max(0, Number(MUSIC_QUEUE_RESTORE.position || 0)) - : (audio && isFinite(audio.currentTime) ? audio.currentTime : 0); - - if (duration) current = Math.max(0, Math.min(duration, current)); - - return { - active: active, - restored: restored, - audio: restored ? null : audio, - track: track, - tracks: tracks, - currentIndex: currentIndex, - hasPrev: active && standaloneIosNeighborIndex(-1) >= 0, - hasNext: active && standaloneIosNeighborIndex(1) >= 0, - canQueue: active && tracks.length > 1, - duration: duration || 0, - current: current || 0, - playing: !!(!restored && audio && MUSIC_IOS_AUDIO.playing && !audio.paused) - }; - } - - function seekStandaloneIosByRangeValue(value) { - var state = standaloneIosPlayerState(); - var position = state.duration ? (state.duration * Number(value || 0) / 1000) : 0; - - if (state.restored) { - MUSIC_QUEUE_RESTORE.position = Math.max(0, Math.min(state.duration || position, position)); - scheduleQueueSnapshotSave(true); - } else if (state.audio && isFinite(position)) { - try { - state.audio.currentTime = Math.max(0, Math.min(state.duration || position, position)); - } catch (e) {} - } - - updateStandaloneIosPositionState(true); - updateStandaloneIosPlayerBar(); - } - - function handleStandaloneIosPlayerAction(action) { - var audio = MUSIC_IOS_AUDIO.audio; - - if (action === 'expand') { - openStandaloneIosFullPlayer(); - return; - } - - if (action === 'collapse') { - closeStandaloneIosFullPlayer(); - return; - } - - if (action === 'sheet-close') { - closeStandaloneIosSheet(); - return; - } - - if (action === 'queue') { - if (MUSIC_IOS_FULL_PLAYER_OPEN) openStandaloneIosQueueSheet(); - else openStandaloneIosFullPlayer(); - return; - } - - if (action === 'radio') { - var radioEnabled = !isRadioAutoplayEnabled(); - setRadioAutoplayEnabled(radioEnabled); - Lampa.Noty.show(radioEnabled ? 'Автоподборка треков включена.' : 'Автоподборка треков выключена.'); - if (MUSIC_IOS_FULL_PLAYER_OPEN) updateStandaloneIosFullPlayer(); - return; - } - - if (action === 'sources') { - openStandaloneIosSourcesSheet(); - return; - } - - if (action === 'lyrics') { - openStandaloneIosLyricsSheet(); - return; - } - - if (action === 'bookmark') { - toggleStandaloneIosCurrentBookmark(); - return; - } - - if (action === 'timer') { - openStandaloneIosTimerSheet(); - return; - } - - if (action === 'stop') { - if (MUSIC_IOS_FULL_PLAYER_OPEN) closeStandaloneIosFullPlayer(); - stopStandaloneIosAudioPlayback(); - return; - } - - if (hasStandaloneIosRestoredQueue()) { - if (action === 'shuffle') { - toggleStandaloneIosShuffle(); - return; - } - - if (action === 'repeat') { - cycleStandaloneIosRepeatMode(); - return; - } - - if (action === 'prev') { - var restoredPrev = standaloneIosNeighborIndex(-1); - if (restoredPrev >= 0) selectRestoredQueueIndex(restoredPrev); - return; - } - - if (action === 'next') { - var restoredNext = standaloneIosNeighborIndex(1); - if (restoredNext >= 0) selectRestoredQueueIndex(restoredNext); - return; - } - - if (action === 'playpause') { - resumeRestoredQueueSnapshot(); - return; - } - } - - if (!isStandaloneIosAudioActive() || !audio) return; - - if (action === 'shuffle') { - toggleStandaloneIosShuffle(); - return; - } - - if (action === 'repeat') { - cycleStandaloneIosRepeatMode(); - return; - } - - if (action === 'prev') { - var prevIndex = standaloneIosNeighborIndex(-1); - if (prevIndex >= 0) standaloneIosPlayIndex(prevIndex); - return; - } - - if (action === 'next') { - var nextIndex = standaloneIosNeighborIndex(1); - if (nextIndex >= 0) standaloneIosPlayIndex(nextIndex); - return; - } - - if (action === 'playpause') { - cancelRadioAutoplayResume(); - if (audio.paused) { - traceStandaloneIosAudio('bar-play', '', true); - startStandaloneIosKeepAlive('bar-play'); - var promise = audio.play(); - if (promise && typeof promise.catch === 'function') promise.catch(function (error) { - traceStandaloneIosAudio('bar-play-rejected', error && error.name ? error.name : String(error || ''), true); - }); - } else { - traceStandaloneIosAudio('bar-pause', '', true); - audio.pause(); - startStandaloneIosKeepAlive('bar-pause'); - } - - updateStandaloneIosPlaybackState(); - updateStandaloneIosPlayerBar(); - } - } - - // ===== IOS FULL PLAYER ===== - - // --- открытие/закрытие фулл-плеера, back-навигация --- - - function refreshStandaloneIosFullFocus() { - var player = MUSIC_IOS_FULL_PLAYER; - if (!MUSIC_IOS_FULL_PLAYER_OPEN || !player || !player.length) return; - if (safeControllerName() !== 'lampac_music_full_player') return; - - var sheetOpen = player.hasClass('lm-ios-full-player--sheet-open'); - var root = player.find(sheetOpen ? '.lm-ios-full-player__sheet-panel' : '.lm-ios-full-player__shell').get(0); - if (!root) return; - - var target = player.data(sheetOpen ? 'sheetFocus' : 'mainFocus'); - if (!target || !root.contains(target) || !$(target).hasClass('selector') || $(target).hasClass('disabled')) { - target = sheetOpen - ? $(root).find('.lm-ios-full-player__queue-item--current, .lm-ios-full-player__sheet-row.selector:not(.disabled), .lm-ios-full-player__lyrics-offset .selector').first().get(0) - || $(root).find('[data-action="sheet-close"]').get(0) - : $(root).find('[data-action="playpause"]').get(0); - } - - Lampa.Controller.collectionSet(root, false, true); - Lampa.Controller.collectionFocus(target || false, root); - } - - function scheduleStandaloneIosFullFocus() { - var player = MUSIC_IOS_FULL_PLAYER; - if (!MUSIC_IOS_FULL_PLAYER_OPEN || !player || player.data('focusRefreshTimer')) return; - - // Batch row creation; never rebuild the focus collection on timeupdate. - player.data('focusRefreshTimer', setTimeout(function () { - player.removeData('focusRefreshTimer'); - refreshStandaloneIosFullFocus(); - }, 0)); - } - - function moveStandaloneIosFullFocus(direction) { - var player = MUSIC_IOS_FULL_PLAYER; - if (!player || !MUSIC_IOS_FULL_PLAYER_OPEN) return; - - var sheetOpen = player.hasClass('lm-ios-full-player--sheet-open'); - var target = player.data(sheetOpen ? 'sheetFocus' : 'mainFocus'); - if (target && $(target).hasClass('lm-ios-full-player__seek') && (direction === 'left' || direction === 'right')) { - stepStandaloneIosSeek(target, direction); - return; - } - - if (target && $(target).hasClass('lm-ios-full-player__lyrics--plain') && (direction === 'up' || direction === 'down')) { - var body = player.find('.lm-ios-full-player__sheet-body').get(0); - var before = body.scrollTop; - body.scrollTop += (direction === 'down' ? 1 : -1) * body.clientHeight * 0.6; - if (body.scrollTop !== before) return; - } - - Navigator.move(direction); - } - - function backStandaloneIosFullPlayer() { - var player = MUSIC_IOS_FULL_PLAYER; - if (!MUSIC_IOS_FULL_PLAYER_OPEN || !player) return; - - if (player.hasClass('lm-ios-full-player--sheet-open')) { - if (player.attr('data-sheet-kind') === 'queue-item') openStandaloneIosQueueSheet(); - else closeStandaloneIosSheet(); - } else closeStandaloneIosFullPlayer(); - } - - function acceptStandaloneIosControlEvent(event, target) { - event.preventDefault(); - event.stopPropagation(); - if ($(target).hasClass('disabled')) return false; - - var full = $(target).closest('.lm-ios-full-player'); - if (full.length) { - var sheetOpen = full.hasClass('lm-ios-full-player--sheet-open'); - var inSheet = $(target).closest('.lm-ios-full-player__sheet-panel').length > 0; - if (!MUSIC_IOS_FULL_PLAYER_OPEN || sheetOpen !== inSheet) return false; - full.data(inSheet ? 'sheetFocus' : 'mainFocus', target); - } else { - var bar = $(target).closest('.lm-ios-player'); - if (MUSIC_IOS_FULL_PLAYER_OPEN || !bar.hasClass('lm-ios-player--visible')) return false; - bar.data('focusTarget', target); - } - - var previous = $(target).data('musicControlEvent'); - var now = Date.now(); - // Lampa emits hover:enter shortly after a native click on a selector. - if (previous && previous.type !== event.type && now - previous.at < 300) return false; - $(target).data('musicControlEvent', { type: event.type, at: now }); - return true; - } - - function openStandaloneIosFullPlayer() { - var player = ensureStandaloneIosFullPlayer(); - - if (!player || !player.length) return; - - if (!MUSIC_IOS_FULL_PLAYER_OPEN) - MUSIC_IOS_FULL_PLAYER_RETURN_CONTROLLER = currentStandaloneIosControllerName(); - - MUSIC_IOS_FULL_PLAYER_OPEN = true; - player.addClass('lm-ios-full-player--visible'); - var root = player.get(0); - if (root) { - root.scrollTop = 0; - root.scrollLeft = 0; - } - player.attr('data-scroll-current', 'true'); - $('body').addClass('lm-ios-full-player-open'); - bumpMusicHeatMetric('fullPlayerOpen'); - logStandaloneIosFullEvent('open'); - updateStandaloneIosFullPlayer(); - - if (Lampa.Controller && typeof Lampa.Controller.add === 'function') { - Lampa.Controller.add('lampac_music_full_player', { - toggle: refreshStandaloneIosFullFocus, - left: function () { moveStandaloneIosFullFocus('left'); }, - right: function () { moveStandaloneIosFullFocus('right'); }, - up: function () { moveStandaloneIosFullFocus('up'); }, - down: function () { moveStandaloneIosFullFocus('down'); }, - back: backStandaloneIosFullPlayer - }); - Lampa.Controller.toggle('lampac_music_full_player'); - } - } - - function scrollStandaloneIosElement(element, block, smooth) { - if (!element || typeof element.getBoundingClientRect !== 'function') return; - - var root = $(element).closest('.lm-ios-full-player').get(0); - if (!root) return; - - // overflow:hidden is still programmatically scrollable in WebKit. Keep the - // fullscreen root fixed and move only one of the lists owned by the player. - root.scrollTop = 0; - root.scrollLeft = 0; - - var scroller = $(element).closest('.lm-ios-full-player__queue-list, .lm-ios-full-player__sheet-body').get(0); - if (!scroller || !root.contains(scroller)) return; - - var maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); - if (!maxScrollTop) return; - - var elementBox = element.getBoundingClientRect(); - var scrollerBox = scroller.getBoundingClientRect(); - var target = scroller.scrollTop; - - if (block === 'center') { - target += elementBox.top - scrollerBox.top - (scroller.clientHeight - elementBox.height) / 2; - } else if (elementBox.top < scrollerBox.top) { - target += elementBox.top - scrollerBox.top; - } else if (elementBox.bottom > scrollerBox.bottom) { - target += elementBox.bottom - scrollerBox.bottom; - } else { - return; - } - - target = Math.max(0, Math.min(maxScrollTop, Math.round(target))); - if (target === scroller.scrollTop) return; - - if (smooth && typeof scroller.scrollTo === 'function') { - try { - scroller.scrollTo({ top: target, behavior: 'smooth' }); - return; - } catch (e) {} - } - - scroller.scrollTop = target; - } - - function closeStandaloneIosFullPlayer() { - bumpMusicHeatMetric('fullPlayerClose'); - logStandaloneIosFullEvent('close'); - MUSIC_IOS_FULL_PLAYER_OPEN = false; - - if (MUSIC_IOS_FULL_PLAYER && MUSIC_IOS_FULL_PLAYER.length) { - clearTimeout(MUSIC_IOS_FULL_PLAYER.data('focusRefreshTimer')); - MUSIC_IOS_FULL_PLAYER.removeData('focusRefreshTimer'); - MUSIC_IOS_FULL_PLAYER.removeClass('lm-ios-full-player--visible'); - MUSIC_IOS_FULL_PLAYER.removeAttr('data-seeking'); - MUSIC_IOS_FULL_PLAYER.removeAttr('data-scroll-current'); - closeStandaloneIosSheet(); - } - - $('body').removeClass('lm-ios-full-player-open'); - - if (Lampa.Controller && typeof Lampa.Controller.toggle === 'function') { - var controller = MUSIC_IOS_FULL_PLAYER_RETURN_CONTROLLER || 'content'; - - MUSIC_IOS_FULL_PLAYER_RETURN_CONTROLLER = 'content'; - if (controller === 'lampac_music_player_bar' && !standaloneIosPlayerState().active) - controller = MUSIC_IOS_BAR.data('returnController') || 'content'; - if (controller !== 'lampac_music_full_player') Lampa.Controller.toggle(controller); - } - flushStandaloneIosDeferredHomeRefresh(); - } - - function currentStandaloneIosControllerName() { - try { - if (Lampa.Controller && typeof Lampa.Controller.enabled === 'function') { - var enabled = Lampa.Controller.enabled(); - var name = enabled && enabled.name ? enabled.name : ''; - - if (name && name !== 'lampac_music_full_player') return name; - } - } catch (e) {} - - return 'content'; - } - - function handleStandaloneIosFullPlayerBack(event) { - if (!MUSIC_IOS_FULL_PLAYER_OPEN) return; - if (safeControllerName() !== 'lampac_music_full_player') return; - - var key = event && (event.key || event.code || ''); - var code = event && (event.keyCode || event.which || 0); - var isBack = key === 'Escape' - || key === 'Backspace' - || key === 'BrowserBack' - || code === 8 - || code === 27 - || code === 461 - || code === 10009; - - if (!isBack) return; - - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - if (!event.repeat) backStandaloneIosFullPlayer(); - } - - // --- нативные свайпы: закрытие плеера и шита жестом --- - - function isStandaloneIosGestureTargetBlocked(target) { - return !!$(target).closest('input, textarea, select, [data-action], .lm-ios-full-player__sheet-panel, .lm-ios-full-player__queue-list').length; - } - - function standaloneIosGestureVelocity(touch, axis) { - if (!touch || !touch.prevTime || !touch.lastTime || touch.lastTime <= touch.prevTime) return 0; - - var distance = axis === 'x' ? (touch.lastX - touch.prevX) : (touch.lastY - touch.prevY); - return Math.abs(distance) / Math.max(1, touch.lastTime - touch.prevTime); - } - - function standaloneIosShellCanScroll(player) { - return false; - } - - function standaloneIosShellCanCaptureVertical(player, dy) { - return true; - } - - function setStandaloneIosFullGestureTransform(player, mode, dx, dy) { - var shell = player.find('.lm-ios-full-player__shell'); - - if (!shell.length) return; - - player.addClass('lm-ios-full-player--gesture-dragging'); - - if (mode === 'vertical') { - var y = dy > 0 ? dy * 0.62 : dy * 0.24; - shell.css({ - transform: 'translate3d(0,' + y + 'px,0)', - opacity: String(Math.max(0.72, 1 - Math.max(0, dy) / 900)) - }); - return; - } - - var x = dx * 0.22; - shell.css({ - transform: 'translate3d(' + x + 'px,0,0)', - opacity: String(Math.max(0.78, 1 - Math.abs(dx) / 850)) - }); - } - - function resetStandaloneIosFullGestureTransform(player) { - if (!player || !player.length) return; - - player.removeClass('lm-ios-full-player--gesture-dragging'); - player.find('.lm-ios-full-player__shell').css({ - transform: '', - opacity: '' - }); - } - - function closeStandaloneIosFullPlayerWithGesture(player) { - var shell = player.find('.lm-ios-full-player__shell'); - - bumpMusicHeatMetric('fullPlayerGestureClose'); - player.removeClass('lm-ios-full-player--gesture-dragging'); - shell.css({ - transform: 'translate3d(0,110%,0)', - opacity: '0' - }); - - setTimeout(function () { - resetStandaloneIosFullGestureTransform(player); - closeStandaloneIosFullPlayer(); - }, 200); - } - - function resetStandaloneIosSheetGestureTransform(player) { - if (!player || !player.length) return; - - player.removeClass('lm-ios-full-player--sheet-dragging'); - player.find('.lm-ios-full-player__sheet-panel').css('transform', ''); - } - - function setStandaloneIosSheetGestureTransform(player, dy) { - if (!player || !player.length || dy <= 0) return; - - player.addClass('lm-ios-full-player--sheet-dragging'); - player.find('.lm-ios-full-player__sheet-panel').css('transform', 'translate3d(0,' + (dy * 0.82) + 'px,0)'); - } - - function shouldCloseStandaloneIosSheetByGesture(dy, vy) { - return dy > 72 || (dy > 40 && vy > 0.34); - } - - function closeStandaloneIosSheetWithGesture(player) { - if (!player || !player.length) return closeStandaloneIosSheet(); - - player.removeClass('lm-ios-full-player--sheet-dragging'); - player.find('.lm-ios-full-player__sheet-panel').css('transform', 'translate3d(0,110%,0)'); - - setTimeout(function () { - closeStandaloneIosSheet(); - resetStandaloneIosSheetGestureTransform(player); - }, 200); - } - - function bindStandaloneIosFullPlayerGestures(player) { - if (!player || !player.length || player.attr('data-gestures-bound') === 'true') return; - - var element = player.get(0); - var fullTouch = null; - var sheetTouch = null; - var queuePullTouch = null; - - player.attr('data-gestures-bound', 'true'); - - element.addEventListener('touchstart', function (event) { - if (!MUSIC_IOS_FULL_PLAYER_OPEN || player.hasClass('lm-ios-full-player--sheet-open')) return; - if (!event.touches || event.touches.length !== 1) return; - if (isStandaloneIosGestureTargetBlocked(event.target)) return; - - var touch = event.touches[0]; - fullTouch = { - x: touch.clientX, - y: touch.clientY, - lastX: touch.clientX, - lastY: touch.clientY, - prevX: touch.clientX, - prevY: touch.clientY, - time: Date.now(), - lastTime: Date.now(), - prevTime: Date.now(), - mode: '' - }; - }, { passive: true }); - - element.addEventListener('touchmove', function (event) { - if (!fullTouch || !event.touches || event.touches.length !== 1) return; - - var touch = event.touches[0]; - var now = Date.now(); - var dx = touch.clientX - fullTouch.x; - var dy = touch.clientY - fullTouch.y; - var absX = Math.abs(dx); - var absY = Math.abs(dy); - - fullTouch.prevX = fullTouch.lastX; - fullTouch.prevY = fullTouch.lastY; - fullTouch.prevTime = fullTouch.lastTime; - fullTouch.lastX = touch.clientX; - fullTouch.lastY = touch.clientY; - fullTouch.lastTime = now; - - if (!fullTouch.mode && Math.max(absX, absY) > 14) { - if (absX > absY * 1.18) { - fullTouch.mode = 'horizontal'; - } else if (absY > absX * 1.18 && standaloneIosShellCanCaptureVertical(player, dy)) { - fullTouch.mode = 'vertical'; - } else if (standaloneIosShellCanScroll(player)) { - fullTouch = null; - return; - } - } - - if (!fullTouch || !fullTouch.mode) return; - - event.preventDefault(); - bumpMusicHeatMetric('fullPlayerGestureMove'); - setStandaloneIosFullGestureTransform(player, fullTouch.mode, dx, dy); - }, { passive: false }); - - element.addEventListener('touchend', function () { - if (!fullTouch) return; - - var dx = fullTouch.lastX - fullTouch.x; - var dy = fullTouch.lastY - fullTouch.y; - var absX = Math.abs(dx); - var absY = Math.abs(dy); - var mode = fullTouch.mode; - var vx = standaloneIosGestureVelocity(fullTouch, 'x'); - var vy = standaloneIosGestureVelocity(fullTouch, 'y'); - var height = Math.max(1, element.clientHeight || window.innerHeight || 1); - - fullTouch = null; - - if (mode === 'vertical' && absY > absX * 1.18 && (absY > Math.min(150, height * 0.18) || (absY > 40 && vy > 0.32))) { - if (dy > 0) { - closeStandaloneIosFullPlayerWithGesture(player); - } else { - resetStandaloneIosFullGestureTransform(player); - openStandaloneIosQueueSheet(); - } - return; - } - - resetStandaloneIosFullGestureTransform(player); - - if (mode === 'horizontal' && absX > 78 && absX > absY * 1.25 && vx > 0.16) { - if (dx < 0) handleStandaloneIosPlayerAction('next'); - else handleStandaloneIosPlayerAction('prev'); - } - }, { passive: true }); - - element.addEventListener('touchcancel', function () { - fullTouch = null; - resetStandaloneIosFullGestureTransform(player); - }, { passive: true }); - - player.on('touchstart', '.lm-ios-full-player__sheet-head', function (event) { - var original = event.originalEvent; - if (!original || !original.touches || original.touches.length !== 1) return; - - var touch = original.touches[0]; - sheetTouch = { - y: touch.clientY, - lastY: touch.clientY, - prevY: touch.clientY, - lastTime: Date.now(), - prevTime: Date.now() - }; - }); - - player.on('touchmove', '.lm-ios-full-player__sheet-head', function (event) { - var original = event.originalEvent; - if (!sheetTouch || !original || !original.touches || original.touches.length !== 1) return; - - var touch = original.touches[0]; - var now = Date.now(); - var dy = touch.clientY - sheetTouch.y; - - sheetTouch.prevY = sheetTouch.lastY; - sheetTouch.prevTime = sheetTouch.lastTime; - sheetTouch.lastY = touch.clientY; - sheetTouch.lastTime = now; - - if (dy <= 0) return; - - event.preventDefault(); - bumpMusicHeatMetric('fullPlayerSheetGestureMove'); - setStandaloneIosSheetGestureTransform(player, dy); - }); - - player.on('touchend touchcancel', '.lm-ios-full-player__sheet-head', function () { - if (!sheetTouch) return; - - var dy = sheetTouch.lastY - sheetTouch.y; - var vy = standaloneIosGestureVelocity(sheetTouch, 'y'); - - sheetTouch = null; - - if (shouldCloseStandaloneIosSheetByGesture(dy, vy)) { - closeStandaloneIosSheetWithGesture(player); - } else { - resetStandaloneIosSheetGestureTransform(player); - } - }); - - player.on('touchstart', '.lm-ios-full-player__queue-list', function (event) { - var original = event.originalEvent; - if (!original || !original.touches || original.touches.length !== 1) return; - - var touch = original.touches[0]; - queuePullTouch = { - x: touch.clientX, - y: touch.clientY, - lastX: touch.clientX, - lastY: touch.clientY, - prevX: touch.clientX, - prevY: touch.clientY, - lastTime: Date.now(), - prevTime: Date.now(), - captured: false, - scroller: this - }; - }); - - player.on('touchmove', '.lm-ios-full-player__queue-list', function (event) { - var original = event.originalEvent; - if (!queuePullTouch || !original || !original.touches || original.touches.length !== 1) return; - - var touch = original.touches[0]; - var now = Date.now(); - var dx = touch.clientX - queuePullTouch.x; - var dy = touch.clientY - queuePullTouch.y; - var absX = Math.abs(dx); - var absY = Math.abs(dy); - var atTop = (queuePullTouch.scroller ? queuePullTouch.scroller.scrollTop : this.scrollTop) <= 1; - - queuePullTouch.prevX = queuePullTouch.lastX; - queuePullTouch.prevY = queuePullTouch.lastY; - queuePullTouch.prevTime = queuePullTouch.lastTime; - queuePullTouch.lastX = touch.clientX; - queuePullTouch.lastY = touch.clientY; - queuePullTouch.lastTime = now; - - if (!queuePullTouch.captured) { - if (dy <= 0 || absY <= 14) return; - if (!atTop || absY <= absX * 1.18) return; - queuePullTouch.captured = true; - queuePullTouch.x = touch.clientX; - queuePullTouch.y = touch.clientY; - queuePullTouch.prevX = touch.clientX; - queuePullTouch.prevY = touch.clientY; - queuePullTouch.lastX = touch.clientX; - queuePullTouch.lastY = touch.clientY; - queuePullTouch.prevTime = now; - queuePullTouch.lastTime = now; - dx = 0; - dy = 0; - } - - event.preventDefault(); - bumpMusicHeatMetric('fullPlayerQueuePullMove'); - setStandaloneIosSheetGestureTransform(player, dy); - }); - - player.on('touchend touchcancel', '.lm-ios-full-player__queue-list', function () { - if (!queuePullTouch) return; - - var dy = queuePullTouch.lastY - queuePullTouch.y; - var vy = standaloneIosGestureVelocity(queuePullTouch, 'y'); - var captured = queuePullTouch.captured; - - queuePullTouch = null; - - if (captured && shouldCloseStandaloneIosSheetByGesture(dy, vy)) { - closeStandaloneIosSheetWithGesture(player); - } else if (captured) { - resetStandaloneIosSheetGestureTransform(player); - } - }); - } - - // --- bottom-sheet: каркас, очередь --- - - function closeStandaloneIosSheet() { - if (!MUSIC_IOS_FULL_PLAYER || !MUSIC_IOS_FULL_PLAYER.length) return; - - bumpMusicHeatMetric('fullPlayerSheetClose'); - logStandaloneIosFullEvent('sheet-close', MUSIC_IOS_FULL_PLAYER.attr('data-sheet-kind') || ''); - MUSIC_IOS_FULL_PLAYER.removeClass('lm-ios-full-player--sheet-open'); - MUSIC_IOS_FULL_PLAYER.find('.lm-ios-full-player__sheet-body').removeClass('lm-ios-full-player__sheet-body--queue').empty(); - MUSIC_IOS_FULL_PLAYER.removeAttr('data-sheet-track-id'); - MUSIC_IOS_FULL_PLAYER.removeAttr('data-sheet-kind'); - MUSIC_IOS_FULL_PLAYER.removeAttr('data-lyrics-line'); - MUSIC_IOS_FULL_PLAYER.removeAttr('data-lyrics-manual'); - MUSIC_IOS_FULL_PLAYER.removeAttr('data-queue-key'); - MUSIC_IOS_FULL_PLAYER.removeAttr('data-current-index'); - MUSIC_IOS_FULL_PLAYER.removeAttr('data-scroll-current'); - MUSIC_IOS_FULL_PLAYER.removeData('sheetFocus'); - refreshStandaloneIosFullFocus(); - } - - function showStandaloneIosSheet(title) { - var player = ensureStandaloneIosFullPlayer(); - if (!player || !player.length) return $(); - - var body = player.find('.lm-ios-full-player__sheet-body'); - player.find('.lm-ios-full-player__sheet-title').text(title || ''); - body.removeClass('lm-ios-full-player__sheet-body--queue').empty(); - player.removeAttr('data-sheet-kind'); - player.removeAttr('data-sheet-track-id'); - player.removeData('sheetFocus'); - player.addClass('lm-ios-full-player--sheet-open'); - bumpMusicHeatMetric('fullPlayerSheetOpen'); - logStandaloneIosFullEvent('sheet-open', title || ''); - scheduleStandaloneIosFullFocus(); - return body; - } - - function appendStandaloneIosSheetMessage(body, message, loading) { - var item = $('<div class="lm-ios-full-player__sheet-message"></div>'); - item.toggleClass('lm-ios-full-player__sheet-message--loading', !!loading); - item.text(message || ''); - body.append(item); - return item; - } - - function appendStandaloneIosSheetRow(body, options) { - options = options || {}; - - var row = $('<div class="lm-ios-full-player__sheet-row"></div>'); - var main = $('<div class="lm-ios-full-player__sheet-row-main"></div>'); - var title = $('<div class="lm-ios-full-player__sheet-row-title"></div>').text(options.title || ''); - var subtitle = $('<div class="lm-ios-full-player__sheet-row-subtitle"></div>').text(options.subtitle || ''); - var trailing = $('<div class="lm-ios-full-player__sheet-row-trailing"></div>').text(options.trailing || ''); - - row.toggleClass('active', !!options.active); - row.toggleClass('disabled', !!options.disabled); - main.append(title); - if (options.subtitle) main.append(subtitle); - row.append(main); - if (options.trailing) row.append(trailing); - - if (options.onSelect) { - if (!options.disabled) row.addClass('selector').attr('data-controller', 'lampac_music_full_player'); - row.on('click hover:enter', function (event) { - if (!acceptStandaloneIosControlEvent(event, this)) return; - options.onSelect(row); - }); - } - - body.append(row); - bindStandaloneIosFullControls(MUSIC_IOS_FULL_PLAYER, row.filter('.selector')); - scheduleStandaloneIosFullFocus(); - return row; - } - - // операции с очередью из шита (идея из Spotube: player_queue_actions). - // Мутируем синхронно ОБА представления очереди — standalone-массивы - // (tracks+playlist параллельны!) и зеркало MUSIC_QUEUE — со сдвигом - // currentIndex; shuffleOrder сбрасывается (пересоберётся лениво), - // снапшот форсится. Текущий играющий трек не трогаем. - function mutateStandaloneIosQueue(action, index) { - var tracks = MUSIC_IOS_AUDIO.tracks || []; - var playlist = MUSIC_IOS_AUDIO.playlist || []; - var current = MUSIC_IOS_AUDIO.currentIndex; - var fromRestore = false; - - // восстановленная после рестарта очередь может жить только в - // MUSIC_QUEUE (standalone-массивы пустые, пока play не нажат) — - // тогда правим её напрямую - if (!tracks.length && hasRestoredQueueSnapshot()) { - fromRestore = true; - tracks = queueTracks(); - playlist = []; - current = queueCurrentIndex(); - } - - if (index < 0 || index >= tracks.length || tracks.length < 2) return false; - if (index === current) return false; - - var insertedAt = -1; - - function moveTo(target) { - var track = tracks.splice(index, 1)[0]; - var playback = playlist.length > index ? playlist.splice(index, 1)[0] : null; - - if (index < current) current--; - if (target > tracks.length) target = tracks.length; - - tracks.splice(target, 0, track); - if (playback !== null) playlist.splice(target, 0, playback); - - if (target <= current) current++; - insertedAt = target; - } - - if (action === 'remove') { - tracks.splice(index, 1); - if (playlist.length > index) playlist.splice(index, 1); - if (index < current) current--; - } else if (action === 'next') { - if (index === current + 1) { - if (!isStandaloneIosShuffle()) return true; // уже следующий - insertedAt = index; // физически на месте — поправим только shuffle-порядок - } else { - moveTo(index < current ? current : current + 1); - } - } else if (action === 'first') { - if (index === 0) return true; - moveTo(0); - } else if (action === 'last') { - if (index === tracks.length - 1) return true; - moveTo(tracks.length); - } else { - return false; - } - - if (!fromRestore) MUSIC_IOS_AUDIO.currentIndex = current; - MUSIC_IOS_AUDIO.shuffleOrder = null; - - MUSIC_QUEUE.tracks = tracks.slice(); - MUSIC_QUEUE.currentIndex = current; - MUSIC_QUEUE.currentTrackId = tracks[current] ? tracks[current].id : null; - - // при shuffle физический порядок на «следующий» не влияет — трек надо - // ещё и вставить сразу после текущего в свежем shuffle-порядке, иначе - // «Играть следующим» отдаст случайного соседа - if (action === 'next' && insertedAt > -1 && isStandaloneIosShuffle()) { - var order = standaloneIosOrder(); // лениво пересоберётся, текущий в начале - if (order) { - var movedPos = order.indexOf(insertedAt); - if (movedPos > -1) order.splice(movedPos, 1); - - var currentPos = order.indexOf(current); - order.splice((currentPos > -1 ? currentPos : 0) + 1, 0, insertedAt); - } - } - - scheduleQueueSnapshotSave(true); - updateStandaloneIosPlayerBar(); - return true; - } - - // вставка трека из ЛЮБОГО списка (альбом/плейлист/поиск/home) в играющую - // standalone-очередь без прерывания воспроизведения (Spotify-стиль) - function canEnqueueToStandaloneQueue(track) { - if (!track || !track.id || !shouldUseStandaloneIosAudio()) return false; - return isStandaloneIosAudioActive() || hasRestoredQueueSnapshot(); - } - - // позиция трека в живой standalone-очереди (учитывая restored-режим); - // -1 = трека в очереди нет - function standaloneQueueIndexOfTrack(trackId) { - if (!trackId) return -1; - - var tracks = (MUSIC_IOS_AUDIO.tracks || []).length - ? MUSIC_IOS_AUDIO.tracks - : (hasRestoredQueueSnapshot() ? queueTracks() : []); - - for (var i = 0; i < tracks.length; i++) { - if (tracks[i] && tracks[i].id === trackId) return i; - } - - return -1; - } - - function standaloneQueueCurrentIndexValue() { - return (MUSIC_IOS_AUDIO.tracks || []).length - ? MUSIC_IOS_AUDIO.currentIndex - : queueCurrentIndex(); - } - - function enqueueTrackToStandaloneQueue(track, mode) { - if (!canEnqueueToStandaloneQueue(track)) return false; - - var fromRestore = !(MUSIC_IOS_AUDIO.tracks || []).length && hasRestoredQueueSnapshot(); - var tracks = fromRestore ? queueTracks() : (MUSIC_IOS_AUDIO.tracks || []); - var playlist = fromRestore ? [] : (MUSIC_IOS_AUDIO.playlist || []); - var current = fromRestore ? queueCurrentIndex() : MUSIC_IOS_AUDIO.currentIndex; - - if (!tracks.length || current < 0) return false; - - // трек уже в очереди — двигаем существующий экземпляр, а не дублируем: - // учёт текущего трека (updateQueueCurrent/queueCurrentIndex) ищет по id, - // дубликаты сломали бы его - var existing = standaloneQueueIndexOfTrack(track.id); - - if (existing > -1) { - if (existing === current) return true; // уже играет - return mutateStandaloneIosQueue(mode === 'next' ? 'next' : 'last', existing); - } - - var target = mode === 'next' ? current + 1 : tracks.length; - - tracks.splice(target, 0, track); - if (!fromRestore) playlist.splice(target, 0, buildPlayback(track)); - - MUSIC_IOS_AUDIO.shuffleOrder = null; - - MUSIC_QUEUE.tracks = tracks.slice(); - MUSIC_QUEUE.currentIndex = current; - MUSIC_QUEUE.currentTrackId = tracks[current] ? tracks[current].id : null; - - // тот же приём, что в mutateStandaloneIosQueue: при shuffle новый трек - // надо ещё и поставить сразу после текущего в свежем shuffle-порядке - if (mode === 'next' && isStandaloneIosShuffle()) { - var order = standaloneIosOrder(); - if (order) { - var movedPos = order.indexOf(target); - if (movedPos > -1) order.splice(movedPos, 1); - - var currentPos = order.indexOf(current); - order.splice((currentPos > -1 ? currentPos : 0) + 1, 0, target); - } - } - - scheduleQueueSnapshotSave(true); - updateStandaloneIosPlayerBar(); - return true; - } - - function openStandaloneIosQueueItemMenu(index) { - var state = standaloneIosPlayerState(); - var track = state.tracks && state.tracks[index]; - if (!track) return; - - if (index === state.currentIndex) { - Lampa.Noty.show('Этот трек сейчас играет.'); - return; - } - - var player = ensureStandaloneIosFullPlayer(); - var body = showStandaloneIosSheet(track.title || 'Трек'); - - player.attr('data-sheet-kind', 'queue-item'); - - function apply(action) { - mutateStandaloneIosQueue(action, index); - openStandaloneIosQueueSheet(); - } - - appendStandaloneIosSheetRow(body, { title: 'Играть следующим', subtitle: track.artist_name || '', onSelect: function () { apply('next'); } }); - appendStandaloneIosSheetRow(body, { title: 'В начало очереди', onSelect: function () { apply('first'); } }); - appendStandaloneIosSheetRow(body, { title: 'В конец очереди', onSelect: function () { apply('last'); } }); - appendStandaloneIosSheetRow(body, { title: 'Убрать из очереди', onSelect: function () { apply('remove'); } }); - } - - function openStandaloneIosQueueSheet() { - var state = standaloneIosPlayerState(); - if (!state.active || !state.track) { - Lampa.Noty.show('Очередь пуста.'); - return; - } - - var player = ensureStandaloneIosFullPlayer(); - var body = showStandaloneIosSheet('Очередь'); - - body.addClass('lm-ios-full-player__sheet-body--queue'); - body.append('<div class="lm-ios-full-player__queue-summary"></div>'); - body.append('<div class="lm-ios-full-player__queue-list"></div>'); - - player.attr('data-sheet-kind', 'queue'); - player.removeAttr('data-queue-key'); - player.removeAttr('data-current-index'); - player.attr('data-scroll-current', 'true'); - - updateStandaloneIosFullQueue(player, state); - } - - // --- шит «Источники»: матчи, pin/unpin, ручной поиск --- - - function renderSourceMatchRows(body, track, matches, selectedMatch, saveQuery) { - matches.forEach(function (match) { - var artists = Array.isArray(match.artists) ? match.artists.join(', ') : ''; - var isSelected = selectedMatch - && selectedMatch.id === match.id - && selectedMatch.provider_id === match.provider_id; - - appendStandaloneIosSheetRow(body, { - title: match.title || formatMatchTitle(match) || 'Источник', - subtitle: artists || match.provider_id || '', - trailing: isSelected ? 'Выбран' : (match.duration_ms ? formatDuration(match.duration_ms) : ''), - active: isSelected, - onSelect: function (row) { - if (isSelected) { - closeStandaloneIosSheet(); - return; - } - - row.addClass('disabled'); - saveTrackMatch(track, match, function (saved) { - if (!saved) { - row.removeClass('disabled'); - Lampa.Noty.show('Не удалось выбрать источник.'); - return; - } - - Lampa.Noty.show('Источник сохранён.'); - closeStandaloneIosSheet(); - updateStandaloneIosPlayerBar(); - }, saveQuery); - } - }); - }); - } - - function appendManualSourceSearchRow(body, track, initialValue) { - appendStandaloneIosSheetRow(body, { - title: 'Найти вручную…', - subtitle: 'Поиск источника своим запросом', - onSelect: function () { - openStandaloneIosManualSourceSearch(track, initialValue); - } - }); - } - - // последний рубеж для битых метаданных: пользователь ищет источник - // произвольным запросом, выбор пинуется той же механикой - function openStandaloneIosManualSourceSearch(track, initialValue) { - var prefill = typeof initialValue === 'string' && initialValue - ? initialValue - : (((track && track.artist_name) || '') + ' ' + ((track && track.title) || '')).trim(); - - Lampa.Input.edit({ - value: prefill, - title: 'Поиск источника', - free: true, - nosave: true, - nomic: true - }, function (queryValue) { - queryValue = String(queryValue || '').trim(); - if (!queryValue) return; - - renderManualSourceResults(track, queryValue); - }); - } - - function renderManualSourceResults(track, queryValue) { - var player = ensureStandaloneIosFullPlayer(); - var body = showStandaloneIosSheet('Поиск источника'); - var trackId = (track && track.id) || ''; - - player.attr('data-sheet-track-id', trackId); - appendStandaloneIosSheetMessage(body, 'Ищу «' + queryValue + '»...', true); - - loadTrackMatches(track, function (json) { - if (!MUSIC_IOS_FULL_PLAYER || player.attr('data-sheet-track-id') !== trackId) return; - - var matches = json && Array.isArray(json.matches) ? json.matches : []; - var selectedMatch = json && json.selected_match ? json.selected_match : null; - - body.empty(); - - appendStandaloneIosSheetRow(body, { - title: 'Изменить запрос', - subtitle: '«' + queryValue + '»', - onSelect: function () { - openStandaloneIosManualSourceSearch(track, queryValue); - } - }); - - if (!matches.length) { - appendStandaloneIosSheetMessage(body, 'Ничего не найдено. Попробуй другой запрос.'); - return; - } - - renderSourceMatchRows(body, track, matches, selectedMatch, queryValue); - }, queryValue); - } - - function openStandaloneIosSourcesSheet() { - var state = standaloneIosPlayerState(); - if (!state.active || !state.track) { - Lampa.Noty.show('Трек не выбран.'); - return; - } - - var player = ensureStandaloneIosFullPlayer(); - var body = showStandaloneIosSheet('Источники'); - var trackId = state.track.id || ''; - - player.attr('data-sheet-track-id', trackId); - appendStandaloneIosSheetMessage(body, 'Загружаю источники...', true); - - loadTrackMatches(state.track, function (json) { - if (!MUSIC_IOS_FULL_PLAYER || MUSIC_IOS_FULL_PLAYER.attr('data-sheet-track-id') !== trackId) return; - - var matches = json && Array.isArray(json.matches) ? json.matches : []; - var selectedMatch = json && json.selected_match ? json.selected_match : null; - - body.empty(); - - if (!matches.length) { - appendStandaloneIosSheetMessage(body, 'Альтернативные источники не найдены.'); - appendManualSourceSearchRow(body, state.track); - return; - } - - if (selectedMatch && selectedMatch.pinned) { - appendStandaloneIosSheetRow(body, { - title: 'Сбросить выбор', - subtitle: 'Вернуть автоматический подбор источника', - onSelect: function (row) { - row.addClass('disabled'); - resetTrackMatch(state.track, function (reset) { - if (!reset) { - row.removeClass('disabled'); - Lampa.Noty.show('Не удалось сбросить выбор.'); - return; - } - - Lampa.Noty.show('Источник подберётся автоматически.'); - closeStandaloneIosSheet(); - updateStandaloneIosPlayerBar(); - }); - } - }); - } - - renderSourceMatchRows(body, state.track, matches, selectedMatch, null); - appendManualSourceSearchRow(body, state.track); - }); - } - - var MUSIC_LYRICS_CACHE = {}; - var LYRICS_OFFSET_STEP_MS = 500; - var LYRICS_OFFSET_MIN_MS = -10000; - var LYRICS_OFFSET_MAX_MS = 10000; - - // --- лирика: шит с построчной подсветкой --- - - function buildLyricsCacheKey(id, title, artist, album, durationMs, youtubeId) { - var bucket = durationMs ? Math.max(0, Math.floor(Number(durationMs) / 10000)) : 0; - - return [ - id || '', - title || '', - artist || '', - album || '', - bucket, - youtubeId || '' - ].join('::'); - } - - function buildLyricsUrl(title, artist, album, durationMs, youtubeId) { - return MUSIC.endpoints.lyrics + '?title=' + encodeURIComponent(title || '') - + '&artist_name=' + encodeURIComponent(artist || '') - + '&album_title=' + encodeURIComponent(album || '') - + (durationMs ? '&duration_ms=' + encodeURIComponent(durationMs) : '') - + (youtubeId ? '&youtube_id=' + encodeURIComponent(youtubeId) : ''); - } - - function buildLyricsLineMeta(root, selector) { - var meta = { - times: [], - elements: [], - activeIndex: -1, - activeElement: null - }; - - root.find(selector).each(function () { - meta.times.push(Number($(this).attr('data-time') || 0)); - meta.elements.push(this); - }); - - return meta; - } - - function clampLyricsOffsetMs(value) { - value = Number(value || 0); - return isFinite(value) ? Math.max(LYRICS_OFFSET_MIN_MS, Math.min(LYRICS_OFFSET_MAX_MS, value)) : 0; - } - - function getLyricsOffsetMs(container) { - if (!container || !container.length) return 0; - return clampLyricsOffsetMs(container.data('lyricsOffsetMs')); - } - - function formatLyricsOffsetMs(value) { - value = clampLyricsOffsetMs(value); - if (!value) return '0.0 с'; - - return (value > 0 ? '+' : '−') + (Math.abs(value) / 1000).toFixed(1) + ' с'; - } - - function updateLyricsOffsetValue(container) { - if (!container || !container.length) return; - container.find('.lm-lyrics-offset__value').text(formatLyricsOffsetMs(getLyricsOffsetMs(container))); - } - - function resetLyricsOffsetForTrack(container, trackKey) { - if (!container || !container.length) return; - - trackKey = String(trackKey || ''); - if (container.attr('data-lyrics-offset-track') !== trackKey) { - container.attr('data-lyrics-offset-track', trackKey); - container.data('lyricsOffsetMs', 0); - container.removeAttr('data-lyrics-line'); - } - - updateLyricsOffsetValue(container); - } - - function changeLyricsOffset(container, deltaMs) { - if (!container || !container.length) return 0; - - var value = clampLyricsOffsetMs(getLyricsOffsetMs(container) + Number(deltaMs || 0)); - value = Math.round(value / LYRICS_OFFSET_STEP_MS) * LYRICS_OFFSET_STEP_MS; - - container.data('lyricsOffsetMs', value); - container.removeAttr('data-lyrics-line'); - updateLyricsOffsetValue(container); - return value; - } - - function buildLyricsOffsetControl(extraClass, controller) { - var control = $('<div class="lm-lyrics-offset ' + (extraClass || '') + '"></div>'); - var earlier = $('<div class="selector lm-lyrics-offset__control" data-lyrics-offset-delta="-500" aria-label="Показывать текст раньше">−</div>'); - var value = $('<div class="selector lm-lyrics-offset__control lm-lyrics-offset__value" data-lyrics-offset-reset="true" aria-label="Сбросить сдвиг текста">0.0 с</div>'); - var later = $('<div class="selector lm-lyrics-offset__control" data-lyrics-offset-delta="500" aria-label="Показывать текст позже">+</div>'); - - if (controller) earlier.add(value).add(later).attr('data-controller', controller); - control.append(earlier, value, later); - return control; - } - - function findLyricsLineIndex(times, timeMs) { - var left = 0; - var right = times.length - 1; - var index = -1; - - while (left <= right) { - var middle = (left + right) >> 1; - - if (times[middle] <= timeMs) { - index = middle; - left = middle + 1; - } else { - right = middle - 1; - } - } - - return index; - } - - function activateLyricsLine(meta, activeIndex) { - if (!meta) return null; - - if (meta.activeElement && meta.activeIndex !== activeIndex) - $(meta.activeElement).removeClass('active'); - - meta.activeIndex = activeIndex; - meta.activeElement = activeIndex >= 0 ? meta.elements[activeIndex] : null; - - if (meta.activeElement) - $(meta.activeElement).addClass('active'); - - return meta.activeElement; - } - - function openStandaloneIosLyricsSheet() { - var state = standaloneIosPlayerState(); - if (!state.active || !state.track) { - Lampa.Noty.show('Трек не выбран.'); - return; - } - - var player = ensureStandaloneIosFullPlayer(); - var body = showStandaloneIosSheet('Текст'); - var track = state.track; - var playback = Array.isArray(MUSIC_IOS_AUDIO.playlist) ? MUSIC_IOS_AUDIO.playlist[state.currentIndex] : null; - var title = track.title || (playback && playback.title) || ''; - var artist = track.artist_name || (playback && playback.artist) || ''; - var album = track.album_title || (playback && playback.music_album_title) || ''; - var durationMs = track.duration_ms || (playback && playback.music_duration_ms) || 0; - var youtubeId = extractYouTubeTrackId(track) || (playback && playback.music_youtube_id) || ''; - var trackId = buildLyricsCacheKey(track.id || '', title, artist, album, durationMs, youtubeId); - - resetLyricsOffsetForTrack(player, trackId); - player.attr('data-sheet-kind', 'lyrics'); - player.attr('data-sheet-track-id', trackId); - player.removeAttr('data-lyrics-line'); - - var render = function (json) { - if (!MUSIC_IOS_FULL_PLAYER || player.attr('data-sheet-track-id') !== trackId) return; - if (player.attr('data-sheet-kind') !== 'lyrics') return; - - body.empty(); - player.removeData('lyricsLineMeta'); - - var hasLines = json && json.available && Array.isArray(json.lines) && json.lines.length; - var hasPlain = json && json.available && json.plain; - - if (!hasLines && !hasPlain) { - appendStandaloneIosSheetMessage(body, json && json.retry ? 'Сервис текстов не ответил. Открой ещё раз.' : 'Текст не найден.'); - return; - } - - if (json.synced && hasLines) { - var list = $('<div class="lm-ios-full-player__lyrics"></div>'); - - body.append(buildLyricsOffsetControl('lm-ios-full-player__lyrics-offset', 'lampac_music_full_player')); - updateLyricsOffsetValue(player); - - json.lines.forEach(function (line, index) { - var row = $('<div class="lm-ios-full-player__lyrics-line selector" data-controller="lampac_music_full_player"></div>'); - - row.attr('data-line', index); - row.attr('data-time', line.time_ms || 0); - - if (line.text) row.text(line.text); - else row.addClass('lm-ios-full-player__lyrics-line--empty').text('♪'); - - list.append(row); - }); - - body.append(list); - player.data('lyricsLineMeta', buildLyricsLineMeta(body, '.lm-ios-full-player__lyrics-line')); - updateStandaloneIosLyricsHighlight(true); - } else { - var plain = $('<div class="lm-ios-full-player__lyrics lm-ios-full-player__lyrics--plain selector" data-controller="lampac_music_full_player"></div>'); - plain.text(json.plain || json.lines.map(function (line) { return line.text || ''; }).join('\n')); - body.append(plain); - } - bindStandaloneIosFullControls(player, body.find('.selector')); - scheduleStandaloneIosFullFocus(); - }; - - if (MUSIC_LYRICS_CACHE[trackId]) { - render(MUSIC_LYRICS_CACHE[trackId]); - return; - } - - appendStandaloneIosSheetMessage(body, 'Ищу текст...', true); - - request(buildLyricsUrl(title, artist, album, durationMs, youtubeId), function (json) { - json = parseJson(json); - // неудачи не кэшируем: повторное открытие шита должно попробовать снова - if (json && json.available) setCappedCacheEntry(MUSIC_LYRICS_CACHE, trackId, json, 60); - render(json); - }, function () { - render(null); - }); - } - - function updateStandaloneIosLyricsHighlight(force) { - var heatStartedAt = musicHeatNow(); - var player = MUSIC_IOS_FULL_PLAYER; - if (!player || !player.length || player.attr('data-sheet-kind') !== 'lyrics') return; - bumpMusicHeatMetric('fullPlayerLyricsHighlight'); - - var audio = MUSIC_IOS_AUDIO.audio; - if (!audio || !isFinite(audio.currentTime)) { - bumpMusicHeatDuration('fullPlayerLyricsHighlight', heatStartedAt); - return; - } - - var meta = player.data('lyricsLineMeta'); - if (!meta || !meta.elements || !meta.elements.length) { - meta = buildLyricsLineMeta(player, '.lm-ios-full-player__lyrics-line'); - player.data('lyricsLineMeta', meta); - } - - if (!meta.elements.length) { - bumpMusicHeatDuration('fullPlayerLyricsHighlight', heatStartedAt); - return; - } - - var timeMs = audio.currentTime * 1000 - getLyricsOffsetMs(player); - var activeIndex = findLyricsLineIndex(meta.times, timeMs); - - if (!force && player.attr('data-lyrics-line') === String(activeIndex)) return; - player.attr('data-lyrics-line', String(activeIndex)); - - var element = activateLyricsLine(meta, activeIndex); - if (!element) { - bumpMusicHeatDuration('fullPlayerLyricsHighlight', heatStartedAt); - return; - } - - // пока пользователь листает текст руками, автоскролл не дёргаем - var manualAt = Number(player.attr('data-lyrics-manual') || 0); - if (!force && Date.now() - manualAt < 4000) { - bumpMusicHeatDuration('fullPlayerLyricsHighlight', heatStartedAt); - return; - } - - if (element) { - scrollStandaloneIosElement(element, 'center', !force); - bumpMusicHeatMetric('fullPlayerLyricsScroll'); - } - - bumpMusicHeatDuration('fullPlayerLyricsHighlight', heatStartedAt); - } - - function toggleStandaloneIosCurrentBookmark() { - var state = standaloneIosPlayerState(); - if (!state.active || !state.track || !state.track.id) { - Lampa.Noty.show('Трек не выбран.'); - return; - } - - var added = toggleBookmarkedEntity(MUSIC.storage.bookmarked_tracks, state.track); - Lampa.Noty.show(added ? 'Трек добавлен в закладки.' : 'Трек удалён из закладок.'); - updateStandaloneIosFullPlayer(); - } - - // --- sleep-таймер --- - - function formatStandaloneIosTimerLeft() { - if (!MUSIC_IOS_SLEEP_TIMER.endAt) return ''; - - var left = Math.max(0, Math.ceil((MUSIC_IOS_SLEEP_TIMER.endAt - Date.now()) / 1000)); - var minutes = Math.ceil(left / 60); - - if (minutes >= 60) { - var hours = Math.floor(minutes / 60); - var rest = minutes % 60; - return rest ? (hours + ' ч ' + rest + ' мин') : (hours + ' ч'); - } - - return minutes + ' мин'; - } - - function clearStandaloneIosSleepTimer(showNotice) { - if (MUSIC_IOS_SLEEP_TIMER.timer) { - clearTimeout(MUSIC_IOS_SLEEP_TIMER.timer); - MUSIC_IOS_SLEEP_TIMER.timer = 0; - } - - MUSIC_IOS_SLEEP_TIMER.endAt = 0; - updateStandaloneIosFullPlayer(); - - if (showNotice) Lampa.Noty.show('Таймер сна отключён.'); - } - - function expireStandaloneIosSleepTimer(origin, force) { - if (!MUSIC_IOS_SLEEP_TIMER.endAt) return false; - if (!force && Date.now() < MUSIC_IOS_SLEEP_TIMER.endAt) return false; - - cancelRadioAutoplayResume(); - if (MUSIC_IOS_SLEEP_TIMER.timer) { - clearTimeout(MUSIC_IOS_SLEEP_TIMER.timer); - MUSIC_IOS_SLEEP_TIMER.timer = 0; - } - - MUSIC_IOS_SLEEP_TIMER.endAt = 0; - - var audio = MUSIC_IOS_AUDIO.audio; - - if (audio && !audio.paused) { - try { - audio.pause(); - startStandaloneIosKeepAlive('sleep-timer-' + (origin || 'expire')); - } catch (e) {} - } - - updateStandaloneIosPlaybackState(); - updateStandaloneIosPlayerBar(); - Lampa.Noty.show('Таймер сна: музыка поставлена на паузу.'); - return true; - } - - function setStandaloneIosSleepTimer(minutes) { - clearStandaloneIosSleepTimer(false); - - minutes = Math.max(1, Number(minutes || 0)); - MUSIC_IOS_SLEEP_TIMER.endAt = Date.now() + minutes * 60 * 1000; - MUSIC_IOS_SLEEP_TIMER.timer = setTimeout(function () { - expireStandaloneIosSleepTimer('timeout', true); - }, minutes * 60 * 1000); - - updateStandaloneIosFullPlayer(); - Lampa.Noty.show('Таймер сна: ' + minutes + ' мин.'); - } - - function openStandaloneIosTimerSheet() { - var body = showStandaloneIosSheet('Таймер сна'); - var options = [ - { title: '15 минут', minutes: 15 }, - { title: '30 минут', minutes: 30 }, - { title: '1 час', minutes: 60 }, - { title: '2 часа', minutes: 120 } - ]; - - if (MUSIC_IOS_SLEEP_TIMER.endAt) - appendStandaloneIosSheetMessage(body, 'Активен: ' + formatStandaloneIosTimerLeft()); - - options.forEach(function (item) { - appendStandaloneIosSheetRow(body, { - title: item.title, - subtitle: 'Поставить музыку на паузу', - onSelect: function () { - setStandaloneIosSleepTimer(item.minutes); - closeStandaloneIosSheet(); - } - }); - }); - - if (MUSIC_IOS_SLEEP_TIMER.endAt) { - appendStandaloneIosSheetRow(body, { - title: 'Отключить таймер', - subtitle: 'Музыка продолжит играть', - onSelect: function () { - clearStandaloneIosSleepTimer(true); - closeStandaloneIosSheet(); - } - }); - } - } - - // --- DOM фулл-плеера и его обновление --- - - function updateStandaloneIosFullActions(player, state) { - var bookmarked = !!(state.track && state.track.id && isBookmarkedEntity(MUSIC.storage.bookmarked_tracks, state.track.id)); - var timerActive = !!MUSIC_IOS_SLEEP_TIMER.endAt; - var bookmark = player.find('[data-action="bookmark"]'); - var timerStatus = player.find('.lm-ios-full-player__timer-status'); - - bookmark.toggleClass('active', bookmarked); - setTextIfChanged(bookmark.find('span'), bookmarked ? 'В закладках' : 'В закладки'); - player.find('[data-action="radio"]').toggleClass('active', isRadioAutoplayEnabled()); - player.find('[data-action="timer"]').toggleClass('active', timerActive); - timerStatus.toggleClass('lm-ios-full-player__timer-status--visible', timerActive); - setTextIfChanged(timerStatus, timerActive ? ('Таймер сна: ' + formatStandaloneIosTimerLeft()) : ''); - } - - function ensureStandaloneIosFullPlayer() { - if (MUSIC_IOS_FULL_PLAYER && MUSIC_IOS_FULL_PLAYER.length) return MUSIC_IOS_FULL_PLAYER; - - var player = $( - '<div class="lm-ios-full-player">' - + '<div class="lm-ios-full-player__backdrop"></div>' - + '<div class="lm-ios-full-player__shell">' - + '<div class="lm-ios-full-player__grabber"></div>' - + '<div class="lm-ios-full-player__head">' - + '<div class="lm-ios-full-player__tool" data-action="collapse">' + IOS_PLAYER_DOWN_ICON + '</div>' - + '<div class="lm-ios-full-player__head-title">Сейчас играет</div>' - + '<div class="lm-ios-full-player__tool" data-action="collapse">' + IOS_PLAYER_CLOSE_ICON + '</div>' - + '</div>' - + '<div class="lm-ios-full-player__hero">' - + '<div class="lm-ios-full-player__art"><img src="' + IMG_BG + '" alt=""></div>' - + '<div class="lm-ios-full-player__title">Музыка</div>' - + '<div class="lm-ios-full-player__artist"></div>' - + '<div class="lm-ios-full-player__progress">' - + '<input class="lm-ios-full-player__seek" type="range" min="0" max="1000" step="1" value="0">' - + '<div class="lm-ios-full-player__times">' - + '<div class="lm-ios-full-player__time lm-ios-full-player__time--current">0:00</div>' - + '<div class="lm-ios-full-player__time lm-ios-full-player__time--total">0:00</div>' - + '</div>' - + '</div>' - + '<div class="lm-ios-full-player__actions">' - + '<div class="lm-ios-full-player__btn lm-ios-full-player__btn--mini" data-action="shuffle">' + IOS_PLAYER_SHUFFLE_ICON + '</div>' - + '<div class="lm-ios-full-player__btn lm-ios-full-player__btn--ghost" data-action="prev">' + IOS_PLAYER_PREV_ICON + '</div>' - + '<div class="lm-ios-full-player__btn lm-ios-full-player__btn--primary" data-action="playpause">' + IOS_PLAYER_PAUSE_ICON + '</div>' - + '<div class="lm-ios-full-player__btn lm-ios-full-player__btn--ghost" data-action="next">' + IOS_PLAYER_NEXT_ICON + '</div>' - + '<div class="lm-ios-full-player__btn lm-ios-full-player__btn--mini" data-action="repeat">' + IOS_PLAYER_REPEAT_ICON + '</div>' - + '</div>' - + '<div class="lm-ios-full-player__quick-actions">' - + '<div class="lm-ios-full-player__quick" data-action="queue">' + IOS_PLAYER_QUEUE_ICON + '<span>Очередь</span></div>' - + '<div class="lm-ios-full-player__quick" data-action="sources">' + IOS_PLAYER_SOURCE_ICON + '<span>Источники</span></div>' - + '<div class="lm-ios-full-player__quick" data-action="lyrics">' + IOS_PLAYER_LYRICS_ICON + '<span>Текст</span></div>' - + '<div class="lm-ios-full-player__quick" data-action="bookmark">' + BOOKMARK_ICON + '<span>В закладки</span></div>' - + '<div class="lm-ios-full-player__quick" data-action="timer">' + IOS_PLAYER_TIMER_ICON + '<span>Таймер</span></div>' - + '<div class="lm-ios-full-player__quick" data-action="radio">' + IOS_PLAYER_RADIO_ICON + '<span>Подборка</span></div>' - + '</div>' - + '<div class="lm-ios-full-player__timer-status"></div>' - + '<div class="lm-ios-full-player__stop" data-action="stop">Остановить</div>' - + '</div>' - + '<div class="lm-ios-full-player__sheet">' - + '<div class="lm-ios-full-player__sheet-panel">' - + '<div class="lm-ios-full-player__sheet-head">' - + '<div class="lm-ios-full-player__sheet-title"></div>' - + '<div class="lm-ios-full-player__sheet-close" data-action="sheet-close">' + IOS_PLAYER_CLOSE_ICON + '</div>' - + '</div>' - + '<div class="lm-ios-full-player__sheet-body"></div>' - + '</div>' - + '</div>' - + '</div>' - ); - - player.find('[data-action], .lm-ios-full-player__seek').addClass('selector').attr('data-controller', 'lampac_music_full_player'); - bindStandaloneIosFullControls(player, player.find('.selector')); - - player.on('click', '.lm-ios-full-player__sheet', function (event) { - if (event.target === this) closeStandaloneIosSheet(); - }); - - player.on('touchstart', '.lm-ios-full-player__lyrics', function () { - player.attr('data-lyrics-manual', String(Date.now())); - }); - - bindStandaloneIosFullPlayerGestures(player); - - // Capture Back once, before Lampa also dispatches it to the active controller. - document.addEventListener('keydown', handleStandaloneIosFullPlayerBack, true); - - $('body').append(player); - MUSIC_IOS_FULL_PLAYER = player; - return MUSIC_IOS_FULL_PLAYER; - } - - function bindStandaloneIosFullControls(player, controls) { - // Lampa's hover events do not bubble: bind to each control, including new sheet rows. - controls = controls.filter(function () { - if ($(this).data('musicControlsBound')) return false; - $(this).data('musicControlsBound', true); - return true; - }); - - controls.on('hover:focus hover:hover', function () { - var inSheet = $(this).closest('.lm-ios-full-player__sheet-panel').length > 0; - player.data(inSheet ? 'sheetFocus' : 'mainFocus', this); - if ($(this).hasClass('lm-ios-full-player__lyrics-line')) - player.attr('data-lyrics-manual', String(Date.now())); - if (inSheet) - scrollStandaloneIosElement(this, 'nearest', false); - }); - - controls.filter('.lm-ios-full-player__seek').on('keydown', function (event) { - if (safeControllerName() === 'lampac_music_full_player' && (event.keyCode === 37 || event.keyCode === 39)) - event.preventDefault(); - }); - - controls.filter('[data-action]').on('click hover:enter', function (event) { - if (!acceptStandaloneIosControlEvent(event, this)) return; - handleStandaloneIosPlayerAction($(this).attr('data-action')); - }); - - controls.filter('.lm-ios-full-player__seek').on('input', function () { - var state = standaloneIosPlayerState(); - var value = Number($(this).val() || 0); - var position = state.duration ? (state.duration * value / 1000) : 0; - - player.attr('data-seeking', 'true'); - this.style.setProperty('--lm-seek-fill', Math.max(0, Math.min(100, value / 10)) + '%'); - player.find('.lm-ios-full-player__time--current').text(Lampa.Utils.secondsToTime(position)); - }); - - controls.filter('.lm-ios-full-player__seek').on('change', function () { - player.removeAttr('data-seeking'); - seekStandaloneIosByRangeValue($(this).val()); - }); - - // лонг-тап по треку очереди → меню действий (убрать/переставить); - // touchmove отменяет таймер, чтобы скролл списка не открывал меню - var queueItemHold = { timer: 0, target: null }; - - function clearQueueItemHoldTimer() { - if (queueItemHold.timer) { - clearTimeout(queueItemHold.timer); - queueItemHold.timer = 0; - } - } - - controls.filter('.lm-ios-full-player__queue-item').on('touchstart', function () { - var index = Number($(this).attr('data-index') || 0); - var target = this; - - queueItemHold.target = null; - clearQueueItemHoldTimer(); - queueItemHold.timer = setTimeout(function () { - queueItemHold.timer = 0; - queueItemHold.target = target; - openStandaloneIosQueueItemMenu(index); - }, 550); - }); - - controls.filter('.lm-ios-full-player__queue-item').on('touchmove touchend touchcancel', function () { - clearQueueItemHoldTimer(); - }); - - controls.filter('.lm-ios-full-player__queue-item').on('contextmenu hover:long', function (event) { - event.preventDefault(); - event.stopPropagation(); - if (queueItemHold.target === this) return; - queueItemHold.target = this; - clearQueueItemHoldTimer(); - openStandaloneIosQueueItemMenu(Number($(this).attr('data-index') || 0)); - }); - - controls.filter('.lm-ios-full-player__queue-item').on('click hover:enter', function (event) { - if (!acceptStandaloneIosControlEvent(event, this)) return; - - if (queueItemHold.target === this) { - queueItemHold.target = null; - return; - } - - standaloneIosPlayIndex(Number($(this).attr('data-index') || 0)); - }); - - controls.filter('.lm-ios-full-player__lyrics-line').on('click hover:enter', function (event) { - if (!acceptStandaloneIosControlEvent(event, this)) return; - - var audio = MUSIC_IOS_AUDIO.audio; - var time = Number($(this).attr('data-time') || 0) + getLyricsOffsetMs(player); - - if (!audio || !isFinite(time)) return; - - try { - audio.currentTime = Math.max(0, time / 1000); - } catch (e) {} - - updateStandaloneIosPositionState(true); - updateStandaloneIosLyricsHighlight(true); - }); - - controls.filter('[data-lyrics-offset-delta]').on('click hover:enter', function (event) { - if (!acceptStandaloneIosControlEvent(event, this)) return; - changeLyricsOffset(player, Number($(this).attr('data-lyrics-offset-delta') || 0)); - updateStandaloneIosLyricsHighlight(true); - }); - - controls.filter('[data-lyrics-offset-reset]').on('click hover:enter', function (event) { - if (!acceptStandaloneIosControlEvent(event, this)) return; - changeLyricsOffset(player, -getLyricsOffsetMs(player)); - updateStandaloneIosLyricsHighlight(true); - }); - - } - - function updateStandaloneIosFullQueue(player, state) { - var heatStartedAt = musicHeatNow(); - var tracks = state.tracks || []; - var ids = tracks.map(function (track) { return track && track.id ? track.id : ''; }).join('|'); - var queueKey = ids; - var list = player.find('.lm-ios-full-player__queue-list'); - - if (!list.length) return; - bumpMusicHeatMetric('fullPlayerQueueUpdate'); - - var now = Date.now(); - var currentIndexText = String(state.currentIndex); - var queueAlreadySynced = player.attr('data-queue-key') === queueKey - && player.attr('data-queue-duration-key') - && player.attr('data-current-index') === currentIndexText - && player.attr('data-scroll-current') !== 'true'; - var throttleKey = queueKey + '|' + currentIndexText; - var lastThrottleKey = player.data('queueThrottleKey') || ''; - var lastThrottleAt = Number(player.data('queueThrottleAt') || 0); - - if (queueAlreadySynced && lastThrottleKey === throttleKey && now - lastThrottleAt < 900) { - bumpMusicHeatMetric('fullPlayerQueueUpdateSkipped'); - bumpMusicHeatDuration('fullPlayerQueueUpdate', heatStartedAt); - return; - } - - player.data('queueThrottleKey', throttleKey); - player.data('queueThrottleAt', now); - - player.find('.lm-ios-full-player__queue-summary').text(tracks.length ? ((state.currentIndex + 1) + ' из ' + tracks.length) : 'Очередь пуста'); - - if (player.attr('data-queue-key') !== queueKey) { - var renderStartedAt = musicHeatNow(); - list.empty(); - player.attr('data-queue-key', queueKey); - player.removeAttr('data-queue-duration-key'); - - // граница ручной очереди и автоподборки — один спокойный - // разделитель перед первым auto_radio-треком, без пометок в строках - var radioDividerAdded = false; - - tracks.forEach(function (track, index) { - if (!radioDividerAdded && track && track.auto_radio) { - radioDividerAdded = true; - list.append($('<div class="lm-ios-full-player__queue-divider"></div>').text('Дальше автоподборка')); - } - - var item = $('<div class="lm-ios-full-player__queue-item selector" data-controller="lampac_music_full_player"></div>'); - var imageWrap = $('<div class="lm-ios-full-player__queue-img"></div>'); - var img = $('<img src="" alt="">'); - var body = $('<div class="lm-ios-full-player__queue-body"></div>'); - var title = $('<div class="lm-ios-full-player__queue-track"></div>').text(track && track.title ? track.title : 'Track'); - var artistText = track && track.artist_name ? track.artist_name : ''; - var artist = $('<div class="lm-ios-full-player__queue-artist"></div>').text(artistText); - var duration = track && track.duration_ms ? Math.max(0, Math.round(track.duration_ms / 1000)) : 0; - var time = $('<div class="lm-ios-full-player__queue-time"></div>').text(duration ? Lampa.Utils.secondsToTime(duration) : ''); - - item.attr('data-index', index); - img.attr('loading', 'lazy'); - img.attr('decoding', 'async'); - img.attr('src', trackImage(track) || IMG_BG); - imageWrap.append(img); - body.append(title); - body.append(artist); - item.append(imageWrap); - item.append(body); - item.append(time); - list.append(item); - }); - bindStandaloneIosFullControls(player, list.find('.selector')); - bumpMusicHeatMetric('fullPlayerQueueRender'); - bumpMusicHeatMetric('fullPlayerQueueRenderItems', tracks.length); - bumpMusicHeatDuration('fullPlayerQueueRender', renderStartedAt); - scheduleStandaloneIosFullFocus(); - } - - var durationKey = tracks.map(function (track) { - return track && track.duration_ms ? String(track.duration_ms) : ''; - }).join('|'); - - if (player.attr('data-queue-duration-key') !== durationKey) { - bumpMusicHeatMetric('fullPlayerQueueDurationRefresh'); - tracks.forEach(function (track, index) { - var duration = track && track.duration_ms ? Math.max(0, Math.round(track.duration_ms / 1000)) : 0; - var text = duration ? Lampa.Utils.secondsToTime(duration) : ''; - var time = list.find('.lm-ios-full-player__queue-item[data-index="' + index + '"] .lm-ios-full-player__queue-time'); - - setTextIfChanged(time, text); - }); - - player.attr('data-queue-duration-key', durationKey); - } - - if (player.attr('data-current-index') !== currentIndexText) { - list.find('.lm-ios-full-player__queue-item--current').removeClass('lm-ios-full-player__queue-item--current'); - list.find('.lm-ios-full-player__queue-item[data-index="' + state.currentIndex + '"]').addClass('lm-ios-full-player__queue-item--current'); - player.attr('data-current-index', currentIndexText); - bumpMusicHeatMetric('fullPlayerQueueCurrentSync'); - } - - if (player.attr('data-scroll-current') === 'true') { - var scroller = list.get(0); - var current = list.find('.lm-ios-full-player__queue-item[data-index="' + state.currentIndex + '"]').get(0); - - player.removeAttr('data-scroll-current'); - - if (scroller && current) - scrollStandaloneIosElement(current, 'center', false); - } - - bumpMusicHeatDuration('fullPlayerQueueUpdate', heatStartedAt); - } - - function updateStandaloneIosFullPlayer() { - var heatStartedAt = musicHeatNow(); - if (!MUSIC_IOS_FULL_PLAYER || !MUSIC_IOS_FULL_PLAYER.length) return; - - var state = standaloneIosPlayerState(); - var player = MUSIC_IOS_FULL_PLAYER; - - if (!MUSIC_IOS_FULL_PLAYER_OPEN && !player.hasClass('lm-ios-full-player--visible')) return; - bumpMusicHeatMetric('fullPlayerUpdate'); - - if (!state.active || !state.track) { - closeStandaloneIosFullPlayer(); - player.removeAttr('data-queue-key'); - bumpMusicHeatDuration('fullPlayerUpdate', heatStartedAt); - return; - } - - var image = trackImage(state.track) || IMG_BG; - var title = state.track.title || 'Track'; - var artist = state.track.artist_name || ''; - var trackKey = [state.track.id || '', image, title, artist].join('|'); - var isSeeking = player.attr('data-seeking') === 'true'; - - if (MUSIC_IOS_AUDIO.fullUiKey !== trackKey) { - var safeImage = String(image || '').replace(/"/g, '\\"'); - var refreshLyricsSheet = player.attr('data-sheet-kind') === 'lyrics'; - - player.find('.lm-ios-full-player__backdrop').css('background-image', 'url("' + safeImage + '")'); - player.find('.lm-ios-full-player__art img').attr('src', image); - setTextIfChanged(player.find('.lm-ios-full-player__title'), title); - setTextIfChanged(player.find('.lm-ios-full-player__artist'), artist); - MUSIC_IOS_AUDIO.fullUiKey = trackKey; - bumpMusicHeatMetric('fullPlayerTrackUi'); - - if (refreshLyricsSheet) - setTimeout(openStandaloneIosLyricsSheet, 0); - } - - var repeatMode = getStandaloneIosRepeatMode(); - var playbackKey = [ - state.playing ? '1' : '0', - state.hasPrev ? '1' : '0', - state.hasNext ? '1' : '0', - isStandaloneIosShuffle() ? '1' : '0', - repeatMode, - state.duration || 0, - state.currentIndex, - state.tracks.length, - state.canQueue ? '1' : '0' - ].join('|'); - - if (MUSIC_IOS_AUDIO.fullPlaybackKey !== playbackKey) { - player.find('[data-action="playpause"]').html(state.playing ? IOS_PLAYER_PAUSE_ICON : IOS_PLAYER_PLAY_ICON); - player.toggleClass('lm-ios-full-player--paused', !state.playing); - player.find('[data-action="shuffle"]').toggleClass('active', isStandaloneIosShuffle()); - player.attr('data-repeat-mode', repeatMode); - player.find('[data-action="repeat"]') - .html(repeatMode === 'one' ? IOS_PLAYER_REPEAT_ONE_ICON : IOS_PLAYER_REPEAT_ICON) - .toggleClass('active', repeatMode !== 'off'); - player.find('[data-action="prev"]').toggleClass('disabled', !state.hasPrev); - player.find('[data-action="next"]').toggleClass('disabled', !state.hasNext); - setTextIfChanged(player.find('.lm-ios-full-player__time--total'), Lampa.Utils.secondsToTime(state.duration || 0)); - setTextIfChanged(player.find('[data-action="queue"] span'), state.tracks.length > 1 ? ('Очередь ' + (state.currentIndex + 1) + '/' + state.tracks.length) : 'Очередь'); - MUSIC_IOS_AUDIO.fullPlaybackKey = playbackKey; - bumpMusicHeatMetric('fullPlayerPlaybackUi'); - } - - if (player.attr('data-tint-url') !== image) { - player.attr('data-tint-url', image); - bumpMusicHeatMetric('fullPlayerTintRequest'); - computeStandaloneIosArtTint(image, function (tint) { - if (!MUSIC_IOS_FULL_PLAYER || player.attr('data-tint-url') !== image) return; - - try { - player.get(0).style.setProperty('--lm-full-tint', tint || ''); - } catch (e) {} - }); - } - - updateStandaloneIosFullActions(player, state); - bumpMusicHeatMetric('fullPlayerActionsUpdate'); - - if (!isSeeking) { - var seekValue = state.duration > 0 ? Math.max(0, Math.min(1000, Math.round((state.current / state.duration) * 1000))) : 0; - var currentText = Lampa.Utils.secondsToTime(state.current || 0); - var progressKey = currentText + '|' + seekValue; - - if (MUSIC_IOS_AUDIO.fullProgressKey !== progressKey) { - var seekInput = player.find('.lm-ios-full-player__seek'); - - setTextIfChanged(player.find('.lm-ios-full-player__time--current'), currentText); - seekInput.val(seekValue); - if (seekInput.get(0)) seekInput.get(0).style.setProperty('--lm-seek-fill', (seekValue / 10) + '%'); - MUSIC_IOS_AUDIO.fullProgressKey = progressKey; - bumpMusicHeatMetric('fullPlayerProgressUi'); - } - } - - if (player.attr('data-sheet-kind') === 'queue') updateStandaloneIosFullQueue(player, state); - if (player.attr('data-sheet-kind') === 'lyrics') updateStandaloneIosLyricsHighlight(false); - bumpMusicHeatDuration('fullPlayerUpdate', heatStartedAt); - } - - // --- мини-бар плеера --- - - function stepStandaloneIosSeek(target, direction) { - var state = standaloneIosPlayerState(); - if (!state.duration) return; - var value = Math.max(0, Math.min(1000, Number($(target).val() || 0) + (direction === 'right' ? 5000 : -5000) / state.duration)); - $(target).val(value); - seekStandaloneIosByRangeValue(value); - } - - function focusStandaloneIosPlayerBar() { - var bar = MUSIC_IOS_BAR; - if (!bar || !bar.hasClass('lm-ios-player--visible') || MUSIC_IOS_FULL_PLAYER_OPEN) return; - - var current = safeControllerName(); - if (current !== 'lampac_music_player_bar') { - bar.data('returnController', current && current !== 'lampac_music_full_player' ? current : 'content'); - Lampa.Controller.toggle('lampac_music_player_bar'); - return; - } - - var target = bar.data('focusTarget'); - if (!target || !bar.get(0).contains(target) || $(target).hasClass('disabled')) - target = bar.find('[data-action="expand"]').get(0); - Lampa.Controller.collectionSet(bar.get(0)); - Lampa.Controller.collectionFocus(target, bar.get(0)); - } - - function leaveStandaloneIosPlayerBar() { - if (safeControllerName() !== 'lampac_music_player_bar') return; - Lampa.Controller.toggle(MUSIC_IOS_BAR.data('returnController') || 'content'); - flushStandaloneIosDeferredHomeRefresh(); - } - - function moveStandaloneIosBarFocus(direction) { - var target = MUSIC_IOS_BAR.data('focusTarget'); - if (target && $(target).hasClass('lm-ios-player__seek') && (direction === 'left' || direction === 'right')) - stepStandaloneIosSeek(target, direction); - else Navigator.move(direction); - } - - function ensureStandaloneIosPlayerBar() { - if (MUSIC_IOS_BAR && MUSIC_IOS_BAR.length) return MUSIC_IOS_BAR; - - var bar = $( - '<div class="lm-ios-player">' - + '<div class="lm-ios-player__top">' - + '<div class="lm-ios-player__meta" data-action="expand">' - + '<div class="lm-ios-player__title">Музыка</div>' - + '<div class="lm-ios-player__artist"></div>' - + '</div>' - + '<div class="lm-ios-player__tools">' - + '<div class="lm-ios-player__queue" data-action="queue">' + IOS_PLAYER_QUEUE_ICON + '<span>Сейчас играет</span></div>' - + '<div class="lm-ios-player__toolbtn lm-ios-player__toolbtn--close" data-action="stop">' + IOS_PLAYER_CLOSE_ICON + '</div>' - + '</div>' - + '</div>' - + '<div class="lm-ios-player__progress">' - + '<div class="lm-ios-player__time lm-ios-player__time--current">0:00</div>' - + '<input class="lm-ios-player__seek" type="range" min="0" max="1000" step="1" value="0">' - + '<div class="lm-ios-player__time lm-ios-player__time--total">0:00</div>' - + '</div>' - + '<div class="lm-ios-player__actions">' - + '<div class="lm-ios-player__btn lm-ios-player__btn--ghost" data-action="prev">' + IOS_PLAYER_PREV_ICON + '</div>' - + '<div class="lm-ios-player__btn lm-ios-player__btn--primary" data-action="playpause">' + IOS_PLAYER_PAUSE_ICON + '</div>' - + '<div class="lm-ios-player__btn lm-ios-player__btn--ghost" data-action="next">' + IOS_PLAYER_NEXT_ICON + '</div>' - + '</div>' - + '</div>' - ); - - var controls = bar.find('[data-action], .lm-ios-player__seek'); - controls.addClass('selector').attr('data-controller', 'lampac_music_player_bar'); - controls.on('hover:focus hover:hover', function () { - bar.data('focusTarget', this); - }); - controls.filter('[data-action]').on('click hover:enter', function (event) { - if (!acceptStandaloneIosControlEvent(event, this)) return; - if (Lampa.Platform.screen('tv')) focusStandaloneIosPlayerBar(); - handleStandaloneIosPlayerAction($(this).attr('data-action')); - }); - controls.filter('.lm-ios-player__seek').on('keydown', function (event) { - if (safeControllerName() === 'lampac_music_player_bar' && (event.keyCode === 37 || event.keyCode === 39)) - event.preventDefault(); - }); - - bar.on('input', '.lm-ios-player__seek', function () { - var state = standaloneIosPlayerState(); - var value = Number($(this).val() || 0); - var position = state.duration ? (state.duration * value / 1000) : 0; - - bar.attr('data-seeking', 'true'); - bar.find('.lm-ios-player__time--current').text(Lampa.Utils.secondsToTime(position)); - }); - - bar.on('change', '.lm-ios-player__seek', function () { - bar.removeAttr('data-seeking'); - seekStandaloneIosByRangeValue($(this).val()); - }); - - $('body').append(bar); - MUSIC_IOS_BAR = bar; - Lampa.Controller.add('lampac_music_player_bar', { - toggle: focusStandaloneIosPlayerBar, - left: function () { moveStandaloneIosBarFocus('left'); }, - right: function () { moveStandaloneIosBarFocus('right'); }, - up: function () { moveStandaloneIosBarFocus('up'); }, - down: function () { moveStandaloneIosBarFocus('down'); }, - back: leaveStandaloneIosPlayerBar - }); - return MUSIC_IOS_BAR; - } - - function updateStandaloneIosPlayerBar() { - var bar = ensureStandaloneIosPlayerBar(); - var state = standaloneIosPlayerState(); - var isSeeking = bar.attr('data-seeking') === 'true'; - - if (!bar || !bar.length) return; - - if (!state.active || !state.track) { - bar.removeAttr('data-seeking'); - MUSIC_IOS_AUDIO.barUiKey = ''; - MUSIC_IOS_AUDIO.barProgressKey = ''; - MUSIC_IOS_AUDIO.fullUiKey = ''; - MUSIC_IOS_AUDIO.fullPlaybackKey = ''; - MUSIC_IOS_AUDIO.fullProgressKey = ''; - bar.removeClass('lm-ios-player--visible'); - if (!MUSIC_IOS_AUDIO.active) { - MUSIC_IOS_AUDIO.barFocusRequested = false; - leaveStandaloneIosPlayerBar(); - } - updateStandaloneIosFullPlayer(); - return; - } - - var title = state.track.title || 'Track'; - var artist = state.track.artist_name || ''; - var uiKey = [ - state.track.id || '', - title, - artist, - state.playing ? '1' : '0', - state.hasPrev ? '1' : '0', - state.hasNext ? '1' : '0', - state.canQueue ? '1' : '0', - state.duration || 0 - ].join('|'); - - if (MUSIC_IOS_AUDIO.barUiKey !== uiKey) { - setTextIfChanged(bar.find('.lm-ios-player__title'), title); - setTextIfChanged(bar.find('.lm-ios-player__artist'), artist); - bar.find('[data-action="playpause"]').html(state.playing ? IOS_PLAYER_PAUSE_ICON : IOS_PLAYER_PLAY_ICON); - bar.find('[data-action="prev"]').toggleClass('disabled', !state.hasPrev); - bar.find('[data-action="next"]').toggleClass('disabled', !state.hasNext); - bar.find('[data-action="queue"]').toggleClass('disabled', !state.canQueue); - setTextIfChanged(bar.find('.lm-ios-player__time--total'), Lampa.Utils.secondsToTime(state.duration || 0)); - MUSIC_IOS_AUDIO.barUiKey = uiKey; - } - - if (!isSeeking) { - var currentText = Lampa.Utils.secondsToTime(state.current || 0); - var seekValue = state.duration > 0 ? Math.max(0, Math.min(1000, Math.round((state.current / state.duration) * 1000))) : 0; - var progressKey = currentText + '|' + seekValue; - - if (MUSIC_IOS_AUDIO.barProgressKey !== progressKey) { - setTextIfChanged(bar.find('.lm-ios-player__time--current'), currentText); - bar.find('.lm-ios-player__seek').val(seekValue); - MUSIC_IOS_AUDIO.barProgressKey = progressKey; - } - } - - bar.addClass('lm-ios-player--visible'); - if (MUSIC_IOS_AUDIO.barFocusRequested) { - MUSIC_IOS_AUDIO.barFocusRequested = false; - focusStandaloneIosPlayerBar(); - } - updateStandaloneIosFullPlayer(); - } - - // --- длительность/прогресс, synthetic-ended, watchdog и recovery --- - - function updateStandaloneIosPlaybackState() { - var session = mediaSessionObject(); - var audio = MUSIC_IOS_AUDIO.audio; - - if (!session || !audio) return; - - try { - if ('playbackState' in session) session.playbackState = MUSIC_IOS_AUDIO.playing && !audio.paused ? 'playing' : 'paused'; - } catch (e) {} - - updateStandaloneIosPlayerBar(); - } - - // для скраббера нужна реальная длительность стрима (метаданные расходятся с - // рипом на секунды и seek попадает мимо), НО WebKit парсит YouTube fMP4 с - // удвоенной длительностью: при диком расхождении со стримом верим метаданным - function standaloneIosEffectiveDuration(audio, track) { - var fallbackDuration = track && track.duration_ms ? Math.max(0, Math.round(track.duration_ms / 1000)) : 0; - var streamDuration = audio && isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 0; - - if (streamDuration && fallbackDuration && (streamDuration > fallbackDuration * 1.5 || streamDuration * 1.5 < fallbackDuration)) - return fallbackDuration; - - return streamDuration || fallbackDuration; - } - - // когда WebKit завысил длительность (fMP4 2×), реальные данные кончаются на - // effective-отметке и штатный 'ended' не стреляет — очередь двигаем вручную - function maybeSyntheticStandaloneIosEnded(origin) { - var audio = MUSIC_IOS_AUDIO.audio; - var track = MUSIC_IOS_AUDIO.tracks[MUSIC_IOS_AUDIO.currentIndex]; - - if (!audio || !track || MUSIC_IOS_AUDIO.switching || audio.paused || audio.ended) return false; - - var effective = standaloneIosEffectiveDuration(audio, track); - var streamDuration = isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 0; - - if (!effective || streamDuration <= effective + 1) return false; - - var tail = origin === 'timeupdate' ? 0.3 : 3; - if (!isFinite(audio.currentTime) || audio.currentTime < effective - tail) return false; - - traceStandaloneIosAudio('synthetic-ended', origin + ' ct=' + (Math.round(audio.currentTime * 100) / 100) + ' effective=' + effective, true); - - MUSIC_IOS_AUDIO.playing = false; - - if (!standaloneIosHandleTrackEnd()) { - try { audio.pause(); } catch (e) {} - stopStandaloneIosKeepAlive('ended'); - syncStandaloneIosMediaSession(); - updateStandaloneIosPlayerBar(); - } - - return true; - } - - function updateStandaloneIosPositionState(force) { - var session = mediaSessionObject(); - var audio = MUSIC_IOS_AUDIO.audio; - var track = MUSIC_IOS_AUDIO.tracks[MUSIC_IOS_AUDIO.currentIndex]; - var duration = standaloneIosEffectiveDuration(audio, track); - var position = MUSIC_IOS_AUDIO.switching ? 0 : (audio && isFinite(audio.currentTime) ? audio.currentTime : 0); - var now = Date.now(); - - if (!session || !audio || typeof session.setPositionState !== 'function') return; - if (audio.getAttribute('data-source-url') === 'silent-warmup') return; - if (!duration) return; - - // В фоне iOS сам ведёт позицию Media Session по последнему - // setPositionState. Частая запись раз в секунду только будит JS. - var minPositionInterval = document.hidden ? 5000 : 1000; - if (!force && now - (MUSIC_IOS_AUDIO.lastPositionSync || 0) < minPositionInterval) return; - - try { - session.setPositionState({ - duration: duration, - playbackRate: audio.playbackRate || 1, - position: Math.max(0, Math.min(duration, position)) - }); - MUSIC_IOS_AUDIO.lastPositionSync = now; - bumpMusicHeatMetric('standalonePositionState'); - } catch (e) {} - } - - // watchdog после play(): резолв промиса play() и readyState=4 НЕ доказывают, - // что звук идёт — iOS умеет держать элемент "играющим" с замороженным - // currentTime (типично после lock screen команд). Проверяем реальный прогресс - // через 700ms: attempt 0 — цикл pause()+play() («nudge», единственное, что - // размораживает clock), attempt 1 — hidden: полная переприцепка src - // (recoverStandaloneIosPlayback), visible: только трейс play-unfreeze-failed - function watchStandaloneIosPlayProgress(media, origin, attempt) { - var token = ++MUSIC_IOS_AUDIO.playWatchToken; - var basePosition = media && isFinite(media.currentTime) ? media.currentTime : 0; - var startedHidden = document.visibilityState === 'hidden' || !!document.hidden; - var delay = startedHidden ? 1400 : 700; - - attempt = attempt || 0; - - setTimeout(function () { - if (token !== MUSIC_IOS_AUDIO.playWatchToken) return; - - var audio = MUSIC_IOS_AUDIO.audio; - if (!audio || audio !== media || !isStandaloneIosAudioActive()) return; - if (MUSIC_IOS_AUDIO.switching || audio.paused || audio.ended) return; - - var progressed = (isFinite(audio.currentTime) ? audio.currentTime : 0) - basePosition; - if (progressed > 0.3) return; - - // элемент "играет" (paused=false, readyState в норме), но clock заморожен: - // единственный приём, который эмпирически снимает заморозку — цикл pause()+play() - if (attempt === 0) { - nudgeStandaloneIosPlayback(audio, origin, basePosition, true); - watchStandaloneIosPlayProgress(media, origin, 1); - return; - } - - if (document.visibilityState === 'hidden' || document.hidden) { - recoverStandaloneIosPlayback(origin, basePosition); - return; - } - - traceStandaloneIosAudio('play-unfreeze-failed', origin + ' delta=' + (Math.round(progressed * 100) / 100), true); - updateStandaloneIosPlaybackState(); - }, delay); - } - - function nudgeStandaloneIosPlayback(audio, origin, position, cycle) { - traceStandaloneIosAudio(cycle ? 'play-nudge-cycle' : 'play-nudge', origin + ' position=' + (Math.round((position || 0) * 100) / 100), true); - - if (cycle) { - try { - audio.pause(); - } catch (e) {} - } - - try { - var promise = audio.play(); - if (promise && typeof promise.catch === 'function') promise.catch(function (error) { - traceStandaloneIosAudio('play-nudge-rejected', error && error.name ? error.name : String(error || ''), true); - updateStandaloneIosPlaybackState(); - }); - } catch (e) { - traceStandaloneIosAudio('play-nudge-error', e && e.name ? e.name : String(e || ''), true); - updateStandaloneIosPlaybackState(); - } - - setTimeout(function () { - if (MUSIC_IOS_AUDIO.audio !== audio || MUSIC_IOS_AUDIO.switching || audio.paused || audio.ended) return; - - var moved = (isFinite(audio.currentTime) ? audio.currentTime : 0) - (position || 0); - traceStandaloneIosAudio(moved > 0.3 ? 'play-nudge-ok' : 'play-nudge-stalled', 'delta=' + (Math.round(moved * 100) / 100), true); - updateStandaloneIosPlaybackState(); - }, 900); - } - - function scheduleStandaloneIosVisibleUnfreeze() { - var audio = MUSIC_IOS_AUDIO.audio; - - if (!audio || !isStandaloneIosAudioActive()) return; - if (MUSIC_IOS_AUDIO.switching || audio.paused || audio.ended) return; - - // после разблокировки элемент может остаться в "играет, но clock стоит": - // watchdog проверит прогресс и при заморозке сделает pause()+play() - watchStandaloneIosPlayProgress(audio, 'visible-unfreeze', 0); - } - - // последний рубеж watchdog'а: полная переприцепка src (data-source-url) с - // восстановлением позиции. По трейсам июля 2026 из заморозки под блокировкой - // НИ РАЗУ не спасала (iOS не реактивирует аудио-сессию фоновой страницы — - // это лечит keep-alive) — держим как страховку от других причин залипания. - // Удалять только отдельным коммитом после теста именно заблокированного экрана - function recoverStandaloneIosPlayback(origin, position) { - var audio = MUSIC_IOS_AUDIO.audio; - var sourceUrl = audio ? (audio.getAttribute('data-source-url') || '') : ''; - - if (!audio || !sourceUrl) return; - - traceStandaloneIosAudio('play-recovery', origin + ' position=' + (Math.round((position || 0) * 100) / 100), true); - - var resumed = false; - var resume = function () { - if (resumed) return; - resumed = true; - audio.removeEventListener('loadedmetadata', resume); - - if (MUSIC_IOS_AUDIO.audio !== audio || MUSIC_IOS_AUDIO.switching) return; - - try { - if (position > 0) audio.currentTime = position; - } catch (e) {} - - try { - var promise = audio.play(); - if (promise && typeof promise.catch === 'function') promise.catch(function (error) { - traceStandaloneIosAudio('play-recovery-rejected', error && error.name ? error.name : String(error || ''), true); - updateStandaloneIosPlaybackState(); - }); - } catch (e) { - traceStandaloneIosAudio('play-recovery-error', e && e.name ? e.name : String(e || ''), true); - updateStandaloneIosPlaybackState(); - } - }; - - audio.addEventListener('loadedmetadata', resume); - - try { - audio.src = sourceUrl; - audio.load(); - } catch (e) { - audio.removeEventListener('loadedmetadata', resume); - traceStandaloneIosAudio('play-recovery-load-error', e && e.name ? e.name : String(e || ''), true); - return; - } - - setTimeout(function () { - if (MUSIC_IOS_AUDIO.audio !== audio || MUSIC_IOS_AUDIO.switching || audio.paused || audio.ended) return; - - var moved = (isFinite(audio.currentTime) ? audio.currentTime : 0) - (position || 0); - traceStandaloneIosAudio(moved > 0.3 ? 'play-recovery-ok' : 'play-recovery-stalled', 'delta=' + (Math.round(moved * 100) / 100), true); - - if (moved <= 0.3) updateStandaloneIosPlaybackState(); - }, 1400); - } - - // --- Media Session standalone-плеера (lock screen iOS) --- - - function syncStandaloneIosMediaSession() { - var session = mediaSessionObject(); - var audio = MUSIC_IOS_AUDIO.audio; - var track = MUSIC_IOS_AUDIO.tracks[MUSIC_IOS_AUDIO.currentIndex]; - var trackId = track && track.id ? track.id : ''; - - if (!session || !audio || !track) { - clearStandaloneIosMediaSession(); - return; - } - - if (MUSIC_IOS_AUDIO.mediaSessionTrackId !== trackId && typeof window.MediaMetadata === 'function') { - try { - session.metadata = new MediaMetadata({ - title: track.title || 'Track', - artist: track.artist_name || '', - album: track.album_title || '', - artwork: buildMediaSessionArtwork(track, MUSIC_IOS_AUDIO.playlist[MUSIC_IOS_AUDIO.currentIndex] || null) - }); - } catch (e) {} - } - - MUSIC_IOS_AUDIO.mediaSessionTrackId = trackId; - armStandaloneIosMediaSessionHandlers(); - - updateStandaloneIosPlaybackState(); - updateStandaloneIosPositionState(true); - } - - // все обработчики регистрируются ОДИН раз и ДО первого play(): iOS - // фиксирует возможности Now Playing-сессии в момент её создания — - // seekto, зарегистрированный после старта, не включал скраббер до паузы - function armStandaloneIosMediaSessionHandlers() { - if (MUSIC_IOS_AUDIO.mediaSessionArmed) return; - - var session = mediaSessionObject(); - if (!session) return; - - MUSIC_IOS_AUDIO.mediaSessionArmed = true; - - { - setMediaSessionHandler('play', function () { - var media = standaloneIosAudioElement(); - if (!media) return; - - traceStandaloneIosAudio('media-session-play', '', true); - - try { - startStandaloneIosKeepAlive('media-session-play'); - var promise = media.play(); - if (promise && typeof promise.then === 'function') { - promise.then(function () { - traceStandaloneIosAudio('media-session-play-resolved', '', true); - updateStandaloneIosPlaybackState(); - updateStandaloneIosPositionState(true); - watchStandaloneIosPlayProgress(media, 'media-session-play'); - }).catch(function () { - traceStandaloneIosAudio('media-session-play-rejected', '', true); - updateStandaloneIosPlaybackState(); - }); - } else { - traceStandaloneIosAudio('media-session-play-sync', '', true); - updateStandaloneIosPlaybackState(); - watchStandaloneIosPlayProgress(media, 'media-session-play-sync'); - } - } catch (e) { - traceStandaloneIosAudio('media-session-play-error', e && e.name ? e.name : String(e || ''), true); - updateStandaloneIosPlaybackState(); - } - }); - setMediaSessionHandler('pause', function () { - cancelRadioAutoplayResume(); - var media = standaloneIosAudioElement(); - if (!media) return; - - // с активным keep-alive iOS может считать страницу играющей и слать - // pause вместо play: повторный pause на уже стоящем треке = команда play - if (media.paused && MUSIC_IOS_AUDIO.keepAliveActive) { - traceStandaloneIosAudio('media-session-pause-as-play', '', true); - - try { - var playPromise = media.play(); - if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(function (error) { - traceStandaloneIosAudio('media-session-pause-as-play-rejected', error && error.name ? error.name : String(error || ''), true); - updateStandaloneIosPlaybackState(); - }); - watchStandaloneIosPlayProgress(media, 'pause-as-play'); - } catch (e) { - traceStandaloneIosAudio('media-session-pause-as-play-error', e && e.name ? e.name : String(e || ''), true); - } - - updateStandaloneIosPlaybackState(); - return; - } - - traceStandaloneIosAudio('media-session-pause', '', true); - - try { - media.pause(); - } catch (e) { - traceStandaloneIosAudio('media-session-pause-error', e && e.name ? e.name : String(e || ''), true); - } - - startStandaloneIosKeepAlive('media-session-pause'); - updateStandaloneIosPlaybackState(); - updateStandaloneIosPositionState(true); - }); - - setMediaSessionHandler('seekto', function (details) { - var media = standaloneIosAudioElement(); - if (!media || typeof details.seekTime !== 'number') return; - - var seekTrack = MUSIC_IOS_AUDIO.tracks[MUSIC_IOS_AUDIO.currentIndex]; - var seekDuration = standaloneIosEffectiveDuration(media, seekTrack); - var target = Math.max(0, seekDuration ? Math.min(seekDuration, details.seekTime) : details.seekTime); - - traceStandaloneIosAudio('media-session-seekto', 'to=' + (Math.round(target * 100) / 100) + (details.fastSeek ? ' fast' : ''), true); - - try { - if (details.fastSeek && typeof media.fastSeek === 'function') - media.fastSeek(target); - else - media.currentTime = target; - } catch (e) { - traceStandaloneIosAudio('media-session-seekto-error', e && e.name ? e.name : String(e || ''), true); - } - - updateStandaloneIosPositionState(true); - updateStandaloneIosPlayerBar(); - }); - setMediaSessionHandler('seekforward', null); - setMediaSessionHandler('seekbackward', null); - - setMediaSessionHandler('previoustrack', function () { - var prevIndex = standaloneIosNeighborIndex(-1); - if (prevIndex >= 0) standaloneIosPlayIndex(prevIndex); - }); - - setMediaSessionHandler('nexttrack', function () { - var nextIndex = standaloneIosNeighborIndex(1); - if (nextIndex >= 0) standaloneIosPlayIndex(nextIndex); - }); - } - } - - // --- запуск/остановка standalone-воспроизведения, смена трека --- - - function stopStandaloneIosAudioPlayback() { - resetRadioAutoplayRequest(); - clearStandaloneIosSleepTimer(false); - - if (MUSIC_IOS_AUDIO.audio) { - setStandaloneIosTimeupdateListener(false); - - try { - MUSIC_IOS_AUDIO.audio.pause(); - } catch (e) {} - - try { - MUSIC_IOS_AUDIO.audio.removeAttribute('src'); - MUSIC_IOS_AUDIO.audio.load(); - } catch (e) {} - - try { - if (MUSIC_IOS_AUDIO.audio.parentNode) - MUSIC_IOS_AUDIO.audio.parentNode.removeChild(MUSIC_IOS_AUDIO.audio); - } catch (e) {} - } - - MUSIC_IOS_AUDIO.active = false; - MUSIC_IOS_AUDIO.switching = false; - MUSIC_IOS_AUDIO.playing = false; - MUSIC_IOS_AUDIO.playlist = []; - MUSIC_IOS_AUDIO.tracks = []; - MUSIC_IOS_AUDIO.currentIndex = -1; - MUSIC_IOS_AUDIO.audio = null; - MUSIC_IOS_AUDIO.timeupdateHandler = null; - MUSIC_IOS_AUDIO.timeupdateAttached = false; - MUSIC_IOS_AUDIO.prepareToken = (MUSIC_IOS_AUDIO.prepareToken || 0) + 1; - MUSIC_IOS_AUDIO.playWatchToken++; - stopStandaloneIosKeepAlive('stop'); - MUSIC_QUEUE.tracks = []; - MUSIC_QUEUE.currentIndex = 0; - MUSIC_QUEUE.currentTrackId = null; - clearQueueSnapshot(); - - clearPendingTrackPlayed(); - clearStandaloneIosMediaSession(); - updateStandaloneIosPlayerBar(); - } - - function applyStandaloneIosResumePosition(audio, track) { - var position = Math.max(0, Number(MUSIC_IOS_AUDIO.resumePosition || 0)); - - MUSIC_IOS_AUDIO.resumePosition = 0; - if (!audio || !position || position < 2) return; - - var duration = track && track.duration_ms ? Math.max(0, Math.round(track.duration_ms / 1000)) : 0; - if (duration && position >= duration - 3) return; - - function seek() { - try { - audio.currentTime = Math.max(0, position); - scheduleQueueSnapshotSave(true); - } catch (e) {} - } - - if (audio.readyState >= 1) { - seek(); - return; - } - - try { - audio.addEventListener('loadedmetadata', seek, { once: true }); - } catch (e) {} - } - - function standaloneIosPlayIndex(index) { - var audio = standaloneIosAudioElement(); - var playback = MUSIC_IOS_AUDIO.playlist[index]; - var track = MUSIC_IOS_AUDIO.tracks[index]; - - if (!audio || !playback || !playback.url || !track) return false; - if (MUSIC_IOS_AUDIO.switching) return false; - - cancelRadioAutoplayResume(); - MUSIC_IOS_AUDIO.switching = true; - MUSIC_IOS_AUDIO.playing = false; - MUSIC_IOS_AUDIO.playWatchToken++; - MUSIC_IOS_AUDIO.currentIndex = index; - updateQueueCurrent(track.id); - scheduleTrackPlayed(track); - updateStandaloneIosPlayerBar(); - - if (typeof playback.url === 'function') { - var resolveToken = MUSIC_IOS_AUDIO.playWatchToken; - var prepareToken = MUSIC_IOS_AUDIO.prepareToken; - syncStandaloneIosMediaSession(); - updateStandaloneIosPositionState(); - - playback.url(function () { - if (resolveToken !== MUSIC_IOS_AUDIO.playWatchToken - || prepareToken !== MUSIC_IOS_AUDIO.prepareToken) return; - MUSIC_IOS_AUDIO.switching = false; - - if (!playback.url || typeof playback.url !== 'string') { - // резолв не удался — глушим warmup-тишину, если она играла - if (audio.getAttribute('data-source-url') === 'silent-warmup') { - try { - audio.loop = false; - audio.pause(); - } catch (e) {} - } - - syncStandaloneIosMediaSession(); - updateStandaloneIosPlayerBar(); - return; - } - - var target = MUSIC_IOS_AUDIO.tracks.indexOf(track); - if (target >= 0) standaloneIosPlayIndex(target); - }); - - return true; - } - - try { - var currentSourceUrl = audio.getAttribute('data-source-url') || ''; - - if (currentSourceUrl !== playback.url) { - try { - audio.muted = false; - audio.playbackRate = 1; - audio.loop = false; - } catch (e) {} - - audio.src = playback.url; - audio.setAttribute('data-source-url', playback.url); - audio.currentTime = 0; - traceStandaloneIosAudio('track-source', '', true); - } - - applyStandaloneIosResumePosition(audio, track); - syncStandaloneIosMediaSession(); - updateStandaloneIosPositionState(); - - traceStandaloneIosAudio('track-play', '', true); - var promise = audio.play(); - if (promise && typeof promise.catch === 'function') { - promise.catch(function (error) { - MUSIC_IOS_AUDIO.switching = false; - MUSIC_IOS_AUDIO.playing = false; - traceStandaloneIosAudio('track-play-rejected', error && error.name ? error.name : String(error || ''), true); - }); - } - } catch (e) { - MUSIC_IOS_AUDIO.switching = false; - MUSIC_IOS_AUDIO.playing = false; - traceStandaloneIosAudio('track-play-error', e && e.name ? e.name : String(e || ''), true); - return false; - } - - return true; - } - - function startStandaloneIosAudioPlayback(tracks, preparedList, startIndex, resumePosition) { - MUSIC_IOS_AUDIO.playlist = (preparedList || []).slice(); - MUSIC_IOS_AUDIO.tracks = (tracks || []).slice(); - MUSIC_IOS_AUDIO.currentIndex = -1; - MUSIC_IOS_AUDIO.active = true; - MUSIC_IOS_AUDIO.switching = false; - MUSIC_IOS_AUDIO.playing = false; - MUSIC_IOS_AUDIO.mediaSessionTrackId = null; - MUSIC_IOS_AUDIO.lastPositionSync = 0; - MUSIC_IOS_AUDIO.prepareToken = (MUSIC_IOS_AUDIO.prepareToken || 0) + 1; - MUSIC_IOS_AUDIO.shuffleOrder = null; - MUSIC_IOS_AUDIO.resumePosition = Math.max(0, Number(resumePosition || 0)); - MUSIC_IOS_AUDIO.barFocusRequested = Lampa.Platform.screen('tv') && !MUSIC_IOS_FULL_PLAYER_OPEN; - updateStandaloneIosPlayerBar(); - startStandaloneIosKeepAlive('playlist-start'); - - // выдержка сессии: iOS фиксирует capabilities Now Playing (в т.ч. - // seekable — скраббер локскрина) в первые сотни ms после жестового - // play(). С тёплым кэшем (prefetch rev 313/314) реальный src подменял - // silent-warmup через ~30ms — сессия строилась на emptied-элементе с - // пустым seekable, скраббер оставался серым. Даём warmup-тишине - // доиграть минимум settle-окно, как это естественно происходило при - // холодном резолве; при медленном резолве задержка равна нулю - var settleElapsed = Date.now() - (MUSIC_IOS_AUDIO.gestureWarmupAt || 0); - var settleDelay = Math.max(0, 700 - settleElapsed); - - if (!settleDelay) - return standaloneIosPlayIndex(startIndex) ? MUSIC_IOS_AUDIO.prepareToken : 0; - - var settleToken = MUSIC_IOS_AUDIO.prepareToken; - traceStandaloneIosAudio('session-settle-delay', 'ms=' + settleDelay, true); - - setTimeout(function () { - if (MUSIC_IOS_AUDIO.prepareToken !== settleToken || !MUSIC_IOS_AUDIO.active) return; - standaloneIosPlayIndex(startIndex); - }, settleDelay); - - return settleToken; - } - - // --- Media Session: общие хелперы обоих режимов --- - - function mediaSessionObject() { - try { - return navigator && navigator.mediaSession ? navigator.mediaSession : null; - } catch (e) { - return null; - } - } - - function setMediaSessionHandler(action, handler) { - var session = mediaSessionObject(); - if (!session || typeof session.setActionHandler !== 'function') return; - - try { - session.setActionHandler(action, handler || null); - } catch (e) {} - } - - function guessArtworkMimeType(url) { - var value = String(url || ''); - - if (value.indexOf('data:image/png') === 0) return 'image/png'; - if (value.indexOf('data:image/webp') === 0) return 'image/webp'; - if (value.indexOf('data:image/svg+xml') === 0) return 'image/svg+xml'; - if (value.indexOf('png') !== -1) return 'image/png'; - if (value.indexOf('webp') !== -1) return 'image/webp'; - return 'image/jpeg'; - } - - function buildMediaSessionArtwork(track, data) { - var image = trackImage(track) || (data && (data.img || data.poster)) || ''; - if (!image) return []; - - return [{ - src: image, - sizes: '512x512', - type: guessArtworkMimeType(image) - }]; - } - - // --- embedded-режим (player=inner): lock screen поверх Lampa.PlayerVideo. - // NB: живёт здесь, а не в LAMPA PLAYER BRIDGE — тесно завязан на общие - // media-session-хелперы выше --- - - function activeMusicMediaElement() { - return embeddedIosStockMediaElement(); - } - - function embeddedIosStockMediaElement() { - return musicPlayerPanelMedia || document.querySelector('.player-video video, .player-video audio'); - } - - function prepareEmbeddedIosMediaElement(media, origin) { - if (!media || !shouldUseEmbeddedIosLockscreenMode()) return false; - - var alreadyPrepared = media.getAttribute('data-music-ios-prepared') === '1'; - - try { - media.playsInline = true; - media.preload = 'auto'; - media.setAttribute('playsinline', 'playsinline'); - media.setAttribute('webkit-playsinline', 'webkit-playsinline'); - media.setAttribute('x-webkit-airplay', 'allow'); - media.setAttribute('data-music-ios-prepared', '1'); - } catch (e) {} - - if (!alreadyPrepared) - traceEmbeddedIos('media-prepare', origin || '', true); - - return true; - } - - function scheduleEmbeddedIosMediaPrepare(origin) { - if (!shouldUseEmbeddedIosLockscreenMode()) return; - - [0, 16, 50, 120, 300].forEach(function (delay) { - setTimeout(function () { - prepareEmbeddedIosMediaElement(document.querySelector('.player-video video, .player-video audio'), origin); - }, delay); - }); - } - - function shouldPreferTrackNavigationControls() { - var player = currentExternalPlayer(); - - return Lampa.Platform.is('apple') - && player === 'ios' - && !shouldUseStandaloneIosAudio(); - } - - function shouldUseEmbeddedIosLockscreenMode() { - return Lampa.Platform.is('apple') - && getPlaybackMode() === 'audio' - && !shouldUseStandaloneIosAudio() - && usesInternalPlaybackFlow(); - } - - function updateMediaSessionPositionState() { - var session = mediaSessionObject(); - var media = activeMusicMediaElement(); - var data = activePlayerData() || MUSIC_EMBEDDED_IOS.lastData || {}; - var track = queueCurrentTrack(); - var duration = media && isFinite(media.duration) && media.duration > 0 ? media.duration : 0; - - if (!session || typeof session.setPositionState !== 'function') return; - if (shouldUseEmbeddedIosLockscreenSupport(data)) return; - if (!duration && data && data.from_music_cluster) - duration = data.music_duration || data.duration || (data.timeline && data.timeline.duration) || 0; - if (!duration && track && track.duration_ms) - duration = Math.max(0, Math.round(Number(track.duration_ms) / 1000)); - - if (!isFinite(duration) || duration <= 0) return; - - try { - session.setPositionState({ - duration: duration, - playbackRate: media && media.playbackRate ? media.playbackRate : 1, - position: Math.max(0, Math.min(duration, media && isFinite(media.currentTime) - ? media.currentTime - : ((data.timeline && data.timeline.time) || 0))) - }); - bumpMusicHeatMetric('embeddedPositionState'); - } catch (e) {} - } - - function shouldUseEmbeddedIosLockscreenSupport(data) { - return shouldUseEmbeddedIosLockscreenMode() - && data - && data.from_music_cluster - && getPlaybackMode() === 'audio'; - } - - function embeddedIosActiveData() { - var data = activePlayerData(); - return data && data.from_music_cluster ? data : MUSIC_EMBEDDED_IOS.lastData; - } - - function updateEmbeddedIosPlaybackState() { - var session = mediaSessionObject(); - var media = activeMusicMediaElement(); - - if (!session || !media) return; - - try { - if ('playbackState' in session) session.playbackState = media.paused || media.ended ? 'paused' : 'playing'; - } catch (e) {} - - updateMediaSessionPositionState(); - } - - function embeddedIosEffectiveDuration(media, data) { - var duration = media && isFinite(media.duration) && media.duration > 0 ? media.duration : 0; - - if (duration) return duration; - - data = data || embeddedIosActiveData(); - return (data && (data.music_duration || data.duration || (data.timeline && data.timeline.duration))) || 0; - } - - function applyEmbeddedIosMediaSessionMetadata(data, track) { - var session = mediaSessionObject(); - - if (!session || typeof window.MediaMetadata !== 'function') return; - - track = track || queueCurrentTrack(); - if ((!track || !track.id) && data && data.music_track_id) { - track = { - id: data.music_track_id, - title: data.title || 'Track', - artist_name: data.artist || '', - img: data.img || data.poster || '' - }; - } - - if (!track) return; - - try { - session.metadata = new MediaMetadata({ - title: track.title || (data && data.title) || 'Track', - artist: track.artist_name || (data && data.artist) || '', - album: track.album_title || '', - artwork: buildMediaSessionArtwork(track, data) - }); - } catch (e) {} - } - - function warmupEmbeddedIosGesture(track) { - if (!shouldUseEmbeddedIosLockscreenMode() || !track) return; - - var data = { - from_music_cluster: true, - music_track_id: track.id || '', - music_isrc: track.isrc || '', - title: track.title || 'Track', - artist: track.artist_name || '', - poster: trackImage(track), - img: trackImage(track), - music_duration_ms: track.duration_ms || 0, - music_duration: track.duration_ms ? Math.max(0, Math.round(Number(track.duration_ms) / 1000)) : 0 - }; - - MUSIC_EMBEDDED_IOS.active = true; - MUSIC_EMBEDDED_IOS.lastData = data; - bindEmbeddedIosLifecycleTrace(); - armEmbeddedIosMediaSessionHandlers(); - applyEmbeddedIosMediaSessionMetadata(data, track); - updateMediaSessionPositionState(); - scheduleEmbeddedIosMediaPrepare('gesture-warmup'); - startStandaloneIosKeepAlive('embedded-gesture-warmup'); - traceEmbeddedIos('gesture-warmup', '', true); - } - - function primeEmbeddedIosMediaSession(data, track, origin) { - if (!shouldUseEmbeddedIosLockscreenSupport(data)) return; - - MUSIC_EMBEDDED_IOS.active = true; - MUSIC_EMBEDDED_IOS.lastData = data; - bindEmbeddedIosLifecycleTrace(); - armEmbeddedIosMediaSessionHandlers(); - applyEmbeddedIosMediaSessionMetadata(data, track); - updateMediaSessionPositionState(); - scheduleEmbeddedIosMediaPrepare(origin || 'prime'); - startStandaloneIosKeepAlive('embedded-' + (origin || 'prime')); - traceEmbeddedIos('prime', origin || '', true); - } - - function armEmbeddedIosMediaSessionHandlers() { - if (MUSIC_EMBEDDED_IOS.mediaSessionArmed) return; - - var session = mediaSessionObject(); - if (!session) return; - - MUSIC_EMBEDDED_IOS.mediaSessionArmed = true; - traceEmbeddedIos('media-session-arm', '', true); - - setMediaSessionHandler('play', function () { - var data = embeddedIosActiveData(); - if (!shouldUseEmbeddedIosLockscreenSupport(data)) return; - - traceEmbeddedIos('media-session-play', '', true); - playEmbeddedMusicMedia('media-session-play'); - }); - - setMediaSessionHandler('pause', function () { - var data = embeddedIosActiveData(); - var media = activeMusicMediaElement(); - if (!shouldUseEmbeddedIosLockscreenSupport(data)) return; - - if (media && media.paused && MUSIC_IOS_AUDIO.keepAliveActive) { - traceEmbeddedIos('media-session-pause-as-play', '', true); - playEmbeddedMusicMedia('media-session-pause-as-play'); - return; - } - - traceEmbeddedIos('media-session-pause', '', true); - pauseEmbeddedMusicMedia('media-session-pause'); - }); - - setMediaSessionHandler('seekto', null); - setMediaSessionHandler('seekforward', null); - setMediaSessionHandler('seekbackward', null); - setMediaSessionHandler('previoustrack', function () { - traceEmbeddedIos('media-session-previoustrack', '', true); - playEmbeddedQueueOffset(-1); - }); - setMediaSessionHandler('nexttrack', function () { - traceEmbeddedIos('media-session-nexttrack', '', true); - playEmbeddedQueueOffset(1); - }); - } - - function copyEmbeddedPlaybackData(target, playback) { - if (!target || !playback) return; - - Object.keys(playback).forEach(function (key) { - target[key] = playback[key]; - }); - } - - function applyEmbeddedPlaybackData(track, playback, index) { - var work = activePlayerData(); - var playlist = activePlaylist(); - - copyEmbeddedPlaybackData(work, playback); - - if (playlist && playlist[index]) - copyEmbeddedPlaybackData(playlist[index], playback); - - updateQueueCurrent(track.id); - - try { - if (Lampa.PlayerPlaylist && typeof Lampa.PlayerPlaylist.url === 'function') - Lampa.PlayerPlaylist.url(playback.url); - } catch (e) {} - - try { - if (Lampa.Player && typeof Lampa.Player.playlist === 'function') - Lampa.Player.playlist(playlist); - } catch (e) {} - - try { - if (Lampa.PlayerInfo && typeof Lampa.PlayerInfo.set === 'function') - Lampa.PlayerInfo.set('name', playback.title || track.title || 'Track'); - } catch (e) {} - - try { - if (Lampa.PlayerPanel && typeof Lampa.PlayerPanel.quality === 'function') - Lampa.PlayerPanel.quality(playback.quality, playback.url); - } catch (e) {} - - try { - if (Lampa.PlayerPanel && typeof Lampa.PlayerPanel.showNextEpisodeName === 'function') - Lampa.PlayerPanel.showNextEpisodeName({ - playlist: playlist, - position: index - }); - } catch (e) {} - - musicPlayerPanelSyntheticEndKey = ''; - musicPlayerVisualMode = 'art'; - musicPlayerVisualLyricsRequest = ''; - renderMusicPlayerVisual(playback); - } - - function selectedMatchDurationMs(match) { - var duration = Number(match && match.duration_ms ? match.duration_ms : 0); - return isFinite(duration) && duration > 0 ? Math.round(duration) : 0; - } - - function patchTrackDuration(track, durationMs) { - if (!track || !durationMs) return false; - - var previous = Number(track.duration_ms || 0); - track.duration_ms = durationMs; - return previous !== durationMs; - } - - function patchPlaybackDuration(playback, durationMs) { - if (!playback || !durationMs) return false; - - var duration = Math.max(0, Math.round(durationMs / 1000)); - var changed = playback.music_duration_ms !== durationMs || playback.music_duration !== duration; - - playback.music_duration_ms = durationMs; - playback.music_duration = duration; - - if (playback.timeline) { - playback.timeline.duration = duration; - if (playback.timeline.time > duration) playback.timeline.time = duration; - playback.timeline.percent = duration > 0 - ? Math.max(0, Math.min(100, (playback.timeline.time || 0) / duration * 100)) - : 0; - } - - return changed; - } - - function patchTrackCollectionDuration(items, trackId, durationMs) { - if (!Array.isArray(items) || !trackId || !durationMs) return false; - - var changed = false; - items.forEach(function (item) { - if (item && item.id === trackId) - changed = patchTrackDuration(item, durationMs) || changed; - }); - - return changed; - } - - function patchPlaybackCollectionDuration(items, trackId, durationMs) { - if (!Array.isArray(items) || !trackId || !durationMs) return false; - - var changed = false; - items.forEach(function (item) { - if (item && item.music_track_id === trackId) - changed = patchPlaybackDuration(item, durationMs) || changed; - }); - - return changed; - } - - function applySelectedMatchMetadata(track, match) { - var durationMs = selectedMatchDurationMs(match); - var trackId = track && track.id ? track.id : ''; - var changed = false; - - if (!trackId || !durationMs) return false; - - changed = patchTrackDuration(track, durationMs) || changed; - changed = patchTrackCollectionDuration(queueTracks(), trackId, durationMs) || changed; - changed = patchTrackCollectionDuration(MUSIC_IOS_AUDIO.tracks, trackId, durationMs) || changed; - changed = patchPlaybackCollectionDuration(MUSIC_IOS_AUDIO.playlist, trackId, durationMs) || changed; - changed = patchPlaybackCollectionDuration(activePlaylist(), trackId, durationMs) || changed; - - var work = activePlayerData(); - if (work && work.from_music_cluster && work.music_track_id === trackId) - changed = patchPlaybackDuration(work, durationMs) || changed; - - if (MUSIC_EMBEDDED_IOS.lastData && MUSIC_EMBEDDED_IOS.lastData.music_track_id === trackId) - patchPlaybackDuration(MUSIC_EMBEDDED_IOS.lastData, durationMs); - - if (changed) { - scheduleQueueSnapshotSave(true); - applyMusicPlayerPanelFix(false); - updateStandaloneIosPlayerBar(); - updateMediaSessionPositionState(); - } - - return changed; - } - - function stopEmbeddedDuplicateMedia(keep) { - try { - document.querySelectorAll('.player-video video, .player-video audio').forEach(function (item) { - if (item === keep) return; - - try { item.pause(); } catch (e) {} - try { item.removeAttribute('src'); } catch (e) {} - try { item.load(); } catch (e) {} - try { item.parentNode && item.parentNode.removeChild(item); } catch (e) {} - }); - } catch (e) {} - } - - function switchEmbeddedMediaSource(media, url) { - if (!media || !url) return false; - - prepareEmbeddedIosMediaElement(media, 'switch-source'); - stopEmbeddedDuplicateMedia(media); - - try { - media.pause(); - } catch (e) {} - - try { - media.removeAttribute('src'); - media.load(); - } catch (e) {} - - try { - media.src = url; - media.currentTime = 0; - media.load(); - - if (Lampa.PlayerVideo && typeof Lampa.PlayerVideo.play === 'function') - Lampa.PlayerVideo.play(); - - var promise = media.play(); - if (promise && typeof promise.then === 'function') { - promise.then(function () { - updateEmbeddedIosPlaybackState(); - applyMusicPlayerPanelFix(false); - }).catch(function (error) { - traceEmbeddedIos('queue-inplace-play-rejected', error && error.name ? error.name : String(error || ''), true); - updateEmbeddedIosPlaybackState(); - }); - } else { - updateEmbeddedIosPlaybackState(); - applyMusicPlayerPanelFix(false); - } - } catch (e) { - traceEmbeddedIos('queue-inplace-error', e && e.name ? e.name : String(e || ''), true); - return false; - } - - return true; - } - - function playEmbeddedQueueIndexInPlace(index, origin) { - var tracks = queueTracks(); - var track = tracks[index]; - var switchToken = ++MUSIC_EMBEDDED_IOS.switchToken; - - if (!track) return false; - - cancelRadioAutoplayResume(); - if (origin === 'radio-resume' && musicPlayerPanelEndedCleanup) { - clearTimeout(musicPlayerPanelEndedCleanup); - musicPlayerPanelEndedCleanup = 0; - } - MUSIC_EMBEDDED_IOS.navigationIndex = index; - - // Lampa вычисляет позицию PlayerPlaylist по current URL. Отмечаем - // выбранный элемент до резолва, иначе после ошибки штатный next снова - // считает текущим предыдущий успешно запущенный трек. - try { - var activeItems = activePlaylist(); - if (activeItems[index] && Lampa.PlayerPlaylist && typeof Lampa.PlayerPlaylist.url === 'function') - Lampa.PlayerPlaylist.url(activeItems[index].url); - } catch (e) {} - - traceEmbeddedIos('queue-inplace-request', 'index=' + index + ' origin=' + (origin || ''), true); - - requestPlay(track, function (json) { - if (switchToken !== MUSIC_EMBEDDED_IOS.switchToken) { - traceEmbeddedIos('queue-inplace-stale', 'index=' + index, true); - return; - } - - var media = activeMusicMediaElement(); - if (!media) { - traceEmbeddedIos('queue-inplace-no-media', 'index=' + index, true); - return; - } - - var playback = buildResolvedPlayback(track, json); - applyEmbeddedPlaybackData(track, playback, index); - - traceEmbeddedIos('queue-inplace-source', 'index=' + index + ' url=' + describeMusicValue(playback.url), true); - - if (!switchEmbeddedMediaSource(media, playback.url)) - return; - - // The normal end cleanup may have detached the panel while radio was loading. - if (origin === 'radio-resume') startMusicPlayerPanelFix(playback); - startStandaloneIosKeepAlive('embedded-inplace'); - syncMusicMediaSession(playback); - updateMediaSessionPositionState(); - traceEmbeddedIos('queue-inplace-started', 'index=' + index, true); - }, function (json) { - if (switchToken !== MUSIC_EMBEDDED_IOS.switchToken) { - traceEmbeddedIos('queue-inplace-failed-stale', 'index=' + index, true); - return; - } - - traceEmbeddedIos('queue-inplace-failed', (json && json.message) || ('index=' + index), true); - - if (!maybeAutoSkipUnavailableTrack(json, function () { - if (switchToken !== MUSIC_EMBEDDED_IOS.switchToken) return; - - var queued = queueTracks(); - if (index + 1 >= queued.length) return; - - scheduleTrackPlayed(queued[index + 1]); - playEmbeddedQueueIndexInPlace(index + 1, 'auto-skip'); - })) - Lampa.Noty.show(json && json.message ? json.message : 'Источник для трека пока не найден.'); - }); - - return true; - } - - function playEmbeddedQueueOffset(offset) { - var tracks = queueTracks(); - var navigationIndex = Number(MUSIC_EMBEDDED_IOS.navigationIndex); - var currentIndex = isFinite(navigationIndex) - && navigationIndex === Math.floor(navigationIndex) - && navigationIndex >= 0 - && navigationIndex < tracks.length - ? navigationIndex - : queueCurrentIndex(); - var nextIndex = currentIndex + offset; - - if (!tracks.length || currentIndex < 0) return false; - if (nextIndex < 0 || nextIndex >= tracks.length) return false; - - traceEmbeddedIos(offset > 0 ? 'nexttrack' : 'previoustrack', 'from=' + currentIndex + ' to=' + nextIndex, true); - - scheduleTrackPlayed(tracks[nextIndex]); - return playEmbeddedQueueIndexInPlace(nextIndex, offset > 0 ? 'nexttrack' : 'previoustrack'); - } - - function playEmbeddedMusicMedia(origin) { - var media = activeMusicMediaElement(); - if (!media) return null; - - traceEmbeddedIos('play', origin || '', true); - startStandaloneIosKeepAlive('embedded-' + (origin || 'play')); - - try { - if (Lampa.PlayerVideo && typeof Lampa.PlayerVideo.play === 'function') - Lampa.PlayerVideo.play(); - } catch (e) {} - - try { - var promise = media.play(); - if (promise && typeof promise.then === 'function') { - promise.then(function () { - traceEmbeddedIos('play-resolved', origin || '', true); - updateEmbeddedIosPlaybackState(); - watchEmbeddedIosPlayProgress(media, origin || 'play', 0); - }).catch(function () { - traceEmbeddedIos('play-rejected', origin || '', true); - updateEmbeddedIosPlaybackState(); - }); - } else { - traceEmbeddedIos('play-sync', origin || '', true); - updateEmbeddedIosPlaybackState(); - watchEmbeddedIosPlayProgress(media, origin || 'play-sync', 0); - } - } catch (e) { - traceEmbeddedIos('play-error', (e && e.name ? e.name : String(e || '')), true); - updateEmbeddedIosPlaybackState(); - } - - return media; - } - - function pauseEmbeddedMusicMedia(origin) { - cancelRadioAutoplayResume(); - var media = activeMusicMediaElement(); - if (!media) return; - - traceEmbeddedIos('pause', origin || '', true); - - try { - if (Lampa.PlayerVideo && typeof Lampa.PlayerVideo.pause === 'function') - Lampa.PlayerVideo.pause(); - else - media.pause(); - } catch (e) { - try { media.pause(); } catch (_) {} - } - - startStandaloneIosKeepAlive('embedded-' + (origin || 'pause')); - updateEmbeddedIosPlaybackState(); - } - - function watchEmbeddedIosPlayProgress(media, origin, attempt) { - var token = ++MUSIC_EMBEDDED_IOS.playWatchToken; - var basePosition = media && isFinite(media.currentTime) ? media.currentTime : 0; - var startedHidden = document.visibilityState === 'hidden' || !!document.hidden; - var delay = startedHidden ? 1400 : 700; - - attempt = attempt || 0; - - setTimeout(function () { - if (token !== MUSIC_EMBEDDED_IOS.playWatchToken) return; - if (!MUSIC_EMBEDDED_IOS.active || activeMusicMediaElement() !== media) return; - if (!media || media.paused || media.ended) return; - - var progressed = (isFinite(media.currentTime) ? media.currentTime : 0) - basePosition; - if (progressed > 0.3) return; - - if (attempt === 0) { - try { media.pause(); } catch (e) {} - playEmbeddedMusicMedia(origin || 'watchdog'); - watchEmbeddedIosPlayProgress(media, origin || 'watchdog', 1); - return; - } - - updateEmbeddedIosPlaybackState(); - }, delay); - } - - function scheduleEmbeddedIosVisibleUnfreeze() { - var data = embeddedIosActiveData(); - var media = activeMusicMediaElement(); - - if (!shouldUseEmbeddedIosLockscreenSupport(data) || !media || media.paused || media.ended) return; - - watchEmbeddedIosPlayProgress(media, 'visible-unfreeze', 0); - } - - function scheduleEmbeddedIosLockKick() { - var data = embeddedIosActiveData(); - var media = activeMusicMediaElement(); - - if (!shouldUseEmbeddedIosLockscreenSupport(data) || !media || media.paused || media.ended) return; - - setTimeout(function () { - if (!document.hidden || activeMusicMediaElement() !== media || media.paused || media.ended) return; - - try { - media.pause(); - var promise = media.play(); - if (promise && typeof promise.catch === 'function') promise.catch(function () {}); - } catch (e) {} - }, 400); - } - - function bindEmbeddedIosLifecycleTrace() { - if (MUSIC_EMBEDDED_IOS.lifecycleBound) return; - MUSIC_EMBEDDED_IOS.lifecycleBound = true; - - ['visibilitychange', 'pagehide', 'pageshow', 'focus', 'blur'].forEach(function (eventName) { - var target = eventName === 'visibilitychange' ? document : window; - target.addEventListener(eventName, function () { - var data = embeddedIosActiveData(); - if (!shouldUseEmbeddedIosLockscreenSupport(data)) return; - - traceEmbeddedIos('lifecycle-' + eventName, '', true); - updateEmbeddedIosPlaybackState(); - - if (eventName === 'visibilitychange' && !document.hidden) { - reviveStandaloneIosKeepAlive(); - scheduleEmbeddedIosVisibleUnfreeze(); - } - - if (eventName === 'visibilitychange' && document.hidden) { - syncMusicMediaSession(data); - scheduleEmbeddedIosLockKick(); - } - }); - }); - } - - function clearEmbeddedIosLockscreenSupport(origin) { - MUSIC_EMBEDDED_IOS.active = false; - MUSIC_EMBEDDED_IOS.lastData = null; - MUSIC_EMBEDDED_IOS.playWatchToken++; - MUSIC_EMBEDDED_IOS.switchToken++; - MUSIC_EMBEDDED_IOS.mediaSessionArmed = false; - MUSIC_EMBEDDED_IOS.navigationIndex = -1; - - if (!isStandaloneIosAudioActive()) - stopStandaloneIosKeepAlive('embedded-' + (origin || 'clear')); - } - - // --- навигация по очереди (общая для обоих режимов) и маршрутизация Media Session --- - - function playQueueOffset(offset) { - var tracks = queueTracks(); - var currentIndex = queueCurrentIndex(); - var nextIndex = currentIndex + offset; - - if (!tracks.length || currentIndex < 0) return false; - if (nextIndex < 0 || nextIndex >= tracks.length) return false; - - if (canReuseActiveQueue(tracks) && selectFromActiveQueue(nextIndex)) { - scheduleTrackPlayed(tracks[nextIndex]); - return true; - } - - playTrack(tracks[nextIndex], tracks, nextIndex); - return true; - } - - function canUseActivePlaylistNavigation() { - var tracks = queueTracks(); - return !!(tracks.length > 1 && canReuseActiveQueue(tracks)); - } - - function playQueueOffsetViaActivePlaylist(offset) { - var tracks = queueTracks(); - var currentIndex = queueCurrentIndex(); - var nextIndex = currentIndex + offset; - - if (!tracks.length || currentIndex < 0) return false; - if (nextIndex < 0 || nextIndex >= tracks.length) return false; - if (!canUseActivePlaylistNavigation()) return false; - - if (selectFromActiveQueue(nextIndex)) { - scheduleTrackPlayed(tracks[nextIndex]); - return true; - } - - return false; - } - - function syncMusicMediaSession(data) { - var session = mediaSessionObject(); - if (!session) return; - - if (!data || !data.from_music_cluster) { - bumpMusicHeatMetric('embeddedMediaSessionClear'); - clearEmbeddedIosLockscreenSupport('clear'); - setMediaSessionHandler('play', null); - setMediaSessionHandler('pause', null); - setMediaSessionHandler('seekto', null); - setMediaSessionHandler('seekforward', null); - setMediaSessionHandler('seekbackward', null); - setMediaSessionHandler('nexttrack', null); - setMediaSessionHandler('previoustrack', null); - - try { - if ('playbackState' in session) session.playbackState = 'none'; - } catch (e) {} - - return; - } - - bumpMusicHeatMetric('embeddedMediaSessionSync'); - - if (shouldUseEmbeddedIosLockscreenSupport(data)) { - MUSIC_EMBEDDED_IOS.active = true; - MUSIC_EMBEDDED_IOS.lastData = data; - armEmbeddedIosMediaSessionHandlers(); - bindEmbeddedIosLifecycleTrace(); - traceEmbeddedIos('sync', '', true); - - var embeddedMedia = activeMusicMediaElement(); - if (embeddedMedia) - startStandaloneIosKeepAlive('embedded-sync'); - } - - var track = queueCurrentTrack(); - if ((!track || !track.id) && data.music_track_id) { - track = { - id: data.music_track_id, - title: data.title || 'Track', - artist_name: data.artist || '', - img: data.img || data.poster || '' - }; - } - - applyEmbeddedIosMediaSessionMetadata(data, track); - - setMediaSessionHandler('play', function () { - var currentData = activePlayerData() || data; - - if (shouldUseEmbeddedIosLockscreenSupport(currentData)) { - traceEmbeddedIos('media-session-play', '', true); - playEmbeddedMusicMedia('media-session-play'); - return; - } - - if (Lampa.PlayerVideo && typeof Lampa.PlayerVideo.play === 'function') { - Lampa.PlayerVideo.play(); - } else { - var media = activeMusicMediaElement(); - if (media && media.paused) media.play(); - } - - try { - if ('playbackState' in session) session.playbackState = 'playing'; - } catch (e) {} - }); - - setMediaSessionHandler('pause', function () { - var currentData = activePlayerData() || data; - cancelRadioAutoplayResume(); - var media = activeMusicMediaElement(); - - if (shouldUseEmbeddedIosLockscreenSupport(currentData)) { - // С активным keep-alive iOS иногда присылает pause вместо play. - // Если основной media уже на паузе, трактуем повторный pause как play. - if (media && media.paused && MUSIC_IOS_AUDIO.keepAliveActive) { - traceEmbeddedIos('media-session-pause-as-play', '', true); - playEmbeddedMusicMedia('media-session-pause-as-play'); - return; - } - - traceEmbeddedIos('media-session-pause', '', true); - pauseEmbeddedMusicMedia('media-session-pause'); - return; - } - - if (Lampa.PlayerVideo && typeof Lampa.PlayerVideo.pause === 'function') { - Lampa.PlayerVideo.pause(); - } else { - if (media && !media.paused) media.pause(); - } - - try { - if ('playbackState' in session) session.playbackState = 'paused'; - } catch (e) {} - }); - - if (shouldUseEmbeddedIosLockscreenSupport(activePlayerData() || data)) { - var embeddedQueueLength = queueTracks().length; - var embeddedIndex = queueCurrentIndex(); - - setMediaSessionHandler('seekto', null); - setMediaSessionHandler('seekforward', null); - setMediaSessionHandler('seekbackward', null); - setMediaSessionHandler('previoustrack', embeddedQueueLength > 1 && embeddedIndex > 0 - ? function () { - traceEmbeddedIos('media-session-previoustrack', '', true); - playEmbeddedQueueOffset(-1); - } - : null); - setMediaSessionHandler('nexttrack', embeddedQueueLength > 1 && embeddedIndex >= 0 && embeddedIndex < embeddedQueueLength - 1 - ? function () { - traceEmbeddedIos('media-session-nexttrack', '', true); - playEmbeddedQueueOffset(1); - } - : null); - } else if (shouldPreferTrackNavigationControls() && queueTracks().length > 1) { - var canNavigateQueue = canUseActivePlaylistNavigation(); - - setMediaSessionHandler('seekto', null); - setMediaSessionHandler('seekforward', null); - setMediaSessionHandler('seekbackward', null); - setMediaSessionHandler('previoustrack', canNavigateQueue && queueCurrentIndex() > 0 - ? function () { playQueueOffsetViaActivePlaylist(-1); } - : null); - setMediaSessionHandler('nexttrack', canNavigateQueue && queueCurrentIndex() >= 0 && queueCurrentIndex() < queueTracks().length - 1 - ? function () { playQueueOffsetViaActivePlaylist(1); } - : null); - } else { - setMediaSessionHandler('seekto', function (details) { - var media = activeMusicMediaElement(); - if (!media || typeof details.seekTime !== 'number') return; - media.currentTime = details.seekTime; - updateMediaSessionPositionState(); - }); - setMediaSessionHandler('previoustrack', null); - setMediaSessionHandler('nexttrack', null); - } - - updateMediaSessionPositionState(); - - try { - var media = activeMusicMediaElement(); - if ('playbackState' in session) session.playbackState = media && media.paused ? 'paused' : 'playing'; - } catch (e) {} - } - - function openBookmarksSection(sectionKey, title) { - Lampa.Activity.push({ - title: title || 'Закладки', - component: 'lampac_music_section', - section_key: sectionKey, - section_title: title || 'Закладки', - page: 1, - noinfo: true - }); - } - - // ===== SEARCH DATA ===== - - function normalizeSearchCacheKey(query) { - return String(query || '').trim().toLowerCase(); - } - - function getExpandedSearchGroup(query, type) { - var key = normalizeSearchCacheKey(query); - var cache = key ? SEARCH_EXPANDED_CACHE[key] : null; - if (!cache || !type) - return null; - - if (Array.isArray(cache[type]) && cache[type].length) - return cache[type].slice(); - - if (Array.isArray(cache.sections)) { - for (var i = 0; i < cache.sections.length; i++) { - var section = cache.sections[i]; - if (section && section.id === type && Array.isArray(section.entries) && section.entries.length) - return section.entries.slice(); - } - } - - return null; - } - - function loadExpandedSearch(query, callback) { - var key = normalizeSearchCacheKey(query); - if (!key) { - if (callback) callback(null); - return; - } - - if (SEARCH_EXPANDED_CACHE[key]) { - if (callback) callback(SEARCH_EXPANDED_CACHE[key]); - return; - } - - if (callback) { - if (!SEARCH_EXPANDED_WAITERS[key]) SEARCH_EXPANDED_WAITERS[key] = []; - SEARCH_EXPANDED_WAITERS[key].push(callback); - } - - if (SEARCH_EXPANDED_PENDING[key]) return; - - SEARCH_EXPANDED_PENDING[key] = true; - request(MUSIC.endpoints.search + '?q=' + encodeURIComponent(query) + '&full=true', function (json) { - delete SEARCH_EXPANDED_PENDING[key]; - SEARCH_EXPANDED_CACHE[key] = normalizeSearchResults(parseJson(json) || {}); - var waiters = SEARCH_EXPANDED_WAITERS[key] || []; - delete SEARCH_EXPANDED_WAITERS[key]; - waiters.forEach(function (fn) { - fn(SEARCH_EXPANDED_CACHE[key]); - }); - }, function () { - delete SEARCH_EXPANDED_PENDING[key]; - var waiters = SEARCH_EXPANDED_WAITERS[key] || []; - delete SEARCH_EXPANDED_WAITERS[key]; - waiters.forEach(function (fn) { - fn(null); - }); - }); - } - - function warmExpandedSearch(query) { - loadExpandedSearch(query); - } - - function hasPendingSearchMetadata(groups) { - return !!(groups && groups.metadata_pending); - } - - function normalizeEntriesPagination(options) { - var pagination = options && options.pagination ? options.pagination : null; - if (!pagination || !pagination.next_page || !pagination.section_id) return null; - - return { - kind: pagination.kind || 'artist_section', - section_id: pagination.section_id, - provider: pagination.provider || '', - next_page: pagination.next_page, - limit: pagination.limit || HOME_SECTION_LIMIT - }; - } - - function openSearchSection(title, entries, options) { - var resolvedEntries = Array.isArray(entries) ? entries.slice() : []; - var pagination = normalizeEntriesPagination(options); - - function pushSection(list, paging) { - Lampa.Activity.push({ - title: title || 'Результаты', - component: 'lampac_music_entries', - entries_title: title || 'Результаты', - entries: list, - entries_pagination: paging || null, - page: 1, - noinfo: true - }); - } - - if (options && options.query && options.type) { - var expanded = getExpandedSearchGroup(options.query, options.type); - if (expanded && expanded.length > resolvedEntries.length) { - pushSection(expanded, null); - return; - } - - loadExpandedSearch(options.query, function (cache) { - var nextEntries = resolvedEntries; - if (cache && cache[options.type] && cache[options.type].length > resolvedEntries.length) - nextEntries = cache[options.type].slice(); - - pushSection(nextEntries, null); - }); - return; - } - - pushSection(resolvedEntries, pagination); - } - - function openBookmarksScreen() { - Lampa.Activity.push({ - title: 'Закладки', - component: 'lampac_music_bookmarks', - page: 1, - noinfo: true - }); - } - - function activePlayerData() { - if (!Lampa.Player || typeof Lampa.Player.playdata !== 'function') return null; - return Lampa.Player.playdata() || null; - } - - function activePlaylist() { - if (!Lampa.PlayerPlaylist || typeof Lampa.PlayerPlaylist.get !== 'function') return []; - - var playlist = Lampa.PlayerPlaylist.get(); - return Array.isArray(playlist) ? playlist : []; - } - - function canReuseStandaloneIosQueue(tracks) { - var currentTracks = Array.isArray(MUSIC_IOS_AUDIO.tracks) ? MUSIC_IOS_AUDIO.tracks : []; - - if (!isStandaloneIosAudioActive()) return false; - if (!tracks || !tracks.length || tracks.length !== currentTracks.length) return false; - - for (var i = 0; i < tracks.length; i++) { - if (!tracks[i] || !currentTracks[i] || tracks[i].id !== currentTracks[i].id) - return false; - } - - return true; - } - - function canReuseActiveQueue(tracks) { - var current = activePlayerData(); - var playlist = activePlaylist(); - - if (!current || !current.from_music_cluster) return false; - if (!tracks || !tracks.length || playlist.length !== tracks.length) return false; - - for (var i = 0; i < tracks.length; i++) { - if (!playlist[i] || playlist[i].music_track_id !== tracks[i].id) return false; - } - - return true; - } - - function selectFromActiveQueue(index) { - var playlist = activePlaylist(); - var item = playlist[index]; - - if (!item || !Lampa.PlayerPlaylist || !Lampa.PlayerPlaylist.listener || typeof Lampa.PlayerPlaylist.listener.send !== 'function') - return false; - - Lampa.PlayerPlaylist.listener.send('select', { - playlist: playlist, - position: index, - item: item - }); - - return true; - } - - function selectFromStandaloneIosQueue(index) { - var selected = standaloneIosPlayIndex(index); - if (selected && Lampa.Platform.screen('tv')) focusStandaloneIosPlayerBar(); - return selected; - } - - function requestPlay(track, done, fail) { - bumpMusicHeatMetric('playRequest'); - - var cached = getCachedPlayResponse(track); - if (cached) { - bumpMusicHeatMetric('playCacheHit'); - // done строго асинхронно: standalone iOS-запуск рассчитан на резолв - // ВНЕ жеста (silent-warmup даёт gesture-кредит в тапе, реальный src - // приезжает следующим тиком — так тестировалась вся локскрин-сага). - // Синхронный done из кэша (после prefetch-ревизий кэш тёплый почти - // всегда) запускал плеер в одном тике с warmup — ломался скраббер - if (done) { - setTimeout(function () { - if (getCachedPlayResponse(track) === cached) done(cached); - else if (fail) fail(null); - }, 0); - } - return; - } - - var key = buildPlayCacheKey(track); - if (PLAY_PREFETCH_PENDING[key]) { - bumpMusicHeatMetric('playPendingJoin'); - PLAY_PREFETCH_PENDING[key].push({ - done: done, - fail: fail - }); - return; - } - - bumpMusicHeatMetric('playNetwork'); - var callbacks = PLAY_PREFETCH_PENDING[key] = [{ - done: done, - fail: fail - }]; - - // Холодный YouTube-резолв иногда дольше общего сетевого таймаута - // клиента (особенно для Spotify-треков без duration/isrc). Не обрываем - // живой серверный запрос на 20-й секунде: lazy URL штатного плеера после - // такого обрыва превращался в пустую строку и Lampa показывала ложное - // «Видео не найдено или повреждено». - request(buildPlayUrl(track), function (json) { - if (PLAY_PREFETCH_PENDING[key] !== callbacks) return; - var parsed = parseJson(json); - delete PLAY_PREFETCH_PENDING[key]; - - if (parsed && parsed.available && parsed.sources && parsed.sources.length) { - saveCachedPlayResponse(track, parsed, key); - callbacks.forEach(function (cb) { - if (cb.done) cb.done(parsed); - }); - return; - } - - callbacks.forEach(function (cb) { - if (cb.fail) cb.fail(parsed); - }); - }, function () { - if (PLAY_PREFETCH_PENDING[key] !== callbacks) return; - delete PLAY_PREFETCH_PENDING[key]; - callbacks.forEach(function (cb) { - if (cb.fail) cb.fail(null); - }); - }, 45000); - } - - function normalizePlayedMs(track, playedMs) { - var value = Math.max(0, Math.round(Number(playedMs || 0))); - var duration = track && track.duration_ms ? Math.max(0, Number(track.duration_ms)) : 0; - - if (duration && value > duration) - value = duration; - - return Math.min(value, 6 * 60 * 60 * 1000); - } - - function appendPlayedMsParam(params, track, playedMs) { - var value = normalizePlayedMs(track, playedMs); - return value > 0 ? params + '&played_ms=' + encodeURIComponent(value) : params; - } - - function buildHistoryRequestParams(track) { - var params = buildTrackRequestParams(track); - var image = selectSizedImage(track && track.images, 250); - if (typeof image !== 'string') return params; - - image = image.trim(); - if (image.indexOf('//') === 0) image = 'https:' + image; - if (!/^https?:\/\//i.test(image) || image.length > 8192) return params; - - return params + '&image=' + encodeURIComponent(image); - } - - function markTrackPlayed(track, playedMs) { - if (!track || !track.id) return; - - touchHomeCacheEntry('recently_played', mapTrackCard(track), RECENT_SECTION_STORAGE_LIMIT); - updateHomeSectionMetaFromCache('recently_played'); - emitRecentChanged('recently_played', track); - - // count_play — только здесь: это честный play-путь (после задержки - // реального воспроизведения); refreshRecentlyPlayedTrack обновляет - // payload без прослушивания и счётчик статистики не трогает - requestPost(MUSIC.endpoints.markHistory, appendPlayedMsParam(buildHistoryRequestParams(track) + '&count_play=true', track, playedMs), function () { - refreshStatsTopAfterPlayedTrack(); - }, function () {}); - } - - function markTrackPlayedTime(track, playedMs) { - if (!track || !track.id) return; - - var value = normalizePlayedMs(track, playedMs); - if (value <= 0) return; - - requestPost(MUSIC.endpoints.markHistory, appendPlayedMsParam(buildHistoryRequestParams(track), track, value), function () {}, function () {}); - } - - function refreshRecentlyPlayedTrack(track) { - if (!track || !track.id) return; - - touchHomeCacheEntry('recently_played', mapTrackCard(track), RECENT_SECTION_STORAGE_LIMIT); - updateHomeSectionMetaFromCache('recently_played'); - emitRecentChanged('recently_played', track); - requestPost(MUSIC.endpoints.markHistory, buildHistoryRequestParams(track), function () {}, function () {}); - } - - function findQueueTrackById(trackId) { - var tracks = queueTracks(); - - for (var i = 0; i < tracks.length; i++) { - if (tracks[i] && tracks[i].id === trackId) - return tracks[i]; - } - - return null; - } - - function findPlaylistItemByTrackId(trackId) { - var playlist = activePlaylist(); - - for (var i = 0; i < playlist.length; i++) { - if (playlist[i] && playlist[i].music_track_id === trackId) - return playlist[i]; - } - - return null; - } - - function buildTrackFromPlaybackData(data) { - if (!data || !data.music_track_id) return null; - - var image = data.img || data.poster || ''; - - return { - id: data.music_track_id, - title: data.title || 'Track', - artist_name: data.artist || '', - album_title: data.music_album_title || '', - isrc: data.music_isrc || '', - duration_ms: data.music_duration_ms || (data.music_duration ? Math.round(Number(data.music_duration || 0) * 1000) : 0), - image: image, - images: image ? [{ url: image }] : [] - }; - } - - function trackFromPlaybackData(data) { - if (!data || !data.music_track_id) return null; - - return findQueueTrackById(data.music_track_id) - || buildTrackFromPlaybackData(findPlaylistItemByTrackId(data.music_track_id)) - || buildTrackFromPlaybackData(data); - } - - function buildTimeline(track) { - var duration = track && track.duration_ms ? Math.max(0, Math.round(track.duration_ms / 1000)) : 0; - - return { - hash: 'music:' + (track && track.id ? track.id : Lampa.Utils.uid()), - time: 0, - percent: 0, - duration: duration, - stop_recording: true - }; - } - - function buildQualityMap(json) { - return json.sources.reduce(function (acc, item) { - if (item && item.quality && item.url) acc[item.quality] = item.url; - return acc; - }, {}); - } - - // WebKit не умеет перематывать webm по range (seek-индекс в конце файла): - // для standalone ios-пути m4a/mp4 в приоритете, webm — последний; внутри - // групп сохраняется серверный порядок (качество/режим пользователя) - function iosSourceRank(source) { - var mime = String(source && source.mime_type || '').toLowerCase(); - var quality = String(source && source.quality || '').toLowerCase(); - - if (mime.indexOf('mp4') >= 0 || quality.indexOf('m4a') >= 0 || quality.indexOf('mp4') >= 0) return 0; - if (mime.indexOf('webm') >= 0 || quality.indexOf('webm') >= 0) return 2; - return 1; - } - - function pickPlaybackSource(sources) { - if (!Array.isArray(sources) || !sources.length) return null; - - function isHls(s) { - var quality = String(s && s.quality || '').toLowerCase(); - var protocol = String(s && s.protocol || '').toLowerCase(); - var mime = String(s && s.mime_type || '').toLowerCase(); - var url = String(s && s.url || '').toLowerCase(); - return quality.indexOf('hls') !== -1 - || protocol.indexOf('hls') !== -1 - || mime.indexOf('mpegurl') !== -1 - || url.indexOf('.m3u8') !== -1; - } - - if (!shouldUseStandaloneIosAudio()) { - var nonHls = sources.filter(function (s) { return !isHls(s); }); - return nonHls.length ? nonHls[0] : sources[0]; - } - - var candidates = Lampa.Platform.is('apple') - ? sources - : sources.filter(function (s) { return !isHls(s); }); - if (!candidates.length) candidates = sources; - - return candidates.slice().sort(function (a, b) { - return iosSourceRank(a) - iosSourceRank(b); - })[0]; - } - - // у треков из discovery-лент (VK-чарт) метаданные не содержат длительность: - // без якоря sanity-фильтр не ловит удвоенный парс fMP4, время на экране врёт, - // а synthetic-ended не срабатывает; длительность матча — надёжный источник - function backfillTrackDurationFromMatch(track, json) { - if (!track || !json) return; - - var matchDuration = json.selected_match && json.selected_match.duration_ms ? Number(json.selected_match.duration_ms) : 0; - if (!matchDuration || matchDuration <= 0) return; - - if (!track.duration_ms || track.duration_ms <= 0) - track.duration_ms = matchDuration; - } - - function buildResolvedPlayback(track, json) { - backfillTrackDurationFromMatch(track, json); - - var source = pickPlaybackSource(json.sources) || json.sources[0]; - var image = trackImage(track); - var useExternalUrl = !usesInternalPlaybackFlow() && source.external_url; - - return { - from_music_cluster: true, - music_track_id: track && track.id ? track.id : null, - music_playback_mode: getPlaybackMode(), - music_youtube_id: extractResolvedYouTubeTrackId(track, json), - music_album_title: track && track.album_title ? track.album_title : '', - music_isrc: track && track.isrc ? track.isrc : '', - music_duration_ms: track && track.duration_ms ? track.duration_ms : 0, - music_duration: track && track.duration_ms ? Math.max(0, Math.round(track.duration_ms / 1000)) : 0, - timeline: buildTimeline(track), - title: track.title || 'Track', - poster: image, - img: image, - artist: track.artist_name || '', - url: useExternalUrl ? source.external_url : source.url, - headers: source.headers || {}, - quality: buildQualityMap(json) - }; - } - - function buildExternalPlayback(track) { - var image = trackImage(track); - - return { - from_music_cluster: true, - music_track_id: track && track.id ? track.id : null, - music_playback_mode: getPlaybackMode(), - music_youtube_id: extractYouTubeTrackId(track), - music_album_title: track && track.album_title ? track.album_title : '', - music_isrc: track && track.isrc ? track.isrc : '', - music_duration_ms: track && track.duration_ms ? track.duration_ms : 0, - music_duration: track && track.duration_ms ? Math.max(0, Math.round(track.duration_ms / 1000)) : 0, - timeline: buildTimeline(track), - title: track.title || 'Track', - poster: image, - img: image, - artist: track.artist_name || '', - url: buildStreamUrl(track) - }; - } - - // --- авто-скип мёртвого трека очереди --- - // Раньше провал резолва был тупиком: Noty + стоящая очередь, дальше только - // руками. Скипаем ТОЛЬКО честный отказ сервера (available=false — все - // провайдеры не нашли источник); сбой клиент↔Lampac (json=null/битый ответ) - // и сервер↔audio-provider (reason=transient) НЕ скипаем — iOS душит сеть - // скрытой страницы, и авто-скип прощёлкал бы живые треки под локскрином. - // Кап подряд идущих скипов ограничивает - // каскад по мёртвой полосе плейлиста; счётчик сбрасывается только на - // реальном старте звука (playing с настоящим src), не на выборе трека. - var MUSIC_AUTO_SKIP_LIMIT = 3; - var MUSIC_AUTO_SKIP_COUNT = 0; - - function resetAutoSkipCounter() { - MUSIC_AUTO_SKIP_COUNT = 0; - } - - function maybeAutoSkipUnavailableTrack(json, advance) { - if (!json || json.available !== false) return false; - if (json.reason === 'transient') return false; - if (getStandaloneIosRepeatMode() === 'one') return false; - - if (MUSIC_AUTO_SKIP_COUNT >= MUSIC_AUTO_SKIP_LIMIT) { - Lampa.Noty.show('Несколько треков подряд недоступны.'); - return false; - } - - MUSIC_AUTO_SKIP_COUNT++; - Lampa.Noty.show('Трек недоступен — включаю следующий.'); - // на следующий тик: не конфликтуем с текущим колбэком запуска плеера - setTimeout(advance, 0); - return true; - } - - // переход после мёртвого трека для путей, идущих через buildPlayback: - // standalone iOS — сосед по очереди (shuffle/repeat учтены в neighborIndex), - // внутренний плеер — точная следующая позиция. Штатный next здесь нельзя: - // до успешного резолва Lampa всё ещё считает текущим предыдущий URL. - function advanceQueueAfterUnavailable(trackId, failedPlayback) { - if (isStandaloneIosAudioActive()) { - var nextIndex = standaloneIosNeighborIndex(1); - if (nextIndex >= 0 && nextIndex !== MUSIC_IOS_AUDIO.currentIndex) - standaloneIosPlayIndex(nextIndex); - return; - } - - var tracks = queueTracks(); - var playlist = activePlaylist(); - var index = -1; - - // Ссылка на playback однозначна даже для плейлиста с повторяющимися - // track id. По id ищем только как fallback после пересборки ядром. - for (var p = 0; p < playlist.length; p++) { - if (playlist[p] === failedPlayback) { - index = p; - break; - } - } - - var currentIndex = queueCurrentIndex(); - if (index < 0) { - for (var i = Math.max(0, currentIndex + 1); i < tracks.length; i++) { - if (tracks[i] && tracks[i].id === trackId) { - index = i; - break; - } - } - } - - if (index < 0) { - for (var j = 0; j < tracks.length; j++) { - if (tracks[j] && tracks[j].id === trackId) { - index = j; - break; - } - } - } - - if (index < 0 || index + 1 >= tracks.length) return; - - MUSIC_EMBEDDED_IOS.navigationIndex = index; - var targetIndex = index + 1; - - if (selectFromActiveQueue(targetIndex)) { - scheduleTrackPlayed(tracks[targetIndex]); - return; - } - - if (activeMusicMediaElement()) { - playEmbeddedQueueIndexInPlace(targetIndex, 'auto-skip'); - return; - } - - playTrack(tracks[targetIndex], tracks, targetIndex, { forceFresh: true }); - } - - function buildPlayback(track) { - var image = trackImage(track); - var playback = { - from_music_cluster: true, - music_track_id: track && track.id ? track.id : null, - music_playback_mode: getPlaybackMode(), - music_youtube_id: extractYouTubeTrackId(track), - music_album_title: track && track.album_title ? track.album_title : '', - music_isrc: track && track.isrc ? track.isrc : '', - music_duration_ms: track && track.duration_ms ? track.duration_ms : 0, - music_duration: track && track.duration_ms ? Math.max(0, Math.round(track.duration_ms / 1000)) : 0, - timeline: buildTimeline(track), - title: track.title || 'Track', - poster: image, - img: image, - artist: track.artist_name || '', - url: function resolvePlaybackUrl(call) { - var embeddedResolveToken = shouldUseStandaloneIosAudio() - ? 0 - : ++MUSIC_EMBEDDED_IOS.switchToken; - var standaloneResolveToken = embeddedResolveToken ? null : MUSIC_IOS_AUDIO.playWatchToken; - var standalonePrepareToken = MUSIC_IOS_AUDIO.prepareToken; - function standaloneStale() { - return standaloneResolveToken !== null - && (standaloneResolveToken !== MUSIC_IOS_AUDIO.playWatchToken - || standalonePrepareToken !== MUSIC_IOS_AUDIO.prepareToken); - } - - requestPlay(track, function (json) { - if (standaloneStale()) return; - if (embeddedResolveToken && embeddedResolveToken !== MUSIC_EMBEDDED_IOS.switchToken) { - traceEmbeddedIos('playlist-resolve-stale', track && track.id ? track.id : '', true); - return; - } - - backfillTrackDurationFromMatch(track, json); - - var source = pickPlaybackSource(json.sources) || json.sources[0]; - playback.url = source.url; - playback.headers = source.headers || {}; - playback.quality = buildQualityMap(json); - playback.music_playback_mode = getPlaybackMode(); - playback.music_youtube_id = extractResolvedYouTubeTrackId(track, json); - playback.music_duration = track.duration_ms ? Math.max(0, Math.round(track.duration_ms / 1000)) : playback.music_duration; - playback.music_duration_ms = track.duration_ms || playback.music_duration_ms; - call(); - }, function (json) { - if (standaloneStale()) return; - if (embeddedResolveToken && embeddedResolveToken !== MUSIC_EMBEDDED_IOS.switchToken) { - traceEmbeddedIos('playlist-resolve-failed-stale', track && track.id ? track.id : '', true); - return; - } - - // Сохраняем выбранную lazy-позицию для штатных next/prev. - // После call пустой URL заменится обратно resolver-функцией. - try { - if (Lampa.PlayerPlaylist && typeof Lampa.PlayerPlaylist.url === 'function') - Lampa.PlayerPlaylist.url(resolvePlaybackUrl); - } catch (e) {} - - playback.url = ''; - - if (!maybeAutoSkipUnavailableTrack(json, function () { - if (standaloneStale()) return; - if (embeddedResolveToken && embeddedResolveToken !== MUSIC_EMBEDDED_IOS.switchToken) return; - advanceQueueAfterUnavailable(track && track.id, playback); - })) - Lampa.Noty.show(json && json.message ? json.message : 'Источник для трека пока не найден.'); - - call(); - - // Неудачный ответ может быть временным. Пустой URL нужен - // только текущему запуску, а ручная попытка должна снова - // обратиться к провайдерам вместо вечного отказа. - setTimeout(function () { - if (playback.url === '') playback.url = resolvePlaybackUrl; - }, 0); - }); - } - }; - - return playback; - } - - var musicPlayerPanelMedia = null; - var musicPlayerPanelHandler = null; - var musicPlayerPanelRetry = 0; - var musicPlayerVisualContainer = null; - var musicPlayerVisualKey = ''; - var musicPlayerVisualMode = 'art'; - var musicPlayerVisualLyricsRequest = ''; - var musicPlayerVisualToggleAt = 0; - var musicPlayerVisualAutoScrollAt = 0; - var musicPlayerVisualCleanupToken = 0; - var musicPlayerPanelRebindToken = 0; - var musicPlayerPanelEndedCleanup = 0; - var musicPlayerPanelSyntheticEndKey = ''; - var pendingInternalPlaylist = null; - var pendingPlayedTrack = null; - var historyPlaybackTrackId = ''; - - function hasMusicPlayerVisualArtifacts() { - return $('.lm-player-visual, .player-panel__music-controls, .player-panel__music-lyrics, .player-panel__music-lyrics-offset, .player-video.lm-player-video--music').length > 0; - } - - function removeMusicPlayerVisual() { - $('.lm-player-visual').remove(); - $('.player-panel__music-controls').remove(); - $('.player-panel__music-lyrics, .player-panel__music-lyrics-offset').remove(); - $('.player-video.lm-player-video--music').removeClass('lm-player-video--music'); - musicPlayerVisualContainer = null; - musicPlayerVisualKey = ''; - musicPlayerVisualMode = 'art'; - musicPlayerVisualLyricsRequest = ''; - musicPlayerVisualToggleAt = 0; - musicPlayerVisualAutoScrollAt = 0; - } - - function invalidateMusicPlayerVisualCleanup() { - musicPlayerVisualCleanupToken++; - } - - function scheduleMusicPlayerVisualCleanup(origin, force) { - var token = ++musicPlayerVisualCleanupToken; - - [0, 80, 250, 700, 1500].forEach(function (delay) { - setTimeout(function () { - if (token !== musicPlayerVisualCleanupToken) return; - - var data = activePlayerData(); - if (force || !shouldRenderMusicPlayerVisual(data)) { - removeMusicPlayerVisual(); - traceEmbeddedIos('visual-cleanup', (origin || '') + ' delay=' + delay, true); - } - }, delay); - }); - } - - function stopMusicPlayerPanelFix() { - if (musicPlayerPanelRetry) { - clearTimeout(musicPlayerPanelRetry); - musicPlayerPanelRetry = 0; - } - - if (musicPlayerPanelEndedCleanup) { - clearTimeout(musicPlayerPanelEndedCleanup); - musicPlayerPanelEndedCleanup = 0; - } - - if (musicPlayerPanelMedia && musicPlayerPanelHandler) { - musicPlayerPanelMedia.removeEventListener('timeupdate', musicPlayerPanelHandler); - musicPlayerPanelMedia.removeEventListener('loadedmetadata', musicPlayerPanelHandler); - musicPlayerPanelMedia.removeEventListener('durationchange', musicPlayerPanelHandler); - musicPlayerPanelMedia.removeEventListener('playing', musicPlayerPanelHandler); - musicPlayerPanelMedia.removeEventListener('pause', musicPlayerPanelHandler); - musicPlayerPanelMedia.removeEventListener('seeking', musicPlayerPanelHandler); - musicPlayerPanelMedia.removeEventListener('seeked', musicPlayerPanelHandler); - musicPlayerPanelMedia.removeEventListener('ended', musicPlayerPanelHandler); - musicPlayerPanelMedia.removeEventListener('error', musicPlayerPanelHandler); - } - - musicPlayerPanelMedia = null; - musicPlayerPanelHandler = null; - musicPlayerPanelSyntheticEndKey = ''; - clearEmbeddedIosLockscreenSupport('panel-stop'); - removeMusicPlayerVisual(); - } - - function resetMusicPlayerBridgeForNonMusic(origin) { - clearPendingInternalPlaylist(); - clearPendingTrackPlayed(); - stopMusicPlayerPanelFix(); - scheduleMusicPlayerVisualCleanup(origin || 'non-music', true); - syncMusicMediaSession(null); - traceEmbeddedIos('non-music-reset', origin || '', true); - } - - function scheduleMusicPlayerPanelEndCleanup(origin) { - if (musicPlayerPanelEndedCleanup) - clearTimeout(musicPlayerPanelEndedCleanup); - - musicPlayerPanelEndedCleanup = setTimeout(function () { - musicPlayerPanelEndedCleanup = 0; - - var data = activePlayerData(); - var media = activeMusicMediaElement(); - - if (!data || !data.from_music_cluster || !media || media.ended || media.error) { - var request = MUSIC_RADIO_STATE.request; - var waiting = isRadioAutoplayRequestCurrent(request) && canResumeRadioAutoplay(request) - ? request.waiting : null; - traceEmbeddedIos('panel-cleanup', origin || '', true); - stopMusicPlayerPanelFix(); - syncMusicMediaSession(null); - // End cleanup invalidates source switches, but is not a user navigation command. - if (waiting && MUSIC_RADIO_STATE.request === request && request.waiting === waiting) - waiting.switchToken = MUSIC_EMBEDDED_IOS.switchToken; - } - }, 900); - } - - function clearPendingInternalPlaylist() { - pendingInternalPlaylist = null; - } - - function activePendingTrackMedia() { - return isStandaloneIosAudioActive() - ? MUSIC_IOS_AUDIO.audio - : (musicPlayerPanelMedia || activeMusicMediaElement()); - } - - function updatePendingTrackPlayedProgress(media) { - var pending = pendingPlayedTrack; - if (!pending || !pending.armed) return 0; - - var now = Date.now(); - - if (media && isFinite(media.currentTime)) { - var current = Number(media.currentTime); - var wallDelta = pending.lastWallAt ? Math.max(0, now - pending.lastWallAt) : 0; - - if (typeof pending.lastMediaTime === 'number' && isFinite(pending.lastMediaTime)) { - var mediaDelta = current - pending.lastMediaTime; - if (media.paused !== true && media.seeking !== true && mediaDelta > 0 && mediaDelta < 30) { - // A seek can move currentTime by many seconds between two - // adjacent events. Credit no more than real elapsed time, - // so changing position cannot forge listening. - var creditedMs = Math.min(mediaDelta * 1000, wallDelta); - pending.playedMs += Math.max(0, Math.round(creditedMs)); - } - } - - pending.lastMediaTime = current; - pending.lastWallAt = now; - } else if (pending.lastWallAt) { - var wallDelta = now - pending.lastWallAt; - if (wallDelta > 0 && wallDelta < 30000) - pending.playedMs += wallDelta; - - pending.lastWallAt = now; - } - - pending.playedMs = normalizePlayedMs(pending.track, pending.playedMs); - return pending.playedMs; - } - - function flushPendingTrackPlayedTime() { - var pending = pendingPlayedTrack; - if (!pending || !pending.counted) return; - - var playedMs = updatePendingTrackPlayedProgress(activePendingTrackMedia()); - var delta = normalizePlayedMs(pending.track, playedMs - (pending.sentMs || 0)); - - if (delta > 0) { - pending.sentMs = normalizePlayedMs(pending.track, (pending.sentMs || 0) + delta); - markTrackPlayedTime(pending.track, delta); - } - } - - function clearPendingTrackPlayed(skipFlush) { - if (!skipFlush) - flushPendingTrackPlayedTime(); - - if (pendingPlayedTrack && pendingPlayedTrack.timer) { - clearTimeout(pendingPlayedTrack.timer); - } - - pendingPlayedTrack = null; - } - - function scheduleTrackPlayed(track) { - if (!track || !track.id) { - clearPendingTrackPlayed(); - return; - } - - clearPendingTrackPlayed(); - historyPlaybackTrackId = track.id; - - pendingPlayedTrack = { - track: track, - trackId: track.id, - timer: 0, - armed: false, - counted: false, - playedMs: 0, - sentMs: 0, - lastWallAt: 0, - lastMediaTime: null - }; - } - - function armPendingTrackPlayed(trackId, media) { - if (!pendingPlayedTrack || !trackId || pendingPlayedTrack.trackId !== trackId) - return false; - - if (pendingPlayedTrack.counted) { - updatePendingTrackPlayedProgress(media); - return true; - } - - if (pendingPlayedTrack.timer) { - updatePendingTrackPlayedProgress(media); - return true; - } - - pendingPlayedTrack.armed = true; - pendingPlayedTrack.lastWallAt = Date.now(); - - if (media && isFinite(media.currentTime)) - pendingPlayedTrack.lastMediaTime = Number(media.currentTime); - - function completeWhenListenedEnough() { - if (!pendingPlayedTrack || pendingPlayedTrack.trackId !== trackId) - return; - - pendingPlayedTrack.timer = 0; - - var playedMs = updatePendingTrackPlayedProgress(activePendingTrackMedia()); - if (playedMs < MUSIC_HISTORY_MARK_DELAY) { - pendingPlayedTrack.timer = setTimeout(completeWhenListenedEnough, Math.max(1000, MUSIC_HISTORY_MARK_DELAY - playedMs)); - return; - } - - pendingPlayedTrack.counted = true; - pendingPlayedTrack.sentMs = playedMs; - markTrackPlayed(pendingPlayedTrack.track, playedMs); - } - - pendingPlayedTrack.timer = setTimeout(completeWhenListenedEnough, MUSIC_HISTORY_MARK_DELAY); - - return true; - } - - function flushPendingTrackPlayed(data, media) { - if (!pendingPlayedTrack || !data || !data.from_music_cluster) return false; - if (!data.music_track_id || data.music_track_id !== pendingPlayedTrack.trackId) return false; - - return armPendingTrackPlayed(data.music_track_id, media); - } - - function syncActivePlaybackHistory(data, media) { - if (!data || !data.from_music_cluster || !data.music_track_id) return false; - - var track = trackFromPlaybackData(data); - if (!track || !track.id) return false; - - if (MUSIC_QUEUE.currentTrackId !== data.music_track_id) - updateQueueCurrent(data.music_track_id); - - if (historyPlaybackTrackId !== data.music_track_id) - scheduleTrackPlayed(track); - - // media есть только у panel-событий живого элемента — это «фактический - // старт playback», а не тап; дедуп по трек-id внутри - if (media) { - scheduleNextTrackPrefetch(data.music_track_id); - - // реальный прогресс звука — цепочка авто-скипов прервана - if (!media.paused && isFinite(media.currentTime) && media.currentTime > 0.5) - resetAutoSkipCounter(); - } - - return flushPendingTrackPlayed({ - from_music_cluster: true, - music_track_id: data.music_track_id - }, media); - } - - function scheduleInternalPlaylist(track, list) { - if (!track || !track.id || !list || !list.length) { - traceEmbeddedIos('playlist-schedule-reject', 'track=' + !!(track && track.id) + ' list=' + (list ? list.length : 0), true); - pendingInternalPlaylist = null; - return; - } - - traceEmbeddedIos('playlist-schedule', 'id=' + track.id + ' len=' + list.length, true); - pendingInternalPlaylist = { - trackId: track.id, - list: list - }; - } - - function flushPendingInternalPlaylist(trackId) { - if (!pendingInternalPlaylist || !trackId) { - traceEmbeddedIos('playlist-flush-skip', 'pending=' + !!pendingInternalPlaylist + ' id=' + (trackId || ''), true); - return false; - } - if (pendingInternalPlaylist.trackId !== trackId) { - traceEmbeddedIos('playlist-flush-mismatch', 'want=' + pendingInternalPlaylist.trackId + ' got=' + trackId, true); - return false; - } - - var list = pendingInternalPlaylist.list; - pendingInternalPlaylist = null; - - if (list && list.length) { - Lampa.Player.playlist(list); - traceEmbeddedIos('playlist-flush-ok', 'len=' + list.length, true); - syncMusicMediaSession(activePlayerData()); - } - return true; - } - - // ===== LAMPA PLAYER BRIDGE ===== - - function attachInternalPlaylistDeferred(trackId, list, isStale) { - if (!trackId || !list || !list.length) return; - - [80, 300].forEach(function (delay) { - setTimeout(function () { - if (isStale && isStale()) { - traceEmbeddedIos('playlist-attach-stale', 'delay=' + delay, true); - return; - } - - var work = activePlayerData(); - if (!work || !work.from_music_cluster || work.music_track_id !== trackId) { - traceEmbeddedIos('playlist-attach-bail', 'delay=' + delay + ' work=' + !!work - + ' cluster=' + !!(work && work.from_music_cluster) - + ' id=' + (work && work.music_track_id || ''), true); - return; - } - Lampa.Player.playlist(list); - traceEmbeddedIos('playlist-attach-ok', 'delay=' + delay + ' len=' + list.length, true); - syncMusicMediaSession(work); - }, delay); - }); - } - - function applyMusicPlayerPanelFix(lightUpdate) { - var work = Lampa.Player.playdata(); - - if (!work || !work.from_music_cluster) { - stopMusicPlayerPanelFix(); - return; - } - - bumpMusicHeatMetric('embeddedPanelFix'); - - var visualMissing = !musicPlayerVisualContainer - || !document.documentElement.contains(musicPlayerVisualContainer) - || !$('.lm-player-visual').length; - - if (!lightUpdate || visualMissing) - renderMusicPlayerVisual(work); - - var duration = work.music_duration || (work.timeline && work.timeline.duration) || 0; - if (duration) { - var media = musicPlayerPanelMedia || document.querySelector('.player-video video, .player-video audio'); - var current = media && isFinite(media.currentTime) ? media.currentTime : ((work.timeline && work.timeline.time) || 0); - var displayCurrent = Math.max(0, Math.min(duration, current)); - var percent = duration > 0 ? Math.max(0, Math.min(100, displayCurrent / duration * 100)) : 0; - - // Штатный PlayerPanel.update двигает именно тот слой, по которому - // Lampa рисует прогресс. Внутренний div нужен только для ручки. - requestAnimationFrame(function () { - if (Lampa.PlayerPanel && typeof Lampa.PlayerPanel.update === 'function') { - Lampa.PlayerPanel.update('timenow', Lampa.Utils.secondsToTime(displayCurrent)); - Lampa.PlayerPanel.update('timeend', Lampa.Utils.secondsToTime(duration)); - Lampa.PlayerPanel.update('position', percent + '%'); - } else { - $('.player-panel__timenow').text(Lampa.Utils.secondsToTime(displayCurrent)); - $('.player-panel__timeend').text(Lampa.Utils.secondsToTime(duration)); - $('.player-panel__position').css({ width: percent + '%' }); - } - - $('.player-panel__position > div').css({ width: '' }); - }); - } - - scheduleQueueSnapshotSave(false); - updateMusicPlayerVisualLyricsHighlight(false); - } - - function handleMusicPlayerPanelMouseRewind(event) { - var work = Lampa.Player.playdata(); - if (!work || !work.from_music_cluster || !event) return; - - if (event.time && typeof event.time.addClass === 'function') - event.time.addClass('hide').text(''); - - if (typeof event.percent !== 'number') return; - - var duration = Number(work.music_duration || (work.timeline && work.timeline.duration) || 0); - if (!duration) return; - - var media = musicPlayerPanelMedia || document.querySelector('.player-video video, .player-video audio'); - var nativeDuration = media && isFinite(media.duration) ? Number(media.duration) : 0; - var percent = Math.max(0, Math.min(1, event.percent)); - var target = Math.max(0, Math.min(duration, duration * percent)); - - if (event.method !== 'click') return; - if (!nativeDuration || Math.abs(nativeDuration - duration) < 1) return; - - musicPlayerPanelSyntheticEndKey = ''; - - if (Lampa.PlayerVideo && typeof Lampa.PlayerVideo.to === 'function') - Lampa.PlayerVideo.to(target); - else if (media) - media.currentTime = target; - } - - function handleMusicPlayerPanelEffectiveEnd(event) { - if (!event || event.type !== 'timeupdate') return false; - - var work = activePlayerData(); - if (!work || !work.from_music_cluster) return false; - - var duration = Number(work.music_duration || (work.timeline && work.timeline.duration) || 0); - if (!duration) return false; - - var media = musicPlayerPanelMedia || document.querySelector('.player-video video, .player-video audio'); - var current = media && isFinite(media.currentTime) ? Number(media.currentTime) : 0; - if (!media || media.paused || media.ended || current < duration - 0.25) return false; - - var trackKey = [ - work.music_track_id || '', - MUSIC_QUEUE.currentTrackId || '', - queueCurrentIndex(), - duration - ].join('|'); - - if (musicPlayerPanelSyntheticEndKey === trackKey) return true; - musicPlayerPanelSyntheticEndKey = trackKey; - - traceEmbeddedIos('panel-effective-ended', 'time=' + (Math.round(current * 100) / 100) + '/' + duration, true); - - try { - media.currentTime = Math.min(duration, media.duration && isFinite(media.duration) ? media.duration : duration); - } catch (e) {} - - try { - if (Lampa.PlayerVideo && typeof Lampa.PlayerVideo.pause === 'function') - Lampa.PlayerVideo.pause(); - else - media.pause(); - } catch (e) { - try { media.pause(); } catch (_) {} - } - - applyMusicPlayerPanelFix(false); - - if (playEmbeddedQueueOffset(1)) - return true; - - waitForRadioAutoplay(media); - updateEmbeddedIosPlaybackState(); - return true; - } - - function scheduleMusicPlayerPanelRebind(origin) { - var token = ++musicPlayerPanelRebindToken; - - [0, 80, 220, 500, 1000].forEach(function (delay) { - setTimeout(function () { - if (token !== musicPlayerPanelRebindToken) return; - - var work = activePlayerData(); - if (!work || !work.from_music_cluster) return; - - var media = document.querySelector('.player-video video, .player-video audio'); - var container = document.querySelector('.player-video'); - var visualMissing = !container || !container.querySelector('.lm-player-visual'); - var mediaChanged = !!(media && media !== musicPlayerPanelMedia); - - if (mediaChanged) { - traceEmbeddedIos('panel-rebind-media', origin || '', true); - startMusicPlayerPanelFix(work); - return; - } - - if (visualMissing) { - traceEmbeddedIos('panel-rebind-visual', origin || '', true); - renderMusicPlayerVisual(work); - } - - applyMusicPlayerPanelFix(false); - syncMusicMediaSession(work); - updateMediaSessionPositionState(); - }, delay); - }); - } - - function musicPlayerLyricsTrackKey(data) { - if (!data) return ''; - - return buildLyricsCacheKey( - data.music_track_id || '', - data.title || '', - data.artist || '', - data.music_album_title || '', - musicPlayerLyricsDurationMs(data), - data.music_youtube_id || '' - ); - } - - function musicPlayerLyricsDurationMs(data) { - if (!data) return 0; - - return data.music_duration_ms - || (data.music_duration ? Math.max(0, Math.round(Number(data.music_duration) * 1000)) : 0); - } - - function buildMusicPlayerLyricsUrl(data) { - return buildLyricsUrl( - data.title || '', - data.artist || '', - data.music_album_title || '', - musicPlayerLyricsDurationMs(data), - data.music_youtube_id || '' - ); - } - - function renderMusicPlayerLyrics(box, data, json) { - if (!box || !box.length) return; - - var body = box.find('.lm-player-visual__lyrics-scroll'); - if (!body.length) return; - - box.attr('data-lyrics-track', musicPlayerLyricsTrackKey(data)); - box.removeClass('lm-player-visual--lyrics-synced'); - box.removeData('lyricsLineMeta'); - body.empty(); - box.removeAttr('data-lyrics-line'); - box.removeAttr('data-lyrics-manual'); - box.removeClass('lm-player-visual--lyrics-manual'); - updateMusicPlayerPanelLyricsOffsetButtons(box); - - var hasLines = json && json.available && Array.isArray(json.lines) && json.lines.length; - var hasPlain = json && json.available && json.plain; - - if (!hasLines && !hasPlain) { - body.append($('<div class="lm-player-visual__lyrics-message"></div>').text(json && json.retry ? 'Сервис текстов не ответил. Попробуй ещё раз.' : 'Текст не найден.')); - return; - } - - if (json.synced && hasLines) { - box.addClass('lm-player-visual--lyrics-synced'); - updateLyricsOffsetValue(box); - updateMusicPlayerPanelLyricsOffsetButtons(box); - - json.lines.forEach(function (line, index) { - var row = $('<div class="lm-player-visual__lyrics-line"></div>'); - - row.attr('data-line', index); - row.attr('data-time', line.time_ms || 0); - - if (line.text) row.text(line.text); - else row.addClass('lm-player-visual__lyrics-line--empty').text('♪'); - - body.append(row); - }); - - box.data('lyricsLineMeta', buildLyricsLineMeta(body, '.lm-player-visual__lyrics-line')); - updateMusicPlayerVisualLyricsHighlight(true); - return; - } - - body.append($('<div class="lm-player-visual__lyrics-plain"></div>').text(json.plain || json.lines.map(function (line) { - return line.text || ''; - }).join('\n'))); - } - - function loadMusicPlayerLyrics(data, box) { - var trackKey = musicPlayerLyricsTrackKey(data); - if (!trackKey || !box || !box.length) return; - - if (box.attr('data-lyrics-track') === trackKey && box.find('.lm-player-visual__lyrics-scroll').children().length) - return; - - if (MUSIC_LYRICS_CACHE[trackKey]) { - renderMusicPlayerLyrics(box, data, MUSIC_LYRICS_CACHE[trackKey]); - return; - } - - if (musicPlayerVisualLyricsRequest === trackKey) return; - musicPlayerVisualLyricsRequest = trackKey; - bumpMusicHeatMetric('lyricsRequest'); - - box.find('.lm-player-visual__lyrics-scroll') - .empty() - .append($('<div class="lm-player-visual__lyrics-message"></div>').text('Ищу текст...')); - - request(buildMusicPlayerLyricsUrl(data), function (json) { - json = parseJson(json); - if (json && json.available) setCappedCacheEntry(MUSIC_LYRICS_CACHE, trackKey, json, 60); - - if (musicPlayerLyricsTrackKey(activePlayerData()) !== trackKey) return; - musicPlayerVisualLyricsRequest = ''; - renderMusicPlayerLyrics(box, data, json); - }, function () { - if (musicPlayerLyricsTrackKey(activePlayerData()) !== trackKey) return; - musicPlayerVisualLyricsRequest = ''; - renderMusicPlayerLyrics(box, data, null); - }); - } - - function updateMusicPlayerVisualLyricsHighlight(force) { - if (musicPlayerVisualMode !== 'lyrics') return; - - var box = $('.lm-player-visual'); - if (!box.length || !box.hasClass('lm-player-visual--lyrics')) return; - - var media = activeMusicMediaElement(); - if (!media || !isFinite(media.currentTime)) return; - - var meta = box.data('lyricsLineMeta'); - if (!meta || !meta.elements || !meta.elements.length) { - meta = buildLyricsLineMeta(box, '.lm-player-visual__lyrics-line'); - box.data('lyricsLineMeta', meta); - } - - if (!meta.elements.length) return; - - var timeMs = media.currentTime * 1000 - getLyricsOffsetMs(box); - var activeIndex = findLyricsLineIndex(meta.times, timeMs); - - if (!force && box.attr('data-lyrics-line') === String(activeIndex)) return; - box.attr('data-lyrics-line', String(activeIndex)); - - var element = activateLyricsLine(meta, activeIndex); - if (!element) return; - if (!force && box.attr('data-lyrics-manual') === '1') return; - - if (element && typeof element.scrollIntoView === 'function') { - try { - musicPlayerVisualAutoScrollAt = Date.now(); - element.scrollIntoView({ block: 'center', behavior: force ? 'auto' : 'smooth' }); - } catch (e) { - musicPlayerVisualAutoScrollAt = Date.now(); - element.scrollIntoView(); - } - } - } - - function markMusicPlayerLyricsManual(box) { - if (!box || !box.length || musicPlayerVisualMode !== 'lyrics') return; - - box.attr('data-lyrics-manual', '1'); - box.addClass('lm-player-visual--lyrics-manual'); - } - - function resumeMusicPlayerLyricsAutoScroll() { - var box = $('.lm-player-visual'); - if (!box.length) return; - - box.removeAttr('data-lyrics-manual'); - box.removeClass('lm-player-visual--lyrics-manual'); - updateMusicPlayerVisualLyricsHighlight(true); - } - - function toggleMusicPlayerVisualLyrics() { - var now = Date.now(); - if (now - musicPlayerVisualToggleAt < 280) return; - musicPlayerVisualToggleAt = now; - - var data = activePlayerData(); - if (!shouldRenderMusicPlayerVisual(data)) return; - - musicPlayerVisualMode = musicPlayerVisualMode === 'lyrics' ? 'art' : 'lyrics'; - renderMusicPlayerVisual(data); - } - - function updateMusicPlayerPanelLyricsOffsetButtons(box) { - box = box && box.length ? box : $('.lm-player-visual'); - - var visible = musicPlayerVisualMode === 'lyrics' - && box.length - && box.hasClass('lm-player-visual--lyrics-synced'); - - $('.player-panel__music-lyrics-offset').toggleClass('hide', !visible); - } - - function ensureMusicPlayerPanelLyricsButton() { - var modernPanel = $('.player-panel__right.player-panel__tv-visible').filter(function () { - return $(this).find('.player-panel__box-buttons').length > 0; - }).first(); - var panel = modernPanel.length ? modernPanel : $('.player-panel__right').first(); - if (!panel.length) return; - - var modern = modernPanel.length > 0; - if (modern) { - panel.children('.player-panel__music-lyrics, .player-panel__music-lyrics-offset').remove(); - $('.player-panel__right.player-panel__mobile-visible') - .find('.player-panel__music-lyrics, .player-panel__music-lyrics-offset') - .remove(); - } - - var controls = modern ? panel.children('.player-panel__music-controls').first() : panel; - - if (modern && !controls.length) { - controls = $('<div class="player-panel__box-buttons player-panel__music-controls"></div>'); - - var qualityGroup = panel.find('.player-panel__quality').first().closest('.player-panel__box-buttons'); - if (qualityGroup.length) qualityGroup.after(controls); - else panel.prepend(controls); - } - - var button = controls.find('.player-panel__music-lyrics').first(); - if (!button.length) { - button = $('<div class="player-panel__music-lyrics button selector" data-controller="player_panel">Текст</div>'); - button.on('hover:enter click', function (event) { - event.preventDefault(); - event.stopPropagation(); - toggleMusicPlayerVisualLyrics(); - }); - - if (modern) controls.append(button); - else { - var playlistButton = panel.find('.player-panel__playlist'); - if (playlistButton.length) playlistButton.before(button); - else panel.prepend(button); - } - } - - if (!controls.find('.player-panel__music-lyrics-offset').length) { - var earlier = $('<div class="player-panel__music-lyrics-offset button selector hide" data-controller="player_panel" data-lyrics-offset-delta="-500" aria-label="Показывать текст раньше">−</div>'); - var later = $('<div class="player-panel__music-lyrics-offset button selector hide" data-controller="player_panel" data-lyrics-offset-delta="500" aria-label="Показывать текст позже">+</div>'); - - earlier.add(later).on('hover:enter click', function (event) { - event.preventDefault(); - event.stopPropagation(); - - var box = $('.lm-player-visual'); - if (!box.length || !box.hasClass('lm-player-visual--lyrics-synced')) return; - - var value = changeLyricsOffset(box, Number($(this).attr('data-lyrics-offset-delta') || 0)); - updateMusicPlayerVisualLyricsHighlight(true); - Lampa.Noty.show('Сдвиг текста: ' + formatLyricsOffsetMs(value)); - }); - - if (modern) controls.append(earlier, later); - else button.before(earlier, later); - } - - button - .toggleClass('active', musicPlayerVisualMode === 'lyrics') - .text(musicPlayerVisualMode === 'lyrics' ? 'Обл.' : 'Текст'); - - updateMusicPlayerPanelLyricsOffsetButtons($('.lm-player-visual')); - } - - function getMusicPlaybackModeFromData(data) { - return String((data && data.music_playback_mode) || getPlaybackMode() || 'audio').toLowerCase(); - } - - // Contract: music audio owns the custom art/lyrics bridge; music video and - // regular Lampa video must keep the stock video visible, so the bridge is - // removed. The cleanup sweep exists because Lampa can recreate player DOM - // shortly after Player.start, which otherwise leaves stale lyrics/art over - // the next video. - function shouldRenderMusicPlayerVisual(data) { - return !!(data && data.from_music_cluster && getMusicPlaybackModeFromData(data) !== 'video'); - } - - function renderMusicPlayerVisual(data) { - if (!shouldRenderMusicPlayerVisual(data)) { - var hadVisualArtifacts = hasMusicPlayerVisualArtifacts(); - removeMusicPlayerVisual(); - if (data && data.from_music_cluster && hadVisualArtifacts) - scheduleMusicPlayerVisualCleanup('music-video', true); - return false; - } - - invalidateMusicPlayerVisualCleanup(); - - var container = document.querySelector('.player-video'); - if (!container) return false; - - if (musicPlayerVisualContainer && musicPlayerVisualContainer !== container) - removeMusicPlayerVisual(); - - var box = $('.lm-player-visual', container); - if (!box.length) { - box = $( - '<div class="lm-player-visual">\ - <div class="lm-player-visual__backdrop"></div>\ - <div class="lm-player-visual__top">\ - <div class="selector lm-player-visual__lyrics-toggle">Текст</div>\ - </div>\ - <div class="lm-player-visual__center">\ - <div class="lm-player-visual__art-view">\ - <div class="lm-player-visual__art"><img alt=""></div>\ - <div class="lm-player-visual__title"></div>\ - <div class="lm-player-visual__artist"></div>\ - </div>\ - <div class="lm-player-visual__lyrics-view">\ - <div class="selector lm-player-visual__lyrics-follow">К строке</div>\ - <div class="lm-player-visual__lyrics-scroll"></div>\ - </div>\ - </div>\ - </div>' - ); - box.find('.lm-player-visual__top').prepend(buildLyricsOffsetControl('lm-player-visual__lyrics-offset', 'player_panel')); - box.on('click hover:enter', '.lm-player-visual__lyrics-toggle', function (event) { - event.preventDefault(); - event.stopPropagation(); - toggleMusicPlayerVisualLyrics(); - }); - box.on('click hover:enter', '[data-lyrics-offset-delta]', function (event) { - event.preventDefault(); - event.stopPropagation(); - changeLyricsOffset(box, Number($(this).attr('data-lyrics-offset-delta') || 0)); - updateMusicPlayerVisualLyricsHighlight(true); - }); - box.on('click hover:enter', '[data-lyrics-offset-reset]', function (event) { - event.preventDefault(); - event.stopPropagation(); - changeLyricsOffset(box, -getLyricsOffsetMs(box)); - updateMusicPlayerVisualLyricsHighlight(true); - }); - box.on('touchmove wheel', '.lm-player-visual__lyrics-scroll', function () { - markMusicPlayerLyricsManual(box); - }); - box.on('scroll', '.lm-player-visual__lyrics-scroll', function () { - if (Date.now() - musicPlayerVisualAutoScrollAt > 650) - markMusicPlayerLyricsManual(box); - }); - box.on('click hover:enter', '.lm-player-visual__lyrics-follow', function (event) { - event.preventDefault(); - event.stopPropagation(); - resumeMusicPlayerLyricsAutoScroll(); - }); - var display = $('.player-video__display', container); - if (display.length) display.after(box); - else $(container).append(box); - } - - musicPlayerVisualContainer = container; - $(container).addClass('lm-player-video--music'); - ensureMusicPlayerPanelLyricsButton(); - - var image = data.img || data.poster || artwork('track', data.title || 'Музыка', data.artist || 'Music', ['#1d2128', '#46505e']); - var title = data.title || 'Track'; - var artist = data.artist || ''; - var key = [data.music_track_id || '', image || '', title, artist].join('|'); - var trackChanged = musicPlayerVisualKey !== key; - - box.toggleClass('lm-player-visual--lyrics', musicPlayerVisualMode === 'lyrics'); - box.find('.lm-player-visual__lyrics-toggle') - .toggleClass('active', musicPlayerVisualMode === 'lyrics') - .text(musicPlayerVisualMode === 'lyrics' ? 'Обложка' : 'Текст'); - - if (trackChanged) { - musicPlayerVisualKey = key; - resetLyricsOffsetForTrack(box, musicPlayerLyricsTrackKey(data)); - box.removeData('lyricsLineMeta'); - box.removeAttr('data-lyrics-line'); - box.removeAttr('data-lyrics-track'); - box.removeAttr('data-lyrics-manual'); - box.removeClass('lm-player-visual--lyrics-manual lm-player-visual--lyrics-synced'); - box.find('.lm-player-visual__lyrics-scroll').empty(); - box.find('.lm-player-visual__backdrop').css('background-image', 'url(' + JSON.stringify(image) + ')'); - box.find('.lm-player-visual__art img').attr('src', image); - box.find('.lm-player-visual__title').text(title); - box.find('.lm-player-visual__artist').text(artist); - } - - updateMusicPlayerPanelLyricsOffsetButtons(box); - - if (musicPlayerVisualMode === 'lyrics') - loadMusicPlayerLyrics(data, box); - - return true; - } - - function startMusicPlayerPanelFix(data) { - stopMusicPlayerPanelFix(); - - if (!data || !data.from_music_cluster) return; - - function bind() { - renderMusicPlayerVisual(activePlayerData() || data); - - var media = document.querySelector('.player-video video, .player-video audio'); - - if (!media) { - musicPlayerPanelRetry = setTimeout(bind, 100); - return; - } - - prepareEmbeddedIosMediaElement(media, 'panel-bind'); - musicPlayerPanelMedia = media; - musicPlayerPanelHandler = function (event) { - try { - handleMusicPlayerPanelEvent(event); - } catch (e) { - traceEmbeddedIos('panel-handler-error', describeError(e), true); - } - }; - - function handleMusicPlayerPanelEvent(event) { - ensureLampaPlayerController(event && event.type ? ('panel-' + event.type) : 'panel-event'); - - if (event && event.type === 'timeupdate') - bumpMusicHeatMetric('embeddedTimeupdate'); - - if (event && (event.type === 'ended' || event.type === 'error')) { - traceEmbeddedIos('panel-' + event.type, '', true); - clearEmbeddedIosLockscreenSupport(event.type); - updateEmbeddedIosPlaybackState(); - if (event.type === 'ended') waitForRadioAutoplay(media); - scheduleMusicPlayerPanelEndCleanup(event.type); - return; - } - - if (event && event.type !== 'timeupdate') { - bumpMusicHeatMetric('embeddedPanelEvent'); - traceEmbeddedIos('panel-' + event.type, '', true); - } - - syncActivePlaybackHistory(activePlayerData(), media); - - if (handleMusicPlayerPanelEffectiveEnd(event)) - return; - - if (event && event.type === 'timeupdate' && document.hidden) { - bumpMusicHeatMetric('embeddedHiddenTimeupdate'); - scheduleQueueSnapshotSave(false); - maybeRequestRadioAutoplay('embedded-hidden-timeupdate'); - updateMediaSessionPositionState(); - return; - } - - applyMusicPlayerPanelFix(event && event.type === 'timeupdate'); - if (event && event.type === 'timeupdate') - maybeRequestRadioAutoplay('embedded-timeupdate'); - - // timeupdate приходит часто. Полный sync пересоздаёт MediaMetadata и - // handlers, что для iPhone заметно дороже, чем простой position update. - if (!event || event.type !== 'timeupdate') - syncMusicMediaSession(activePlayerData()); - - updateMediaSessionPositionState(); - } - - media.addEventListener('timeupdate', musicPlayerPanelHandler); - media.addEventListener('loadedmetadata', musicPlayerPanelHandler); - media.addEventListener('durationchange', musicPlayerPanelHandler); - media.addEventListener('playing', musicPlayerPanelHandler); - media.addEventListener('pause', musicPlayerPanelHandler); - media.addEventListener('seeking', musicPlayerPanelHandler); - media.addEventListener('seeked', musicPlayerPanelHandler); - media.addEventListener('ended', musicPlayerPanelHandler); - media.addEventListener('error', musicPlayerPanelHandler); - - applyMusicPlayerPanelFix(); - syncMusicMediaSession(activePlayerData()); - updateMediaSessionPositionState(); - } - - bind(); - } - - Lampa.Player.listener.follow('start', function (data) { - if (data && data.from_music_cluster && typeof data.url === 'string' && data.url && !isStandaloneIosAudioActive()) - MUSIC_EMBEDDED_IOS.switchToken++; - - traceEmbeddedIos('player-start-event', 'cluster=' + !!(data && data.from_music_cluster) - + ' id=' + (data && data.music_track_id || '') - + ' pending=' + (pendingInternalPlaylist ? pendingInternalPlaylist.trackId : ''), true); - - if (!pendingInternalPlaylist || !data || !data.from_music_cluster) return; - if (!data.music_track_id || data.music_track_id !== pendingInternalPlaylist.trackId) return; - - setTimeout(function () { - flushPendingInternalPlaylist(data.music_track_id); - }, 0); - }); - - Lampa.Player.listener.follow('start', function (data) { - setTimeout(function () { - flushPendingTrackPlayed(data); - }, 0); - }); - - Lampa.Player.listener.follow('external', function (data) { - setTimeout(function () { - flushPendingTrackPlayed(data); - }, 0); - }); - - Lampa.Player.listener.follow('destroy', function () { - if (!shouldUseStandaloneIosAudio()) resetRadioAutoplayRequest(); - clearPendingInternalPlaylist(); - clearPendingTrackPlayed(); - }); - - function currentExternalPlayer() { - return getMusicPlayerId(); - } - - function shouldUseManagedExternalPlaylist() { - var player = currentExternalPlayer(); - - if (Lampa.Platform.macOS()) - return ['mpv', 'iina', 'nplayer', 'infuse'].indexOf(player) >= 0; - - if (Lampa.Platform.desktop()) - return player === 'other'; - - return false; - } - - function usesInternalPlaybackFlow() { - var player = currentExternalPlayer(); - - if (!player) return true; - if (player === 'inner' || player === 'lampa') return true; - if (player === 'ios') return true; - - return false; - } - - function buildPlaylistTrackOrder(tracks, startIndex) { - var ordered = []; - - for (var i = startIndex; i < tracks.length; i++) ordered.push(tracks[i]); - for (var j = 0; j < startIndex; j++) ordered.push(tracks[j]); - - return ordered; - } - - function buildExternalPlaylistUrl(tracks, startIndex) { - var ordered = buildPlaylistTrackOrder(tracks, startIndex); - var provider = ''; - var audioProviderId = getAudioProviderId(); - var playlistStrategy = ''; - var ids = ordered - .map(function (entry) { - return entry && entry.id ? entry.id : ''; - }) - .filter(Boolean) - .join(','); - - if (ordered.length) { - var firstProvider = getTrackProviderId(ordered[0]); - if (firstProvider && ordered.every(function (entry) { return getTrackProviderId(entry) === firstProvider; })) - provider = firstProvider; - } - - if (audioProviderId === 'soundcloudaudio' && (Lampa.Platform.macOS() || Lampa.Platform.desktop())) - playlistStrategy = 'resolved'; - - var url = MUSIC.endpoints.playlist - + '?ids=' + encodeURIComponent(ids) - + '&audio_provider=' + encodeURIComponent(audioProviderId) - + '&stream_mode=' + encodeURIComponent(getStreamMode()) - + '&playback_mode=' + encodeURIComponent(getPlaybackMode()); - - if (provider) - url += '&provider=' + encodeURIComponent(provider); - - if (playlistStrategy) - url += '&playlist_strategy=' + encodeURIComponent(playlistStrategy); - - return withIdentity(url); - } - - function trimPlaylistItem(playback) { - return { - title: playback.title, - artist: playback.artist || '', - img: playback.img || playback.poster || '', - url: playback.url - }; - } - - function buildExternalPlaylist(tracks, startIndex, currentJson, done) { - var resolved = new Array(tracks.length); - - tracks.forEach(function (entry, entryIndex) { - if (entryIndex === startIndex) { - resolved[entryIndex] = trimPlaylistItem(buildResolvedPlayback(entry, currentJson)); - return; - } - - resolved[entryIndex] = trimPlaylistItem(buildExternalPlayback(entry)); - }); - - done(resolved.filter(Boolean)); - } - - function buildInternalPreparedPlaylist(tracks, startIndex, currentJson, done, providerId) { - var resolved = new Array(tracks.length); - var order = []; - var pointer = 0; - var active = 0; - var concurrency = getPreparedPlaylistConcurrency(providerId); - - resolved[startIndex] = buildResolvedPlayback(tracks[startIndex], currentJson); - - for (var i = startIndex + 1; i < tracks.length; i++) order.push(i); - for (var j = 0; j < startIndex; j++) order.push(j); - - function finish() { - if (pointer >= order.length && active <= 0) { - done(tracks.map(function (entry, index) { - return resolved[index] || buildPlayback(entry); - })); - return; - } - - while (active < concurrency && pointer < order.length) { - var entryIndex = order[pointer++]; - active++; - - (function (resolvedIndex) { - requestPlay(tracks[resolvedIndex], function (json) { - resolved[resolvedIndex] = buildResolvedPlayback(tracks[resolvedIndex], json); - active--; - finish(); - }, function () { - resolved[resolvedIndex] = buildPlayback(tracks[resolvedIndex]); - active--; - finish(); - }); - })(entryIndex); - } - } - - finish(); - } - - function resolvePreparedPlaylistEntries(tracks, indices, resolved, providerId, done, onResolved) { - var pointer = 0; - var active = 0; - var concurrency = getPreparedPlaylistConcurrency(providerId); - - function finish() { - if (pointer >= indices.length && active <= 0) { - done(); - return; - } - - while (active < concurrency && pointer < indices.length) { - var resolvedIndex = indices[pointer++]; - active++; - - (function (entryIndex) { - requestPlay(tracks[entryIndex], function (json) { - resolved[entryIndex] = buildResolvedPlayback(tracks[entryIndex], json); - if (onResolved) onResolved(entryIndex, resolved[entryIndex]); - active--; - finish(); - }, function () { - resolved[entryIndex] = buildPlayback(tracks[entryIndex]); - if (onResolved) onResolved(entryIndex, resolved[entryIndex]); - active--; - finish(); - }); - })(resolvedIndex); - } - } - - finish(); - } - - function buildStandaloneIosPreparedPlaylist(tracks, startIndex, currentJson, done, providerId) { - tracks = tracks.slice(); - if (!shouldUseStagedStandaloneIosPreparation(providerId)) { - buildInternalPreparedPlaylist(tracks, startIndex, currentJson, function (preparedList) { - done(preparedList, null); - }, providerId); - return; - } - - var resolved = new Array(tracks.length); - var order = []; - var eagerCount = Math.max(0, getStandaloneIosInitialResolveCount(providerId) - 1); - - resolved[startIndex] = buildResolvedPlayback(tracks[startIndex], currentJson); - - for (var i = startIndex + 1; i < tracks.length; i++) order.push(i); - for (var j = 0; j < startIndex; j++) order.push(j); - - var eagerOrder = order.slice(0, eagerCount); - var backgroundOrder = order.slice(eagerCount); - - function buildList() { - return tracks.map(function (entry, entryIndex) { - return resolved[entryIndex] || buildPlayback(entry); - }); - } - - function startBackgroundWarm(token) { - if (!backgroundOrder.length) return; - - var pointer = 0; - var active = false; - var waitingVisible = false; - var warmTimer = 0; - var warmDelay = providerId === 'youtubeaudio' ? 1200 : 900; - - function stillCurrent() { - if (!isStandaloneIosAudioActive()) return; - if (MUSIC_IOS_AUDIO.prepareToken !== token) return; - return true; - } - - function scheduleWarm(delay) { - if (warmTimer || !stillCurrent()) return; - - warmTimer = setTimeout(function () { - warmTimer = 0; - runWarm(); - }, delay || warmDelay); - } - - function waitForVisible() { - if (waitingVisible) return; - - waitingVisible = true; - bumpMusicHeatMetric('standaloneWarmPausedHidden'); - - try { - document.addEventListener('visibilitychange', function onVisible() { - if (document.hidden) return; - - waitingVisible = false; - document.removeEventListener('visibilitychange', onVisible); - scheduleWarm(450); - }); - } catch (e) { - scheduleWarm(5000); - } - } - - function runWarm() { - if (!stillCurrent()) return; - if (active || pointer >= backgroundOrder.length) return; - - if (document.hidden) { - waitForVisible(); - return; - } - - var entryIndex = backgroundOrder[pointer++]; - var currentIndex = (MUSIC_IOS_AUDIO.tracks || []).indexOf(tracks[entryIndex]); - var pendingPlayback = currentIndex >= 0 && MUSIC_IOS_AUDIO.playlist[currentIndex]; - if (!pendingPlayback) { - scheduleWarm(warmDelay); - return; - } - active = true; - bumpMusicHeatMetric('standaloneWarmRequest'); - - requestPlay(tracks[entryIndex], function (json) { - active = false; - var target = stillCurrent() ? MUSIC_IOS_AUDIO.playlist.indexOf(pendingPlayback) : -1; - if (target >= 0) - MUSIC_IOS_AUDIO.playlist[target] = buildResolvedPlayback(tracks[entryIndex], json); - scheduleWarm(warmDelay); - }, function () { - active = false; - scheduleWarm(warmDelay); - }); - } - - scheduleWarm(900); - } - - resolvePreparedPlaylistEntries(tracks, eagerOrder, resolved, providerId, function () { - done(buildList(), startBackgroundWarm); - }); - } - - // первый audio.play() случается в async-колбэке резолва URL — вне жеста, - // и WebKit не даёт элементу user-gesture кредит: iOS урезает локскрин-команды - // (pause/seekto молчат, пока пользователь не потрогает плеер в приложении). - // Тихий play() синхронно В ЖЕСТЕ выдаёт кредит заранее; реальный src, - // приехав из резолва, наследует права элемента - function warmupStandaloneIosGesture() { - if (!shouldUseStandaloneIosAudio()) return; - - var audio = standaloneIosAudioElement(); - if (!audio) return; - - // точка отсчёта settle-окна перед прицеплением реального src - // (см. startStandaloneIosAudioPlayback) - MUSIC_IOS_AUDIO.gestureWarmupAt = Date.now(); - - // обработчики должны существовать ДО play(): iOS фиксирует - // возможности сессии (в т.ч. seekable) в момент её создания - armStandaloneIosMediaSessionHandlers(); - - try { - if (audio.paused) { - audio.loop = true; - audio.src = buildSilentWavDataUri(); - audio.setAttribute('data-source-url', 'silent-warmup'); - } - - var promise = audio.play(); - if (promise && typeof promise.catch === 'function') promise.catch(function () {}); - } catch (e) {} - } - - function playTrack(track, playlistTracks, startIndex, options) { - resetRadioAutoplayRequest(); - var launchToken = ++MUSIC_PLAY_LAUNCH_TOKEN; - if (playSpotifyReleaseTrack(track, playlistTracks, startIndex, options)) return; - - MUSIC_SPOTIFY_RELEASE_TOKEN++; - var tracks = playlistTracks && playlistTracks.length ? playlistTracks : [track]; - var index = typeof startIndex === 'number' ? startIndex : 0; - var standaloneIos = shouldUseStandaloneIosAudio(); - var forceFresh = !!(options && options.forceFresh); - var resumePosition = Math.max(0, Number(options && options.resumePosition || 0)); - // оверлей предыдущего (только что отменённого) запуска гасим сразу: - // дальше любой видимый лоадер принадлежит только новейшему флоу - stopMusicPlaybackLoading(); - // новый запуск = новая сессия прифетча next-трека (иначе повторный - // запуск той же очереди с того же трека пропустил бы прогрев) - MUSIC_NEXT_PREFETCH_FOR = ''; - - function launchStale() { - return launchToken !== MUSIC_PLAY_LAUNCH_TOKEN; - } - - index = Math.max(0, Math.min(index, tracks.length - 1)); - rememberQueue(tracks, index); - - if (!forceFresh && standaloneIos && tracks.length > 1 && canReuseStandaloneIosQueue(tracks) && selectFromStandaloneIosQueue(index)) - return; - - if (!forceFresh && tracks.length > 1 && canReuseActiveQueue(tracks) && selectFromActiveQueue(index)) { - scheduleTrackPlayed(tracks[index]); - return; - } - - function startFreshPlayback() { - if (launchStale()) return; - - if (!standaloneIos && isStandaloneIosAudioActive()) - stopStandaloneIosAudioPlayback(); - - if (standaloneIos) - warmupStandaloneIosGesture(); - else if (shouldUseEmbeddedIosLockscreenMode()) - warmupEmbeddedIosGesture(tracks[index]); - - if (tracks.length > 1 && shouldUseManagedExternalPlaylist()) { - var externalFirst = { - title: tracks[index].title || 'Track', - artist: tracks[index].artist_name || '', - poster: trackImage(tracks[index]), - img: trackImage(tracks[index]), - url: buildExternalPlaylistUrl(tracks, index) - }; - - scheduleTrackPlayed(tracks[index]); - launchMusicPlayback(externalFirst); - return; - } - - requestPlay(tracks[index], function (json) { - if (launchStale()) return; - - var providerId = getAudioProviderId(); - - function buildLazyList() { - return tracks.map(function (entry, entryIndex) { - return entryIndex === index - ? buildResolvedPlayback(entry, json) - : buildPlayback(entry); - }); - } - - function launchStandalone(preparedList, afterLaunch) { - var list = preparedList && preparedList.length - ? preparedList - : [buildResolvedPlayback(tracks[index], json)]; - - var token = startStandaloneIosAudioPlayback(tracks, list, index, resumePosition); - if (!token) { - Lampa.Noty.show('Не удалось воспроизвести трек.'); - return; - } - - if (afterLaunch) - afterLaunch(token); - } - - function launch(externalPlaylist, list) { - list = list && list.length ? list : buildLazyList(); - var first = list[index]; - - traceEmbeddedIos('launch-internal', 'len=' + list.length - + ' mode=' + getPlaybackMode() - + ' player=' + currentExternalPlayer() - + ' launch=' + getLaunchMusicPlayerId() - + ' internal=' + usesInternalPlaybackFlow(), true); - - if (externalPlaylist && externalPlaylist.length > 1) { - first.playlist = externalPlaylist; - } - - if (tracks.length > 1 && usesInternalPlaybackFlow()) { - scheduleInternalPlaylist(tracks[index], list); - } else { - clearPendingInternalPlaylist(); - } - - scheduleTrackPlayed(tracks[index]); - if (shouldUseEmbeddedIosLockscreenMode()) - primeEmbeddedIosMediaSession(first, tracks[index], 'launch'); - - launchMusicPlayback(first); - - if (!(tracks.length > 1 && usesInternalPlaybackFlow())) { - Lampa.Player.playlist(list); - } else { - attachInternalPlaylistDeferred(tracks[index].id, list, launchStale); - setTimeout(function () { - if (launchStale()) return; - var work = activePlayerData(); - if (work && work.from_music_cluster && work.music_track_id) { - flushPendingInternalPlaylist(work.music_track_id); - } - }, 150); - } - } - - if (standaloneIos) { - if (tracks.length > 1) { - startMusicPlaybackLoading('Подготавливаю плейлист'); - buildStandaloneIosPreparedPlaylist(tracks, index, json, function (preparedList, afterLaunch) { - if (launchStale()) return; - stopMusicPlaybackLoading(); - launchStandalone(preparedList, afterLaunch); - }, providerId); - return; - } - - launchStandalone(null); - return; - } - - if (tracks.length > 1 && !usesInternalPlaybackFlow()) { - startMusicPlaybackLoading('Подготавливаю альбом'); - buildExternalPlaylist(tracks, index, json, function (externalPlaylist) { - if (launchStale()) return; - stopMusicPlaybackLoading(); - launch(externalPlaylist, null); - }); - return; - } - - if (tracks.length > 1 && usesInternalPlaybackFlow() && requiresPreparedInternalPlaylist(providerId)) { - startMusicPlaybackLoading('Подготавливаю плейлист'); - buildInternalPreparedPlaylist(tracks, index, json, function (preparedList) { - if (launchStale()) return; - stopMusicPlaybackLoading(); - launch(null, preparedList); - }, providerId); - return; - } - - launch(null, null); - }, function (json) { - if (launchStale()) return; - Lampa.Noty.show(json && json.message ? json.message : 'Источник для трека пока не найден.'); - }); - } - - var current = activePlayerData(); - if (current && current.from_music_cluster) { - stopMusicPlayerPanelFix(); - Lampa.Player.close(); - - setTimeout(function () { - startFreshPlayback(); - }, 0); - return; - } - - startFreshPlayback(); - } - - function loadTrackMatches(track, callback, query) { - request(MUSIC.endpoints.matches + '?' - + buildTrackRequestParams(track) - + '&audio_provider=' + encodeURIComponent(getAudioProviderId()) - + '&playback_mode=' + encodeURIComponent(getPlaybackMode()) - + (query ? '&query=' + encodeURIComponent(query) : ''), function (json) { - callback(parseJson(json) || null); - }, function () { - callback(null); - }); - } - - function saveTrackMatch(track, match, done, query) { - var payload = buildTrackRequestParams(track) - + '&audio_provider=' + encodeURIComponent(match.provider_id) - + '&playback_mode=' + encodeURIComponent(getPlaybackMode()) - + '&match_id=' + encodeURIComponent(match.id) - + (query ? '&query=' + encodeURIComponent(query) : ''); - - requestPost(MUSIC.endpoints.selectMatch, payload, function (json) { - if (json && json.saved) { - applySelectedMatchMetadata(track, match); - invalidateTrackPlayCache(track); - refreshRecentlyPlayedTrack(track); - } - done(!!(json && json.saved)); - }, function () { - done(false); - }); - } - - function resetTrackMatch(track, done) { - var payload = buildTrackRequestParams(track) - + '&playback_mode=' + encodeURIComponent(getPlaybackMode()); - - requestPost(MUSIC.endpoints.resetMatch, payload, function (json) { - if (json && json.reset) - invalidateTrackPlayCache(track); - done(!!(json && json.reset)); - }, function () { - done(false); - }); - } - - function formatMatchTitle(match) { - var parts = []; - if (match.title) parts.push(match.title); - if (match.artists && match.artists.length) parts.push(match.artists.join(', ')); - if (match.duration_ms) parts.push(formatDuration(match.duration_ms)); - if (match.provider_id) parts.push(match.provider_id); - return parts.join(' · '); - } - - function getTrackArtistSearchQuery(track) { - if (!track) return ''; - - var artistName = String(track.artist_name || '').trim(); - if (artistName) return artistName; - - if (Array.isArray(track.artists) && track.artists.length) - return track.artists.filter(Boolean).join(' x '); - - return ''; - } - - function buildTrackArtistSearchItems(track) { - var query = getTrackArtistSearchQuery(track); - if (!query) return []; - - return [{ title: 'Поиск по артисту', action: 'artist_search', artist_query: query }]; - } - - function buildTrackAlbumNavigation(track) { - if (!track) return null; - - var albumTitle = String(track.album_title || '').trim(); - var albumId = String(track.album_id || '').trim(); - if (!albumTitle && !albumId) return null; - - var artistName = getTrackArtistSearchQuery(track); - var album = { - title: albumTitle || 'Альбом', - artist_name: artistName || '', - images: Array.isArray(track.images) ? track.images : [] - }; - - if (albumId) album.id = albumId; - - // Для импортированных Spotify/Apple/SoundCloud треков album_id часто - // отсутствует, поэтому открытие альбома идёт через уже существующий - // resolveAlbum: поиск по артисту и названию с retry и срезом edition noise. - // Если есть только прямой album_id без артиста, безопаснее открыть по id. - if (albumTitle && (artistName || !albumId)) - album.lookup_query = [artistName, albumTitle].filter(Boolean).join(' '); - - return album; - } - - function openTrackMenu(track, playlistTracks, startIndex, extraItems, onAction, onBookmarkChanged, menuOptions) { - var restoreContext = captureMusicFocusContext('content'); - var isBookmarked = isBookmarkedEntity(MUSIC.storage.bookmarked_tracks, track && track.id); - var items = [{ title: 'Играть', action: 'play' }]; - var trackAlbum = buildTrackAlbumNavigation(track); - - if (playlistTracks && playlistTracks.length > 1) - items.push({ title: 'Играть альбом отсюда', action: 'play_from_here' }); - - items.push({ title: 'Радио от трека', action: 'radio_from_track' }); - - // на экране самого альбома пункт скрыт (hideOpenAlbum) — он открыл бы - // тот же экран заново. Без album-меты (SoundCloud-аплоады) — фоллбек - // через поиск самого трека - if (!(menuOptions && menuOptions.hideOpenAlbum)) { - if (trackAlbum) - items.push({ title: 'Открыть альбом', action: 'open_album', album: trackAlbum }); - else if (track && track.title) - items.push({ title: 'Открыть альбом', action: 'open_album_lookup' }); - } - - // пункты очереди видны только когда standalone-очередь реально играет - // (или восстановлена) — вне этого им некуда вставлять. Полное - // управление очередью из любого списка, плеер открывать не нужно - if (canEnqueueToStandaloneQueue(track)) { - var queuePos = standaloneQueueIndexOfTrack(track.id); - var inQueue = queuePos > -1 && queuePos !== standaloneQueueCurrentIndexValue(); - - items.push({ title: 'Играть следующим', action: 'queue_next' }); - items.push({ title: inQueue ? 'В конец очереди' : 'Добавить в очередь', action: 'queue_end' }); - if (inQueue) items.push({ title: 'Убрать из очереди', action: 'queue_remove' }); - } - - items.push({ - title: isBookmarked ? 'Убрать из закладок' : 'Добавить в закладки', - action: 'toggle_bookmark' - }); - items.push({ title: 'В плейлист…', action: 'add_to_playlist' }); - items = items.concat(buildTrackArtistSearchItems(track)); - items.push({ title: 'Источники', action: 'sources' }); - if (Array.isArray(extraItems) && extraItems.length) - items = items.concat(extraItems); - - Lampa.Select.show({ - title: track.title || 'Track', - items: items, - onBack: function () { - restoreMusicFocusContext(restoreContext); - }, - onSelect: function (selected) { - if (!selected) { - restoreMusicFocusContext(restoreContext); - return; - } - - if (selected.action === 'play') { - playTrack(track); - restoreMusicFocusContext(restoreContext, 120); - return; - } - - if (selected.action === 'play_from_here') { - playTrack(track, playlistTracks, startIndex || 0); - restoreMusicFocusContext(restoreContext, 120); - return; - } - - if (selected.action === 'radio_from_track') { - startRadioFromTrack(track, restoreContext); - return; - } - - if (selected.action === 'open_album') { - openAlbum(selected.album, { - onFail: function () { - restoreMusicFocusContext(restoreContext); - } - }); - return; - } - - if (selected.action === 'open_album_lookup') { - openAlbumFromTrackSearch(track, restoreContext); - return; - } - - if (selected.action === 'queue_next' || selected.action === 'queue_end') { - var wasQueued = standaloneQueueIndexOfTrack(track && track.id) > -1; - var queued = enqueueTrackToStandaloneQueue(track, selected.action === 'queue_next' ? 'next' : 'end'); - - Lampa.Noty.show(queued - ? (selected.action === 'queue_next' - ? 'Сыграет следующим.' - : (wasQueued ? 'Перемещено в конец очереди.' : 'Добавлено в очередь.')) - : 'Не удалось добавить в очередь.'); - restoreMusicFocusContext(restoreContext, 120); - return; - } - - if (selected.action === 'queue_remove') { - var removeIndex = standaloneQueueIndexOfTrack(track && track.id); - var removed = removeIndex > -1 && mutateStandaloneIosQueue('remove', removeIndex); - - Lampa.Noty.show(removed ? 'Убрано из очереди.' : 'Не удалось убрать из очереди.'); - restoreMusicFocusContext(restoreContext, 120); - return; - } - - if (selected.action === 'toggle_bookmark') { - var added = toggleBookmarkedEntity(MUSIC.storage.bookmarked_tracks, track); - Lampa.Noty.show(added ? 'Трек добавлен в закладки.' : 'Трек удалён из закладок.'); - var focusHandled = onBookmarkChanged ? onBookmarkChanged(added, restoreContext) : false; - if (!focusHandled) - restoreMusicFocusContext(restoreContext, 120); - return; - } - - if (selected.action === 'add_to_playlist') { - openAddToPlaylistMenu(track, restoreContext); - return; - } - - if (selected.action === 'artist_search') { - openSearchQuery(selected.artist_query || track.artist_name || ''); - return; - } - - if (selected.action === 'sources') { - loadTrackMatches(track, function (json) { - var matches = json && json.matches ? json.matches : []; - var selectedMatch = json && json.selected_match ? json.selected_match : null; - - if (!matches.length) { - Lampa.Noty.show('Автоматически не найдено — введи запрос вручную.'); - openManualSourceSearchMenu(track, null, restoreContext); - return; - } - - var items = matches.map(function (match) { - var isSelected = selectedMatch - && selectedMatch.id === match.id - && selectedMatch.provider_id === match.provider_id; - - return { - title: (isSelected ? '✓ ' : '') + formatMatchTitle(match), - match: match - }; - }); - - items.push({ title: 'Найти вручную…', manual_search: true }); - - Lampa.Select.show({ - title: 'Источники', - items: items, - onBack: function () { - restoreMusicFocusContext(restoreContext); - }, - onSelect: function (entry) { - if (!entry) { - restoreMusicFocusContext(restoreContext); - return; - } - - if (entry.manual_search) { - openManualSourceSearchMenu(track, null, restoreContext); - return; - } - - if (!entry.match) return; - - saveTrackMatch(track, entry.match, function (saved) { - if (!saved) { - Lampa.Noty.show('Не удалось выбрать источник.'); - restoreMusicFocusContext(restoreContext); - return; - } - - Lampa.Noty.show('Источник сохранён.'); - restoreMusicFocusContext(restoreContext, 180); - }); - } - }); - }); - return; - } - - if (onAction) - onAction(selected, restoreContext); - } - }); - } - - // меню-версия «Найти вручную» (для контекстного меню карточек): - // тот же серверный путь query-поиска, что и в шите фулл-плеера - function openManualSourceSearchMenu(track, initialValue, restoreContext) { - restoreContext = restoreContext || captureMusicFocusContext('content'); - var prefill = typeof initialValue === 'string' && initialValue - ? initialValue - : (((track && track.artist_name) || '') + ' ' + ((track && track.title) || '')).trim(); - - Lampa.Input.edit({ - value: prefill, - title: 'Поиск источника', - free: true, - nosave: true, - nomic: true - }, function (queryValue) { - queryValue = String(queryValue || '').trim(); - if (!queryValue) { - restoreMusicFocusContext(restoreContext); - return; - } - - loadTrackMatches(track, function (json) { - var matches = json && Array.isArray(json.matches) ? json.matches : []; - var selectedMatch = json && json.selected_match ? json.selected_match : null; - var items = [{ title: 'Изменить запрос («' + queryValue + '»)', retry_query: true }]; - - matches.forEach(function (match) { - var isSelected = selectedMatch - && selectedMatch.id === match.id - && selectedMatch.provider_id === match.provider_id; - - items.push({ - title: (isSelected ? '✓ ' : '') + formatMatchTitle(match), - match: match - }); - }); - - if (!matches.length) - items.push({ title: 'Ничего не найдено', noop: true }); - - Lampa.Select.show({ - title: 'Поиск источника', - items: items, - onBack: function () { - restoreMusicFocusContext(restoreContext); - }, - onSelect: function (entry) { - if (!entry || entry.noop) { - restoreMusicFocusContext(restoreContext); - return; - } - - if (entry.retry_query) { - openManualSourceSearchMenu(track, queryValue, restoreContext); - return; - } - - if (!entry.match) return; - - saveTrackMatch(track, entry.match, function (saved) { - if (!saved) { - Lampa.Noty.show('Не удалось выбрать источник.'); - restoreMusicFocusContext(restoreContext); - return; - } - - Lampa.Noty.show('Источник сохранён.'); - restoreMusicFocusContext(restoreContext, 180); - }, queryValue); - } - }); - }, queryValue); - }); - } - - // ===== DAILY MIXES («Миксы недели») ===== - - // свои пары градиентов на каждый день — полка читается по цвету - var DAILY_MIX_COLORS = [ - ['#1f2c46', '#4a6db3'], // Пн - ['#20353a', '#3f8f7f'], // Вт - ['#3a2b1f', '#b0763a'], // Ср - ['#2f2140', '#8a55b5'], // Чт - ['#3c2130', '#b54a72'], // Пт - ['#1e3536', '#3aa1a8'], // Сб - ['#33301f', '#a89a3a'] // Вс - ]; - - function mapDailyMixCard(summary) { - var day = Math.max(1, Math.min(7, Number(summary && summary.day) || 1)); - var title = (summary && summary.title) || 'Микс'; - var image = artwork('playlist', title, 'MIX', DAILY_MIX_COLORS[day - 1]); - - return { - type: 'daily_mix', - id: 'daily:' + day, - title: title, - subtitle: (summary && summary.artists || []).join(' · '), - badge: summary && summary.today ? 'СЕГОДНЯ' : 'MIX', - image: image, - background: image, - raw: summary - }; - } - - // полка начинается с сегодняшнего дня, дальше — по кругу недели - function buildDailyMixCards(summaries) { - var list = (summaries || []).filter(function (item) { return item && item.day; }); - if (!list.length) return []; - - var todayIndex = -1; - for (var i = 0; i < list.length; i++) { - if (list[i].today) { todayIndex = i; break; } - } - - if (todayIndex > 0) - list = list.slice(todayIndex).concat(list.slice(0, todayIndex)); - - return list.map(mapDailyMixCard); - } - - function mapPlaylistCard(summary) { - var image = summary && summary.images && summary.images.length && summary.images[summary.images.length - 1].url - ? summary.images[summary.images.length - 1].url - : artwork('playlist', summary && summary.title ? summary.title : 'Плейлист', 'PLAYLIST', ['#232838', '#4a5a82']); - var sourceType = summary && summary.source && summary.source.type ? String(summary.source.type) : ''; - - return { - type: 'playlist', - id: summary.id, - title: summary.title || 'Плейлист', - subtitle: formatTrackCountLabel(summary.track_count || 0), - badge: sourceType.indexOf('soundcloud_') === 0 ? 'SOUNDCLOUD' : 'PLAYLIST', - image: image, - background: image, - raw: summary - }; - } - - function mapPlaylistAddCard() { - var image = artwork('playlist_add', 'Добавить', 'PLAYLIST', ['#202a34', '#54726c']); - return { - type: 'playlist_action', - id: 'playlist:add', - title: 'Добавить', - subtitle: 'Создать или импорт', - badge: '+', - image: image, - background: image, - raw: { action: 'add_playlist' } - }; - } - - function buildUserPlaylistCards(playlists) { - return [mapPlaylistAddCard()].concat((playlists || []).map(mapPlaylistCard)); - } - - function buildUserPlaylistCardsWithStats(playlists, stats) { - var cards = buildUserPlaylistCards(playlists); - - // «Твой топ» живёт в полке плейлистов как виртуальный альбом. - // Ставим сразу после «Добавить», но только после серверного unlock. - if (stats && stats.unlocked) - cards.splice(1, 0, mapStatsTopCard(stats)); - - return cards; - } - - function updateRenderedUserPlaylistCard(entry) { - if (!entry || !entry.id) return; - - var focusKey = buildMusicFocusKey(entry); - $('.lm-card.selector[data-music-focus-key]').filter(function () { - return this.getAttribute('data-music-focus-key') === focusKey; - }).each(function () { - var card = $(this); - var image = card.find('.lm-card__img'); - - setTextIfChanged(card.find('.lm-card__title'), entry.title || 'Плейлист'); - setTextIfChanged(card.find('.lm-card__subtitle'), entry.subtitle || ''); - setTextIfChanged(card.find('.lm-card__badge'), entry.badge || ''); - - if (entry.image && image.attr('src') !== entry.image) { - card.removeClass('loaded'); - image.attr('src', entry.image); - } - - if (card.hasClass('focus')) - setBackground(entry.background || IMG_BG); - }); - } - - function updateUserPlaylistHomeCard(summary) { - if (!summary || !summary.id) return false; - - var entry = applySectionKey([mapPlaylistCard(summary)], 'user_playlists')[0]; - var list = Array.isArray(MUSIC_HOME_CACHE.user_playlists) ? MUSIC_HOME_CACHE.user_playlists.slice() : []; - var addCard = list.filter(function (item) { return item && item.type === 'playlist_action'; })[0] - || applySectionKey([mapPlaylistAddCard()], 'user_playlists')[0]; - var rest = list.filter(function (item) { - return item && item.type !== 'playlist_action' && item.id !== entry.id; - }); - - MUSIC_HOME_CACHE.user_playlists = [addCard, entry].concat(rest); - updateHomeSectionMetaFromCache('user_playlists'); - updateRenderedUserPlaylistCard(entry); - - return true; - } - - // название сервиса-источника импортированного плейлиста ('SoundCloud'/'Spotify') - // или null для обычных плейлистов — по нему решаем, показывать ли «Обновить» - function playlistSourceService(entry) { - var sourceType = entry && entry.raw && entry.raw.source && entry.raw.source.type - ? String(entry.raw.source.type) - : ''; - if (sourceType.indexOf('soundcloud_') === 0) return 'SoundCloud'; - if (sourceType.indexOf('spotify_') === 0) return 'Spotify'; - if (sourceType.indexOf('applemusic_') === 0) return 'Apple Music'; - return null; - } - - function formatTotalDurationLabel(totalMs) { - var minutes = Math.round((Number(totalMs) || 0) / 60000); - if (!minutes) return ''; - if (minutes < 60) return minutes + ' мин'; - - var hours = Math.floor(minutes / 60); - var rest = minutes % 60; - return rest ? hours + ' ч ' + rest + ' мин' : hours + ' ч'; - } - - function playTracksShuffled(tracks) { - if (!tracks || !tracks.length) return; - - var shuffledTracks = tracks.slice(); - - for (var i = shuffledTracks.length - 1; i > 0; i--) { - var j = Math.floor(Math.random() * (i + 1)); - var item = shuffledTracks[i]; - shuffledTracks[i] = shuffledTracks[j]; - shuffledTracks[j] = item; - } - - Lampa.Storage.set(MUSIC.storage.shuffle, false); - MUSIC_IOS_AUDIO.shuffleOrder = null; - - // Это one-shot shuffle: физически кладём в плеер перемешанную очередь. - // Отдельный shuffle-флаг выключен, чтобы UI очереди и реальный next совпадали. - playTrack(shuffledTracks[0], shuffledTracks, 0, { forceFresh: true }); - } - - // ===== USER PLAYLISTS ===== - - function formatTrackCountLabel(count) { - count = Number(count) || 0; - var mod10 = count % 10; - var mod100 = count % 100; - - if (mod10 === 1 && mod100 !== 11) return count + ' трек'; - if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return count + ' трека'; - return count + ' треков'; - } - - function loadUserPlaylists(callback) { - request(MUSIC.endpoints.playlists, function (json) { - json = parseJson(json); - callback(json && Array.isArray(json.playlists) ? json.playlists : []); - }, function () { - callback([]); - }); - } - - // сводка статистики для карточки «Твой топ»; кэш 60s, чтобы частые - // перерисовки полки (recently_played-события) не спамили эндпоинт - function loadStatsTopSummary(callback) { - var now = Date.now(); - - if (MUSIC_STATS_TOP.checkedAt && now - MUSIC_STATS_TOP.checkedAt < 60000) { - callback(MUSIC_STATS_TOP.summary); - return; - } - - request(MUSIC.endpoints.statsTop + '?limit=1', function (json) { - MUSIC_STATS_TOP.checkedAt = Date.now(); - MUSIC_STATS_TOP.summary = parseJson(json) || null; - callback(MUSIC_STATS_TOP.summary); - }, function () { - // сбой сети не кэшируем — следующая перерисовка попробует снова - callback(MUSIC_STATS_TOP.summary); - }); - } - - function refreshStatsTopAfterPlayedTrack() { - var hadStatsState = !!(MUSIC_STATS_TOP.checkedAt || MUSIC_STATS_TOP.summary); - var wasUnlocked = !!(MUSIC_STATS_TOP.summary && MUSIC_STATS_TOP.summary.unlocked); - - // Живая перерисовка нужна только если home уже видел locked-состояние: - // тогда этот play мог впервые разблокировать «Твой топ». После unlock - // и без открытого home не трогаем сеть и не дёргаем полку/очередь. - if (!hadStatsState || wasUnlocked) return; - - MUSIC_STATS_TOP.checkedAt = 0; - - loadStatsTopSummary(function (stats) { - if (!stats || !stats.unlocked) return; - - emitRecentChanged('user_playlists', { - restoreState: { sectionKey: 'user_playlists', entryIndex: 1 } - }); - }); - } - - function mapStatsTopCard(stats) { - var image = artwork('playlist', 'Твой топ', 'STATS', ['#2b1e3d', '#7b4bbd']); - - return { - type: 'stats_top', - id: 'stats:top', - title: 'Твой топ', - subtitle: formatPlaysLabel(stats && stats.total_plays), - badge: '♪', - image: image, - background: image, - raw: stats - }; - } - - function formatPlaysLabel(count) { - count = Math.max(0, Number(count) || 0); - var mod10 = count % 10; - var mod100 = count % 100; - var word = 'прослушиваний'; - - if (mod10 === 1 && mod100 !== 11) word = 'прослушивание'; - else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) word = 'прослушивания'; - - return count + ' ' + word; - } - - // бытовой эквивалент заработка — деадпан-пасхалка, подаётся как факт - // каталога; интерфейс никогда не признаётся, что пошутил - function payoutEquivalentLabel(payout) { - if (payout < 0.5) return 'почти жвачка'; - if (payout < 3) return 'почти кофе'; - if (payout < 10) return 'шаурма близко'; - if (payout < 30) return 'уже заметили бухгалтеры'; - return 'индустрия держится'; - } - - // мета hero «Твоего топа»: цифры + шутка про заработок артистов одной - // строкой (ставка ~$0.004 за стрим), сухо, как факт каталога - function buildStatsTopMetaLabel(summary, trackCount) { - if (!summary) return formatTrackCountLabel(trackCount || 0); - - var parts = [formatPlaysLabel(summary.total_plays)]; - - if (summary.total_ms > 0) - parts.push('примерно ' + formatTotalDurationLabel(summary.total_ms)); - - var payout = (Number(summary.total_plays) || 0) * 0.004; - if (payout >= 0.01) { - parts.push('артисты заработали ≈ $' + payout.toFixed(2)); - parts.push(payoutEquivalentLabel(payout)); - } - - return parts.join(' · '); - } - - // личные «сертификации» треков по числу прослушиваний — тихая пометка - // в мете строки, без иконок и объяснений - function trackCertificationLabel(playCount) { - if (playCount >= 250) return 'архивная ценность'; - if (playCount >= 100) return 'платина'; - if (playCount >= 50) return 'золото'; - return ''; - } - - function openCreatePlaylistDialog(onCreated, restoreContext) { - Lampa.Input.edit({ - value: '', - title: 'Название плейлиста', - free: true, - nosave: true, - nomic: true - }, function (titleValue) { - titleValue = String(titleValue || '').trim(); - if (!titleValue) { - restoreMusicFocusContext(restoreContext); - return; - } - - requestPost(MUSIC.endpoints.playlistCreate, 'title=' + encodeURIComponent(titleValue), function (json) { - if (!json || !json.created || !json.playlist_id) { - Lampa.Noty.show('Не удалось создать плейлист.'); - restoreMusicFocusContext(restoreContext); - return; - } - - if (onCreated) { - notifyPlaylistChanged(json.playlist_id, { - skipHomeSection: true, - skipPlaylistSection: true - }); - onCreated(json); - return; - } - - notifyPlaylistChanged(json.playlist_id, { - restoreState: { sectionKey: 'user_playlists', entryIndex: 1 } - }); - Lampa.Noty.show('Плейлист создан.'); - }, function () { - Lampa.Noty.show('Не удалось создать плейлист.'); - restoreMusicFocusContext(restoreContext); - }); - }); - } - - function openImportLinkDialog(restoreContext) { - Lampa.Input.edit({ - value: 'https://', - title: 'Ссылка SoundCloud, Spotify или Apple Music', - free: true, - nosave: true, - nomic: true - }, function (url) { - url = String(url || '').trim(); - if (!url || url === 'https://' || url === 'http://') { - restoreMusicFocusContext(restoreContext); - return; - } - - Lampa.Noty.show('Импортирую плейлист...'); - requestPost(MUSIC.endpoints.playlistImport, 'url=' + encodeURIComponent(url), function (json) { - if (!json || !json.available || !json.playlist_id) { - Lampa.Noty.show((json && json.message) || 'Не удалось импортировать плейлист.'); - restoreMusicFocusContext(restoreContext); - return; - } - - notifyPlaylistChanged(json.playlist_id, { - restoreState: { sectionKey: 'user_playlists', entryIndex: 1 } - }); - var countLabel = 'Импортировано: ' + formatTrackCountLabel(json.track_count || 0) + (json.truncated ? ' (не всё — список слишком большой)' : '') + '.'; - Lampa.Noty.show(json.message || countLabel); - }, function () { - Lampa.Noty.show('Не удалось импортировать плейлист.'); - restoreMusicFocusContext(restoreContext); - }); - }); - } - - function openPlaylistAddMenu() { - var restoreContext = captureMusicFocusContext('content'); - - showContextMenu('Добавить', [ - { title: 'Создать плейлист', action: 'create' }, - { title: 'Импорт по ссылке', action: 'import' } - ], function (selected) { - if (!selected || !selected.action) return; - - if (selected.action === 'create') { - openCreatePlaylistDialog(null, restoreContext); - return; - } - - if (selected.action === 'import') - openImportLinkDialog(restoreContext); - }); - } - - function syncImportedPlaylist(entry, refresh, confirmed, restoreContext, index) { - if (!entry || !entry.id) return; - restoreContext = restoreContext || captureMusicFocusContext('content'); - - var serviceName = playlistSourceService(entry) || 'источник'; - if (!confirmed) { - // sync — слияние: порядок и ручные правки сохраняются, - // новые треки источника добавляются в начало - showContextMenu('Новые треки добавятся в начало', [ - { title: 'Обновить из ' + serviceName, action: 'confirm_sync' }, - { title: 'Отмена', action: 'cancel' } - ], function (selected) { - if (selected && selected.action === 'confirm_sync') { - syncImportedPlaylist(entry, refresh, true, restoreContext, index); - return; - } - - restoreMusicFocusContext(restoreContext); - }, restoreContext); - return; - } - - Lampa.Noty.show('Обновляю из ' + serviceName + '...'); - requestPost(MUSIC.endpoints.playlistSync, 'id=' + encodeURIComponent(entry.id), function (json) { - if (!json || !json.available) { - Lampa.Noty.show((json && json.message) || 'Не удалось обновить плейлист.'); - restoreMusicFocusContext(restoreContext); - return; - } - - notifyPlaylistChanged(entry.id, { - skipPlaylistSection: !!refresh, - skipHomeSection: !!(refresh && refresh.length > 0), - skipActiveSectionRefresh: !!(refresh && refresh.length === 0) - }); - Lampa.Noty.show(json.message || ('Обновлено: ' + formatTrackCountLabel(json.track_count || 0) + '.')); - refreshListWithFocus(refresh, buildEntryRestoreState(entry, index), restoreContext); - }, function () { - Lampa.Noty.show('Не удалось обновить плейлист.'); - restoreMusicFocusContext(restoreContext); - }); - } - - // «В плейлист…»: выбор существующего или создание нового, трек уходит - // на сервер целиком (durable, per-profile как история) - function openAddToPlaylistMenu(track, restoreContext) { - if (!track || !track.id) { - Lampa.Noty.show('Трек не выбран.'); - restoreMusicFocusContext(restoreContext); - return; - } - - restoreContext = restoreContext || captureMusicFocusContext('content'); - - loadUserPlaylists(function (playlists) { - var items = [{ title: 'Создать новый…', create_new: true }]; - - playlists.forEach(function (playlist) { - items.push({ - title: playlist.title + ' (' + formatTrackCountLabel(playlist.track_count || 0) + ')', - playlist: playlist - }); - }); - - Lampa.Select.show({ - title: 'В плейлист', - items: items, - onBack: function () { - restoreMusicFocusContext(restoreContext); - }, - onSelect: function (entry) { - if (!entry) { - restoreMusicFocusContext(restoreContext); - return; - } - - if (entry.create_new) { - openCreatePlaylistDialog(function (json) { - addTrackToPlaylist(track, json.playlist_id, json.title || 'плейлист', restoreContext); - }, restoreContext); - return; - } - - if (entry.playlist) - addTrackToPlaylist(track, entry.playlist.id, entry.playlist.title, restoreContext); - } - }); - }); - } - - function addTrackToPlaylist(track, playlistId, playlistTitle, restoreContext) { - var payload = 'id=' + encodeURIComponent(playlistId) - + '&track=' + encodeURIComponent(JSON.stringify(track)); - - requestPost(MUSIC.endpoints.playlistTrackAdd, payload, function (json) { - if (json && json.saved) { - updateUserPlaylistHomeCard(json.playlist); - notifyPlaylistChanged(playlistId, { - skipHomeSection: true, - skipPlaylistSection: true, - preserveHomeCache: true - }); - Lampa.Noty.show('Добавлено в «' + (playlistTitle || 'плейлист') + '».'); - restoreMusicFocusContext(restoreContext, 120); - return; - } - - Lampa.Noty.show('Не удалось добавить в плейлист.'); - restoreMusicFocusContext(restoreContext); - }, function () { - Lampa.Noty.show('Не удалось добавить в плейлист.'); - restoreMusicFocusContext(restoreContext); - }); - } - - function mapSearchArtistEntry(artist, sectionKey) { - return { - type: 'artist', - id: artist.id, - title: artist.name || 'Unknown Artist', - subtitle: artist.sort_name || artist.country || 'Artist', - badge: artist.country || 'ARTIST', - image: artistImage(artist, 250), - background: artistImage(artist, 500), - raw: artist, - section_key: sectionKey || '' - }; - } - - function mapSearchAlbumEntry(album, sectionKey) { - return { - type: 'album', - id: album.id, - title: album.title || 'Untitled', - subtitle: album.artist_name || formatDate(album.date), - badge: albumBadge(album, sectionKey), - image: albumImage(album, 250), - background: albumImage(album, 500), - raw: album, - section_key: sectionKey || '' - }; - } - - function mapSearchTrackEntry(track, sectionKey) { - return { - type: 'track', - id: track.id, - title: track.title || 'Untitled', - subtitle: track.artist_name || track.album_title || 'Track', - badge: formatDuration(track.duration_ms) || 'TRACK', - image: trackImage(track), - background: trackImage(track), - raw: track, - section_key: sectionKey || '' - }; - } - - function mapSearchSection(section) { - if (!section) return null; - - var sectionKey = 'search_section:' + (section.id || Lampa.Utils.uid()); - var artists = section && Array.isArray(section.artists) ? section.artists : []; - var albums = section && Array.isArray(section.albums) ? section.albums : []; - var tracks = section && Array.isArray(section.tracks) ? section.tracks : []; - var entries = []; - - if (tracks.length) entries = tracks.map(function (track) { return mapSearchTrackEntry(track, sectionKey); }); - else if (albums.length) entries = albums.map(function (album) { return mapSearchAlbumEntry(album, sectionKey); }); - else if (artists.length) entries = artists.map(function (artist) { return mapSearchArtistEntry(artist, sectionKey); }); - - if (!entries.length) return null; - - return { - id: section.id || sectionKey, - title: section.title || 'Результаты', - source_provider: section.source_provider || '', - type: section.type || (tracks.length ? 'tracks' : albums.length ? 'albums' : 'artists'), - has_more: !!section.has_more, - next_page: section.next_page || '', - entries: entries - }; - } - - function normalizeSearchResults(json) { - var artists = json && Array.isArray(json.artists) ? json.artists : []; - var albums = json && Array.isArray(json.albums) ? json.albums : []; - var tracks = json && Array.isArray(json.tracks) ? json.tracks : []; - var sections = json && Array.isArray(json.search_sections) ? json.search_sections : []; - - return { - metadata_pending: !!(json && json.metadata_pending), - artists: artists.map(function (artist) { return mapSearchArtistEntry(artist, 'search_artists'); }), - albums: albums.map(function (album) { return mapSearchAlbumEntry(album, 'search_albums'); }), - tracks: tracks.map(function (track) { return mapSearchTrackEntry(track, 'search_tracks'); }), - sections: sections.map(mapSearchSection).filter(Boolean) - }; - } - - function getSearchSectionGroupTitle(section) { - if (!section) return 'Результаты'; - if ((section.source_provider === 'youtubeaudio' || section.source_provider === 'soundcloudcharts') && section.title) return section.title; - if (section.type === 'artists') return 'Артисты'; - if (section.type === 'albums') return 'Альбомы'; - if (section.type === 'tracks') return 'Треки'; - return 'Результаты'; - } - - function buildRelatedArtistEntries(groups, query) { - var tracks = groups && Array.isArray(groups.tracks) ? groups.tracks : []; - var artists = groups && Array.isArray(groups.artists) ? groups.artists : []; - var queryNorm = normalizeText(query); - var primary = {}; - var related = {}; - - function markPrimary(name) { - var normalized = normalizeText(name); - if (normalized) primary[normalized] = true; - } - - function isPrimary(name) { - var normalized = normalizeText(name); - if (!normalized) return false; - if (primary[normalized]) return true; - - return !!(queryNorm && ( - normalized === queryNorm - || normalized.indexOf(queryNorm) >= 0 - || queryNorm.indexOf(normalized) >= 0 - )); - } - - function addRelated(name, track, index) { - var normalized = normalizeText(name); - if (!normalized || isPrimary(name)) return; - - var state = related[normalized]; - if (!state) { - state = related[normalized] = { - name: String(name || '').trim(), - score: 0, - tracks: [], - raw: { - id: 'related:' + normalized, - name: String(name || '').trim(), - sort_name: '', - country: '', - description: 'Из совместных треков', - images: [] - } - }; - } - - state.score += Math.max(1, 24 - index); - if (track && track.title && state.tracks.indexOf(track.title) < 0) - state.tracks.push(track.title); - } - - if (queryNorm) markPrimary(query); - - artists.slice(0, 4).forEach(function (entry) { - if (entry && entry.raw && entry.raw.name) markPrimary(entry.raw.name); - }); - - tracks.slice(0, 40).forEach(function (entry, index) { - var track = entry && entry.raw ? entry.raw : null; - var trackArtists = track && Array.isArray(track.artists) && track.artists.length - ? track.artists - : track && track.artist_name - ? [track.artist_name] - : []; - - if (trackArtists.length < 2) return; - if (!trackArtists.some(isPrimary)) return; - - trackArtists.forEach(function (name) { - addRelated(name, track, index); - }); - }); - - return Object.keys(related) - .map(function (key) { return related[key]; }) - .sort(function (a, b) { return b.score - a.score; }) - .slice(0, HOME_SECTION_LIMIT) - .map(function (item) { - return { - type: 'artist', - id: item.raw.id, - title: item.raw.name, - subtitle: '', - badge: 'RELATED', - image: artistImage(item.raw, 250), - background: artistImage(item.raw, 250), - raw: item.raw - }; - }); - } - - function buildSearchSources(groups, query) { - var sourcesMap = {}; - var catalogGroups = []; - var relatedArtists = buildRelatedArtistEntries(groups, query); - - function ensureSource(id, title, sourceProvider) { - if (!sourcesMap[id]) { - sourcesMap[id] = { - id: id, - title: title, - source_provider: sourceProvider || '', - count: 0, - pending: false, - groups: [] - }; - } - - return sourcesMap[id]; - } - - function getSearchSourceId(section) { - var id = section && section.id ? section.id : ''; - var provider = section && section.source_provider ? section.source_provider : ''; - - if (provider === 'youtubeaudio' || id.indexOf('search:youtubemusic') === 0) - return 'search:youtubemusic'; - - if (provider === 'soundcloudcharts' || id.indexOf('search:soundcloud') === 0) - return 'search:soundcloud'; - - if (provider === 'spotify' || id.indexOf('search:spotify') === 0) - return 'search:spotify'; - - return id || ('source:' + ((section && section.title) || Lampa.Utils.uid())); - } - - function getSearchSourceTitle(section, sourceId) { - if (sourceId === 'search:youtubemusic') return 'YouTube Music'; - if (sourceId === 'search:soundcloud') return 'SoundCloud'; - if (sourceId === 'search:spotify') return 'Spotify'; - return (section && section.title) || 'Источник'; - } - - if (groups.artists.length) { - catalogGroups.push({ - id: 'artists', - title: 'Артисты', - entries: groups.artists - }); - } - - if (groups.albums.length) { - catalogGroups.push({ - id: 'albums', - title: 'Альбомы', - entries: groups.albums - }); - } - - if (groups.tracks.length) { - catalogGroups.push({ - id: 'tracks', - title: 'Треки', - entries: groups.tracks - }); - } - - if (relatedArtists.length) { - catalogGroups.push({ - id: 'related_artists', - title: 'Также может понравиться', - entries: relatedArtists - }); - } - - var catalogSource = ensureSource('catalog', 'MusicBrainz', 'musicbrainz'); - catalogSource.groups = catalogGroups; - catalogSource.count = catalogGroups.reduce(function (sum, group) { - return sum + (Array.isArray(group.entries) ? group.entries.length : 0); - }, 0); - catalogSource.pending = hasPendingSearchMetadata(groups); - - ensureSource('search:youtubemusic', 'YouTube Music', 'youtubeaudio'); - ensureSource('search:soundcloud', 'SoundCloud', 'soundcloudcharts'); - ensureSource('search:sefon', 'Sefon', 'sefonaudio'); - if (MUSIC_SPOTIFY_SEARCH_ENABLED) - ensureSource('search:spotify', 'Spotify', 'spotify'); - - groups.sections.forEach(function (section) { - if (!section) return; - - var entries = Array.isArray(section.entries) ? section.entries : []; - if (!entries.length) return; - - var sourceId = getSearchSourceId(section); - var source = ensureSource(sourceId, getSearchSourceTitle(section, sourceId), section.source_provider || ''); - - source.groups.push({ - id: section.id, - title: getSearchSectionGroupTitle(section), - entries: entries, - has_more: !!section.has_more - }); - source.count += entries.length; - }); - - return ['catalog', 'search:youtubemusic', 'search:soundcloud', 'search:sefon', 'search:spotify'] - .map(function (id) { return sourcesMap[id]; }) - .filter(Boolean); - } - - function mapArtistCard(artist) { - return { - type: 'artist', - id: artist.id, - title: artist.name || 'Unknown Artist', - subtitle: artist.sort_name || artist.country || 'Artist', - badge: artist.country || 'ARTIST', - image: artistImage(artist, 250), - background: artistImage(artist, 250), - raw: artist - }; - } - - function mapAlbumCard(album) { - return { - type: 'album', - id: album.id, - title: album.title || 'Untitled', - subtitle: album.artist_name || formatDate(album.date), - badge: albumBadge(album), - image: albumImage(album, 250), - background: albumImage(album, 500), - raw: album - }; - } - - function mapQueryCard(queryItem) { - var query = typeof queryItem === 'string' ? queryItem : (queryItem && queryItem.query ? queryItem.query : ''); - var artist = queryItem && typeof queryItem === 'object' ? queryItem.artist : null; - var image = artist ? artistImage(artist, 250) : artwork('album', query, 'Search', ['#1a2531', '#41627e']); - - return { - type: 'query', - id: query, - title: query, - subtitle: artist && artist.name ? artist.name : 'Открыть поиск', - badge: 'SEARCH', - image: image, - background: image, - artist: artist, - raw: query - }; - } - - function mapTrackCard(track) { - var badge = track && track.duration_ms - ? formatDuration(track.duration_ms) - : track && track.track_number - ? '#' + track.track_number - : 'TRACK'; - - return { - type: 'track', - id: track.id, - title: track.title || 'Untitled', - subtitle: track.artist_name || track.album_title || 'Track', - badge: badge, - image: trackImage(track), - background: trackImage(track), - raw: track - }; - } - - function cloneHomeEntry(entry) { - if (!entry) return null; - - var copy = {}; - Object.keys(entry).forEach(function (key) { - if (key !== 'ready') copy[key] = entry[key]; - }); - - return copy; - } - - function applySectionKey(entries, sectionKey) { - return (entries || []).map(function (entry) { - if (entry) entry.section_key = sectionKey; - return entry; - }).filter(Boolean); - } - - function cloneHomeEntries(entries) { - return (entries || []).map(cloneHomeEntry).filter(Boolean); - } - - function mergeHomeEntriesById(primary, secondary, limit) { - var result = []; - var seen = {}; - - function add(entry) { - if (!entry) return; - - var key = entry.id || ((entry.type || '') + ':' + (entry.title || '')); - if (!key || seen[key]) return; - - seen[key] = true; - result.push(entry); - } - - (primary || []).forEach(add); - (secondary || []).forEach(add); - - return result.slice(0, limit || RECENT_SECTION_STORAGE_LIMIT); - } - - function getHomeSectionEntries(sectionKey, home) { - if (sectionKey === 'recently_played') { - var serverEntries = (home && home.recently_played ? home.recently_played : []) - .map(function (item) { return item && item.track ? mapTrackCard(item.track) : null; }) - .filter(Boolean); - var cachedEntries = Array.isArray(MUSIC_HOME_CACHE.recently_played) - ? MUSIC_HOME_CACHE.recently_played.slice() - : []; - - return applySectionKey( - cachedEntries.length - ? mergeHomeEntriesById(cachedEntries, serverEntries, RECENT_SECTION_STORAGE_LIMIT) - : serverEntries, - sectionKey - ); - } - - if (sectionKey === 'recent_albums') - return applySectionKey(getRecentEntities(MUSIC.storage.recent_albums).map(mapAlbumCard), sectionKey); - - if (sectionKey === 'recent_artists') - return applySectionKey(getRecentEntities(MUSIC.storage.recent_artists).map(mapArtistCard), sectionKey); - - if (sectionKey === 'recent_queries') - return applySectionKey(getRecentQueries().map(mapQueryCard), sectionKey); - - var dynamicSection = home && Array.isArray(home.browse_sections) - ? home.browse_sections.filter(function (section) { return section && section.id === sectionKey; })[0] - : null; - - if (dynamicSection) { - if (dynamicSection.type === 'album') - return applySectionKey((dynamicSection.albums || []).map(mapAlbumCard), sectionKey); - - if (dynamicSection.type === 'artist') - return applySectionKey((dynamicSection.artists || []).map(mapArtistCard), sectionKey); - - if (dynamicSection.type === 'track') - return applySectionKey((dynamicSection.tracks || []).map(mapTrackCard), sectionKey); - } - - return []; - } - - function snapshotHomeSections(home) { - var recentlyPlayed = getHomeSectionEntries('recently_played', home); - - MUSIC_HOME_SECTION_TITLES = { - recently_played: 'Недавно слушали', - recent_albums: 'Недавние альбомы', - recent_artists: 'Недавние артисты', - recent_queries: 'Недавние поиски', - user_playlists: 'Мои плейлисты', - daily_mixes: 'Миксы недели' - }; - MUSIC_HOME_SECTION_META = { - recently_played: { has_more: recentlyPlayed.length > HOME_SECTION_LIMIT || !!(home && home.recently_played && home.recently_played.length > HOME_SECTION_LIMIT) }, - recent_albums: { has_more: getRecentEntities(MUSIC.storage.recent_albums).length > HOME_SECTION_LIMIT }, - recent_artists: { has_more: getRecentEntities(MUSIC.storage.recent_artists).length > HOME_SECTION_LIMIT }, - recent_queries: { has_more: getRecentQueries().length > HOME_SECTION_LIMIT } - }; - - MUSIC_HOME_CACHE = { - recently_played: recentlyPlayed, - recent_albums: getHomeSectionEntries('recent_albums'), - recent_artists: getHomeSectionEntries('recent_artists'), - recent_queries: getHomeSectionEntries('recent_queries'), - user_playlists: applySectionKey(buildUserPlaylistCards(home && Array.isArray(home.user_playlists) ? home.user_playlists : []), 'user_playlists'), - daily_mixes: applySectionKey(buildDailyMixCards(home && Array.isArray(home.daily_mixes) ? home.daily_mixes : []), 'daily_mixes') - }; - MUSIC_HOME_SECTION_META.user_playlists = { has_more: (MUSIC_HOME_CACHE.user_playlists || []).length > HOME_SECTION_LIMIT }; - MUSIC_HOME_SECTION_META.daily_mixes = { has_more: false }; - - if (home && Array.isArray(home.browse_sections)) { - home.browse_sections.forEach(function (section) { - if (!section || !section.id) return; - MUSIC_HOME_SECTION_TITLES[section.id] = section.title || 'Секция'; - MUSIC_HOME_SECTION_META[section.id] = { - has_more: section.has_more === true - }; - MUSIC_HOME_CACHE[section.id] = getHomeSectionEntries(section.id, home); - }); - } - - return MUSIC_HOME_CACHE; - } - - function resolveHomeSectionEntries(sectionKey, done) { - if (sectionKey === 'user_playlists') { - loadUserPlaylists(function (playlists) { - loadStatsTopSummary(function (stats) { - var cards = buildUserPlaylistCardsWithStats(playlists, stats); - done(applySectionKey(cards, 'user_playlists')); - }); - }); - return; - } - - if (sectionKey === 'stats:top') { - request(MUSIC.endpoints.statsTop + '?limit=30', function (json) { - json = parseJson(json); - MUSIC_STATS_TOP.summary = json || null; - - var items = json && Array.isArray(json.tracks) ? json.tracks : []; - var tracks = items.map(function (item) { - var track = item && item.track ? item.track : null; - if (track) track.stats_play_count = item.play_count || 0; - return track; - }).filter(Boolean); - - done(applySectionKey(tracks.map(mapTrackCard), sectionKey)); - }, function () { - done([]); - }); - return; - } - - if (sectionKey.indexOf('daily:') === 0) { - var mixDay = parseInt(sectionKey.slice('daily:'.length), 10) || 0; - - var mixUrl = MUSIC.endpoints.daily + '?day=' + mixDay; - var mixSalt = getDailyMixSalt(); - if (mixSalt) mixUrl += '&salt=' + mixSalt; - - request(mixUrl, function (json) { - json = parseJson(json); - var tracks = json && json.available && Array.isArray(json.tracks) ? json.tracks : []; - done(applySectionKey(tracks.map(mapTrackCard), sectionKey)); - }, function () { - done([]); - }); - return; - } - - if (sectionKey.indexOf('playlist:') === 0) { - var playlistId = sectionKey.slice('playlist:'.length); - - request(MUSIC.endpoints.playlistTracks + '?id=' + encodeURIComponent(playlistId), function (json) { - json = parseJson(json); - var tracks = json && Array.isArray(json.tracks) ? json.tracks : []; - done(applySectionKey(tracks.map(mapTrackCard), sectionKey)); - }, function () { - done([]); - }); - return; - } - - if (sectionKey.indexOf('browse:') === 0) { - request(MUSIC.endpoints.section + '?id=' + encodeURIComponent(sectionKey), function (json) { - var parsed = parseJson(json) || null; - if (parsed && parsed.available === false) { - done([]); - return; - } - - if (parsed && parsed.id) { - var fakeHome = { browse_sections: [parsed] }; - if (parsed.title) MUSIC_HOME_SECTION_TITLES[sectionKey] = parsed.title; - MUSIC_HOME_SECTION_META[sectionKey] = { - has_more: parsed.has_more === true - }; - MUSIC_HOME_CACHE[sectionKey] = getHomeSectionEntries(sectionKey, fakeHome); - done((MUSIC_HOME_CACHE[sectionKey] || []).slice()); - return; - } - - done([]); - }, function () { - done([]); - }); - return; - } - - if (MUSIC_HOME_CACHE[sectionKey] && MUSIC_HOME_CACHE[sectionKey].length) { - done(MUSIC_HOME_CACHE[sectionKey].slice()); - return; - } - - if (sectionKey === 'recently_played') { - request(withDailyMixSalt(MUSIC.endpoints.home), function (json) { - var parsed = parseJson(json) || null; - snapshotHomeSections(parsed); - done((MUSIC_HOME_CACHE[sectionKey] || []).slice()); - }, function () { - done([]); - }); - return; - } - - if (sectionKey === 'bookmarked_tracks') { - done(applySectionKey(getBookmarkedEntities(MUSIC.storage.bookmarked_tracks).map(mapTrackCard), sectionKey)); - return; - } - - if (sectionKey === 'bookmarked_albums') { - done(applySectionKey(getBookmarkedEntities(MUSIC.storage.bookmarked_albums).map(mapAlbumCard), sectionKey)); - return; - } - - if (sectionKey === 'bookmarked_artists') { - done(applySectionKey(getBookmarkedEntities(MUSIC.storage.bookmarked_artists).map(mapArtistCard), sectionKey)); - return; - } - - snapshotHomeSections(null); - done((MUSIC_HOME_CACHE[sectionKey] || []).slice()); - } - - function openHomeSection(sectionKey, title) { - Lampa.Activity.push({ - title: title || 'Секция', - component: 'lampac_music_section', - section_key: sectionKey, - section_title: title || 'Секция', - page: 1, - noinfo: true - }); - } - - function activateEntry(entry, entries, index) { - if (!entry) return; - - MUSIC_SPOTIFY_RELEASE_TOKEN++; - stopMusicPlaybackLoading(); - - if (entry.type === 'playlist_action') { - openPlaylistAddMenu(); - return; - } - - if (entry.type === 'playlist') { - openHomeSection('playlist:' + entry.id, entry.title || 'Плейлист'); - return; - } - - if (entry.type === 'stats_top') { - openHomeSection('stats:top', 'Твой топ'); - return; - } - - if (entry.type === 'daily_mix') { - openHomeSection(entry.id, 'Микс · ' + (entry.title || '')); - return; - } - - if (entry.type === 'artist') { - openArtist(entry.raw); - return; - } - - if (entry.type === 'album') { - openAlbum(entry.raw); - return; - } - - if (entry.type === 'track') { - playTrack(entry.raw, entries.map(function (item) { return item.raw; }), index); - return; - } - - if (entry.type === 'query') { - openSearchQuery(entry.raw); - } - } - - // durable-перестановка трека внутри сохранённого плейлиста: порядок - // хранится на сервере (user_playlists.payload), воспроизведение потом - // идёт в новом порядке. Не путать с операциями над играющей очередью - function openPlaylistTrackMoveMenu(entry, entries, index, refresh, restoreContext) { - var playlistId = String(entry.section_key || '').slice('playlist:'.length); - var trackId = (entry.raw && entry.raw.id) || ''; - var lastIndex = entries.length - 1; - var items = []; - - if (!playlistId || !trackId) return; - - if (index > 0) { - items.push({ title: 'В начало', position: 0 }); - if (index > 1) items.push({ title: 'На позицию выше', position: index - 1 }); - } - - if (index < lastIndex) { - if (index < lastIndex - 1) items.push({ title: 'На позицию ниже', position: index + 1 }); - items.push({ title: 'В конец', position: lastIndex }); - } - - if (!items.length) return; - - showContextMenu('Переставить: ' + ((entry.raw && entry.raw.title) || ''), items, function (selected, moveRestoreContext) { - if (!selected || typeof selected.position !== 'number') return; - - var payload = 'id=' + encodeURIComponent(playlistId) - + '&track_id=' + encodeURIComponent(trackId) - + '&position=' + selected.position; - var focusContext = captureMusicNeighborFocusContext(moveRestoreContext || restoreContext, 'content'); - - requestPost(MUSIC.endpoints.playlistTrackMove, payload, function (json) { - if (!json || !json.moved) { - Lampa.Noty.show('Не удалось переставить трек.'); - restoreMusicFocusContext(moveRestoreContext || restoreContext); - return; - } - - updateUserPlaylistHomeCard(json.playlist); - notifyPlaylistChanged(playlistId, { - skipPlaylistSection: !!refresh, - skipHomeSection: !!(refresh && refresh.length > 0), - skipActiveSectionRefresh: !!(refresh && refresh.length === 0), - preserveHomeCache: !!json.playlist - }); - // фокус ведём за треком на его новую позицию - refreshListWithFocus(refresh, buildEntryRestoreState(entry, selected.position), focusContext); - }, function () { - Lampa.Noty.show('Не удалось переставить трек.'); - restoreMusicFocusContext(moveRestoreContext || restoreContext); - }); - }, restoreContext); - } - - function activateEntryMenu(entry, entries, index, refresh) { - if (!entry) return; - - if (entry.type === 'playlist_action') { - openPlaylistAddMenu(); - return; - } - - if (entry.type === 'track') { - var extraTrackItems = []; - - if (entry.raw && String(entry.raw.id || '').indexOf('vkchart:') === 0) { - extraTrackItems.push({ title: 'Открыть поиск', action: 'search' }); - } - - if (entry.section_key === 'recently_played') { - extraTrackItems.push({ - title: 'Удалить из недавно слушали', - action: 'remove_recently_played' - }); - } - - if (String(entry.section_key || '').indexOf('playlist:') === 0) { - if (entries.length > 1) { - extraTrackItems.push({ - title: 'Переставить в плейлисте…', - action: 'move_in_playlist' - }); - } - - extraTrackItems.push({ - title: 'Убрать из плейлиста', - action: 'remove_from_playlist' - }); - } - - openTrackMenu(entry.raw, entries.map(function (item) { return item.raw; }), index, extraTrackItems, function (selected, restoreContext) { - if (!selected || !selected.action) return; - - if (selected.action === 'artist_search') { - openSearchQuery(entry.raw.artist_name || ''); - return; - } - - if (selected.action === 'search') { - openSearchQuery([entry.raw.artist_name, entry.raw.title].filter(Boolean).join(' ')); - return; - } - - if (selected.action === 'move_in_playlist') { - openPlaylistTrackMoveMenu(entry, entries, index, refresh, restoreContext); - return; - } - - if (selected.action === 'remove_from_playlist') { - var playlistId = String(entry.section_key || '').slice('playlist:'.length); - var removePayload = 'id=' + encodeURIComponent(playlistId) - + '&track_id=' + encodeURIComponent((entry.raw && entry.raw.id) || ''); - var removeRestoreContext = captureMusicNeighborFocusContext(restoreContext, 'content'); - - requestPost(MUSIC.endpoints.playlistTrackRemove, removePayload, function (json) { - if (!json || !json.removed) { - Lampa.Noty.show('Не удалось убрать из плейлиста.'); - restoreMusicFocusContext(restoreContext); - return; - } - - Lampa.Noty.show('Убрано из плейлиста.'); - updateUserPlaylistHomeCard(json.playlist); - notifyPlaylistChanged(playlistId, { - skipPlaylistSection: !!refresh, - skipHomeSection: !!(refresh && refresh.length > 0), - skipActiveSectionRefresh: !!(refresh && refresh.length === 0), - preserveHomeCache: !!json.playlist - }); - refreshListWithFocus(refresh, buildEntryRestoreState(entry, index), removeRestoreContext); - }, function () { - Lampa.Noty.show('Не удалось убрать из плейлиста.'); - restoreMusicFocusContext(restoreContext); - }); - return; - } - - if (selected.action !== 'remove_recently_played') return; - - var historyRestoreContext = captureMusicNeighborFocusContext(restoreContext, 'content'); - var historyHomeRefresh = !!(refresh && refresh.length > 0); - - removeHistoryTrack(entry.raw && entry.raw.id, function (removed) { - if (!removed) { - Lampa.Noty.show('Не удалось удалить из истории.'); - restoreMusicFocusContext(restoreContext); - return; - } - - Lampa.Noty.show('Удалено из недавно слушали.'); - if (refresh) { - if (historyHomeRefresh) - refreshListWithFocus(refresh, buildEntryRestoreState(entry, index), historyRestoreContext); - return; - } - - restoreMusicFocusContext(historyRestoreContext, 120); - }, { skipEvent: historyHomeRefresh }); - }, function (added, restoreContext) { - if (entry.section_key === 'bookmarked_tracks' && !added && refresh) { - var trackRestoreContext = captureMusicNeighborFocusContext(restoreContext, 'content'); - refreshListWithFocus(refresh, buildEntryRestoreState(entry, index), trackRestoreContext); - return true; - } - - return false; - }); - return; - } - - if (entry.type === 'playlist') { - var playlistItems = [ - { title: 'Открыть', action: 'open' }, - { title: 'Удалить плейлист', action: 'delete' } - ]; - - var sourceService = playlistSourceService(entry); - if (sourceService) - playlistItems.splice(1, 0, { title: 'Обновить из ' + sourceService, action: 'sync_source' }); - - showContextMenu(entry.title || 'Плейлист', playlistItems, function (selected, restoreContext) { - if (!selected) return; - - if (selected.action === 'open') { - activateEntry(entry, entries, index); - return; - } - - if (selected.action === 'sync_source') { - syncImportedPlaylist(entry, refresh, false, restoreContext, index); - return; - } - - if (selected.action !== 'delete') return; - - var deleteRestoreContext = captureMusicNeighborFocusContext(restoreContext, 'content'); - - requestPost(MUSIC.endpoints.playlistDelete, 'id=' + encodeURIComponent(entry.id || ''), function (json) { - if (!json || !json.removed) { - Lampa.Noty.show('Не удалось удалить плейлист.'); - restoreMusicFocusContext(restoreContext); - return; - } - - Lampa.Noty.show('Плейлист удалён.'); - notifyPlaylistChanged(entry.id, { - skipPlaylistSection: true, - skipHomeSection: !!(refresh && refresh.length > 0), - skipActiveSectionRefresh: !!(refresh && refresh.length === 0) - }); - refreshListWithFocus(refresh, buildEntryRestoreState(entry, index), deleteRestoreContext); - }, function () { - Lampa.Noty.show('Не удалось удалить плейлист.'); - restoreMusicFocusContext(restoreContext); - }); - }); - return; - } - - if (entry.type === 'artist') { - var artistBookmarked = isBookmarkedEntity(MUSIC.storage.bookmarked_artists, entry.raw && entry.raw.id); - var artistItems = [ - { title: 'Открыть поиск', action: 'search' }, - { title: 'Открыть дискографию', action: 'catalog' }, - { title: artistBookmarked ? 'Убрать из закладок' : 'Добавить в закладки', action: 'toggle_bookmark' } - ]; - - if (entry.section_key === 'recent_artists') { - artistItems.push({ - title: 'Удалить из недавних артистов', - action: 'remove_recent_artist' - }); - } - - showContextMenu(entry.title || 'Артист', artistItems, function (selected, restoreContext) { - if (selected.action === 'search') - openSearchQuery(entry.raw && entry.raw.name ? entry.raw.name : entry.title); - else if (selected.action === 'catalog') - openArtistCatalog(entry.raw); - else if (selected.action === 'toggle_bookmark') { - var artistRestoreContext = captureMusicNeighborFocusContext(restoreContext, 'content'); - var added = toggleBookmarkedEntity(MUSIC.storage.bookmarked_artists, entry.raw); - Lampa.Noty.show(added ? 'Артист добавлен в закладки.' : 'Артист удалён из закладок.'); - if (entry.section_key === 'bookmarked_artists' && refresh) { - refreshListWithFocus(refresh, buildEntryRestoreState(entry, index), artistRestoreContext); - } else { - restoreMusicFocusContext(restoreContext, 120); - } - } - else if (selected.action === 'remove_recent_artist') { - var recentArtistRestoreContext = captureMusicNeighborFocusContext(restoreContext, 'content'); - var recentArtistHomeRefresh = !!(refresh && refresh.length > 0); - - if (!removeRecentEntity(MUSIC.storage.recent_artists, entry.raw || entry, { skipEvent: recentArtistHomeRefresh })) { - Lampa.Noty.show('Не удалось удалить артиста.'); - restoreMusicFocusContext(restoreContext); - return; - } - - Lampa.Noty.show('Артист удалён из недавних.'); - if (refresh) { - if (recentArtistHomeRefresh) - refreshListWithFocus(refresh, buildEntryRestoreState(entry, index), recentArtistRestoreContext); - return; - } - - restoreMusicFocusContext(recentArtistRestoreContext, 120); - } - else - openSearchQuery(entry.raw && entry.raw.name ? entry.raw.name : entry.title); - }); - return; - } - - if (entry.type === 'album') { - var isPlaylistAlbum = String(entry.raw && entry.raw.type || '').toUpperCase().indexOf('PLAYLIST') >= 0; - var albumBookmarked = isBookmarkedEntity(MUSIC.storage.bookmarked_albums, entry.raw && entry.raw.id); - var albumItems = [ - { title: isPlaylistAlbum ? 'Открыть плейлист' : 'Открыть альбом', action: 'open' }, - { title: albumBookmarked ? 'Убрать из закладок' : 'Добавить в закладки', action: 'toggle_bookmark' }, - { title: 'Открыть поиск', action: 'search' } - ]; - - if (entry.section_key === 'recent_albums') { - albumItems.push({ - title: 'Удалить из недавних альбомов', - action: 'remove_recent_album' - }); - } - - showContextMenu(entry.title || 'Альбом', albumItems, function (selected, restoreContext) { - if (selected.action === 'search') - openSearchQuery([entry.raw && entry.raw.artist_name, entry.raw && entry.raw.title].filter(Boolean).join(' ')); - else if (selected.action === 'toggle_bookmark') { - var albumRestoreContext = captureMusicNeighborFocusContext(restoreContext, 'content'); - var added = toggleBookmarkedEntity(MUSIC.storage.bookmarked_albums, entry.raw); - Lampa.Noty.show(added ? 'Альбом добавлен в закладки.' : 'Альбом удалён из закладок.'); - if (entry.section_key === 'bookmarked_albums' && refresh) { - refreshListWithFocus(refresh, buildEntryRestoreState(entry, index), albumRestoreContext); - } else { - restoreMusicFocusContext(restoreContext, 120); - } - } - else if (selected.action === 'remove_recent_album') { - var recentAlbumRestoreContext = captureMusicNeighborFocusContext(restoreContext, 'content'); - var recentAlbumHomeRefresh = !!(refresh && refresh.length > 0); - - if (!removeRecentEntity(MUSIC.storage.recent_albums, entry.raw || entry, { skipEvent: recentAlbumHomeRefresh })) { - Lampa.Noty.show('Не удалось удалить альбом.'); - restoreMusicFocusContext(restoreContext); - return; - } - - Lampa.Noty.show('Альбом удалён из недавних.'); - if (refresh) { - if (recentAlbumHomeRefresh) - refreshListWithFocus(refresh, buildEntryRestoreState(entry, index), recentAlbumRestoreContext); - return; - } - - restoreMusicFocusContext(recentAlbumRestoreContext, 120); - } - else - openAlbum(entry.raw); - }); - return; - } - - if (entry.type === 'query') { - var queryItems = [ - { title: 'Открыть поиск', action: 'search' } - ]; - - if (entry.section_key === 'recent_queries') { - queryItems.push({ - title: 'Удалить из недавних поисков', - action: 'remove_recent_query' - }); - } - - showContextMenu(entry.title || 'Поиск', queryItems, function (selected, restoreContext) { - if (!selected || !selected.action) return; - - if (selected.action === 'remove_recent_query') { - var recentQueryRestoreContext = captureMusicNeighborFocusContext(restoreContext, 'content'); - var recentQueryHomeRefresh = !!(refresh && refresh.length > 0); - - if (!removeRecentQuery(entry.raw, { skipEvent: recentQueryHomeRefresh })) { - Lampa.Noty.show('Не удалось удалить запрос.'); - restoreMusicFocusContext(restoreContext); - return; - } - - Lampa.Noty.show('Запрос удалён из недавних.'); - if (refresh) { - if (recentQueryHomeRefresh) - refreshListWithFocus(refresh, buildEntryRestoreState(entry, index), recentQueryRestoreContext); - return; - } - - restoreMusicFocusContext(recentQueryRestoreContext, 120); - return; - } - - openSearchQuery(entry.raw); - }); - } - } - - function MusicCardItem(item) { - var _this = this; - var alive = true; - var html = $('<div class="selector lm-card"></div>'); - var view = $('<div class="lm-card__view"></div>'); - var img = $('<img class="lm-card__img" src="" alt="" />'); - var badge = $('<div class="lm-card__badge"></div>').text(item.badge || ''); - var info = $('<div class="lm-card__info"></div>'); - var title = $('<div class="lm-card__title"></div>').text(item.title || 'Untitled'); - var subtitle = $('<div class="lm-card__subtitle"></div>').text(item.subtitle || ''); - var element = html[0]; - var onVisible = function () { - if (_this.onVisible) _this.onVisible(element, item); - }; - var onUpdate = function () { - if (_this.onUpdate) _this.onUpdate(element, item); - }; - var focusKey = buildMusicFocusKey(item); - - if (focusKey) - html.attr('data-music-focus-key', focusKey); - - function artistImageTarget() { - if (item.type === 'artist' && item.raw) return item.raw; - return null; - } - - function refreshArtistImage() { - var target = artistImageTarget(); - if (!target || !alive) return; - - item.image = artistImage(target, 250); - item.background = artistImage(target, 250); - if (img.attr('src') !== item.image) { - html.removeClass('loaded'); - img.attr('src', item.image || IMG_BG); - } - - if (html.hasClass('focus')) - setBackground(item.background || IMG_BG); - } - - function ensureArtistImage() { - var target = artistImageTarget(); - if (!target || !alive) return; - if (artistHasRemoteImage(target) || item._artistImageRequested) return; - - item._artistImageRequested = true; - - requestArtistImage(target, function (images) { - if (!alive || !images || !images.length) return; - - target.images = images; - if (item.type === 'query' && item.raw) - updateRecentQueryArtist(item.raw, target, false); - refreshArtistImage(); - }); - } - - img.on('load', function () { - html.addClass('loaded'); - }); - img.on('error', function () { - if (img.attr('src') !== IMG_BG) - img.attr('src', IMG_BG); - html.addClass('loaded'); - }); - img.attr('src', item.image || IMG_BG); - - html.on('hover:focus', function () { - ensureArtistImage(); - if (item.type === 'track' && item.raw) - schedulePlayPrefetch(item.raw); - if (_this.onFocus) _this.onFocus(html[0], item); - }); - - html.on('hover:enter', function () { - if (_this.onEnter) _this.onEnter(html[0], item); - }); - - html.on('hover:touch', function () { - if (_this.onHover) _this.onHover(html[0], item); - }); - - html.on('hover:long', function () { - if (_this.onMenu) _this.onMenu(html[0], item); - }); - - view.append(img); - if (item.badge) view.append(badge); - info.append(title); - info.append(subtitle); - html.append(view); - html.append(info); - - this.create = function () { - element.addEventListener('visible', onVisible); - element.addEventListener('update', onUpdate); - }; - - this.visible = function () { - ensureArtistImage(); - onVisible(); - }; - - this.update = function () { - onUpdate(); - }; - - this.render = function (js) { - return js ? html[0] : html; - }; - - this.ensureArtistImage = ensureArtistImage; - - this.destroy = function () { - alive = false; - element.removeEventListener('visible', onVisible); - element.removeEventListener('update', onUpdate); - img.off(); - html.off(); - html.remove(); - }; - } - - function buildTrackRow(track, index, options) { - options = options || {}; - - var isCurrent = !!(track && track.id && MUSIC_QUEUE && MUSIC_QUEUE.currentTrackId === track.id); - var row = $('<div class="selector lm-track"></div>'); - var number = $('<div class="lm-track__num"></div>'); - // в плейлисте номер = позиция в списке (positionNumber), иначе после - // перестановки трек показывал бы свой track_number из источника; - // в альбоме наоборот — track_number и есть правильный номер дорожки - var numText = $('<span class="lm-track__num-text"></span>').text(isCurrent - ? '♪' - : (options.positionNumber ? (index + 1) : (track.track_number || track.number || (index + 1)))); - var numPlay = $('<span class="lm-track__num-play">▶</span>'); - var body = $('<div class="lm-track__body"></div>'); - var name = $('<div class="lm-track__name"></div>').text(track.title || 'Untitled'); - var meta = []; - var metaText = $('<div class="lm-track__meta"></div>'); - var time = $('<div class="lm-track__time"></div>').text(formatDuration(track.duration_ms)); - - if (track.artist_name) meta.push(track.artist_name); - if (track.album_title && !options.hideAlbum) meta.push(track.album_title); - if (track.disc_number) meta.push('Disc ' + track.disc_number); - // экран «Твой топ»: счётчик и личная «сертификация» (×52 · золото) - if (track.stats_play_count) { - meta.push('×' + track.stats_play_count); - - var certification = trackCertificationLabel(track.stats_play_count); - if (certification) meta.push(certification); - } - metaText.text(meta.join(' · ')); - - number.append(numText); - number.append(numPlay); - - body.append(name); - if (meta.length) body.append(metaText); - - row.append(number); - - if (options.thumb) { - var thumb = $('<div class="lm-track__thumb"></div>'); - var thumbImg = $('<img alt="" />'); - - thumbImg.attr('loading', 'lazy'); - thumbImg.attr('decoding', 'async'); - thumbImg.attr('src', options.thumb); - thumb.append(thumbImg); - row.append(thumb); - } - - row.append(body); - row.append(time); - - if (isCurrent) row.addClass('lm-track--current'); - - return row; - } - - function bindMusicLineMoreFocus(line, onFocusMore) { - if (!line || !line.render || typeof onFocusMore !== 'function') return; - - function attach() { - var more = $(line.render(true)).find('.card-more.selector')[0]; - if (!more || more._musicMoreBound) return; - - more._musicMoreBound = true; - $(more).on('hover:focus hover:touch', function () { - onFocusMore(); - }); - } - - requestAnimationFrame(attach); - - if (line.scroll && typeof line.scroll.onScroll === 'function') { - var originalOnScroll = line.scroll.onScroll; - line.scroll.onScroll = function () { - originalOnScroll.apply(this, arguments); - attach(); - }; - } - } - - // ===== HOME / CARD UI ===== - - function createMusicLine(config) { - var title = config.title || ''; - var entries = Array.isArray(config.entries) ? config.entries : []; - var hasMore = !!config.hasMore; - - entries.forEach(function (entry, index) { - entry.params = entry.params || {}; - entry.params.createInstance = function (item) { - return new MusicCardItem(item); - }; - entry.params.on = entry.params.on || {}; - entry.params.on['hover:enter'] = function (item, data) { - if (config.onSelect) - config.onSelect(data, index); - }; - entry.params.on['hover:long'] = function (item, data) { - if (config.onMenu) - config.onMenu(data, index); - }; - }); - - var line = Lampa.Maker.make('Line', { - title: title, - results: entries, - total_pages: hasMore ? 2 : 1, - params: { - items: { - mapping: 'line', - align_left: false, - view: entries.length || HOME_SECTION_LIMIT - } - } - }, function (module) { - return module.only('Items', 'Create', 'More'); - }); - - line.use({ - onActive: function (item, entry) { - if (config.onFocus) - config.onFocus(entry, item); - }, - onMore: function () { - if (config.onMore) - config.onMore(); - }, - onDown: function () { - if (config.onDown) - config.onDown(); - }, - onUp: function () { - if (config.onUp) - config.onUp(); - }, - onBack: function () { - if (config.onBack) - config.onBack(); - } - }); - - line.create(); - bindMusicLineMoreFocus(line, config.onFocusMore); - - return line; - } - - function bindBase(instance, scroll, getLast) { - instance.back = function () { - Lampa.Activity.backward(); - }; - - instance.start = function () { - if (Lampa.Activity.active().activity !== instance.activity) return; - if (isStandaloneIosPlayerForeground()) return; - - Lampa.Controller.add('content', { - toggle: function () { - if (instance.onControllerToggle && instance.onControllerToggle() === true) return; - var last = getLast(); - if (instance.resolveControllerFocusTarget) - last = instance.resolveControllerFocusTarget(last); - - var container = instance.getControllerFocusContainer - ? instance.getControllerFocusContainer(last) - : scroll.render(); - - Lampa.Controller.collectionSet(container || scroll.render()); - Lampa.Controller.collectionFocus(last || false, container || scroll.render()); - if (last) scroll.update($(last), true); - }, - left: function () { - if (instance.onControllerLeft && instance.onControllerLeft() === true) return; - if (Navigator.canmove('left')) Navigator.move('left'); - else Lampa.Controller.toggle('menu'); - }, - right: function () { - if (instance.onControllerRight && instance.onControllerRight() === true) return; - Navigator.move('right'); - }, - up: function () { - if (instance.onControllerUp && instance.onControllerUp() === true) return; - if (Navigator.canmove('up')) Navigator.move('up'); - else Lampa.Controller.toggle('head'); - }, - down: function () { - if (instance.onControllerDown && instance.onControllerDown() === true) return; - if (Navigator.canmove('down')) Navigator.move('down'); - }, - back: instance.back - }); - - Lampa.Controller.toggle('content'); - }; - - instance.pause = function () {}; - instance.stop = function () {}; - } - - function MusicComponent(object) { - var scroll = new Lampa.Scroll({ - mask: false, - over: true, - step: 250 - }); - var items = []; - var html = $('<div></div>'); - var body = $('<div class="category-full lm-grid"></div>'); - var head = $('<div class="lm-search-bar category-full"></div>'); - var actions = $('<div class="lm-search-actions"></div>'); - var searchScreenMode = !!object.search_mode; - var searchBtn = $('<div class="selector lm-search-btn lm-search-btn--icon lm-search-btn--search" aria-label="Поиск музыки">' + SEARCH_ICON + '</div>'); - var filterBtn = $('<div class="selector lm-search-btn lm-search-btn--icon lm-search-btn--filter" aria-label="Фильтр">' + FILTER_ICON + '</div>'); - var bookmarksBtn = $('<div class="selector lm-search-btn lm-search-btn--icon lm-search-btn--bookmark" aria-label="Закладки">' + BOOKMARK_ICON + '</div>'); - var authBtn = $('<div class="selector lm-auth-btn">🔐 Авторизация</div>'); - var homeBtn = $('<div class="selector lm-search-btn lm-search-btn--icon lm-search-btn--home" aria-label="Главная">' + HOME_ICON + '</div>'); - var status = $('<div class="lm-search-status"></div>'); - var authStatus = $('<div class="lm-auth-status"></div>'); - var searchInputWrap = $('<div class="lm-search-input-wrap"></div>'); - var searchInputField = $('<input class="lm-search-input-field" type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />'); - var searchHistoryWrap = $('<div class="lm-search-history"></div>'); - var last; - var destroyed = false; - var currentQuery = object.query || ''; - var searchRequestToken = 0; - var searchPreserveInputFocus = false; - var searchInputFocusTime = 0; - var homeLines = []; - var homeLoadToken = 0; - var homeHeroCard = null; - var homeMode = false; - var homeActiveLine = -1; - var searchLines = []; - var searchSources = []; - var searchSourceTabs = []; - var searchSourceTabsWrap = $('<div class="lm-search-sources"></div>'); - var searchActiveSourceId = ''; - var searchMode = false; - var searchActiveLine = -1; - var searchFocusZone = searchScreenMode ? 'buttons' : ''; - var searchHistoryTargetIndex = 0; - var searchSourceTargetIndex = 0; - var searchMetadataRefreshTimer = null; - var headerFocusTarget = null; - var recentDirty = false; - var unbindRecentListener = function () {}; - var _this = this; - - bindBase(this, scroll, function () { return last || searchBtn[0]; }); - - this.resolveControllerFocusTarget = function (target) { - if (!target || !document.documentElement.contains(target) || !$(target).hasClass('selector')) - target = headerFocusTarget || searchBtn[0]; - - if (homeMode && isHomeHeaderTarget(target)) { - homeActiveLine = -1; - headerFocusTarget = target; - } - - if (searchMode && (isHomeHeaderTarget(target) || isSearchScreenButton(target))) - searchActiveLine = -1; - - return target || searchBtn[0]; - }; - - this.getControllerFocusContainer = function (target) { - if (!target) return scroll.render(); - - return $(target).closest( - '.lm-home-line,.lm-search-line,.lm-search-actions,.lm-search-sources,.lm-search-history,.lm-search-bar,.lm-search-screen' - )[0] || scroll.render(); - }; - - var baseStart = this.start; - this.start = function () { - if (isStandaloneIosPlayerForeground()) return; - baseStart(); - - if (homeMode && (recentDirty || MUSIC_DEFERRED_HOME_REFRESH)) { - recentDirty = false; - MUSIC_DEFERRED_HOME_REFRESH = false; - _this.loadHome(buildHomeRestoreFromLast()); - } - }; - - function handleRecentChanged(e) { - var detail = e && e.detail ? e.detail : {}; - - if (!homeMode) return; - if (!detail.section_key) return; - if (String(detail.section_key).indexOf('playlist:') === 0) return; - - recentDirty = true; - - if (isLampaPlayerOverlayOpen() || isStandaloneIosPlayerForeground()) { - traceEmbeddedIos('recent-refresh-deferred', detail.section_key || '', true); - return; - } - - if (Lampa.Activity.active().activity === _this.activity) { - var restoreState = detail.payload && detail.payload.restoreState - ? detail.payload.restoreState - : null; - - recentDirty = false; - MUSIC_HOME_REFRESH_RESTORE_BLOCK_UNTIL = Date.now() + 1500; - _this.loadHome(restoreState || buildHomeRestoreFromLast() || { - sectionKey: detail.section_key, - entryIndex: 0 - }); - } - } - - function restoreAfterSearchInput(target) { - setTimeout(function () { - if (destroyed) return; - if (Lampa.Activity.active().activity !== _this.activity) return; - - last = target || last || searchBtn[0]; - Lampa.Controller.toggle('content'); - - if (last) scroll.update($(last), true); - }, 0); - } - - function updateSearchButtonDisplay() { - if (!searchScreenMode) return; - - var title = currentQuery || 'Введите текст...'; - if (searchInputField && searchInputField.length) { - if (searchInputField.val() !== currentQuery) - searchInputField.val(currentQuery); - searchInputField.attr('placeholder', 'Введите текст...'); - } else { - searchBtn.text(title); - } - - searchBtn.toggleClass('lm-search-input-btn--placeholder', !currentQuery); - } - - function submitInlineSearch(value, options) { - options = options || {}; - - var previousQuery = currentQuery; - var nextQuery = String(value || '').trim(); - - currentQuery = nextQuery; - object.query = nextQuery; - updateSearchButtonDisplay(); - - if (!nextQuery) { - searchRequestToken++; - searchPreserveInputFocus = !!options.keepInputFocus; - _this.loadSearchLanding(!!options.keepInputFocus); - return; - } - - if (options.saveHistory) { - saveLastQuery(nextQuery); - renderSearchHistory(); - } - - if (!options.force && nextQuery === previousQuery) - return; - - searchPreserveInputFocus = !!options.keepInputFocus; - - if (!searchPreserveInputFocus && searchInputField && searchInputField.length && document.activeElement === searchInputField[0]) { - try { - searchInputField[0].blur(); - } catch (e) {} - } - - _this.activity.loader(true); - _this.loadSearch(); - } - - function searchScreenButtons() { - if (!searchScreenMode) return []; - - var buttons = [filterBtn[0], bookmarksBtn[0], homeBtn[0]]; - if (MUSIC.features.auth) buttons.push(authBtn[0]); - return buttons.filter(function (button) { - return !!(button && button.parentNode); - }); - } - - function setSearchFocusZone(zone) { - if (!searchScreenMode) return; - searchFocusZone = zone || ''; - } - - function registerSearchFocusControllers() { - if (!searchScreenMode) return; - - Lampa.Controller.add('music_search_history', { - toggle: function () { - var items = searchHistoryWrap.find('.selector'); - var target = items.eq(Math.max(0, Math.min(searchHistoryTargetIndex, Math.max(0, items.length - 1)))).get(0) || items.get(0); - if (!target) return; - - last = target; - Lampa.Controller.collectionSet(searchHistoryWrap[0]); - Lampa.Controller.collectionFocus(target, searchHistoryWrap[0]); - scroll.update($(target), true); - }, - left: function () { - Navigator.move('left'); - }, - right: function () { - Navigator.move('right'); - }, - up: function () { - focusSearchInput(); - }, - down: function () { - if (searchSourceTabs.length) - focusSearchSourceTab(Math.max(0, getSearchSourceIndexById(searchActiveSourceId))); - else if (searchLines.length) - openSearchLine(0); - }, - back: _this.back - }); - - Lampa.Controller.add('music_search_sources', { - toggle: function () { - var target = searchSourceTabs[Math.max(0, Math.min(searchSourceTargetIndex, Math.max(0, searchSourceTabs.length - 1)))] || searchSourceTabs[0]; - if (!target) return; - - last = target; - Lampa.Controller.collectionSet(searchSourceTabsWrap[0]); - Lampa.Controller.collectionFocus(target, searchSourceTabsWrap[0]); - scroll.update($(target), true); - }, - left: function () { - Navigator.move('left'); - }, - right: function () { - Navigator.move('right'); - }, - up: function () { - if (searchHistoryWrap.find('.selector').length) - focusSearchHistoryItem(0); - else - focusSearchInput(); - }, - down: function () { - if (ensureFocusedSearchSourceActive()) return; - if (searchLines.length) openSearchLine(0); - }, - back: _this.back - }); - } - - function applySearchFocus(container, target) { - if (!target) return false; - - var useContainer = container || scroll.render(); - last = target; - scroll.update($(target), true); - Lampa.Controller.collectionSet(useContainer); - Lampa.Controller.collectionFocus(target || false, useContainer); - return true; - } - - function focusSearchInput() { - searchActiveLine = -1; - setSearchFocusZone('input'); - headerFocusTarget = searchBtn[0]; - Lampa.Controller.toggle('content'); - return applySearchFocus(searchInputWrap[0], searchBtn[0]); - } - - function isSearchScreenButton(element) { - return searchScreenButtons().indexOf(element) >= 0; - } - - function focusSearchScreenButton() { - var buttons = searchScreenButtons(); - var target = buttons.indexOf(headerFocusTarget) >= 0 ? headerFocusTarget : buttons[0]; - if (!target) return false; - - searchActiveLine = -1; - setSearchFocusZone('buttons'); - focusSearchHeader(target); - return applySearchFocus(actions[0], target); - } - - function findSearchLineFocusTarget(lineElement) { - if (!lineElement) return null; - - var cards = $(lineElement).find('.lm-card.selector'); - if (cards.length) return cards.get(0); - - var nonMore = $(lineElement).find('.selector').not('.card-more'); - if (nonMore.length) return nonMore.get(0); - - return $(lineElement).find('.card-more.selector').get(0) || null; - } - - function renderSearchHistory() { - if (!searchScreenMode) return; - - searchHistoryWrap.empty(); - - var recentQueries = getRecentQueries().slice(0, RECENT_QUERY_QUICK_LIMIT); - recentQueries.forEach(function (item) { - var entry = mapQueryCard(item); - var chip = $('<div class="selector lm-search-history-item"></div>'); - var icon = $('<span class="lm-search-history-item__icon">◷</span>'); - var text = $('<span class="lm-search-history-item__title"></span>').text(entry.title); - - chip.append(icon); - chip.append(text); - - chip.on('hover:focus', function () { - setSearchFocusZone('history'); - last = chip[0]; - scroll.update(chip, true); - setBackground(entry.background || IMG_BG); - }); - - chip.on('hover:enter hover:long', function () { - openSearchQuery(entry.title); - }); - - searchHistoryWrap.append(chip); - }); - - searchHistoryWrap.toggleClass('hide', !recentQueries.length); - } - - function destroyHomeItems() { - Lampa.Arrays.destroy(items); - items = []; - } - - function buildHomeRestoreFromLast() { - if (!homeMode) return null; - - var lineIndex = detectHomeLineIndex(last); - if (lineIndex < 0 || lineIndex >= homeLines.length || !homeLines[lineIndex]) - return null; - - var lineElement = homeLines[lineIndex].render(true); - var cards = $(lineElement).find('.lm-card'); - var cardElement = $(last).closest('.lm-card')[0]; - var cardIndex = cardElement ? cards.index(cardElement) : 0; - - return { - sectionKey: homeLines[lineIndex].section_key, - entryIndex: Math.max(0, cardIndex) - }; - } - - function restoreHomeAfterRender(restoreState) { - if (!restoreState || !restoreState.sectionKey) return; - - requestAnimationFrame(function () { - if (destroyed || !homeMode || Lampa.Activity.active().activity !== _this.activity) return; - if (isStandaloneIosPlayerForeground()) return; - - var lineIndex = -1; - for (var i = 0; i < homeLines.length; i++) { - if (homeLines[i] && homeLines[i].section_key === restoreState.sectionKey) { - lineIndex = i; - break; - } - } - - if (lineIndex < 0 || !homeLines[lineIndex]) { - focusHomeHero(); - return; - } - - function restoreHomeLine() { - if (destroyed || !homeMode || Lampa.Activity.active().activity !== _this.activity) return; - if (isStandaloneIosPlayerForeground()) return; - openHomeLine(lineIndex); - } - - requestAnimationFrame(restoreHomeLine); - setTimeout(restoreHomeLine, 120); - }); - } - - this.create = function () { - this.activity.loader(true); - unbindRecentListener = bindRecentListener(handleRecentChanged); - registerSearchFocusControllers(); - - if (searchScreenMode) { - head.removeClass('lm-search-bar').addClass('lm-search-screen category-full'); - updateSearchButtonDisplay(); - searchBtn.removeClass('lm-search-btn lm-search-btn--icon lm-search-btn--search').addClass('lm-search-input-btn'); - searchBtn.empty().append(searchInputField); - actions.append(filterBtn); - actions.append(bookmarksBtn); - actions.append(homeBtn); - if (MUSIC.features.auth) actions.append(authBtn); - head.append(actions); - searchInputWrap.append(searchBtn); - head.append(searchInputWrap); - head.append(searchHistoryWrap); - head.append(searchSourceTabsWrap); - } else { - actions.append(searchBtn); - actions.append(filterBtn); - actions.append(bookmarksBtn); - if (MUSIC.features.auth) actions.append(authBtn); - if (object.query) actions.append(homeBtn); - head.append(actions); - head.append(status); - if (MUSIC.features.auth) head.append(authStatus); - } - scroll.append(head); - scroll.append(body); - html.append(scroll.render()); - - last = searchBtn[0]; - headerFocusTarget = searchBtn[0]; - updateFilterButton(filterBtn); - - searchBtn.on('hover:focus', function () { - if (homeMode) homeActiveLine = -1; - last = searchBtn[0]; - if (!searchScreenMode) - headerFocusTarget = searchBtn[0]; - else - setSearchFocusZone('input'); - scroll.update(searchBtn, true); - setBackground(artwork('album', currentQuery || 'Музыка', searchScreenMode ? 'Поиск' : 'Поиск музыки', ['#172434', '#375d7f'])); - }); - - searchBtn.on('hover:enter', function () { - if (searchScreenMode) { - if (searchInputField && searchInputField.length) { - searchInputField[0].focus(); - searchInputField[0].select(); - } - return; - } - - if (homeMode) { - openSearchScreen(''); - return; - } - - openSearchInput(currentQuery, { - ignoreSameValue: true, - onCancel: function () { - restoreAfterSearchInput(searchBtn[0]); - } - }); - }); - - if (!searchScreenMode) { - searchBtn.on('hover:long', function () { - openSearchActionsMenu(); - }); - } - - if (searchScreenMode) { - searchInputField.on('focus', function () { - searchInputFocusTime = Date.now(); - setSearchFocusZone('input'); - last = searchBtn[0]; - scroll.update(searchBtn, true); - }); - - searchInputField.on('click mousedown mouseup input keypress paste', function (event) { - event.stopPropagation(); - }); - - searchInputField.on('keydown', function (event) { - if (event.key === 'Enter' || event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - } - - event.stopPropagation(); - }); - - searchInputField.on('keyup', function (event) { - event.stopPropagation(); - - if (searchInputFocusTime + 150 > Date.now()) return; - - if (event.key === 'Enter') { - event.preventDefault(); - this.blur(); - submitInlineSearch($(this).val(), { force: true, saveHistory: true }); - return; - } - - if (event.key === 'ArrowDown') { - event.preventDefault(); - this.blur(); - - if (searchHistoryWrap.find('.selector').length) - focusSearchHistoryItem(0); - else if (searchSourceTabs.length) - focusSearchSourceTab(Math.max(0, getSearchSourceIndexById(searchActiveSourceId))); - else if (searchLines.length) - openSearchLine(0); - - return; - } - - if (event.key === 'ArrowUp' || event.key === 'Escape') { - event.preventDefault(); - this.blur(); - focusSearchScreenButton(); - } - }); - } - - filterBtn.on('hover:focus', function () { - if (homeMode) homeActiveLine = -1; - if (searchScreenMode) setSearchFocusZone('buttons'); - last = filterBtn[0]; - headerFocusTarget = filterBtn[0]; - scroll.update(filterBtn, true); - setBackground(artwork('track', 'Фильтр', getPlaybackModeTitle() + ' · ' + getQualityModeTitle(), ['#1f2734', '#3f5f7d'])); - }); - - filterBtn.on('hover:enter', function () { - openFilterMenu(filterBtn); - }); - - filterBtn.on('hover:long', function () { - openFilterActionsMenu(); - }); - - bookmarksBtn.on('hover:focus', function () { - if (homeMode) homeActiveLine = -1; - if (searchScreenMode) setSearchFocusZone('buttons'); - last = bookmarksBtn[0]; - headerFocusTarget = bookmarksBtn[0]; - scroll.update(bookmarksBtn, true); - setBackground(artwork('album', 'Закладки', 'Артисты · Альбомы · Треки', ['#1d2632', '#486077'])); - }); - - bookmarksBtn.on('hover:enter', function () { - openBookmarksScreen(); - }); - - bookmarksBtn.on('hover:long', function () { - openBookmarksScreen(); - }); - - homeBtn.on('hover:focus', function () { - if (homeMode) homeActiveLine = -1; - if (searchScreenMode) setSearchFocusZone('buttons'); - last = homeBtn[0]; - headerFocusTarget = homeBtn[0]; - scroll.update(homeBtn, true); - setBackground(artwork('album', 'Музыка', 'Главная', ['#15202c', '#35556c'])); - }); - - homeBtn.on('hover:enter', function () { - Lampa.Activity.push({ - title: MUSIC.title, - component: 'lampac_music_home', - page: 1, - noinfo: true - }); - }); - - homeBtn.on('hover:long', function () { - openHomeActionsMenu(); - }); - - if (MUSIC.features.auth) { - authBtn.on('hover:focus', function () { - if (homeMode) homeActiveLine = -1; - if (searchScreenMode) setSearchFocusZone('buttons'); - last = authBtn[0]; - headerFocusTarget = authBtn[0]; - scroll.update(authBtn, true); - setBackground(artwork('album', 'Yandex Music', 'Авторизация', ['#2b173b', '#8a2be2'])); - }); - - authBtn.on('hover:enter', function () { - openAuthMenu(authStatus); - }); - - refreshAuthStatus(authStatus); - } - - if (object.query) - this.loadSearch(); - else if (object.search_mode) - this.loadSearchLanding(); - else - this.loadHome(); - - return this.render(); - }; - - this.clean = function () { - clearSearchMetadataRefresh(); - body.empty(); - homeLines = []; - homeHeroCard = null; - homeMode = false; - homeActiveLine = -1; - searchLines = []; - searchSources = []; - searchSourceTabs = []; - searchActiveSourceId = ''; - searchSourceTabsWrap.empty().detach(); - searchMode = false; - searchActiveLine = -1; - destroyHomeItems(); - this.activity.loader(true); - }; - - function openHomeLine(index) { - if (!homeMode) return false; - if (index < 0 || index >= homeLines.length || !homeLines[index]) return false; - - homeActiveLine = index; - scroll.update($(homeLines[index].render(true)), true); - - requestAnimationFrame(function () { - if (homeLines[index]) homeLines[index].toggle(); - }); - - return true; - } - - function detectHomeLineIndex(element) { - if (!element) return -1; - - var lineElement = $(element).closest('.lm-home-line')[0]; - if (!lineElement) return -1; - - for (var i = 0; i < homeLines.length; i++) { - if (homeLines[i] && homeLines[i].render && homeLines[i].render(true) === lineElement) - return i; - } - - return -1; - } - - function focusHomeHero() { - homeActiveLine = -1; - last = searchBtn[0]; - headerFocusTarget = searchBtn[0]; - if (last) scroll.update($(last), true); - setBackground(artwork('album', 'Музыка', 'Главная', ['#15202c', '#35556c'])); - Lampa.Controller.toggle('content'); - } - - function focusSearchHeader(target) { - if (homeMode) homeActiveLine = -1; - searchActiveLine = -1; - last = target || headerFocusTarget || searchBtn[0]; - headerFocusTarget = last || searchBtn[0]; - - if (last) scroll.update($(last), true); - - if (last === filterBtn[0]) - setBackground(artwork('track', 'Фильтр', getPlaybackModeTitle() + ' · ' + getQualityModeTitle(), ['#1f2734', '#3f5f7d'])); - else if (last === bookmarksBtn[0]) - setBackground(artwork('album', 'Закладки', 'Артисты · Альбомы · Треки', ['#1d2632', '#486077'])); - else if (last === homeBtn[0]) - setBackground(artwork('album', 'Музыка', 'Главная', ['#15202c', '#35556c'])); - else if (last === authBtn[0]) - setBackground(artwork('album', 'Yandex Music', 'Авторизация', ['#2b173b', '#8a2be2'])); - else - setBackground(artwork('album', currentQuery || 'Музыка', 'Поиск музыки', ['#172434', '#375d7f'])); - - Lampa.Controller.toggle('content'); - } - - function isHomeHeaderTarget(target) { - return !!target && ( - target === searchBtn[0] || - target === filterBtn[0] || - target === bookmarksBtn[0] || - target === homeBtn[0] || - (MUSIC.features.auth && target === authBtn[0]) - ); - } - - function openSearchLine(index) { - if (!searchMode) return false; - if (index < 0 || index >= searchLines.length || !searchLines[index]) return false; - - searchActiveLine = index; - setSearchFocusZone('results'); - var line = searchLines[index]; - scroll.update($(line.render(true)), true); - - requestAnimationFrame(function () { - if (!searchLines[index]) return; - line.toggle(); - - requestAnimationFrame(function () { - if (!searchLines[index]) return; - - var lineElement = line.render(true); - var target = findSearchLineFocusTarget(lineElement); - if (!target) return; - - last = target; - Lampa.Controller.collectionSet(lineElement); - Lampa.Controller.collectionFocus(target, lineElement); - scroll.update($(target), true); - }); - }); - - return true; - } - - function detectSearchLineIndex(element) { - if (!element) return -1; - - var lineElement = $(element).closest('.lm-search-line')[0]; - if (!lineElement) return -1; - - for (var i = 0; i < searchLines.length; i++) { - if (searchLines[i] && searchLines[i].render && searchLines[i].render(true) === lineElement) - return i; - } - - return -1; - } - - function detectSearchSourceTabIndex(element) { - if (!element) return -1; - - var tabElement = $(element).closest('.lm-search-source-tab')[0]; - if (!tabElement) return -1; - - return searchSourceTabs.indexOf(tabElement); - } - - function ensureFocusedSearchSourceActive() { - var tabIndex = detectSearchSourceTabIndex(last); - if (tabIndex < 0 || tabIndex >= searchSources.length) return false; - - var source = searchSources[tabIndex]; - if (!source || source.id === searchActiveSourceId) return false; - - activateSearchSource(source.id, false); - return true; - } - - function focusSearchHistoryItem(index) { - var items = searchHistoryWrap.find('.selector'); - if (!items.length) return false; - - var target = items.eq(Math.max(0, Math.min(index, items.length - 1))); - if (!target.length) return false; - - searchActiveLine = -1; - setSearchFocusZone('history'); - searchHistoryTargetIndex = items.index(target[0]); - Lampa.Controller.toggle('music_search_history'); - return true; - } - - function getSearchSourceIndexById(sourceId) { - for (var i = 0; i < searchSources.length; i++) { - if (searchSources[i] && searchSources[i].id === sourceId) - return i; - } - - return -1; - } - - function getActiveSearchSource() { - var index = getSearchSourceIndexById(searchActiveSourceId); - if (index >= 0) return searchSources[index]; - return searchSources.length ? searchSources[0] : null; - } - - function getFirstSearchSourceWithResults() { - for (var i = 0; i < searchSources.length; i++) { - if (searchSources[i] && searchSources[i].count > 0) - return searchSources[i]; - } - - return searchSources.length ? searchSources[0] : null; - } - - function hasPendingSearchSource() { - return searchSources.some(function (source) { - return !!(source && source.pending); - }); - } - - function clearSearchMetadataRefresh() { - if (!searchMetadataRefreshTimer) return; - clearTimeout(searchMetadataRefreshTimer); - searchMetadataRefreshTimer = null; - } - - function scheduleSearchMetadataRefresh(query, requestToken, attempt) { - clearSearchMetadataRefresh(); - - if (!query || attempt > 2) return; - - searchMetadataRefreshTimer = setTimeout(function () { - searchMetadataRefreshTimer = null; - - if (destroyed || requestToken !== searchRequestToken || currentQuery !== query) return; - - request(MUSIC.endpoints.search + '?q=' + encodeURIComponent(query), function (json) { - if (destroyed || requestToken !== searchRequestToken || currentQuery !== query) return; - - var groups = normalizeSearchResults(json); - var sources = buildSearchSources(groups, query); - var wasCatalogActive = searchActiveSourceId === 'catalog'; - var previousSourceId = searchActiveSourceId; - - searchSources = sources; - if (!searchActiveSourceId || getSearchSourceIndexById(searchActiveSourceId) < 0) - searchActiveSourceId = previousSourceId && getSearchSourceIndexById(previousSourceId) >= 0 - ? previousSourceId - : (getFirstSearchSourceWithResults() || {}).id || ''; - - if (wasCatalogActive) - renderSearchLinesForActiveSource(); - else { - renderSearchSourceTabs(); - if (searchFocusZone === 'sources') { - var activeTabIndex = getSearchSourceIndexById(searchActiveSourceId); - if (activeTabIndex >= 0 && searchSourceTabs[activeTabIndex]) { - last = searchSourceTabs[activeTabIndex]; - scroll.update($(last), true); - } - } - } - - if (hasPendingSearchMetadata(groups)) - scheduleSearchMetadataRefresh(query, requestToken, attempt + 1); - }, function () {}); - }, attempt ? 5000 : 3000); - } - - function focusSearchSourceTab(index) { - if (!searchMode) return false; - if (index < 0 || index >= searchSourceTabs.length || !searchSourceTabs[index]) return false; - - searchActiveLine = -1; - setSearchFocusZone('sources'); - searchSourceTargetIndex = index; - Lampa.Controller.toggle('music_search_sources'); - return true; - } - - function renderSearchSourceTabs() { - searchSourceTabs = []; - searchSourceTabsWrap.empty(); - - if (searchScreenMode && !searchSourceTabsWrap.parent().length) - head.append(searchSourceTabsWrap); - - searchSources.forEach(function (source, index) { - var tab = $('<div class="selector lm-search-source-tab"><span class="lm-search-source-tab__title"></span><span class="lm-search-source-tab__count"></span></div>'); - tab.find('.lm-search-source-tab__title').text(source.title || 'Источник'); - tab.find('.lm-search-source-tab__count') - .text(source.pending ? '...' : (source.count || 0)) - .toggleClass('hide', !source.pending && !(source.count > 0)); - tab.toggleClass('active', source.id === searchActiveSourceId); - tab.toggleClass('lm-search-source-tab--pending', !!source.pending); - - tab.on('hover:focus', function () { - setSearchFocusZone('sources'); - searchSourceTargetIndex = index; - last = tab[0]; - scroll.update(tab, true); - setBackground(artwork('album', source.title || 'Источник', currentQuery || 'Поиск', ['#1a2432', '#4b6785'])); - }); - - tab.on('hover:enter hover:long', function () { - if (searchActiveSourceId !== source.id) { - activateSearchSource(source.id, true); - return; - } - - focusSearchSourceTab(index); - }); - - searchSourceTabs.push(tab[0]); - searchSourceTabsWrap.append(tab); - }); - - searchSourceTabsWrap.toggleClass('hide', !searchSourceTabs.length); - } - - function renderSearchLinesForActiveSource() { - var source = getActiveSearchSource(); - - Lampa.Arrays.destroy(items); - items = []; - searchLines = []; - body.empty(); - - renderSearchSourceTabs(); - - if (!source || !Array.isArray(source.groups) || !source.groups.length) { - if (currentQuery) { - body.append($('<div class="lm-search-empty"></div>').text(source && source.pending ? 'MusicBrainz загружается...' : 'В выбранном источнике ничего не найдено.')); - } - return; - } - - source.groups.forEach(function (group) { - _this.appendSearchGroup(group.id, group.title, group.entries, group.has_more); - }); - } - - function activateSearchSource(sourceId, focusTab) { - searchActiveSourceId = sourceId; - renderSearchLinesForActiveSource(); - searchActiveLine = -1; - - requestAnimationFrame(function () { - if (destroyed || !searchMode) return; - - if (focusTab && searchSourceTabs.length) { - focusSearchSourceTab(Math.max(0, getSearchSourceIndexById(searchActiveSourceId))); - return; - } - - if (searchLines.length) { - openSearchLine(0); - return; - } - - focusSearchHeader(headerFocusTarget); - }); - } - - function openSearchActionsMenu() { - var recentQueries = getRecentQueries().slice(0, RECENT_QUERY_QUICK_LIMIT); - var items = [ - { title: 'Открыть поиск', action: 'search' }, - { title: 'Открыть главную', action: 'home' } - ]; - - if (recentQueries.length) { - items.push({ - title: 'Недавние поиски', - separator: true - }); - - items.push({ - title: 'Очистить недавние поиски', - action: 'clear_recent_queries' - }); - - recentQueries.forEach(function (query) { - items.push({ - title: query.query, - action: 'query', - query: query.query - }); - }); - } - - showContextMenu(translate('title_action', 'Действия'), items, function (selected, restoreContext) { - if (!selected || !selected.action) return; - - if (selected.action === 'search') { - if (homeMode) { - openSearchScreen(''); - } else { - openSearchInput(currentQuery, { - ignoreSameValue: true, - onCancel: function () { - restoreAfterSearchInput(searchBtn[0]); - } - }); - } - return; - } - - if (selected.action === 'home') { - Lampa.Activity.push({ - title: MUSIC.title, - component: 'lampac_music_home', - page: 1, - noinfo: true - }); - return; - } - - if (selected.action === 'clear_recent_queries') { - clearRecentQueries(); - Lampa.Noty.show('Недавние поиски очищены.'); - restoreMusicFocusContext(restoreContext, 120); - return; - } - - if (selected.action === 'query') - openSearchQuery(selected.query); - }); - } - - function openFilterActionsMenu() { - openFilterMenu(filterBtn); - } - - function buildHomeActionItems() { - var sectionKeys = Object.keys(MUSIC_HOME_CACHE || {}); - var items = [{ - title: 'Открыть главную', - action: 'home' - }]; - - if (sectionKeys.length) { - items.push({ - title: 'Разделы', - separator: true - }); - - sectionKeys.forEach(function (sectionKey) { - items.push({ - title: MUSIC_HOME_SECTION_TITLES[sectionKey] || 'Секция', - action: 'section', - sectionKey: sectionKey - }); - }); - } - - return items; - } - - function openHomeActionsMenu() { - showContextMenu(translate('title_action', 'Действия'), buildHomeActionItems(), function (selected, restoreContext) { - if (!selected || !selected.action) return; - - if (selected.action === 'home') { - Lampa.Activity.push({ - title: MUSIC.title, - component: 'lampac_music_home', - page: 1, - noinfo: true - }); - return; - } - - if (selected.action === 'section') - openHomeSection(selected.sectionKey, MUSIC_HOME_SECTION_TITLES[selected.sectionKey] || 'Секция'); - else - restoreMusicFocusContext(restoreContext); - }); - } - - this.onControllerToggle = function () { - if (homeMode && homeLines.length) { - var homeIndex = homeActiveLine >= 0 ? homeActiveLine : detectHomeLineIndex(last); - if (homeIndex >= 0) - return openHomeLine(homeIndex); - } - - if (searchMode && searchLines.length) { - var searchIndex = searchActiveLine >= 0 ? searchActiveLine : detectSearchLineIndex(last); - if (searchIndex >= 0) - return openSearchLine(searchIndex); - } - - return false; - }; - - this.onControllerDown = function () { - if (homeMode && homeActiveLine < 0 && homeLines.length && isHomeHeaderTarget(last)) - return openHomeLine(0); - - if (searchMode) { - if (searchScreenMode) { - if (searchFocusZone === 'buttons') - return focusSearchInput(); - - if (searchFocusZone === 'input') { - if (searchHistoryWrap.find('.selector').length) - return focusSearchHistoryItem(0); - - if (searchSourceTabs.length) - return focusSearchSourceTab(Math.max(0, getSearchSourceIndexById(searchActiveSourceId))); - - if (searchLines.length) - return openSearchLine(0); - } - - if (searchFocusZone === 'history') { - if (searchSourceTabs.length) - return focusSearchSourceTab(Math.max(0, getSearchSourceIndexById(searchActiveSourceId))); - - if (searchLines.length) - return openSearchLine(0); - } - - if (searchFocusZone === 'sources') { - if (ensureFocusedSearchSourceActive()) - return true; - - if (searchLines.length) - return openSearchLine(0); - } - } - - var tabIndex = detectSearchSourceTabIndex(last); - var historyIndex = searchHistoryWrap.find('.selector').index(last); - - if (searchScreenMode && isSearchScreenButton(last)) { - return focusSearchInput(); - } - - if (tabIndex >= 0) { - if (ensureFocusedSearchSourceActive()) - return true; - - if (searchLines.length) - return openSearchLine(0); - } - - if (searchScreenMode && historyIndex >= 0 && searchSourceTabs.length) - return focusSearchSourceTab(Math.max(0, getSearchSourceIndexById(searchActiveSourceId))); - - if (searchScreenMode && searchActiveLine < 0 && last === searchBtn[0]) { - if (searchHistoryWrap.find('.selector').length) - return focusSearchHistoryItem(0); - - if (searchSourceTabs.length) - return focusSearchSourceTab(Math.max(0, getSearchSourceIndexById(searchActiveSourceId))); - } - - if (searchActiveLine < 0 && searchSourceTabs.length && isHomeHeaderTarget(last)) - return focusSearchSourceTab(Math.max(0, getSearchSourceIndexById(searchActiveSourceId))); - - if (searchActiveLine < 0 && searchLines.length) - return openSearchLine(0); - } - - return false; - }; - - this.onControllerUp = function () { - if (homeMode && homeActiveLine < 0 && isHomeHeaderTarget(last)) { - headerFocusTarget = last; - Lampa.Controller.toggle('head'); - return true; - } - - if (!searchMode || !searchScreenMode || searchActiveLine >= 0) - return false; - - if (searchFocusZone === 'input') - return focusSearchScreenButton(); - - if (searchFocusZone === 'history') - return focusSearchInput(); - - if (searchFocusZone === 'sources') { - if (searchHistoryWrap.find('.selector').length) - return focusSearchHistoryItem(0); - - return focusSearchInput() || focusSearchScreenButton(); - } - - var tabIndex = detectSearchSourceTabIndex(last); - var historyItems = searchHistoryWrap.find('.selector'); - var historyIndex = historyItems.index(last); - - if (last === searchBtn[0]) - return focusSearchScreenButton(); - - if (tabIndex >= 0) { - if (historyItems.length) - return focusSearchHistoryItem(0); - - return focusSearchScreenButton() || focusSearchInput(); - } - - if (historyIndex >= 0) { - return focusSearchInput(); - } - - return false; - }; - - this.loadHome = function (restoreState) { - status.text(''); - destroyHomeItems(); - body.empty(); - homeLines = []; - homeHeroCard = null; - homeMode = true; - homeActiveLine = -1; - var loadToken = ++homeLoadToken; - _this.activity.loader(true); - - function appendHomeSection(title, sectionKey, entries) { - if (!entries.length) return; - - scroll.minus(); - var visibleEntries = cloneHomeEntries(entries.slice(0, HOME_SECTION_LIMIT)); - var canOpenMore = sectionKey.indexOf('browse:') === 0 - ? !!(MUSIC_HOME_SECTION_META[sectionKey] && MUSIC_HOME_SECTION_META[sectionKey].has_more) - : entries.length > HOME_SECTION_LIMIT; - var line = createMusicLine({ - title: title, - entries: visibleEntries, - hasMore: canOpenMore, - onSelect: function (entry, index) { - activateEntry(index >= 0 ? entries[index] : entry, entries, index); - }, - onMenu: function (entry, index) { - activateEntryMenu(index >= 0 ? entries[index] : entry, entries, index, function (nextRestoreState) { - _this.loadHome(nextRestoreState || { - sectionKey: sectionKey, - entryIndex: Math.max(0, index) - }); - }); - }, - onFocus: function (entry) { - setBackground((entry && entry.background) || IMG_BG); - requestAnimationFrame(function () { - var current = line.render(true).querySelector('.focus'); - if (current) last = current; - }); - }, - onFocusMore: function () { - setBackground(artwork('album', title, 'Ещё', ['#1d2837', '#496786'])); - requestAnimationFrame(function () { - var current = line.render(true).querySelector('.focus'); - if (current) last = current; - }); - }, - onMore: function () { - openHomeSection(sectionKey, title); - } - }); - line.section_key = sectionKey; - $(line.render(true)).addClass('lm-home-line'); - - var lineIndex = homeLines.length; - - line.use({ - onDown: function () { - if (lineIndex < homeLines.length - 1) - openHomeLine(lineIndex + 1); - }, - onUp: function () { - if (lineIndex > 0) { - openHomeLine(lineIndex - 1); - return; - } - - focusHomeHero(); - }, - onBack: _this.back - }); - - body.append(line.render(true)); - homeLines.push(line); - items.push(line); - } - - function renderHome(home) { - body.empty(); - - var sections = snapshotHomeSections(home); - homeHeroCard = null; - - loadStatsTopSummary(function (stats) { - if (destroyed) return; - if (isStandaloneIosPlayerForeground()) { - MUSIC_DEFERRED_HOME_REFRESH = true; - _this.activity.loader(false); - return; - } - - sections.user_playlists = applySectionKey(buildUserPlaylistCardsWithStats( - home && Array.isArray(home.user_playlists) ? home.user_playlists : [], - stats - ), 'user_playlists'); - MUSIC_HOME_CACHE.user_playlists = sections.user_playlists; - MUSIC_HOME_SECTION_META.user_playlists = { has_more: (sections.user_playlists || []).length > HOME_SECTION_LIMIT }; - - appendHomeSection('Недавно слушали', 'recently_played', sections.recently_played || []); - appendHomeSection('Недавние альбомы', 'recent_albums', sections.recent_albums || []); - appendHomeSection('Недавние артисты', 'recent_artists', sections.recent_artists || []); - appendHomeSection('Недавние поиски', 'recent_queries', sections.recent_queries || []); - appendHomeSection('Мои плейлисты', 'user_playlists', sections.user_playlists || []); - appendHomeSection('Миксы недели', 'daily_mixes', sections.daily_mixes || []); - - if (home && Array.isArray(home.browse_sections)) { - home.browse_sections.forEach(function (section) { - if (!section || !section.id) return; - appendHomeSection(section.title || 'Секция', section.id, sections[section.id] || []); - }); - } - - _this.activity.loader(false); - _this.activity.toggle(); - restoreHomeAfterRender(restoreState); - - if (home && home.browse_sections_warming) - scheduleBrowseSectionsRetry(0); - }); - } - - // после ребилда discovery-провайдеры могут не успеть в 2s-бюджет /music/home — - // сервер тогда ставит browse_sections_warming, а мы тихо дозапрашиваем home - // и доклеиваем недостающие полки в конец, не трогая уже отрисованное - var browseRetryDelays = [3000, 5000, 8000]; - - function browseRetryCancelled() { - return destroyed || !homeMode || loadToken !== homeLoadToken; - } - - function appendMissingBrowseSections(home) { - if (!home || !Array.isArray(home.browse_sections)) return; - - var existingKeys = {}; - homeLines.forEach(function (line) { - if (line && line.section_key) existingKeys[line.section_key] = true; - }); - - home.browse_sections.forEach(function (section) { - if (!section || !section.id || existingKeys[section.id]) return; - - var entries = getHomeSectionEntries(section.id, home); - if (!entries.length) return; - - MUSIC_HOME_SECTION_TITLES[section.id] = section.title || 'Секция'; - MUSIC_HOME_SECTION_META[section.id] = { has_more: section.has_more === true }; - MUSIC_HOME_CACHE[section.id] = entries; - - appendHomeSection(section.title || 'Секция', section.id, entries); - }); - } - - function scheduleBrowseSectionsRetry(attempt) { - if (attempt >= browseRetryDelays.length) return; - - setTimeout(function () { - if (browseRetryCancelled()) return; - - request(withDailyMixSalt(MUSIC.endpoints.home), function (json) { - if (browseRetryCancelled()) return; - - var home = parseJson(json); - if (!home) return scheduleBrowseSectionsRetry(attempt + 1); - - appendMissingBrowseSections(home); - - if (home.browse_sections_warming) - scheduleBrowseSectionsRetry(attempt + 1); - }, function () { - if (browseRetryCancelled()) return; - scheduleBrowseSectionsRetry(attempt + 1); - }); - }, browseRetryDelays[attempt]); - } - - request(withDailyMixSalt(MUSIC.endpoints.home), function (json) { - if (destroyed) return; - renderHome(parseJson(json) || null); - }, function () { - if (destroyed) return; - renderHome(null); - }); - }; - - this.loadSearchLanding = function (keepInputFocus) { - searchRequestToken++; - clearSearchMetadataRefresh(); - homeMode = false; - homeActiveLine = -1; - searchMode = true; - searchActiveLine = -1; - searchLines = []; - status.text('Введите запрос или выберите недавний.'); - - _this.clean(); - searchMode = true; - body.empty(); - searchSourceTabsWrap.empty().detach(); - searchSources = buildSearchSources({ - artists: [], - albums: [], - tracks: [], - sections: [] - }, currentQuery); - searchSourceTabs = []; - searchActiveSourceId = searchSources.length ? searchSources[0].id : ''; - updateSearchButtonDisplay(); - renderSearchHistory(); - renderSearchLinesForActiveSource(); - - searchActiveLine = -1; - _this.activity.loader(false); - _this.activity.toggle(); - - if (keepInputFocus && searchScreenMode && searchInputField && searchInputField.length) { - setSearchFocusZone('input'); - last = searchBtn[0]; - scroll.update(searchBtn, true); - requestAnimationFrame(function () { - if (destroyed || !searchInputField[0]) return; - searchInputField[0].focus(); - }); - return; - } - - last = headerFocusTarget || searchBtn[0]; - if (last) scroll.update($(last), true); - }; - - this.loadSearch = function () { - var requestToken = ++searchRequestToken; - var keepInputFocus = !!(searchPreserveInputFocus && searchScreenMode && searchInputField && searchInputField.length); - var caretPosition = keepInputFocus && document.activeElement === searchInputField[0] && typeof searchInputField[0].selectionStart === 'number' - ? searchInputField[0].selectionStart - : null; - searchPreserveInputFocus = false; - homeMode = false; - homeActiveLine = -1; - searchMode = true; - searchActiveLine = -1; - searchLines = []; - status.text(''); - clearSearchMetadataRefresh(); - - request(MUSIC.endpoints.search + '?q=' + encodeURIComponent(currentQuery), function (json) { - if (destroyed || requestToken !== searchRequestToken) return; - - var groups = normalizeSearchResults(json); - var sources = buildSearchSources(groups, currentQuery); - updateSearchButtonDisplay(); - renderSearchHistory(); - if (groups.artists.length && groups.artists[0] && groups.artists[0].raw) { - updateRecentQueryArtist(currentQuery, groups.artists[0].raw); - requestArtistImage(groups.artists[0].raw, function (images) { - if (!images || !images.length) return; - - var artist = groups.artists[0].raw; - artist.images = images; - updateRecentQueryArtist(currentQuery, artist, false); - }); - } - var total = sources.reduce(function (sum, source) { - return sum + ((source && typeof source.count === 'number') ? source.count : 0); - }, 0); - - _this.clean(); - - searchMode = true; - searchActiveLine = -1; - searchLines = []; - searchSources = sources; - - if (!total && !hasPendingSearchSource()) { - _this.empty('По запросу ничего не найдено.'); - _this.activity.loader(false); - return; - } - - if (!searchActiveSourceId || getSearchSourceIndexById(searchActiveSourceId) < 0) - searchActiveSourceId = (getFirstSearchSourceWithResults() || {}).id || ''; - - renderSearchLinesForActiveSource(); - - if (hasPendingSearchMetadata(groups)) - scheduleSearchMetadataRefresh(currentQuery, requestToken, 0); - - warmExpandedSearch(currentQuery); - - searchActiveLine = -1; - _this.activity.loader(false); - _this.activity.toggle(); - - if (keepInputFocus && searchInputField && searchInputField.length) { - setSearchFocusZone('input'); - last = searchBtn[0]; - scroll.update(searchBtn, true); - requestAnimationFrame(function () { - if (destroyed || !searchInputField[0]) return; - searchInputField[0].focus(); - if (caretPosition !== null) { - try { - searchInputField[0].setSelectionRange(caretPosition, caretPosition); - } catch (e) {} - } - }); - return; - } - - last = searchSourceTabs[getSearchSourceIndexById(searchActiveSourceId)] - || headerFocusTarget - || searchBtn[0]; - if (last) scroll.update($(last), true); - }, function () { - if (destroyed || requestToken !== searchRequestToken) return; - updateSearchButtonDisplay(); - renderSearchHistory(); - _this.clean(); - _this.empty('Не удалось загрузить результаты поиска.'); - _this.activity.loader(false); - }); - }; - - this.appendSearchGroup = function (groupType, title, group, hasMoreOverride) { - if (!group.length) return; - - scroll.minus(); - var visibleEntries = cloneHomeEntries(group.slice(0, HOME_SECTION_LIMIT)); - var line = createMusicLine({ - title: title, - entries: visibleEntries, - hasMore: typeof hasMoreOverride === 'boolean' ? hasMoreOverride : group.length > HOME_SECTION_LIMIT, - onSelect: function (entry, index) { - activateEntry(index >= 0 ? group[index] : entry, group, index); - }, - onMenu: function (entry, index) { - activateEntryMenu(index >= 0 ? group[index] : entry, group, index); - }, - onFocus: function (entry) { - setSearchFocusZone('results'); - setBackground((entry && entry.background) || IMG_BG); - requestAnimationFrame(function () { - var current = line.render(true).querySelector('.focus'); - if (current) last = current; - }); - }, - onFocusMore: function () { - setSearchFocusZone('results'); - setBackground(artwork('album', title, 'Ещё', ['#1d2632', '#486077'])); - requestAnimationFrame(function () { - var current = line.render(true).querySelector('.focus'); - if (current) last = current; - }); - }, - onMore: function () { - openSearchSection(title, group, { - query: currentQuery, - type: groupType - }); - } - }); - $(line.render(true)).addClass('lm-home-line lm-search-line'); - - var lineIndex = searchLines.length; - - line.use({ - onDown: function () { - if (lineIndex < searchLines.length - 1) - openSearchLine(lineIndex + 1); - }, - onUp: function () { - if (lineIndex > 0) { - openSearchLine(lineIndex - 1); - return; - } - - if (searchSourceTabs.length) { - focusSearchSourceTab(Math.max(0, getSearchSourceIndexById(searchActiveSourceId))); - return; - } - - focusSearchHeader(headerFocusTarget); - }, - onBack: _this.back - }); - - body.append(line.render(true)); - searchLines.push(line); - items.push(line); - }; - - this.empty = function (message) { - var empty = new Lampa.Empty(); - body.append(empty.render()); - if (message) Lampa.Noty.show(message); - }; - - this.render = function () { - return html; - }; - - this.destroy = function () { - destroyed = true; - clearSearchMetadataRefresh(); - unbindRecentListener(); - Lampa.Arrays.destroy(items); - scroll.destroy(); - html.remove(); - }; - } - - // ===== FULL SCREEN COMPONENTS ===== - - function MusicSectionFull(object) { - var scroll = new Lampa.Scroll({ mask: false, over: true }); - var html = $('<div></div>'); - var body = $('<div class="category-full lm-full"></div>'); - var last; - var destroyed = false; - var items = []; - var sectionKey = object.section_key; - var sectionTitle = object.section_title || object.title || 'Секция'; - var sectionDirty = false; - var unbindRecentListener = function () {}; - var _this = this; - - bindBase(this, scroll, function () { return last; }); - - var baseStart = this.start; - this.start = function () { - if (isStandaloneIosPlayerForeground()) return; - baseStart(); - - if (sectionDirty) { - sectionDirty = false; - renderSection(); - } - }; - - function handleRecentChanged(e) { - var detail = e && e.detail ? e.detail : {}; - if (!detail.section_key || detail.section_key !== sectionKey) return; - if (detail.payload && detail.payload.skipActiveSectionRefresh && Lampa.Activity.active().activity === _this.activity) return; - - sectionDirty = true; - - if (isLampaPlayerOverlayOpen() || isStandaloneIosPlayerForeground()) { - traceEmbeddedIos('section-refresh-deferred', detail.section_key || '', true); - return; - } - - if (Lampa.Activity.active().activity === _this.activity) { - sectionDirty = false; - renderSection(); - } - } - - function clearRenderedItems() { - Lampa.Arrays.destroy(items); - items = []; - body.empty(); - } - - function renderSection() { - _this.activity.loader(true); - clearRenderedItems(); - - resolveHomeSectionEntries(sectionKey, function (entries) { - if (destroyed) return; - - var isStatsTop = sectionKey === 'stats:top'; - var isDailyMix = String(sectionKey || '').indexOf('daily:') === 0; - var isPlaylist = String(sectionKey || '').indexOf('playlist:') === 0 || isStatsTop || isDailyMix; - var wrapper = $('<div class="lm-full__wrapper"></div>'); - var header = $('<div class="lm-full__header selector"></div>'); - var info = $('<div class="lm-full__info"></div>'); - var title = $('<div class="lm-full__title"></div>').text(sectionTitle); - - // плейлист и «Твой топ» — hero-шапка и трек-лист, - // остальные секции — сетка карточек - if (isPlaylist) { - var rawTracks = entries.map(function (entry) { return entry && entry.raw ? entry.raw : null; }).filter(Boolean); - scheduleFirstTrackPrefetch(rawTracks, function () { return destroyed; }); - var heroImg = entries.length && entries[0].image - ? entries[0].image - : artwork('album', sectionTitle, 'PLAYLIST', ['#232838', '#4a5a82']); - var totalMs = rawTracks.reduce(function (sum, item) { return sum + (item.duration_ms || 0); }, 0); - - var poster = $('<div class="lm-full__poster"></div>'); - var posterImg = $('<img class="lm-full__poster-img" src="" alt="" />'); - var metaElem = $('<div class="lm-full__meta"></div>').text(isStatsTop - ? buildStatsTopMetaLabel(MUSIC_STATS_TOP.summary, rawTracks.length) - : [ - formatTrackCountLabel(rawTracks.length), - formatTotalDurationLabel(totalMs), - // сухое объяснение, почему состав микса не меняется - // сегодня и поменяется потом - isDailyMix ? 'Обновится в понедельник' : '' - ].filter(Boolean).join(' · ') - ); - var actions = $('<div class="lm-full__actions"></div>'); - var playBtn = $('<div class="selector lm-small-btn lm-small-btn--primary">▶ Слушать</div>'); - var shuffleBtn = $('<div class="selector lm-small-btn">Перемешать</div>'); - var tracksBox = $('<div class="lm-full__tracks"></div>'); - - posterImg.on('load error', function () { posterImg.css('opacity', 1); }); - posterImg.attr('src', heroImg); - poster.append(posterImg); - - info.append(title); - info.append(metaElem); - - if (rawTracks.length) { - actions.append(playBtn); - actions.append(shuffleBtn); - info.append(actions); - } - - header.append(poster); - header.append(info); - wrapper.append(header); - wrapper.append(tracksBox); - body.append(wrapper); - scroll.append(body); - html.append(scroll.render()); - - computeStandaloneIosArtTint(heroImg, function (tint) { - if (destroyed || !tint) return; - - try { - wrapper.get(0).style.setProperty('--lm-hero-tint', tint); - } catch (e) {} - }); - - last = header[0]; - - header.on('hover:focus', function () { - last = this; - scroll.update($(this), true); - setBackground(heroImg); - }); - - playBtn.on('hover:focus', function () { - last = playBtn[0]; - scroll.update(playBtn, true); - setBackground(heroImg); - }); - - playBtn.on('hover:enter', function () { - if (rawTracks.length) playTrack(rawTracks[0], rawTracks, 0); - }); - - shuffleBtn.on('hover:focus', function () { - last = shuffleBtn[0]; - scroll.update(shuffleBtn, true); - setBackground(heroImg); - }); - - shuffleBtn.on('hover:enter', function () { - playTracksShuffled(rawTracks); - }); - - if (!entries.length) { - tracksBox.append($('<div class="lm-full__empty"></div>') - .text(isStatsTop - ? 'Статистика ещё копится — слушай музыку, топ соберётся сам.' - : isDailyMix - ? 'Микс не собрался — послушай больше музыки и загляни позже.' - : 'Плейлист пуст. Долгое нажатие на любом треке → «В плейлист…»')); - } - - entries.forEach(function (entry, index) { - var row = buildTrackRow(entry.raw, index, { thumb: entry.image, positionNumber: true }); - - row.on('hover:focus', function () { - last = row[0]; - scroll.update(row, true); - setBackground(entry.background || heroImg); - schedulePlayPrefetch(entry.raw); - }); - - row.on('hover:enter', function () { - playTrack(entry.raw, rawTracks, index); - }); - - row.on('hover:long', function () { - activateEntryMenu(entry, entries, index, renderSection); - }); - - tracksBox.append(row); - }); - - _this.activity.loader(false); - _this.activity.toggle(); - return; - } - - var statusElem = $('<div class="lm-full__status"></div>').text('Карточек: ' + entries.length); - var grid = $('<div class="lm-grid"></div>'); - - info.append(title); - info.append(statusElem); - header.append(info); - wrapper.append(header); - wrapper.append(grid); - body.append(wrapper); - scroll.append(body); - html.append(scroll.render()); - - last = header[0]; - - header.on('hover:focus', function () { - last = this; - scroll.update($(this), true); - setBackground(artwork('album', sectionTitle, 'Секция', ['#15202c', '#35556c'])); - }); - - entries.forEach(function (entry, index) { - var cardObj = new MusicCardItem(entry); - var render = cardObj.render(); - - render.on('hover:focus', function () { - last = render[0]; - scroll.update(render, true); - setBackground(entry.background || IMG_BG); - }); - - render.on('hover:enter', function () { - activateEntry(entry, entries, index); - }); - - render.on('hover:long', function () { - activateEntryMenu(entry, entries, index, renderSection); - }); - - grid.append(render); - items.push(cardObj); - }); - - _this.activity.loader(false); - _this.activity.toggle(); - }); - } - - this.create = function () { - scroll.minus(); - unbindRecentListener = bindRecentListener(handleRecentChanged); - renderSection(); - - return this.render(); - }; - - this.render = function () { return html; }; - this.destroy = function () { - destroyed = true; - unbindRecentListener(); - Lampa.Arrays.destroy(items); - scroll.destroy(); - html.remove(); - }; - } - - function MusicEntriesFull(object) { - var scroll = new Lampa.Scroll({ mask: false, over: true }); - var html = $('<div></div>'); - var body = $('<div class="category-full lm-full"></div>'); - var last; - var items = []; - var destroyed = false; - var entriesTitle = object.entries_title || object.title || 'Результаты'; - var entries = Array.isArray(object.entries) ? object.entries.slice() : []; - var pagination = object.entries_pagination || null; - var nextPage = pagination && pagination.next_page ? pagination.next_page : ''; - var waitLoad = false; - var statusElem = null; - var grid = null; - var _this = this; - - bindBase(this, scroll, function () { return last; }); - - function statusText(loading) { - if (!entries.length) return 'Пока пусто'; - - var text = 'Карточек: ' + entries.length; - if (loading) text += ' · загрузка...'; - else if (nextPage) text += ' · ещё есть'; - - return text; - } - - function updateStatus(loading) { - if (statusElem) - statusElem.text(statusText(loading)); - } - - function buildPaginationUrl() { - if (!pagination || pagination.kind !== 'artist_section' || !pagination.section_id || !nextPage) - return ''; - - var url = MUSIC.endpoints.artistSection - + '?id=' + encodeURIComponent(pagination.section_id) - + '&limit=' + encodeURIComponent(pagination.limit || HOME_SECTION_LIMIT); - - if (pagination.provider) - url += '&provider=' + encodeURIComponent(pagination.provider); - - if (pagination.artist_name) - url += '&artist_name=' + encodeURIComponent(pagination.artist_name); - - url += '&page=' + encodeURIComponent(nextPage); - - return url; - } - - function appendEntry(entry, index, appendToController) { - var cardObj = new MusicCardItem(entry); - var render = cardObj.render(); - - render.on('hover:focus', function () { - last = render[0]; - scroll.update(render, true); - setBackground(entry.background || IMG_BG); - }); - - render.on('hover:enter', function () { - activateEntry(entry, entries, index); - }); - - render.on('hover:long', function () { - activateEntryMenu(entry, entries, index); - }); - - grid.append(render); - items.push(cardObj); - items.push({ - destroy: function () { - render.off(); - } - }); - - if (appendToController && Lampa.Controller && typeof Lampa.Controller.collectionAppend === 'function') - Lampa.Controller.collectionAppend(render[0]); - } - - function appendEntries(nextEntries, appendToController) { - (Array.isArray(nextEntries) ? nextEntries : []).forEach(function (entry) { - entries.push(entry); - appendEntry(entry, entries.length - 1, appendToController); - }); - } - - function loadNextPage() { - if (!nextPage || waitLoad) return; - - var url = buildPaginationUrl(); - if (!url) return; - - waitLoad = true; - updateStatus(true); - - request(url, function (response) { - if (destroyed || waitLoad === false) return; - - var nextSection = mapSearchSection(parseJson(response) || response); - var nextEntries = nextSection && Array.isArray(nextSection.entries) ? nextSection.entries : []; - - nextPage = nextSection && nextSection.next_page ? nextSection.next_page : ''; - if (pagination) pagination.next_page = nextPage; - - if (nextEntries.length) - appendEntries(nextEntries, true); - else if (!nextPage) - Lampa.Noty.show('Больше нет данных.'); - - waitLoad = false; - updateStatus(false); - - if (last) scroll.update($(last), true); - }, function () { - if (destroyed) return; - - waitLoad = false; - updateStatus(false); - Lampa.Noty.show('Не удалось догрузить раздел.'); - }); - } - - this.create = function () { - this.activity.loader(true); - scroll.minus(); - scroll.onEnd = loadNextPage; - - var wrapper = $('<div class="lm-full__wrapper"></div>'); - var header = $('<div class="lm-full__header selector"></div>'); - var info = $('<div class="lm-full__info"></div>'); - var title = $('<div class="lm-full__title"></div>').text(entriesTitle); - statusElem = $('<div class="lm-full__status"></div>').text(statusText(false)); - grid = $('<div class="lm-grid"></div>'); - - info.append(title); - info.append(statusElem); - header.append(info); - wrapper.append(header); - wrapper.append(grid); - body.append(wrapper); - scroll.append(body); - html.append(scroll.render()); - - last = header[0]; - - header.on('hover:focus', function () { - last = this; - scroll.update($(this), true); - setBackground(artwork('album', entriesTitle, 'Полный список', ['#1d2632', '#486077'])); - }); - - items.push({ - destroy: function () { - header.off(); - } - }); - - var initialEntries = entries.slice(); - entries = []; - appendEntries(initialEntries, false); - - if (!entries.length) { - var empty = $('<div class="lm-home-info"><div class="lm-home-info__row">Список пока пуст.</div></div>'); - body.append(empty); - items.push({ - destroy: function () { - empty.remove(); - } - }); - } - - this.activity.loader(false); - this.activity.toggle(); - return this.render(); - }; - - this.render = function () { return html; }; - this.destroy = function () { - destroyed = true; - Lampa.Arrays.destroy(items); - scroll.destroy(); - html.remove(); - }; - } - - function MusicBookmarksFull() { - var scroll = new Lampa.Scroll({ mask: false, over: true }); - var html = $('<div></div>'); - var body = $('<div class="category-full lm-full"></div>'); - var last; - var destroyed = false; - var items = []; - var lines = []; - var activeLine = -1; - var header; - var _this = this; - - bindBase(this, scroll, function () { return last; }); - - function bookmarkSections() { - return [ - { - key: 'bookmarked_artists', - title: 'Артисты', - fullTitle: 'Закладки: артисты', - entries: applySectionKey(getBookmarkedEntities(MUSIC.storage.bookmarked_artists).map(mapArtistCard), 'bookmarked_artists') - }, - { - key: 'bookmarked_albums', - title: 'Альбомы', - fullTitle: 'Закладки: альбомы', - entries: applySectionKey(getBookmarkedEntities(MUSIC.storage.bookmarked_albums).map(mapAlbumCard), 'bookmarked_albums') - }, - { - key: 'bookmarked_tracks', - title: 'Треки', - fullTitle: 'Закладки: треки', - entries: applySectionKey(getBookmarkedEntities(MUSIC.storage.bookmarked_tracks).map(mapTrackCard), 'bookmarked_tracks') - } - ]; - } - - function destroyRenderedItems() { - Lampa.Arrays.destroy(items); - items = []; - lines = []; - activeLine = -1; - body.empty(); - } - - function focusHeader() { - activeLine = -1; - last = header ? header[0] : false; - if (last) scroll.update($(last), true); - setBackground(artwork('album', 'Закладки', 'Артисты · Альбомы · Треки', ['#1d2632', '#486077'])); - Lampa.Controller.toggle('content'); - } - - function openLine(index) { - if (index < 0 || index >= lines.length || !lines[index]) return false; - - activeLine = index; - scroll.update($(lines[index].render(true)), true); - - requestAnimationFrame(function () { - if (lines[index]) lines[index].toggle(); - }); - - return true; - } - - function restoreAfterRender(sectionKey) { - if (!sectionKey) { - focusHeader(); - return; - } - - for (var i = 0; i < lines.length; i++) { - if (lines[i] && lines[i].section_key === sectionKey) { - openLine(i); - return; - } - } - - focusHeader(); - } - - function renderScreen(restoreState) { - if (destroyed) return; - - destroyRenderedItems(); - - var sections = bookmarkSections(); - var total = 0; - sections.forEach(function (section) { - total += section.entries.length; - }); - - header = $('<div class="lm-full__header selector"></div>'); - var info = $('<div class="lm-full__info"></div>'); - var title = $('<div class="lm-full__title"></div>').text('Закладки'); - var status = $('<div class="lm-full__status"></div>').text( - total ? ('Карточек: ' + total) : 'Пока пусто' - ); - - info.append(title); - info.append(status); - header.append(info); - body.append(header); - - header.on('hover:focus', function () { - last = this; - scroll.update($(this), true); - setBackground(artwork('album', 'Закладки', 'Артисты · Альбомы · Треки', ['#1d2632', '#486077'])); - }); - - items.push({ - destroy: function () { - header.off(); - } - }); - - sections.forEach(function (section) { - if (!section.entries.length) return; - - scroll.minus(); - - var visibleEntries = cloneHomeEntries(section.entries.slice(0, HOME_SECTION_LIMIT)); - var line = createMusicLine({ - title: section.title, - entries: visibleEntries, - hasMore: section.entries.length > HOME_SECTION_LIMIT, - onSelect: function (entry, index) { - activateEntry(index >= 0 ? section.entries[index] : entry, section.entries, index); - }, - onMenu: function (entry, index) { - activateEntryMenu(index >= 0 ? section.entries[index] : entry, section.entries, index, function () { - renderScreen({ - sectionKey: section.key - }); - }); - }, - onFocus: function (entry) { - setBackground((entry && entry.background) || IMG_BG); - requestAnimationFrame(function () { - var current = line.render(true).querySelector('.focus'); - if (current) last = current; - }); - }, - onFocusMore: function () { - setBackground(artwork('album', section.fullTitle, 'Ещё', ['#1d2632', '#486077'])); - requestAnimationFrame(function () { - var current = line.render(true).querySelector('.focus'); - if (current) last = current; - }); - }, - onMore: function () { - openBookmarksSection(section.key, section.fullTitle); - } - }); - - line.section_key = section.key; - $(line.render(true)).addClass('lm-home-line lm-bookmarks-line'); - - var lineIndex = lines.length; - - line.use({ - onDown: function () { - if (lineIndex < lines.length - 1) - openLine(lineIndex + 1); - }, - onUp: function () { - if (lineIndex > 0) { - openLine(lineIndex - 1); - return; - } - - focusHeader(); - }, - onBack: _this.back - }); - - body.append(line.render(true)); - lines.push(line); - items.push(line); - }); - - if (!total) { - var empty = $('<div class="lm-home-info"><div class="lm-home-info__row">Закладки пока пусты. Добавляй артистов, альбомы и треки через контекстное меню.</div></div>'); - body.append(empty); - items.push({ - destroy: function () { - empty.remove(); - } - }); - } - - _this.activity.loader(false); - _this.activity.toggle(); - restoreAfterRender(restoreState && restoreState.sectionKey); - } - - this.onControllerToggle = function () { - if (lines.length && activeLine >= 0) - return openLine(activeLine); - - return false; - }; - - this.onControllerDown = function () { - if (activeLine < 0 && lines.length && last === header[0]) - return openLine(0); - - return false; - }; - - this.create = function () { - scroll.minus(); - scroll.append(body); - html.append(scroll.render()); - this.activity.loader(true); - renderScreen(); - - return this.render(); - }; - - this.render = function () { return html; }; - this.destroy = function () { - destroyed = true; - Lampa.Arrays.destroy(items); - scroll.destroy(); - html.remove(); - }; - } - - function MusicArtistFull(object) { - var scroll = new Lampa.Scroll({ mask: false, over: true }); - var html = $('<div></div>'); - var body = $('<div class="category-full lm-full"></div>'); - var last; - var destroyed = false; - var items = []; - var artistLines = []; - var artistActiveLine = -1; - var artistHeader = null; - var artistHeaderImage = IMG_BG; - var artistData = null; - var artistSections = []; - var artistId = object.id; - var artistProvider = object.provider || null; - var _this = this; - - bindBase(this, scroll, function () { return last; }); - - function findArtistLineFocusTarget(lineElement) { - if (!lineElement) return null; - - var cards = $(lineElement).find('.lm-card.selector'); - if (cards.length) return cards.get(0); - - var nonMore = $(lineElement).find('.selector').not('.card-more'); - if (nonMore.length) return nonMore.get(0); - - return $(lineElement).find('.card-more.selector').get(0) || null; - } - - function focusArtistHeader() { - artistActiveLine = -1; - last = artistHeader; - - if (last) { - scroll.update($(last), true); - setBackground(artistHeaderImage); - Lampa.Controller.toggle('content'); - return true; - } - - return false; - } - - function forceArtistHeaderFocus() { - if (destroyed || Lampa.Activity.active().activity !== _this.activity) return; - if (!artistHeader) return; - if (artistActiveLine >= 0) return; - if (last && last !== artistHeader && document.documentElement.contains(last)) return; - - artistActiveLine = -1; - last = artistHeader; - - try { - Lampa.Controller.toggle('content'); - Lampa.Controller.collectionSet(scroll.render()); - Lampa.Controller.collectionFocus(artistHeader, scroll.render()); - } catch (e) {} - - scroll.update($(artistHeader), true); - } - - function resetArtistRender() { - Lampa.Arrays.destroy(items); - items = []; - artistLines = []; - artistActiveLine = -1; - artistHeader = null; - body.empty(); - } - - function renderArtist(focusLineIndex) { - if (!artistData) return; - - resetArtistRender(); - scroll.minus(); - - var wrapper = $('<div class="lm-full__wrapper"></div>'); - var header = $('<div class="lm-full__header selector"></div>'); - var poster = $('<div class="lm-full__poster"></div>'); - var posterImg = $('<img class="lm-full__poster-img" src="" alt="" />'); - var info = $('<div class="lm-full__info"></div>'); - var title = $('<div class="lm-full__title"></div>').text(artistData.name || 'Unknown Artist'); - var subtitle = $('<div class="lm-full__artist"></div>').text(artistProvider === 'youtubeaudio' ? 'YouTube Music' : artistProvider === 'soundcloudcharts' ? 'SoundCloud' : artistProvider === 'spotify' ? 'Spotify' : artistData.country || artistData.sort_name || 'MusicBrainz Artist'); - var totalSectionEntries = artistSections.reduce(function (sum, section) { - return sum + (Array.isArray(section.entries) ? section.entries.length : 0); - }, 0); - var albums = Array.isArray(artistData.albums) ? artistData.albums : []; - var statusElem = $('<div class="lm-full__status"></div>').text( - artistSections.length - ? (artistSections.length + ' разделов · ' + totalSectionEntries + ' карточек') - : (albums.length + ' альбомов') - ); - var content = $('<div class="lm-artist-sections"></div>'); - var img = artistImage(artistData, 250); - - posterImg.on('load', function () { - posterImg.css('opacity', 1); - }); - posterImg.on('error', function () { - if (posterImg.attr('src') !== IMG_BG) - posterImg.attr('src', IMG_BG); - posterImg.css('opacity', 1); - }); - posterImg.attr('src', img); - - poster.append(posterImg); - info.append(title); - info.append(subtitle); - info.append(statusElem); - header.append(poster); - header.append(info); - wrapper.append(header); - wrapper.append(content); - body.append(wrapper); - - artistHeader = header[0]; - artistHeaderImage = img; - last = artistHeader; - - header.on('hover:focus', function () { - last = this; - artistActiveLine = -1; - scroll.update($(this), true); - setBackground(img); - }); - - function appendCard(grid, entry, entries, index) { - var cardObj = new MusicCardItem(entry); - var render = cardObj.render(); - - render.on('hover:focus', function () { - last = render[0]; - scroll.update(render, true); - setBackground(entry.background || IMG_BG); - }); - - render.on('hover:enter', function () { - activateEntry(entry, entries, index); - }); - - render.on('hover:long', function () { - activateEntryMenu(entry, entries, index); - }); - - grid.append(render); - items.push(cardObj); - } - - if (artistSections.length) { - artistSections.forEach(function (section) { - var entries = Array.isArray(section.entries) ? section.entries : []; - if (!entries.length) return; - - scroll.minus(); - - var lineEntries = cloneHomeEntries(entries); - var line = createMusicLine({ - title: section.title || 'Раздел', - entries: lineEntries, - hasMore: !!section.next_page || !!section.has_more, - onSelect: function (entry, index) { - activateEntry(index >= 0 ? entries[index] : entry, entries, index); - }, - onMenu: function (entry, index) { - activateEntryMenu(index >= 0 ? entries[index] : entry, entries, index); - }, - onFocus: function (entry) { - setBackground((entry && entry.background) || IMG_BG); - requestAnimationFrame(function () { - var current = line.render(true).querySelector('.focus'); - if (current) last = current; - }); - }, - onFocusMore: function () { - setBackground(artwork('album', section.title || 'Раздел', 'Ещё', ['#1d2632', '#486077'])); - requestAnimationFrame(function () { - var current = line.render(true).querySelector('.focus'); - if (current) last = current; - }); - }, - onMore: function () { - openSearchSection(section.title || 'Раздел', entries, { - pagination: section.next_page ? { - kind: 'artist_section', - section_id: section.id || '', - provider: artistProvider || section.source_provider || '', - artist_name: artistData.name || '', - next_page: section.next_page, - limit: HOME_SECTION_LIMIT - } : null - }); - } - }); - - $(line.render(true)).addClass('lm-home-line lm-artist-line'); - - var lineIndex = artistLines.length; - - line.use({ - onDown: function () { - if (lineIndex < artistLines.length - 1) - openArtistLine(lineIndex + 1); - }, - onUp: function () { - if (lineIndex > 0) { - openArtistLine(lineIndex - 1); - return; - } - - focusArtistHeader(); - }, - onBack: _this.back - }); - - content.append(line.render(true)); - artistLines.push(line); - items.push(line); - }); - } else { - var grid = $('<div class="lm-grid"></div>'); - content.append(grid); - - albums.forEach(function (album) { - album.artist_name = album.artist_name || artistData.name; - var entry = { - type: 'album', - id: album.id, - title: album.title || 'Untitled', - subtitle: album.artist_name || formatDate(album.date), - badge: albumBadge(album), - image: albumImage(album, 250), - background: albumImage(album, 500), - raw: album - }; - - appendCard(grid, entry, [entry], 0); - }); - } - - _this.activity.loader(false); - _this.activity.toggle(); - requestAnimationFrame(forceArtistHeaderFocus); - setTimeout(forceArtistHeaderFocus, 120); - - if (typeof focusLineIndex === 'number' && focusLineIndex >= 0) { - requestAnimationFrame(function () { - openArtistLine(Math.min(focusLineIndex, artistLines.length - 1)); - }); - } - } - - function openArtistLine(index) { - if (index < 0 || index >= artistLines.length || !artistLines[index]) return false; - - artistActiveLine = index; - var line = artistLines[index]; - scroll.update($(line.render(true)), true); - - requestAnimationFrame(function () { - if (!artistLines[index]) return; - line.toggle(); - - requestAnimationFrame(function () { - if (!artistLines[index]) return; - - var lineElement = line.render(true); - var target = findArtistLineFocusTarget(lineElement); - if (!target) return; - - last = target; - Lampa.Controller.collectionSet(lineElement); - Lampa.Controller.collectionFocus(target, lineElement); - scroll.update($(target), true); - }); - }); - - return true; - } - - this.onControllerDown = function () { - if (artistActiveLine < 0 && artistLines.length) - return openArtistLine(0); - - return false; - }; - - this.create = function () { - this.activity.loader(true); - scroll.minus(); - - var artistUrl = MUSIC.endpoints.artist + '?id=' + encodeURIComponent(artistId); - if (artistProvider) - artistUrl += '&provider=' + encodeURIComponent(artistProvider); - - request(artistUrl, function (artist) { - if (destroyed) return; - - artistData = artist; - artistSections = (Array.isArray(artist.sections) ? artist.sections : []) - .map(mapSearchSection) - .filter(Boolean); - scroll.append(body); - html.append(scroll.render()); - renderArtist(); - }, function () { - if (destroyed) return; - body.append(new Lampa.Empty().render()); - scroll.append(body); - html.append(scroll.render()); - _this.activity.loader(false); - }); - - return this.render(); - }; - - this.render = function () { return html; }; - this.destroy = function () { - destroyed = true; - Lampa.Arrays.destroy(items); - scroll.destroy(); - html.remove(); - }; - } - - function MusicAlbumFull(object) { - var scroll = new Lampa.Scroll({ mask: false, over: true }); - var html = $('<div></div>'); - var body = $('<div class="category-full lm-full"></div>'); - var last; - var destroyed = false; - var albumId = object.id; - var albumProvider = object.provider || null; - var _this = this; - - bindBase(this, scroll, function () { return last; }); - - this.create = function () { - this.activity.loader(true); - scroll.minus(); - - requestAlbumDetails({ id: albumId }, albumProvider, function (album) { - if (destroyed) return; - - if (!album || album.available === false || (!album.id && !album.title && !album.artist_name)) { - body.append(new Lampa.Empty().render()); - scroll.append(body); - html.append(scroll.render()); - _this.activity.loader(false); - return; - } - - var wrapper = $('<div class="lm-full__wrapper"></div>'); - var header = $('<div class="lm-full__header selector"></div>'); - var poster = $('<div class="lm-full__poster"></div>'); - var posterImg = $('<img class="lm-full__poster-img" src="" alt="" />'); - var info = $('<div class="lm-full__info"></div>'); - var title = $('<div class="lm-full__title"></div>').text(album.title || 'Untitled'); - var artistElem = $('<div class="lm-full__artist"></div>').text(album.artist_name || 'Unknown Artist'); - var actions = $('<div class="lm-full__actions"></div>'); - var playAlbumBtn = $('<div class="selector lm-small-btn lm-small-btn--primary">▶ Слушать</div>'); - var shuffleAlbumBtn = $('<div class="selector lm-small-btn">Перемешать</div>'); - var tracksBox = $('<div class="lm-full__tracks"></div>'); - var img = albumImage(album, 500); - var tracks = (album.tracks || []).map(function (track) { - track.artist_name = track.artist_name || album.artist_name; - track.album_title = track.album_title || album.title; - return track; - }); - scheduleFirstTrackPrefetch(tracks, function () { return destroyed; }); - var totalMs = tracks.reduce(function (sum, item) { - return sum + (item && item.duration_ms ? item.duration_ms : 0); - }, 0); - var metaElem = $('<div class="lm-full__meta"></div>').text( - [formatYear(album.date), formatTrackCountLabel(tracks.length), formatTotalDurationLabel(totalMs)] - .filter(Boolean) - .join(' · ') - ); - - posterImg.on('load', function () { - posterImg.css('opacity', 1); - }); - posterImg.on('error', function () { - if (posterImg.attr('src') !== IMG_BG) - posterImg.attr('src', IMG_BG); - posterImg.css('opacity', 1); - }); - posterImg.attr('src', img); - - poster.append(posterImg); - info.append(title); - info.append(artistElem); - info.append(metaElem); - actions.append(playAlbumBtn); - actions.append(shuffleAlbumBtn); - info.append(actions); - header.append(poster); - header.append(info); - wrapper.append(header); - wrapper.append(tracksBox); - body.append(wrapper); - scroll.append(body); - html.append(scroll.render()); - - // тинт шапки из среднего цвета обложки — как в фулл-плеере - computeStandaloneIosArtTint(img, function (tint) { - if (destroyed || !tint) return; - - try { - wrapper.get(0).style.setProperty('--lm-hero-tint', tint); - } catch (e) {} - }); - - last = header[0]; - - header.on('hover:focus', function () { - last = this; - scroll.update($(this), true); - setBackground(img); - }); - - playAlbumBtn.on('hover:focus', function () { - last = playAlbumBtn[0]; - scroll.update(playAlbumBtn, true); - setBackground(img); - }); - - playAlbumBtn.on('hover:enter', function () { - if (tracks.length) - playTrack(tracks[0], tracks, 0); - }); - - shuffleAlbumBtn.on('hover:focus', function () { - last = shuffleAlbumBtn[0]; - scroll.update(shuffleAlbumBtn, true); - setBackground(img); - }); - - shuffleAlbumBtn.on('hover:enter', function () { - playTracksShuffled(tracks); - }); - - tracks.forEach(function (track, index) { - var row = buildTrackRow(track, index, { hideAlbum: true }); - - row.on('hover:focus', function () { - last = row[0]; - scroll.update(row, true); - setBackground(img); - schedulePlayPrefetch(track); - }); - - row.on('hover:enter', function () { - playTrack(track, tracks, index); - }); - - row.on('hover:long', function () { - openTrackMenu(track, tracks, index, null, null, null, { hideOpenAlbum: true }); - }); - - tracksBox.append(row); - }); - - _this.activity.loader(false); - _this.activity.toggle(); - }, function () { - if (destroyed) return; - body.append(new Lampa.Empty().render()); - scroll.append(body); - html.append(scroll.render()); - _this.activity.loader(false); - }); - - return this.render(); - }; - - this.render = function () { return html; }; - this.destroy = function () { - destroyed = true; - scroll.destroy(); - html.remove(); - }; - } - - // ===== REGISTRATION / STYLES ===== - - function add() { - if ($('.menu__item[data-action="lampac_music"]').length) return; - - bindMusicClientDebugErrors(); - bindLampaPlayerDebug(); - registerMusicPlayerSetting(); - - Lampa.Player.listener.follow('start', function (data) { - if (!data || !data.from_music_cluster) { - resetMusicPlayerBridgeForNonMusic('player-start'); - - if (isStandaloneIosAudioActive()) - stopStandaloneIosAudioPlayback(); - - return; - } - - stopMusicPlaybackLoading(); - traceEmbeddedIos('player-start', 'url=' + describeMusicValue(data.url), true); - scheduleEmbeddedIosMediaPrepare('player-start'); - - if (data.music_track_id) - syncActivePlaybackHistory(data); - - syncMusicMediaSession(data); - startMusicPlayerPanelFix(data); - [0, 120, 450, 1000].forEach(function (delay) { - setTimeout(function () { - ensureLampaPlayerController('player-start+' + delay); - }, delay); - }); - }); - - Lampa.Player.listener.follow('destroy', function () { - traceEmbeddedIos('player-destroy', '', true); - - if (isStandaloneIosAudioActive()) - syncStandaloneIosMediaSession(); - else - syncMusicMediaSession(null); - - stopMusicPlayerPanelFix(); - }); - - if (Lampa.PlayerPanel && Lampa.PlayerPanel.listener && typeof Lampa.PlayerPanel.listener.follow === 'function') { - Lampa.PlayerPanel.listener.follow('quality', function () { - scheduleMusicPlayerPanelRebind('panel-quality'); - }); - Lampa.PlayerPanel.listener.follow('mouse_rewind', handleMusicPlayerPanelMouseRewind); - } - - if (Lampa.PlayerVideo && Lampa.PlayerVideo.listener && typeof Lampa.PlayerVideo.listener.follow === 'function') { - Lampa.PlayerVideo.listener.follow('canplay', function () { - scheduleMusicPlayerPanelRebind('video-canplay'); - }); - } - - var menuButton = $('<li class="menu__item selector" data-action="lampac_music"><div class="menu__ico">' + MENU_ICON + '</div><div class="menu__text">' + MUSIC.title + '</div></li>'); - - menuButton.on('hover:enter', function () { - Lampa.Activity.push({ - title: MUSIC.title, - component: 'lampac_music_home', - page: 1, - noinfo: true - }); - }); - - $('.menu .menu__list').eq(0).append(menuButton); - $('body').append(Lampa.Template.get('lampac_music_style', {}, true)); - restoreQueueSnapshot(); - } - - function createMusic() { - Lampa.Template.add('lampac_music_style', '\ - <style>\ - .card--mask::before, .card--mask::after { display: none !important; }\ - .lm-grid { padding: 1em; }\ - .lm-search-bar { padding: 1em 1em 0 1em; }\ - .lm-search-actions { display: flex; align-items: center; gap: 0.65em; flex-wrap: wrap; }\ - .lm-search-btn {\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - min-width: 14em;\ - background: rgba(255, 255, 255, 0.10);\ - border-radius: 0.4em;\ - padding: 0.82em 1.4em;\ - font-size: 1.15em;\ - text-align: center;\ - transition: background 0.2s;\ - }\ - .lm-search-btn svg { width: 1.2em; height: 1.2em; display: block; }\ - .lm-search-btn--icon {\ - width: 3.1em;\ - min-width: 3.1em;\ - height: 3.1em;\ - padding: 0;\ - border-radius: 0.72em;\ - }\ - .lm-search-btn--icon svg {\ - width: 1.28em;\ - height: 1.28em;\ - }\ - .lm-search-btn--search svg {\ - width: 1.22em;\ - height: 1.22em;\ - }\ - .lm-search-btn.focus {\ - background: #fff;\ - color: #000;\ - }\ - .lm-auth-btn {\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - min-width: 12em;\ - background: rgba(117, 63, 255, 0.22);\ - border-radius: 0.4em;\ - padding: 0.82em 1.25em;\ - font-size: 1.05em;\ - text-align: center;\ - transition: background 0.2s;\ - }\ - .lm-auth-btn.focus {\ - background: #fff;\ - color: #000;\ - }\ - .lm-search-status {\ - margin-top: 0.7em;\ - color: #aeb7c4;\ - font-size: 0.95em;\ - }\ - .lm-search-screen {\ - display: block;\ - padding: 1em 1em 0;\ - }\ - .lm-search-screen > * {\ - width: 100%;\ - }\ - .lm-search-input-wrap {\ - width: 100%;\ - }\ - .lm-search-input-btn {\ - display: block;\ - width: 100%;\ - box-sizing: border-box;\ - padding: 0.62em 0;\ - border: 0;\ - background: transparent;\ - color: #f4f6fa;\ - font-size: 2.05em;\ - line-height: 1.2;\ - text-align: left;\ - border-radius: 0;\ - border-bottom: 1px solid rgba(255,255,255,0.10);\ - }\ - .lm-search-input-field {\ - display: block;\ - width: 100%;\ - margin: 0;\ - padding: 0;\ - border: 0;\ - outline: none;\ - box-shadow: none;\ - background: transparent;\ - color: inherit;\ - font: inherit;\ - line-height: inherit;\ - appearance: none;\ - -webkit-appearance: none;\ - }\ - .lm-search-input-field::placeholder {\ - color: rgba(244,246,250,0.58);\ - }\ - .lm-search-input-btn--placeholder {\ - color: rgba(244,246,250,0.58);\ - }\ - .lm-search-input-btn.focus {\ - color: #ffffff;\ - border-bottom-color: rgba(255,255,255,0.35);\ - }\ - .lm-search-history {\ - display: flex;\ - align-items: center;\ - gap: 0.85em;\ - padding: 0.9em 0 1.15em;\ - overflow-x: auto;\ - overflow-y: hidden;\ - }\ - .lm-search-history.hide { display: none; }\ - .lm-search-history-item {\ - display: inline-flex;\ - align-items: center;\ - gap: 0.65em;\ - flex-shrink: 0;\ - padding: 0.9em 1.15em;\ - border-radius: 0.72em;\ - background: rgba(255,255,255,0.10);\ - color: #f4f6fa;\ - font-size: 1.05em;\ - }\ - .lm-search-history-item.focus {\ - background: #fff;\ - color: #000;\ - }\ - .lm-search-history-item__icon {\ - opacity: 0.78;\ - font-size: 1em;\ - }\ - .lm-search-history-item__title {\ - white-space: nowrap;\ - }\ - .lm-search-screen .lm-search-actions {\ - padding: 0.15em 0 0.8em;\ - }\ - .lm-auth-status {\ - margin-top: 0.35em;\ - color: #c6afd9;\ - font-size: 0.92em;\ - white-space: nowrap;\ - overflow: hidden;\ - text-overflow: ellipsis;\ - }\ - .lm-search-sources {\ - display: flex;\ - align-items: center;\ - gap: 0.7em;\ - padding: 0.35em 1em 1em;\ - flex-wrap: nowrap;\ - overflow-x: auto;\ - overflow-y: hidden;\ - }\ - .lm-search-screen .lm-search-sources {\ - padding: 0 0 1em;\ - }\ - .lm-search-source-tab {\ - display: inline-flex;\ - align-items: center;\ - gap: 0.55em;\ - flex-shrink: 0;\ - padding: 0.82em 1.05em;\ - border-radius: 0.72em;\ - background: rgba(255,255,255,0.08);\ - color: #d8e0ea;\ - font-size: 1.02em;\ - transition: background 0.2s, color 0.2s;\ - }\ - .lm-search-source-tab.active {\ - background: rgba(255,255,255,0.16);\ - color: #ffffff;\ - }\ - .lm-search-source-tab--pending .lm-search-source-tab__count {\ - color: #ffffff;\ - background: rgba(255,255,255,0.20);\ - }\ - .lm-search-source-tab.focus {\ - background: #fff;\ - color: #000;\ - }\ - .lm-search-source-tab__title {\ - white-space: nowrap;\ - }\ - .lm-search-source-tab__count {\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - min-width: 1.65em;\ - height: 1.65em;\ - padding: 0 0.35em;\ - border-radius: 999px;\ - background: rgba(255,255,255,0.14);\ - font-size: 0.8em;\ - font-weight: 700;\ - line-height: 1;\ - }\ - .lm-search-source-tab.focus .lm-search-source-tab__count {\ - background: rgba(0,0,0,0.10);\ - color: #000;\ - }\ - .lm-search-empty {\ - padding: 0.2em 1em 1.2em;\ - color: #aeb7c4;\ - font-size: 1em;\ - }\ - .lm-home-line { width: 100%; padding: 0.2em 0 0.8em; }\ - .lm-home-line .items-line__head { padding: 0 1em 0.45em; }\ - .lm-home-line .items-line__title { font-size: 1.95em; line-height: 1.14; font-weight: 700; color: #f3f6fa; }\ - .lm-home-line .items-line__body { width: 100%; }\ - .lm-home-line .mapping--line { padding: 0 1em 0.2em; }\ - .lm-home-line .mapping--line > * { flex-shrink: 0; }\ - .lm-home-line .mapping--line > .lm-card {\ - width: 20%;\ - min-width: 14.5em;\ - max-width: 18em;\ - padding: 0;\ - display: block;\ - }\ - .lm-home-line .mapping--line > .card-more { flex-shrink: 0; }\ - .lm-home-tags {\ - display: flex;\ - flex-wrap: wrap;\ - gap: 0.8em;\ - padding: 0.4em 1em 0.6em;\ - }\ - .lm-home-tag {\ - padding: 0.72em 1.05em;\ - border-radius: 999px;\ - background: rgba(255,255,255,0.10);\ - font-size: 1em;\ - transition: background 0.2s;\ - }\ - .lm-home-tag.focus { background: #fff; color: #000; }\ - .lm-home-info { padding: 0.2em 1em 1em; }\ - .lm-home-info__row {\ - padding: 0.9em 1em;\ - margin-bottom: 0.55em;\ - border-radius: 0.45em;\ - background: rgba(255,255,255,0.06);\ - color: #d7deea;\ - line-height: 1.4;\ - }\ - .lm-section-title {\ - width: 100%;\ - padding: 0.45em 0.7em 0.2em;\ - box-sizing: border-box;\ - font-size: 1.35em;\ - font-weight: 700;\ - color: #f3f6fa;\ - }\ - .lm-card {\ - display: inline-block;\ - vertical-align: top;\ - width: 16.6666%;\ - padding: 0.7em;\ - box-sizing: border-box;\ - transition: transform 0.2s ease;\ - }\ - .lm-card.focus {\ - transform: scale(1.07);\ - z-index: 10;\ - position: relative;\ - }\ - .lm-card__view {\ - position: relative;\ - padding-bottom: 100%;\ - background: #202020;\ - border-radius: 0.45em;\ - overflow: hidden;\ - box-shadow: 0 0.5em 1em rgba(0,0,0,0.3);\ - }\ - .lm-card.focus .lm-card__view {\ - box-shadow: 0 0.8em 2em rgba(0,0,0,0.6);\ - }\ - .lm-card.focus .lm-card__view::after {\ - border: solid 0.25em #fff;\ - content: "";\ - display: block;\ - position: absolute;\ - left: 0;\ - top: 0;\ - right: 0;\ - bottom: 0;\ - border-radius: 0.45em;\ - z-index: 5;\ - }\ - .lm-card__img {\ - position: absolute;\ - top: 0;\ - left: 0;\ - width: 100%;\ - height: 100%;\ - object-fit: cover;\ - opacity: 0;\ - transition: opacity 0.3s ease;\ - }\ - .loaded .lm-card__img { opacity: 1; }\ - .lm-card__badge {\ - position: absolute;\ - bottom: 0.55em;\ - right: 0.55em;\ - background: rgba(0,0,0,0.72);\ - color: #fff;\ - padding: 0.2em 0.55em;\ - border-radius: 0.3em;\ - font-size: 0.78em;\ - font-weight: 700;\ - z-index: 2;\ - }\ - .lm-card__info {\ - min-height: 3.6em;\ - margin-top: 0.55em;\ - overflow: hidden;\ - }\ - .lm-card__title {\ - font-size: 1.05em;\ - font-weight: 700;\ - white-space: nowrap;\ - overflow: hidden;\ - text-overflow: ellipsis;\ - }\ - .lm-card__subtitle {\ - font-size: 0.9em;\ - color: #aaa;\ - white-space: nowrap;\ - overflow: hidden;\ - text-overflow: ellipsis;\ - margin-top: 0.18em;\ - }\ - .lm-full { padding: 2em; width: 100%; }\ - .lm-full__wrapper { width: 100%; max-width: 1200px; }\ - .lm-full__header {\ - display: flex;\ - align-items: center;\ - gap: 2em;\ - margin-bottom: 1.6em;\ - padding: 1.6em;\ - border-radius: 1.1em;\ - background: linear-gradient(135deg, var(--lm-hero-tint, rgba(56,62,82,0.38)) 0%, rgba(18,20,26,0.55) 100%);\ - transition: background 0.3s;\ - }\ - .lm-full__header.focus { outline: 2px solid rgba(255,255,255,0.55); outline-offset: -2px; }\ - .lm-full__poster { width: 260px; flex-shrink: 0; }\ - .lm-full__poster-img {\ - width: 100%;\ - aspect-ratio: 1 / 1;\ - object-fit: cover;\ - border-radius: 0.9em;\ - box-shadow: 0 1.4em 3em rgba(0,0,0,0.55), 0 0.3em 0.9em rgba(0,0,0,0.35);\ - opacity: 0;\ - transition: opacity 0.3s ease;\ - }\ - .lm-full__actions { margin-top: 1.3em; display: flex; flex-wrap: wrap; gap: 0.7em; }\ - .lm-small-btn {\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - min-width: 10.5em;\ - background: rgba(255, 255, 255, 0.10);\ - border-radius: 999px;\ - padding: 0.72em 1.4em;\ - font-size: 1em;\ - font-weight: 700;\ - transition: background 0.2s, color 0.2s;\ - }\ - .lm-small-btn--primary { background: #f5f5f7; color: #111; }\ - .lm-small-btn.focus { background: #fff; color: #000; outline: 2px solid rgba(255,255,255,0.8); }\ - .lm-full__title { font-size: 2.5em; font-weight: 800; letter-spacing: -0.012em; line-height: 1.08; margin-bottom: 0.22em; }\ - .lm-full__artist { font-size: 1.28em; color: rgba(235,235,245,0.72); margin-bottom: 0.45em; }\ - .lm-full__meta { font-size: 0.98em; color: rgba(235,235,245,0.55); font-weight: 600; }\ - .lm-full__empty { padding: 2em 1em; color: rgba(235,235,245,0.55); font-size: 1.05em; }\ - .lm-full__year { font-size: 1.1em; margin-bottom: 0.7em; }\ - .lm-full__status { font-size: 1.02em; color: rgba(235,235,245,0.55); font-weight: 700; }\ - .lm-track {\ - display: flex;\ - align-items: center;\ - gap: 1em;\ - padding: 0.85em 1em;\ - border-bottom: 1px solid rgba(255,255,255,0.05);\ - border-radius: 0.6em;\ - transition: background 0.2s;\ - }\ - .lm-track:hover { background: rgba(255,255,255,0.06); }\ - .lm-track--current {\ - background: rgba(255,255,255,0.08);\ - }\ - .lm-track.focus { background: #fff; color: #000; }\ - .lm-track.focus .lm-track__num, .lm-track.focus .lm-track__meta, .lm-track.focus .lm-track__time { color: rgba(0,0,0,0.68); }\ - .lm-track__num { width: 2.4em; color: rgba(235,235,245,0.45); font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; flex-shrink: 0; }\ - .lm-track__num-play { display: none; font-size: 0.82em; }\ - .lm-track.focus .lm-track__num-text, .lm-track:hover .lm-track__num-text { display: none; }\ - .lm-track.focus .lm-track__num-play, .lm-track:hover .lm-track__num-play { display: inline; }\ - .lm-track__thumb { width: 3.1em; height: 3.1em; border-radius: 0.5em; overflow: hidden; flex-shrink: 0; background: rgba(255,255,255,0.06); }\ - .lm-track__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }\ - .lm-track__body { flex: 1; min-width: 0; }\ - .lm-track__name { font-size: 1.06em; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\ - .lm-track__meta { font-size: 0.86em; color: rgba(235,235,245,0.5); margin-top: 0.24em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\ - .lm-track__time { width: 4.2em; text-align: right; color: rgba(235,235,245,0.5); font-weight: 600; font-variant-numeric: tabular-nums; flex-shrink: 0; }\ - .lm-track--current .lm-track__name, .lm-track--current .lm-track__num-text { color: #ffffff; }\ - .lm-full__tracks { padding-bottom: 5em; }\ - .player-video.lm-player-video--music {\ - position: relative;\ - background: #101012 !important;\ - overflow: hidden;\ - }\ - .player-video.lm-player-video--music .player-video__display,\ - .player-video.lm-player-video--music .player-video__video,\ - .player-video.lm-player-video--music video {\ - opacity: 0 !important;\ - }\ - .player-panel__music-lyrics {\ - min-width: 3.35em;\ - padding-left: 0.65em;\ - padding-right: 0.65em;\ - font-size: 0.92em;\ - font-weight: 800;\ - letter-spacing: 0;\ - text-transform: none;\ - }\ - .player-panel__music-lyrics.active {\ - background: #fff;\ - color: #111318;\ - }\ - .player-panel__music-lyrics-offset {\ - min-width: 2.25em;\ - padding-left: 0.45em;\ - padding-right: 0.45em;\ - font-size: 1.05em;\ - font-weight: 800;\ - letter-spacing: 0;\ - text-transform: none;\ - }\ - .player-panel__music-controls {\ - flex-shrink: 0;\ - }\ - .player-panel__music-controls .player-panel__music-lyrics {\ - width: auto;\ - min-width: 3.35em;\ - padding-left: 1em;\ - padding-right: 1em;\ - border-radius: 5em;\ - }\ - .player-panel__music-controls .player-panel__music-lyrics-offset {\ - width: 3em;\ - min-width: 3em;\ - padding-left: 0.9em;\ - padding-right: 0.9em;\ - border-radius: 50%;\ - }\ - .lm-player-visual {\ - position: fixed;\ - inset: 0;\ - z-index: 0;\ - display: flex;\ - align-items: center;\ - justify-content: center;\ - overflow: hidden;\ - color: #f5f5f7;\ - pointer-events: none;\ - background: #101012;\ - }\ - .lm-player-visual__backdrop {\ - position: absolute;\ - inset: -3em;\ - background-size: cover;\ - background-position: center;\ - filter: blur(52px) saturate(150%);\ - opacity: 0.46;\ - transform: scale(1.12);\ - }\ - .lm-player-visual__backdrop::after {\ - content: "";\ - position: absolute;\ - inset: 0;\ - background: radial-gradient(circle at 50% 42%, rgba(255,255,255,0.10) 0%, rgba(16,16,18,0.34) 38%, rgba(10,10,12,0.88) 100%);\ - }\ - .lm-player-visual__top {\ - position: absolute;\ - top: max(2.1em, env(safe-area-inset-top));\ - right: max(2.1em, env(safe-area-inset-right));\ - z-index: 3;\ - display: flex;\ - align-items: center;\ - gap: 0.65em;\ - pointer-events: auto;\ - }\ - .lm-lyrics-offset {\ - display: flex;\ - align-items: center;\ - gap: 0.32em;\ - }\ - .lm-lyrics-offset__control {\ - width: 2.3em;\ - height: 2.3em;\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - flex: 0 0 auto;\ - border-radius: 999px;\ - background: rgba(255,255,255,0.14);\ - color: rgba(245,245,247,0.92);\ - font-size: 1em;\ - font-weight: 800;\ - font-variant-numeric: tabular-nums;\ - box-shadow: inset 0 0 0 1px rgba(255,255,255,0.16);\ - }\ - .lm-lyrics-offset__value { width: 4.7em; }\ - .lm-lyrics-offset__control.focus {\ - background: rgba(255,255,255,0.94);\ - color: #111318;\ - }\ - .lm-player-visual__lyrics-offset { display: none; }\ - .lm-player-visual--lyrics.lm-player-visual--lyrics-synced .lm-player-visual__lyrics-offset { display: flex; }\ - .lm-player-visual__lyrics-toggle {\ - padding: 0.58em 1.05em;\ - border-radius: 999px;\ - background: rgba(255,255,255,0.14);\ - color: rgba(245,245,247,0.92);\ - font-size: 1em;\ - font-weight: 750;\ - backdrop-filter: blur(18px);\ - -webkit-backdrop-filter: blur(18px);\ - box-shadow: inset 0 0 0 1px rgba(255,255,255,0.16), 0 0.8em 1.8em rgba(0,0,0,0.24);\ - }\ - .lm-player-visual__lyrics-toggle.focus,\ - .lm-player-visual__lyrics-toggle.active {\ - background: rgba(255,255,255,0.92);\ - color: #111318;\ - }\ - .lm-player-visual__center {\ - position: relative;\ - z-index: 1;\ - display: flex;\ - flex-direction: column;\ - align-items: center;\ - justify-content: center;\ - width: min(78vw, 32em);\ - text-align: center;\ - transform: translateY(-2vh);\ - }\ - .lm-player-visual__art-view {\ - display: flex;\ - flex-direction: column;\ - align-items: center;\ - justify-content: center;\ - width: 100%;\ - }\ - .lm-player-visual__art {\ - width: min(46vh, 42vw, 24em);\ - aspect-ratio: 1 / 1;\ - border-radius: 1.35em;\ - overflow: hidden;\ - background: rgba(255,255,255,0.08);\ - box-shadow: 0 2.2em 4.5em rgba(0,0,0,0.48), 0 0.45em 1.2em rgba(0,0,0,0.32);\ - }\ - .lm-player-visual__art img {\ - width: 100%;\ - height: 100%;\ - object-fit: cover;\ - display: block;\ - }\ - .lm-player-visual__title {\ - max-width: 100%;\ - margin-top: 1.05em;\ - font-size: 2.15em;\ - line-height: 1.12;\ - font-weight: 800;\ - white-space: nowrap;\ - overflow: hidden;\ - text-overflow: ellipsis;\ - text-shadow: 0 0.2em 0.9em rgba(0,0,0,0.38);\ - }\ - .lm-player-visual__artist {\ - max-width: 100%;\ - margin-top: 0.32em;\ - font-size: 1.18em;\ - color: rgba(235,235,245,0.72);\ - white-space: nowrap;\ - overflow: hidden;\ - text-overflow: ellipsis;\ - text-shadow: 0 0.2em 0.9em rgba(0,0,0,0.34);\ - }\ - .lm-player-visual__lyrics-view {\ - display: none;\ - width: min(84vw, 54em);\ - max-height: min(70vh, 34em);\ - position: relative;\ - pointer-events: auto;\ - }\ - .lm-player-visual--lyrics .lm-player-visual__center {\ - width: min(84vw, 54em);\ - transform: translateY(0);\ - }\ - .lm-player-visual--lyrics .lm-player-visual__art-view { display: none; }\ - .lm-player-visual--lyrics .lm-player-visual__lyrics-view { display: block; }\ - .lm-player-visual__lyrics-scroll {\ - max-height: min(70vh, 34em);\ - overflow-y: auto;\ - -webkit-overflow-scrolling: touch;\ - padding: 1.2em 1.45em 1.6em;\ - border-radius: 1.15em;\ - background: rgba(10,10,12,0.34);\ - box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);\ - text-align: left;\ - }\ - .lm-player-visual__lyrics-follow {\ - display: none;\ - position: absolute;\ - right: 1.1em;\ - bottom: 1.1em;\ - z-index: 2;\ - padding: 0.48em 0.85em;\ - border-radius: 999px;\ - background: rgba(255,255,255,0.9);\ - color: #111318;\ - font-size: 0.92em;\ - font-weight: 800;\ - box-shadow: 0 0.8em 2em rgba(0,0,0,0.28);\ - }\ - .lm-player-visual--lyrics-manual .lm-player-visual__lyrics-follow { display: block; }\ - .lm-player-visual__lyrics-follow.focus { background: #fff; color: #000; }\ - .lm-player-visual__lyrics-line {\ - padding: 0.34em 0.12em;\ - font-size: 1.42em;\ - line-height: 1.32;\ - font-weight: 800;\ - color: rgba(245,245,247,0.42);\ - transition: color 0.22s ease, transform 0.22s ease;\ - }\ - .lm-player-visual__lyrics-line.active {\ - color: #ffffff;\ - transform: translateX(0.18em);\ - }\ - .lm-player-visual__lyrics-line--empty { font-size: 1em; opacity: 0.64; }\ - .lm-player-visual__lyrics-plain {\ - white-space: pre-wrap;\ - font-size: 1.22em;\ - line-height: 1.54;\ - font-weight: 650;\ - color: rgba(245,245,247,0.88);\ - }\ - .lm-player-visual__lyrics-message {\ - min-height: 8em;\ - display: flex;\ - align-items: center;\ - justify-content: center;\ - text-align: center;\ - font-size: 1.2em;\ - font-weight: 700;\ - color: rgba(245,245,247,0.72);\ - }\ - .lm-ios-full-player {\ - position: fixed;\ - inset: 0;\ - z-index: 1002;\ - display: none;\ - color: #f5f5f7;\ - background: #101012;\ - overflow: hidden;\ - }\ - .lm-ios-full-player--visible { display: block; }\ - .lm-ios-full-player .selector.focus, .lm-ios-player .selector.focus { outline: 2px solid #f5f5f7; outline-offset: 2px; }\ - body.lm-ios-full-player-open .lm-ios-player { display: none !important; }\ - .lm-ios-full-player__backdrop {\ - position: absolute;\ - inset: -2em;\ - background-size: cover;\ - background-position: center;\ - filter: blur(46px) saturate(160%);\ - opacity: 0.52;\ - transform: scale(1.1);\ - }\ - .lm-ios-full-player__backdrop::after {\ - content: "";\ - position: absolute;\ - inset: 0;\ - background: linear-gradient(180deg, var(--lm-full-tint, rgba(52,52,60,0.42)) 0%, rgba(13,13,16,0.78) 52%, rgba(10,10,12,0.94) 100%);\ - }\ - .lm-ios-full-player__shell {\ - position: relative;\ - height: 100%;\ - box-sizing: border-box;\ - display: flex;\ - flex-direction: column;\ - overflow: hidden;\ - padding: calc(env(safe-area-inset-top, 0px) + 0.8em) 1.05em calc(env(safe-area-inset-bottom, 0px) + 1.2em);\ - transition: transform 0.18s cubic-bezier(0.2,0.8,0.2,1), opacity 0.18s ease;\ - will-change: transform;\ - }\ - .lm-ios-full-player--gesture-dragging .lm-ios-full-player__shell { transition: none; }\ - .lm-ios-full-player__grabber {\ - width: 2.6em;\ - height: 0.3em;\ - flex-shrink: 0;\ - border-radius: 999px;\ - background: rgba(255,255,255,0.30);\ - margin: 0 auto 0.75em;\ - }\ - .lm-ios-full-player__head {\ - display: flex;\ - align-items: center;\ - justify-content: space-between;\ - gap: 0.8em;\ - width: 100%;\ - max-width: 46em;\ - margin: 0 auto 1.05em;\ - }\ - .lm-ios-full-player__head-title {\ - min-width: 0;\ - flex: 1;\ - text-align: center;\ - font-size: 0.92em;\ - font-weight: 700;\ - color: rgba(245,245,247,0.72);\ - white-space: nowrap;\ - overflow: hidden;\ - text-overflow: ellipsis;\ - }\ - .lm-ios-full-player__tool {\ - width: 2.65em;\ - height: 2.65em;\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - flex-shrink: 0;\ - border-radius: 999px;\ - background: rgba(255,255,255,0.07);\ - color: rgba(255,255,255,0.85);\ - }\ - .lm-ios-full-player__tool svg { width: 1.15em; height: 1.15em; display: block; }\ - .lm-ios-full-player__hero {\ - max-width: 34em;\ - width: 100%;\ - margin: 0 auto;\ - text-align: center;\ - min-height: 0;\ - flex: 1 1 auto;\ - display: flex;\ - flex-direction: column;\ - justify-content: center;\ - }\ - .lm-ios-full-player__art {\ - width: min(82vw, 23em, 46vh);\ - aspect-ratio: 1 / 1;\ - margin: 0 auto 1.35em;\ - border-radius: 0.95em;\ - overflow: hidden;\ - background: rgba(255,255,255,0.08);\ - box-shadow: 0 1.6em 3.6em rgba(0,0,0,0.55), 0 0.3em 1em rgba(0,0,0,0.35);\ - transition: transform 0.38s cubic-bezier(0.2,0.9,0.3,1.18), box-shadow 0.38s ease;\ - }\ - .lm-ios-full-player--paused .lm-ios-full-player__art {\ - transform: scale(0.86);\ - box-shadow: 0 0.9em 2.2em rgba(0,0,0,0.42);\ - }\ - .lm-ios-full-player__art img {\ - width: 100%;\ - height: 100%;\ - object-fit: cover;\ - display: block;\ - }\ - .lm-ios-full-player__title {\ - font-size: 1.62em;\ - letter-spacing: -0.012em;\ - line-height: 1.16;\ - font-weight: 800;\ - display: -webkit-box;\ - -webkit-line-clamp: 2;\ - -webkit-box-orient: vertical;\ - white-space: normal;\ - overflow: hidden;\ - }\ - .lm-ios-full-player__artist {\ - margin-top: 0.35em;\ - color: rgba(235,235,245,0.68);\ - font-size: 1.02em;\ - white-space: nowrap;\ - overflow: hidden;\ - text-overflow: ellipsis;\ - }\ - .lm-ios-full-player__progress {\ - margin-top: 1.35em;\ - }\ - .lm-ios-full-player__seek {\ - -webkit-appearance: none;\ - appearance: none;\ - width: 100%;\ - height: 2.1em;\ - margin: 0;\ - background: transparent;\ - --lm-seek-fill: 0%;\ - }\ - .lm-ios-full-player__seek::-webkit-slider-runnable-track {\ - height: 0.36em;\ - border-radius: 999px;\ - background: linear-gradient(90deg, rgba(245,245,247,0.95) 0%, rgba(245,245,247,0.95) var(--lm-seek-fill), rgba(255,255,255,0.22) var(--lm-seek-fill), rgba(255,255,255,0.22) 100%);\ - }\ - .lm-ios-full-player__seek::-webkit-slider-thumb {\ - -webkit-appearance: none;\ - width: 1.15em;\ - height: 1.15em;\ - margin-top: -0.4em;\ - border-radius: 50%;\ - background: #ffffff;\ - box-shadow: 0 0.12em 0.5em rgba(0,0,0,0.45);\ - }\ - .lm-ios-full-player__times {\ - display: flex;\ - justify-content: space-between;\ - margin-top: 0.32em;\ - color: rgba(235,235,245,0.58);\ - font-size: 0.82em;\ - font-variant-numeric: tabular-nums;\ - }\ - .lm-ios-full-player__actions {\ - display: flex;\ - align-items: center;\ - justify-content: center;\ - gap: clamp(0.6em, 3.4vw, 1.4em);\ - margin-top: 1.35em;\ - }\ - .lm-ios-full-player__btn--mini {\ - width: 3em;\ - height: 3em;\ - position: relative;\ - color: rgba(245,245,247,0.55);\ - }\ - .lm-ios-full-player__btn--mini svg { width: 1.25em; height: 1.25em; }\ - .lm-ios-full-player__btn--mini.active { color: #ffffff; }\ - .lm-ios-full-player__btn--mini.active::after {\ - content: "";\ - position: absolute;\ - bottom: 0.3em;\ - left: 50%;\ - transform: translateX(-50%);\ - width: 0.28em;\ - height: 0.28em;\ - border-radius: 50%;\ - background: #ffffff;\ - }\ - .lm-ios-full-player__btn {\ - width: 3.9em;\ - height: 3.9em;\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - border-radius: 999px;\ - color: #fff;\ - background: transparent;\ - }\ - .lm-ios-full-player__btn:active { background: rgba(255,255,255,0.10); }\ - .lm-ios-full-player__btn svg { width: 1.7em; height: 1.7em; display: block; }\ - .lm-ios-full-player__btn--primary {\ - width: 4.9em;\ - height: 4.9em;\ - background: #f5f5f7;\ - color: #111;\ - box-shadow: 0 0.5em 1.4em rgba(0,0,0,0.35);\ - }\ - .lm-ios-full-player__btn--primary:active { background: #e4e4e8; }\ - .lm-ios-full-player__btn--primary svg { width: 1.9em; height: 1.9em; }\ - .lm-ios-full-player__quick-actions {\ - width: 100%;\ - display: grid;\ - grid-template-columns: repeat(6, minmax(0, 1fr));\ - gap: 0.45em;\ - margin-top: 1.1em;\ - }\ - .lm-ios-full-player__quick {\ - min-width: 0;\ - height: 3.2em;\ - display: inline-flex;\ - flex-direction: column;\ - align-items: center;\ - justify-content: center;\ - gap: 0.32em;\ - padding: 0 0.35em;\ - border-radius: 1em;\ - background: rgba(255,255,255,0.06);\ - color: rgba(245,245,247,0.72);\ - font-size: 0.74em;\ - font-weight: 700;\ - white-space: nowrap;\ - }\ - .lm-ios-full-player__quick.active { background: rgba(255,255,255,0.20); color: #fff; }\ - .lm-ios-full-player__quick svg { width: 1em; height: 1em; display: block; flex-shrink: 0; }\ - .lm-ios-full-player__quick span { overflow: hidden; text-overflow: ellipsis; }\ - .lm-ios-full-player__timer-status {\ - display: none;\ - margin-top: 0.55em;\ - color: rgba(235,235,245,0.62);\ - font-size: 0.82em;\ - font-weight: 700;\ - }\ - .lm-ios-full-player__timer-status--visible { display: block; }\ - .lm-ios-full-player__stop {\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - min-width: 8.5em;\ - min-height: 2.6em;\ - margin: 0.8em auto 0;\ - padding: 0 1.05em;\ - border-radius: 999px;\ - background: transparent;\ - color: rgba(245,245,247,0.5);\ - font-size: 0.86em;\ - font-weight: 700;\ - }\ - .lm-ios-full-player__queue-summary {\ - flex: 0 0 auto;\ - padding: 0 0.75em 0.65em;\ - color: rgba(235,235,245,0.58);\ - font-size: 0.86em;\ - font-weight: 700;\ - }\ - .lm-ios-full-player__queue-list {\ - min-height: 0;\ - flex: 1 1 auto;\ - overflow-y: auto;\ - -webkit-overflow-scrolling: touch;\ - padding-bottom: 1em;\ - }\ - .lm-ios-full-player__queue-divider {\ - text-align: center;\ - font-size: 0.74em;\ - letter-spacing: 0.06em;\ - text-transform: uppercase;\ - color: rgba(255, 255, 255, 0.45);\ - padding: 0.9em 0 0.35em;\ - }\ - .lm-ios-full-player__queue-item {\ - display: flex;\ - align-items: center;\ - gap: 0.82em;\ - min-height: 4.4em;\ - padding: 0.55em 0.62em;\ - border-radius: 0.72em;\ - color: #f5f5f7;\ - }\ - .lm-ios-full-player__queue-item--current { background: rgba(255,255,255,0.12); }\ - .lm-ios-full-player__queue-item.focus { background: #f5f5f7; color: #111; }\ - .lm-ios-full-player__queue-img {\ - width: 3.2em;\ - height: 3.2em;\ - flex-shrink: 0;\ - border-radius: 0.45em;\ - overflow: hidden;\ - background: rgba(255,255,255,0.08);\ - }\ - .lm-ios-full-player__queue-img img { width: 100%; height: 100%; object-fit: cover; display: block; }\ - .lm-ios-full-player__queue-body { min-width: 0; flex: 1; text-align: left; }\ - .lm-ios-full-player__queue-track { font-size: 0.98em; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\ - .lm-ios-full-player__queue-artist { margin-top: 0.18em; color: rgba(235,235,245,0.58); font-size: 0.84em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\ - .lm-ios-full-player__queue-item.focus .lm-ios-full-player__queue-artist { color: rgba(0,0,0,0.58); }\ - .lm-ios-full-player__queue-time { width: 3.5em; flex-shrink: 0; color: rgba(235,235,245,0.58); font-size: 0.8em; font-weight: 700; font-variant-numeric: tabular-nums; text-align: right; }\ - .lm-ios-full-player__queue-item.focus .lm-ios-full-player__queue-time { color: rgba(0,0,0,0.58); }\ - .lm-ios-full-player__btn.disabled { opacity: 0.36; pointer-events: none; }\ - .lm-ios-full-player__sheet {\ - position: absolute;\ - inset: 0;\ - z-index: 4;\ - display: none;\ - align-items: flex-end;\ - justify-content: center;\ - background: rgba(0,0,0,0.48);\ - }\ - .lm-ios-full-player--sheet-open .lm-ios-full-player__sheet { display: flex; }\ - .lm-ios-full-player__sheet-panel {\ - width: 100%;\ - max-width: 44em;\ - max-height: 72%;\ - display: flex;\ - flex-direction: column;\ - overflow: hidden;\ - border-radius: 1.2em 1.2em 0 0;\ - background: rgba(28,28,30,0.96);\ - border: 1px solid rgba(255,255,255,0.10);\ - box-shadow: 0 -1em 2.6em rgba(0,0,0,0.42);\ - transition: transform 0.18s cubic-bezier(0.2,0.8,0.2,1);\ - will-change: transform;\ - }\ - .lm-ios-full-player--sheet-dragging .lm-ios-full-player__sheet-panel { transition: none; }\ - .lm-ios-full-player__sheet-panel::before {\ - content: "";\ - width: 2.8em;\ - height: 0.28em;\ - flex: 0 0 auto;\ - margin: 0.55em auto 0;\ - border-radius: 999px;\ - background: rgba(235,235,245,0.34);\ - }\ - .lm-ios-full-player__sheet-head {\ - display: flex;\ - align-items: center;\ - justify-content: space-between;\ - gap: 1em;\ - padding: 1em 1em 0.65em;\ - flex: 0 0 auto;\ - }\ - .lm-ios-full-player__sheet-title { font-size: 1.08em; font-weight: 800; }\ - .lm-ios-full-player__sheet-close {\ - width: 2.3em;\ - height: 2.3em;\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - border-radius: 999px;\ - background: rgba(255,255,255,0.10);\ - color: rgba(255,255,255,0.86);\ - flex-shrink: 0;\ - }\ - .lm-ios-full-player__sheet-close svg { width: 1em; height: 1em; display: block; }\ - .lm-ios-full-player__sheet-body {\ - min-height: 0;\ - overflow-y: auto;\ - -webkit-overflow-scrolling: touch;\ - padding: 0.2em 0.75em 1em;\ - }\ - .lm-ios-full-player__lyrics {\ - padding: 0.3em 0.6em 1.4em;\ - }\ - .lm-ios-full-player__lyrics-offset {\ - position: sticky;\ - top: 0;\ - z-index: 2;\ - justify-content: center;\ - padding: 0.35em 0 0.55em;\ - background: rgba(28,28,30,0.96);\ - }\ - .lm-ios-full-player__lyrics-line {\ - padding: 0.4em 0.3em;\ - font-size: 1.16em;\ - font-weight: 700;\ - line-height: 1.32;\ - color: rgba(245,245,247,0.4);\ - border-radius: 0.5em;\ - transition: color 0.25s ease;\ - }\ - .lm-ios-full-player__lyrics-line.active { color: #ffffff; }\ - .lm-ios-full-player__lyrics-line--empty { font-size: 0.9em; opacity: 0.6; }\ - .lm-ios-full-player__lyrics--plain {\ - white-space: pre-wrap;\ - font-size: 1em;\ - line-height: 1.5;\ - color: rgba(245,245,247,0.85);\ - }\ - .lm-ios-full-player__sheet-body--queue {\ - display: flex;\ - flex-direction: column;\ - overflow: hidden;\ - padding-left: 0.75em;\ - padding-right: 0.75em;\ - }\ - .lm-ios-full-player__sheet-body--queue .lm-ios-full-player__queue-list {\ - overflow-y: auto;\ - -webkit-overflow-scrolling: touch;\ - }\ - .lm-ios-full-player__sheet-row {\ - display: flex;\ - align-items: center;\ - gap: 0.8em;\ - min-height: 3.7em;\ - padding: 0.7em 0.75em;\ - border-radius: 0.82em;\ - color: #f5f5f7;\ - }\ - .lm-ios-full-player__sheet-row.active { background: rgba(255,255,255,0.13); }\ - .lm-ios-full-player__sheet-row.disabled { opacity: 0.45; pointer-events: none; }\ - .lm-ios-full-player__sheet-row-main { min-width: 0; flex: 1; }\ - .lm-ios-full-player__sheet-row-title { font-size: 0.98em; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\ - .lm-ios-full-player__sheet-row-subtitle { margin-top: 0.18em; color: rgba(235,235,245,0.58); font-size: 0.84em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\ - .lm-ios-full-player__sheet-row-trailing { flex-shrink: 0; color: rgba(235,235,245,0.62); font-size: 0.82em; font-weight: 700; }\ - .lm-ios-full-player__sheet-message { padding: 1.4em 0.8em; color: rgba(235,235,245,0.68); font-weight: 700; text-align: center; }\ - .lm-ios-full-player__sheet-message--loading { color: rgba(245,245,247,0.9); }\ - .lm-ios-player {\ - position: fixed;\ - left: 0.85em;\ - right: 0.85em;\ - top: 74%;\ - bottom: auto;\ - transform: translateY(-50%);\ - z-index: 1001;\ - display: none;\ - align-items: center;\ - justify-content: space-between;\ - gap: 1em;\ - padding: 0.9em 1em;\ - border-radius: 1.28em;\ - background: rgba(28, 28, 30, 0.72);\ - border: 1px solid rgba(255,255,255,0.08);\ - box-shadow: 0 1.1em 2.5em rgba(0,0,0,0.28);\ - backdrop-filter: blur(24px) saturate(180%);\ - -webkit-backdrop-filter: blur(24px) saturate(180%);\ - flex-direction: column;\ - align-items: stretch;\ - }\ - .lm-ios-player--visible { display: flex; }\ - .lm-ios-player__top { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.9em; width: 100%; }\ - .lm-ios-player__meta { min-width: 0; flex: 1; }\ - .lm-ios-player__title { font-size: 1.08em; font-weight: 700; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\ - .lm-ios-player__artist { margin-top: 0.2em; font-size: 0.9em; color: rgba(235,235,245,0.62); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\ - .lm-ios-player__tools { display: flex; align-items: center; gap: 0.48em; flex-shrink: 0; }\ - .lm-ios-player__queue {\ - height: 2.4em;\ - display: inline-flex;\ - align-items: center;\ - gap: 0.42em;\ - padding: 0 0.82em;\ - border-radius: 999px;\ - background: rgba(255,255,255,0.10);\ - color: rgba(255,255,255,0.92);\ - font-size: 0.82em;\ - font-weight: 700;\ - white-space: nowrap;\ - }\ - .lm-ios-player__queue svg { width: 1em; height: 1em; display: block; }\ - .lm-ios-player__toolbtn {\ - width: 2.4em;\ - height: 2.4em;\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - border-radius: 999px;\ - background: rgba(255,255,255,0.10);\ - color: rgba(255,255,255,0.9);\ - }\ - .lm-ios-player__toolbtn svg { width: 1em; height: 1em; display: block; }\ - .lm-ios-player__progress { display: flex; align-items: center; gap: 0.65em; width: 100%; }\ - .lm-ios-player__time {\ - width: 3.15em;\ - font-size: 0.76em;\ - color: rgba(235,235,245,0.58);\ - font-variant-numeric: tabular-nums;\ - text-align: center;\ - flex-shrink: 0;\ - }\ - .lm-ios-player__seek {\ - flex: 1;\ - margin: 0;\ - accent-color: #f5f5f7;\ - }\ - .lm-ios-player__actions { display: flex; align-items: center; justify-content: center; gap: 0.9em; width: 100%; }\ - .lm-ios-player__btn {\ - width: 3.05em;\ - height: 3.05em;\ - display: inline-flex;\ - align-items: center;\ - justify-content: center;\ - border-radius: 999px;\ - color: #fff;\ - background: rgba(255,255,255,0.10);\ - box-sizing: border-box;\ - }\ - .lm-ios-player__btn svg {\ - width: 1.14em;\ - height: 1.14em;\ - display: block;\ - }\ - .lm-ios-player__btn--primary {\ - width: 3.55em;\ - height: 3.55em;\ - background: #f5f5f7;\ - color: #111;\ - box-shadow: 0 0.3em 0.9em rgba(255,255,255,0.16);\ - }\ - .lm-ios-player__btn--primary svg { width: 1.3em; height: 1.3em; }\ - .lm-ios-player__toolbtn--close { color: rgba(255,255,255,0.76); }\ - .lm-ios-player__btn.disabled, .lm-ios-player__queue.disabled {\ - opacity: 0.38;\ - }\ - @media screen and (max-width: 1200px) { .lm-card { width: 20%; } }\ - @media screen and (max-width: 900px) { .lm-card { width: 25%; } .lm-full__header { flex-direction: column; } .lm-full__poster { width: 220px; } }\ - @media screen and (max-width: 700px) {\ - .lm-card { width: 33.3333%; }\ - .lm-ios-full-player__shell { padding-left: 0.88em; padding-right: 0.88em; }\ - .lm-ios-full-player__head { margin-bottom: 0.65em; }\ - .lm-ios-full-player__tool { width: 2.48em; height: 2.48em; }\ - .lm-ios-full-player__art { width: min(74vw, 18em, 38vh); margin-bottom: 0.95em; border-radius: 0.95em; }\ - .lm-ios-full-player__title { font-size: 1.18em; }\ - .lm-ios-full-player__artist { font-size: 0.9em; }\ - .lm-ios-full-player__progress { margin-top: 0.82em; }\ - .lm-ios-full-player__actions { gap: 0.76em; margin-top: 0.86em; }\ - .lm-ios-full-player__btn { width: 2.9em; height: 2.9em; }\ - .lm-ios-full-player__btn--primary { width: 3.55em; height: 3.55em; }\ - /* на телефоне 6 чипов в ряд обрезают подписи (ellipsis) —\ - два ряда по три читаются лучше */\ - .lm-ios-full-player__quick-actions { gap: 0.4em; margin-top: 0.82em; grid-template-columns: repeat(3, minmax(0, 1fr)); }\ - .lm-ios-full-player__quick { height: 3.25em; padding: 0 0.2em; font-size: 0.68em; }\ - .lm-ios-full-player__timer-status { margin-top: 0.42em; font-size: 0.76em; }\ - .lm-ios-full-player__stop { min-height: 2.32em; margin-top: 0.58em; font-size: 0.78em; }\ - .lm-ios-full-player__queue-item { min-height: 4.05em; padding: 0.48em 0.5em; }\ - .lm-ios-full-player__queue-img { width: 2.9em; height: 2.9em; }\ - .lm-ios-full-player__sheet-panel { max-height: 78%; }\ - .lm-ios-player { left: 0.72em; right: 0.72em; top: 75%; padding: 0.92em 0.94em; gap: 0.86em; }\ - .lm-ios-player__title { font-size: 1em; }\ - .lm-ios-player__artist { font-size: 0.86em; }\ - .lm-ios-player__tools { gap: 0.42em; }\ - .lm-ios-player__queue { height: 2.28em; padding: 0 0.74em; font-size: 0.78em; }\ - .lm-ios-player__toolbtn { width: 2.28em; height: 2.28em; }\ - .lm-ios-player__progress { gap: 0.5em; }\ - .lm-ios-player__time { width: 2.9em; font-size: 0.74em; }\ - .lm-ios-player__actions { gap: 0.78em; }\ - .lm-ios-player__btn { width: 2.92em; height: 2.92em; }\ - .lm-ios-player__btn svg { width: 1.08em; height: 1.08em; }\ - .lm-ios-player__btn--primary { width: 3.35em; height: 3.35em; }\ - .lm-ios-player__btn--primary svg { width: 1.22em; height: 1.22em; }\ - .lm-ios-player__toolbtn svg { width: 0.96em; height: 0.96em; }\ - }\ - @media screen and (max-height: 560px) {\ - .lm-ios-full-player__shell { padding-top: calc(env(safe-area-inset-top, 0px) + 0.45em); padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 0.55em); }\ - .lm-ios-full-player__head { margin-bottom: 0.38em; }\ - .lm-ios-full-player__tool { width: 2.18em; height: 2.18em; }\ - .lm-ios-full-player__art { width: min(46vw, 12em, 30vh); margin-bottom: 0.48em; border-radius: 0.82em; }\ - .lm-ios-full-player__title { font-size: 1.02em; -webkit-line-clamp: 1; }\ - .lm-ios-full-player__artist { margin-top: 0.18em; font-size: 0.78em; }\ - .lm-ios-full-player__progress { margin-top: 0.52em; }\ - .lm-ios-full-player__actions { gap: 0.62em; margin-top: 0.58em; }\ - .lm-ios-full-player__btn { width: 2.55em; height: 2.55em; }\ - .lm-ios-full-player__btn--primary { width: 3.05em; height: 3.05em; }\ - .lm-ios-full-player__quick-actions { margin-top: 0.58em; gap: 0.34em; }\ - .lm-ios-full-player__quick { height: 2.75em; font-size: 0.62em; }\ - .lm-ios-full-player__timer-status { margin-top: 0.28em; font-size: 0.68em; }\ - .lm-ios-full-player__stop { min-height: 2.05em; margin-top: 0.34em; font-size: 0.68em; }\ - }\ - </style>\ - '); - - Lampa.Component.add('lampac_music_home', MusicComponent); - Lampa.Component.add('lampac_music_search', MusicComponent); - Lampa.Component.add('lampac_music_bookmarks', MusicBookmarksFull); - Lampa.Component.add('lampac_music_entries', MusicEntriesFull); - Lampa.Component.add('lampac_music_section', MusicSectionFull); - Lampa.Component.add('lampac_music_artist', MusicArtistFull); - Lampa.Component.add('lampac_music_album', MusicAlbumFull); - - if (window.appready) add(); - else { - Lampa.Listener.follow('app', function (event) { - if (event.type === 'ready') add(); - }); - } - } - - createMusic(); -})();