From 7b8e28b3bfd9e46ff14b4bd62094274441d74368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A4=D0=B5=D0=B4=D0=BE=D1=80=D0=BE=D0=B2=20=D0=94=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D1=80=D0=B8=D0=B8=CC=86?= Date: Fri, 2 Oct 2026 12:11:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5:=20=D1=82=D0=BE=D0=BC=20./mods:/lampac?= =?UTF-8?q?/mods=20=D0=B2=20compose=20=D0=B8=20=D0=BA=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D0=B8=2010=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=81=20enable:true;=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20SkipModules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENT.md | 24 +- README.md | 39 +- config/init.conf | 24 +- docker-compose.yaml | 6 +- mods/AdminPanel/AdminPanel.csproj | 14 + mods/AdminPanel/AdminPanelController.cs | 366 + mods/AdminPanel/ConfigSectionGroups.cs | 107 + mods/AdminPanel/ModInit.cs | 18 + mods/AdminPanel/README.md | 98 + mods/AdminPanel/auth.html | 401 + mods/AdminPanel/index.html | 3538 +++ mods/AdminPanel/manifest.json | 9 + mods/DatabaseEditor/DatabaseEditor.csproj | 14 + .../DatabaseEditorController.cs | 328 + mods/DatabaseEditor/DatabaseStore.cs | 1512 ++ mods/DatabaseEditor/ModInit.cs | 64 + mods/DatabaseEditor/README.md | 42 + mods/DatabaseEditor/app.js | 649 + mods/DatabaseEditor/index.html | 151 + mods/DatabaseEditor/manifest.json | 6 + mods/DatabaseEditor/style.css | 439 + mods/ExternalBind/ExternalBind.csproj | 13 + mods/ExternalBind/ModInit.cs | 47 + mods/ExternalBind/README.md | 45 + mods/ExternalBind/manifest.json | 7 + mods/LogUserRequest-Lite/AppDbContext.cs | 88 + .../Controllers/ApiController.cs | 229 + mods/LogUserRequest-Lite/GlobalUsings.cs | 24 + .../LogUserRequest-Lite/LogUserRequest.csproj | 13 + .../LogUserRequestListener.cs | 182 + mods/LogUserRequest-Lite/ModInit.cs | 375 + .../Models/DTO/ApiResponse.cs | 11 + .../Models/DTO/JournalItemDto.cs | 14 + .../Models/DTO/StatsDto.cs | 17 + mods/LogUserRequest-Lite/README.md | 336 + mods/LogUserRequest-Lite/auth.html | 110 + mods/LogUserRequest-Lite/index.html | 487 + mods/LogUserRequest-Lite/manifest.json | 6 + mods/Music/Controllers/ApiController.cs | 295 + mods/Music/Controllers/AuthController.cs | 41 + mods/Music/Controllers/PlaybackController.cs | 169 + mods/Music/Controllers/SearchController.cs | 87 + mods/Music/GlobalUsings.cs | 11 + mods/Music/ModInit.cs | 136 + mods/Music/Models/Contracts.cs | 309 + mods/Music/Models/MusicIsrc.cs | 39 + mods/Music/ModuleConf.cs | 78 + mods/Music/Music.csproj | 19 + .../Music/Providers/Abstractions/Contracts.cs | 62 + .../Providers/AppleMusic/AppleMusicSupport.cs | 481 + .../Audio/Sefon/SefonAudioProvider.cs | 62 + .../Providers/Audio/Sefon/SefonSupport.cs | 672 + .../SoundCloud/SoundCloudAudioProvider.cs | 87 + .../Audio/YouTube/YouTubeAudioProvider.cs | 361 + .../Audio/YouTube/YouTubeAudioSupport.cs | 708 + .../Providers/Audio/Z3Fm/Z3FmAudioProvider.cs | 51 + .../Music/Providers/Audio/Z3Fm/Z3FmSupport.cs | 626 + .../Auth/SoundCloud/SoundCloudAuthProvider.cs | 56 + .../AppleMusic/AppleMusicDiscoveryProvider.cs | 390 + .../SoundCloud/SoundCloudDiscoveryProvider.cs | 63 + .../Spotify/SpotifyDiscoveryProvider.cs | 314 + .../VkTopChart/VkTopChartDiscoveryProvider.cs | 183 + .../YouTubeMusic/YouTubeMusicSearchSupport.cs | 707 + .../MusicBrainzMetadataProvider.cs | 1553 ++ .../Providers/MusicMap/MusicMapSupport.cs | 298 + .../Providers/SoundCloud/SoundCloudDtos.cs | 108 + .../SoundCloud/SoundCloudSupport.Audio.cs | 748 + .../SoundCloud/SoundCloudSupport.Auth.cs | 87 + .../SoundCloud/SoundCloudSupport.Catalog.cs | 1378 ++ .../SoundCloud/SoundCloudSupport.Import.cs | 276 + .../Providers/SoundCloud/SoundCloudSupport.cs | 662 + .../Music/Providers/Spotify/SpotifySupport.cs | 1395 ++ .../Providers/Yandex/YandexMusicProviders.cs | 266 + .../Providers/Yandex/YandexMusicSupport.cs | 378 + mods/Music/README.md | 359 + mods/Music/SQL/MusicContext.cs | 614 + mods/Music/Services/Auth/MusicAuthService.cs | 26 + .../Services/Auth/MusicAuthStorageService.cs | 103 + .../Services/Auth/MusicProfileIdentity.cs | 38 + .../Services/Catalog/MusicCatalogService.cs | 797 + .../Services/Catalog/MusicProviderRegistry.cs | 111 + mods/Music/Services/Http/MusicHttp.cs | 121 + mods/Music/Services/Http/MusicProxyService.cs | 135 + .../Images/DiscogsArtistImageService.cs | 342 + .../Services/Images/MusicImageProxyService.cs | 346 + .../Services/Lyrics/MusicLyricsService.cs | 284 + .../Lyrics/YouTubeMusicLyricsFallback.cs | 773 + .../Services/Playback/MusicDailyMixService.cs | 1598 ++ .../Playback/MusicPlaybackHistoryService.cs | 197 + .../Playback/MusicPlaybackModeService.cs | 21 + .../Services/Playback/MusicPlaybackService.cs | 215 + .../Services/Playback/MusicPlaylistService.cs | 327 + .../Services/Playback/MusicRadioService.cs | 753 + mods/Music/Services/Playback/MusicResolver.cs | 493 + .../Services/Playback/MusicStatsService.cs | 258 + .../Playback/MusicStreamModeService.cs | 78 + .../Playback/MusicStreamRelayService.cs | 282 + .../Playback/MusicStreamTicketService.cs | 125 + .../Playlists/MusicPlaylistImportHelpers.cs | 77 + .../Playlists/MusicUserPlaylistService.cs | 637 + mods/Music/Services/Utils/MusicJson.cs | 17 + mods/Music/docs/client-architecture.md | 158 + .../docs/ios-embedded-lockscreen-seek.md | 110 + mods/Music/manifest.json | 17 + mods/Music/plugin.js | 17934 ++++++++++++++++ mods/Telemetry/AppDbContext.cs | 48 + .../Controllers/TelemetryController.cs | 539 + mods/Telemetry/ModInit.cs | 148 + mods/Telemetry/Models/SqlModels.cs | 56 + mods/Telemetry/RequestListener.cs | 273 + mods/Telemetry/Telemetry.csproj | 13 + mods/Telemetry/index.html | 2337 ++ mods/Telemetry/manifest.json | 11 + .../Controllers/TgNotifyController.cs | 131 + mods/Tg-notify.bot/ModInit.cs | 1825 ++ mods/Tg-notify.bot/Models/TelegramBotConf.cs | 30 + mods/Tg-notify.bot/README.md | 163 + mods/Tg-notify.bot/Tg-notify.bot.csproj | 28 + mods/Tg-notify.bot/init.merge.example.json | 13 + mods/Tg-notify.bot/manifest.json | 12 + .../Tg-notify.bot/references/Telegram.Bot.dll | Bin 0 -> 2542592 bytes mods/Tg-notify.bot/tg-notify.js | 407 + mods/Tracks/Controller.cs | 148 + mods/Tracks/ModInit.cs | 46 + mods/Tracks/ModuleConf.cs | 8 + mods/Tracks/README.md | 30 + mods/Tracks/Tracks.csproj | 19 + mods/Tracks/manifest.json | 9 + mods/Tracks/plugin.js | 401 + mods/Transcoding/Controller.cs | 594 + mods/Transcoding/ModInit.cs | 54 + mods/Transcoding/ModuleConf.cs | 153 + mods/Transcoding/README.md | 35 + mods/Transcoding/Services/TranscodingJob.cs | 130 + .../Transcoding/Services/TranscodingModels.cs | 25 + .../Services/TranscodingService.cs | 1003 + mods/Transcoding/Transcoding.csproj | 19 + mods/Transcoding/manifest.json | 9 + mods/Transcoding/plugin.js | 356 + mods/WatchTogether/ModInit.cs | 72 + mods/WatchTogether/README.md | 44 + mods/WatchTogether/RelayServer.cs | 456 + mods/WatchTogether/ShadowHost.cs | 826 + mods/WatchTogether/WatchTogether.csproj | 26 + mods/WatchTogether/WatchTogetherController.cs | 92 + mods/WatchTogether/manifest.json | 10 + mods/WatchTogether/plugin.js | 3651 ++++ mods/WatchTogether/web/i18n.js | 324 + mods/WatchTogether/web/index.html | 1456 ++ mods/WatchTogether/web/lparty-web.js | 1898 ++ mods/WatchTogether/web/names.js | 386 + 151 files changed, 68625 insertions(+), 44 deletions(-) create mode 100644 mods/AdminPanel/AdminPanel.csproj create mode 100644 mods/AdminPanel/AdminPanelController.cs create mode 100644 mods/AdminPanel/ConfigSectionGroups.cs create mode 100644 mods/AdminPanel/ModInit.cs create mode 100644 mods/AdminPanel/README.md create mode 100644 mods/AdminPanel/auth.html create mode 100644 mods/AdminPanel/index.html create mode 100644 mods/AdminPanel/manifest.json create mode 100644 mods/DatabaseEditor/DatabaseEditor.csproj create mode 100644 mods/DatabaseEditor/DatabaseEditorController.cs create mode 100644 mods/DatabaseEditor/DatabaseStore.cs create mode 100644 mods/DatabaseEditor/ModInit.cs create mode 100644 mods/DatabaseEditor/README.md create mode 100644 mods/DatabaseEditor/app.js create mode 100644 mods/DatabaseEditor/index.html create mode 100644 mods/DatabaseEditor/manifest.json create mode 100644 mods/DatabaseEditor/style.css create mode 100644 mods/ExternalBind/ExternalBind.csproj create mode 100644 mods/ExternalBind/ModInit.cs create mode 100644 mods/ExternalBind/README.md create mode 100644 mods/ExternalBind/manifest.json create mode 100644 mods/LogUserRequest-Lite/AppDbContext.cs create mode 100644 mods/LogUserRequest-Lite/Controllers/ApiController.cs create mode 100644 mods/LogUserRequest-Lite/GlobalUsings.cs create mode 100644 mods/LogUserRequest-Lite/LogUserRequest.csproj create mode 100644 mods/LogUserRequest-Lite/LogUserRequestListener.cs create mode 100644 mods/LogUserRequest-Lite/ModInit.cs create mode 100644 mods/LogUserRequest-Lite/Models/DTO/ApiResponse.cs create mode 100644 mods/LogUserRequest-Lite/Models/DTO/JournalItemDto.cs create mode 100644 mods/LogUserRequest-Lite/Models/DTO/StatsDto.cs create mode 100644 mods/LogUserRequest-Lite/README.md create mode 100644 mods/LogUserRequest-Lite/auth.html create mode 100644 mods/LogUserRequest-Lite/index.html create mode 100644 mods/LogUserRequest-Lite/manifest.json create mode 100644 mods/Music/Controllers/ApiController.cs create mode 100644 mods/Music/Controllers/AuthController.cs create mode 100644 mods/Music/Controllers/PlaybackController.cs create mode 100644 mods/Music/Controllers/SearchController.cs create mode 100644 mods/Music/GlobalUsings.cs create mode 100644 mods/Music/ModInit.cs create mode 100644 mods/Music/Models/Contracts.cs create mode 100644 mods/Music/Models/MusicIsrc.cs create mode 100644 mods/Music/ModuleConf.cs create mode 100644 mods/Music/Music.csproj create mode 100644 mods/Music/Providers/Abstractions/Contracts.cs create mode 100644 mods/Music/Providers/AppleMusic/AppleMusicSupport.cs create mode 100644 mods/Music/Providers/Audio/Sefon/SefonAudioProvider.cs create mode 100644 mods/Music/Providers/Audio/Sefon/SefonSupport.cs create mode 100644 mods/Music/Providers/Audio/SoundCloud/SoundCloudAudioProvider.cs create mode 100644 mods/Music/Providers/Audio/YouTube/YouTubeAudioProvider.cs create mode 100644 mods/Music/Providers/Audio/YouTube/YouTubeAudioSupport.cs create mode 100644 mods/Music/Providers/Audio/Z3Fm/Z3FmAudioProvider.cs create mode 100644 mods/Music/Providers/Audio/Z3Fm/Z3FmSupport.cs create mode 100644 mods/Music/Providers/Auth/SoundCloud/SoundCloudAuthProvider.cs create mode 100644 mods/Music/Providers/Discovery/AppleMusic/AppleMusicDiscoveryProvider.cs create mode 100644 mods/Music/Providers/Discovery/SoundCloud/SoundCloudDiscoveryProvider.cs create mode 100644 mods/Music/Providers/Discovery/Spotify/SpotifyDiscoveryProvider.cs create mode 100644 mods/Music/Providers/Discovery/VkTopChart/VkTopChartDiscoveryProvider.cs create mode 100644 mods/Music/Providers/Discovery/YouTubeMusic/YouTubeMusicSearchSupport.cs create mode 100644 mods/Music/Providers/Metadata/MusicBrainz/MusicBrainzMetadataProvider.cs create mode 100644 mods/Music/Providers/MusicMap/MusicMapSupport.cs create mode 100644 mods/Music/Providers/SoundCloud/SoundCloudDtos.cs create mode 100644 mods/Music/Providers/SoundCloud/SoundCloudSupport.Audio.cs create mode 100644 mods/Music/Providers/SoundCloud/SoundCloudSupport.Auth.cs create mode 100644 mods/Music/Providers/SoundCloud/SoundCloudSupport.Catalog.cs create mode 100644 mods/Music/Providers/SoundCloud/SoundCloudSupport.Import.cs create mode 100644 mods/Music/Providers/SoundCloud/SoundCloudSupport.cs create mode 100644 mods/Music/Providers/Spotify/SpotifySupport.cs create mode 100644 mods/Music/Providers/Yandex/YandexMusicProviders.cs create mode 100644 mods/Music/Providers/Yandex/YandexMusicSupport.cs create mode 100644 mods/Music/README.md create mode 100644 mods/Music/SQL/MusicContext.cs create mode 100644 mods/Music/Services/Auth/MusicAuthService.cs create mode 100644 mods/Music/Services/Auth/MusicAuthStorageService.cs create mode 100644 mods/Music/Services/Auth/MusicProfileIdentity.cs create mode 100644 mods/Music/Services/Catalog/MusicCatalogService.cs create mode 100644 mods/Music/Services/Catalog/MusicProviderRegistry.cs create mode 100644 mods/Music/Services/Http/MusicHttp.cs create mode 100644 mods/Music/Services/Http/MusicProxyService.cs create mode 100644 mods/Music/Services/Images/DiscogsArtistImageService.cs create mode 100644 mods/Music/Services/Images/MusicImageProxyService.cs create mode 100644 mods/Music/Services/Lyrics/MusicLyricsService.cs create mode 100644 mods/Music/Services/Lyrics/YouTubeMusicLyricsFallback.cs create mode 100644 mods/Music/Services/Playback/MusicDailyMixService.cs create mode 100644 mods/Music/Services/Playback/MusicPlaybackHistoryService.cs create mode 100644 mods/Music/Services/Playback/MusicPlaybackModeService.cs create mode 100644 mods/Music/Services/Playback/MusicPlaybackService.cs create mode 100644 mods/Music/Services/Playback/MusicPlaylistService.cs create mode 100644 mods/Music/Services/Playback/MusicRadioService.cs create mode 100644 mods/Music/Services/Playback/MusicResolver.cs create mode 100644 mods/Music/Services/Playback/MusicStatsService.cs create mode 100644 mods/Music/Services/Playback/MusicStreamModeService.cs create mode 100644 mods/Music/Services/Playback/MusicStreamRelayService.cs create mode 100644 mods/Music/Services/Playback/MusicStreamTicketService.cs create mode 100644 mods/Music/Services/Playlists/MusicPlaylistImportHelpers.cs create mode 100644 mods/Music/Services/Playlists/MusicUserPlaylistService.cs create mode 100644 mods/Music/Services/Utils/MusicJson.cs create mode 100644 mods/Music/docs/client-architecture.md create mode 100644 mods/Music/docs/ios-embedded-lockscreen-seek.md create mode 100644 mods/Music/manifest.json create mode 100644 mods/Music/plugin.js create mode 100644 mods/Telemetry/AppDbContext.cs create mode 100644 mods/Telemetry/Controllers/TelemetryController.cs create mode 100644 mods/Telemetry/ModInit.cs create mode 100644 mods/Telemetry/Models/SqlModels.cs create mode 100644 mods/Telemetry/RequestListener.cs create mode 100644 mods/Telemetry/Telemetry.csproj create mode 100644 mods/Telemetry/index.html create mode 100644 mods/Telemetry/manifest.json create mode 100644 mods/Tg-notify.bot/Controllers/TgNotifyController.cs create mode 100644 mods/Tg-notify.bot/ModInit.cs create mode 100644 mods/Tg-notify.bot/Models/TelegramBotConf.cs create mode 100644 mods/Tg-notify.bot/README.md create mode 100644 mods/Tg-notify.bot/Tg-notify.bot.csproj create mode 100644 mods/Tg-notify.bot/init.merge.example.json create mode 100644 mods/Tg-notify.bot/manifest.json create mode 100755 mods/Tg-notify.bot/references/Telegram.Bot.dll create mode 100644 mods/Tg-notify.bot/tg-notify.js create mode 100644 mods/Tracks/Controller.cs create mode 100644 mods/Tracks/ModInit.cs create mode 100644 mods/Tracks/ModuleConf.cs create mode 100644 mods/Tracks/README.md create mode 100644 mods/Tracks/Tracks.csproj create mode 100644 mods/Tracks/manifest.json create mode 100644 mods/Tracks/plugin.js create mode 100644 mods/Transcoding/Controller.cs create mode 100644 mods/Transcoding/ModInit.cs create mode 100644 mods/Transcoding/ModuleConf.cs create mode 100644 mods/Transcoding/README.md create mode 100644 mods/Transcoding/Services/TranscodingJob.cs create mode 100644 mods/Transcoding/Services/TranscodingModels.cs create mode 100644 mods/Transcoding/Services/TranscodingService.cs create mode 100644 mods/Transcoding/Transcoding.csproj create mode 100644 mods/Transcoding/manifest.json create mode 100644 mods/Transcoding/plugin.js create mode 100644 mods/WatchTogether/ModInit.cs create mode 100644 mods/WatchTogether/README.md create mode 100644 mods/WatchTogether/RelayServer.cs create mode 100644 mods/WatchTogether/ShadowHost.cs create mode 100644 mods/WatchTogether/WatchTogether.csproj create mode 100644 mods/WatchTogether/WatchTogetherController.cs create mode 100644 mods/WatchTogether/manifest.json create mode 100644 mods/WatchTogether/plugin.js create mode 100644 mods/WatchTogether/web/i18n.js create mode 100644 mods/WatchTogether/web/index.html create mode 100644 mods/WatchTogether/web/lparty-web.js create mode 100644 mods/WatchTogether/web/names.js diff --git a/AGENT.md b/AGENT.md index b110aea..ae409bb 100644 --- a/AGENT.md +++ b/AGENT.md @@ -10,7 +10,7 @@ - Порт: **9118**, рабочая директория в контейнере: `/lampac` - Последняя задеплоенная версия: `1.61.2` (при установке 01.10.2026) - Git: `ssh://git@gitea.prodik.ru:222/prodik/lampac.git`, ветка **main** (эта); ветка `synology` — тот же код с абсолютными путями томов Synology NAS; remote настроен как `origin` -- Коммиты: `git add -A && git commit && git push origin main` — секреты/данные отсекает `.gitignore`; в git идут только compose, `init.conf`, `modules/`, доки, `.gitignore` +- Коммиты: `git add -A && git commit && git push origin main` — секреты/данные отсекает `.gitignore`; в git идут только compose, `init.conf`, `mods/` (включённые модули), `modules/` (резерв, не монтируется), доки, `.gitignore` ## Продакшн: Proxmox LXC @@ -26,11 +26,11 @@ | Файл | Назначение | Правила | | --- | --- | --- | -| `docker-compose.yaml` | Единственный compose-файл | тома обязательны; фиксированной сети/subnet нет (дефолтная сеть compose) | +| `docker-compose.yaml` | Единственный compose-файл | тома обязательны; фиксированной сети/subnet нет (дефолтная сеть compose). **Том `./mods:/lampac/mods` ОБЯЗАТЕЛЕН** — без него 10 модулей не загружаются (см. п.4) | | `config/init.conf` | Конфиг сервера (JSON) | hot-reload для большинства ключей (кроме `BaseModule.SkipModules` — нужен рестарт); строго валидный JSON | | `config/passwd` | Пароль root, **секрет** | не выводить в чат/логи/коммиты, в `.gitignore` | -| `modules/*/manifest.json` | Manifest-оверрайды (`enable: true`) | монтируются поверх `/lampac/module//manifest.json`; для модулей, у которых в образе `enable: false` (сейчас: `Tracks`, `Music`, `Tg-notify.bot`, `AdminPanel`) | -| `modules/LampaWeb/plugins/extensions.json` | Каталог магазина плагинов (`/extensions`) | override образного файла, добавлена секция «Lampac» (DLNA); после правки сбросить кеш `cache/static/63/` | +| `modules/*` | Старые override-копии (`manifest.json` ×4, `extensions.json`) | **не монтируются** (убраны 01.10.2026): docs https://docs.lampac.dev/installation описывают только 6 томов (`passwd`, `init.conf`, `lampainit.js`, `cache`, `database`, `mods/`); одиночные json-тома — нештатный способ | +| `mods/` | Копии 10 модулей с `enable: true` (AdminPanel, DatabaseEditor, ExternalBind, LogUserRequest-Lite, Music, Telemetry, Tg-notify.bot, Tracks, Transcoding, WatchTogether) | монтируется одним томом `./mods:/lampac/mods` — **единственный способ включить модули с `enable: false` в образе**; при обновлении образа копии пересоздавать вручную (см. п.4) | | `cache/` | Кеш | монтируется в `/lampac/cache` | | `database/` | SQLite-БД (Sync, TimeCode, SISI) | монтируется в `/lampac/database`, не удалять | | `dlna/` | Медиа/превью DLNA | монтируется в `/lampac/data/dlna`, не удалять | @@ -56,12 +56,14 @@ curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:9118/online.js # о 1. **Отвечать по-русски.** 2. После любых изменений в проекте — дополнять `README.md` и `AGENT.md`. 3. Конфиг менять только в `config/init.conf`; валидность проверять: `python3 -m json.tool config/init.conf`. -4. Включение/выключение модулей — через `BaseModule.SkipModules` в `init.conf` (добавить имя = выключить; имя = имя папки в `/lampac/module/`, группы вроде `OnlineAnime` выключаются целиком). `SkipModules` **не hot-reload** — после правки нужен `docker compose restart lampac`. Модули, у которых в образе `manifest.json: enable: false` (`Tracks`, `Music`, `Tg-notify.bot`, `AdminPanel`), включаются только manifest-оверрайдом: копия манифеста с `enable: true` в `modules//manifest.json` + монтирование в compose. Список модулей: https://docs.lampac.dev/modules/overview - - Текущее состояние: выключены `OnlineAnime`, `OnlineUKR`, `OnlinePaid`, `OnlineGEO`, `Transcoding`; включены все остальные (TorrServer, Sync/TimeCode/Storage/SyncEvents, Catalog, JacRed, DLNA, SISI, Tracks, Music, Tg-notify.bot, AdminPanel, WebLog). - - Плагины Lampa: `/online.js`, `/sisi.js`, `/dlna.js` и т.д. Большинство Lampac сам подключает клиенту через `/lampainit.js` (см. ниже про `initPlugins`); при необходимости плагин можно добавить вручную по URL. - - **Два уровня включения модуля:** (1) `SkipModules`/manifest — модуль скомпилирован и работает на сервере; (2) `LampaWeb.initPlugins.` в `init.conf` — плагин попадает в список, который `/lampainit.js` добавляет клиенту Lampa (`Lampa.Plugins.add`). Без второго флага сервер отвечает, но пункт в Lampa не появится (так было с `dlna` — флаг `initPlugins.dlna` добавлен в init.conf). Дефолты флагов — в `current.conf` секция `LampaWeb.initPlugins`. - - **Магазин плагинов** в Lampa (Настройки → Плагины) читает `/extensions` → `LampaWeb/plugins/extensions.json` (каталог образа). Свои записи добавляются через override-файл в `modules/LampaWeb/plugins/extensions.json` (монтирован в compose). Ответ кешируется `Staticache` на диске: `cache/static//` — после правки удалить соответствующий файл (`grep -rl secuses cache/static/`) или весь каталог static, затем перезапросить. -5. Новые пользовательские модули класть в `mods//` (монтировать в `/lampac/mods/`), переопределения плагинов — `plugins/lampainit.js` → `/lampac/plugins/override/lampainit.js`. +4. Включение/выключение модулей — через `BaseModule.SkipModules` в `init.conf` (добавить имя = выключить; имя = имя папки в `/lampac/module/`, группы вроде `OnlineAnime` выключаются целиком). `SkipModules` **не hot-reload** — после правки нужен `docker compose restart lampac`. Модули с `manifest.json: enable: false` в образе (`Tracks`, `Music`, `Tg-notify.bot`, `AdminPanel`, `ExternalBind` и др.) включаются только штатно: копия **всего каталога** модуля с `enable: true`, смонтированная в `/lampac/module//`, либо каталог в `mods//`. Одиночные тома `manifest.json`/`extensions.json` **не использовать** — docs не описывают таких томов (https://docs.lampac.dev/installation, https://docs.lampac.dev/modules/overview). Список модулей: https://docs.lampac.dev/modules/overview + - Текущее состояние (проверка: `docker logs lampac | grep -c "loaded module"` → **117**): `SkipModules` **пуст** — включены ВСЕ модули, включая группы `OnlineAnime`/`OnlineUKR`/`OnlinePaid`/`OnlineGEO` и `Transcoding`. 10 модулей с `enable: false` в образе подняты через копии в `mods/` (таблица «Файлы»). Если после обновления образа какой-то модуль из `mods/` перестал компилироваться — пересоздать копию: `docker cp lampac:/lampac/module/ ./mods/` и выставить `enable: true` в её manifest.json, затем `docker compose up -d`. + - Плагины Lampa: `/online.js`, `/sisi.js`, `/dlna.js`, `/ts.js` и т.д. Большинство Lampac сам подключает клиенту через `/lampainit.js` (см. ниже про `initPlugins`); при необходимости плагин можно добавить вручную по URL. + - **Два уровня включения модуля:** (1) `SkipModules`/manifest — модуль скомпилирован и работает на сервере; (2) `LampaWeb.initPlugins.` в `init.conf` — плагин попадает в список `{initiale}`, который `/lampainit.js` добавляет клиенту Lampa (`Lampa.Plugins.add`). Без второго флага сервер отвечает, но пункт в Lampa не появится (так было с `dlna` — флаг `initPlugins.dlna` добавлен в init.conf). Сейчас подключены: `dlna: true` (в `init.conf`), из `base.conf`: `online`, `sisi`, `torrserver`, `jacred`, `cubProxy`, `tmdbProxy`, `timecode`, `pirate_store`. Особый случай `jacred`: модуль `/jacred.js` не регистрирует — флаг подставляет `{jachost}` = хост Lampac в шаблон, клиент пишет его в `jackett_url` (проверка: `curl /api/v1.0/conf` → 200). + - **Override `plugins/override/lampainit.js` (том `./plugins/lampainit.js`) модули НЕ подключает**: `{initiale}` сервер собирает из `LampaWeb.initPlugins` и подставляет в шаблон после чтения (`ApiController.cs:695`), т.е. и при override тоже. Override — только для правки шаблона настроек клиента (`window.lampa_settings`); в проекте не используется. + - **Магазин плагинов** в Lampa (Настройки → Плагины) читает `/extensions` → образный `LampaWeb/plugins/extensions.json` (override-монтирование отключено). **Дефект образа:** 15 записей каталога ссылаются на `127.0.0.1:9118/plugins/*.js` (pubtorr, checker, hotkeys, torrents...) — HTTP-роута `/plugins/` в коде нет, все эти ссылки битые, устанавливать из магазина такие записи нельзя. Рабочий путь — прямой внешний URL (ручной установкой в Лампе или через `LampaWeb.customPlugins`; так подключён PubTorr: `https://lampame.github.io/main/pubtorr.js`). Если плагин уже установился с битым URL — удалить его в Лампе (Настройки → Плагины), иначе дедуп не даст добавить правильный (семейство `pubtorr.js`). Ответ кешируется `Staticache` на диске: `cache/static//` — после правки удалить соответствующий файл (`grep -rl secuses cache/static/`) или весь каталог static, затем перезапросить. + - **Плагины модулей для ручной установки в Лампе** (Настройки → Плагины → по URL): `/tracks.js`, `/music.js`, `/tg-notify.js`, `/watchtogether.js` (проверяются `curl -o /dev/null -w '%{http_code}' http://localhost:9118/.js` → 200). +5. Новые пользовательские модули и копии включаемых — класть в `mods//` (один том `./mods:/lampac/mods`), переопределения шаблона настроек клиента — `plugins/lampainit.js` → `/lampac/plugins/override/lampainit.js` (модули через это НЕ подключаются — см. п.4). 6. Не раскрывать пароль из `passwd`, не коммитить его, не выводить в логи. 7. Не открывать порт 9118 в интернет без файрвола/reverse proxy; модули `DLNA`, `Tracks`, `Transcoding`, `GStreamer`, `Catalog` не предназначены для публичного доступа. 8. Образ пиннить по тегу при необходимости воспроизводимости (в compose сейчас `:latest`). @@ -86,7 +88,7 @@ curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:9118/online.js # о - **Контейнер нестабилен / Chromium падает** — проверить `shm_size: 1024mb` в compose (обязательно для Playwright). - **Не стартует после правки init.conf** — проверить JSON: `python3 -m json.tool config/init.conf`; бэкапы в `/lampac/database/backup/init/`. -- **Модуль не загрузился** — смотреть `docker compose logs lampac | grep -E "skip compilation|loaded module"`; для модулей с `enable: false` в образе проверить manifest-оверрайд и его монтирование в compose. +- **Модуль не загрузился** — смотреть `docker compose logs lampac | grep -E "skip compilation|loaded module"`; для модулей с `enable: false` в образе — копия в `mods/` с `enable: true` (см. п.4), монтирование одиночных manifest не используется. - **Tg-notify молчит** — нужен `bot_token` в секции `TelegramBot` init.conf (без него long polling не стартует, в логе предупреждение). - **Занят порт 9118** — `lsof -i :9118` или сменить порт в `init.conf` (`listen.port`) и в `ports` compose-файла одновременно. - **Сеть** — в compose нет фиксированной сети/subnet; используется дефолтная сеть проекта (`lampac_default`). diff --git a/README.md b/README.md index bdccdc9..b8080c4 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,8 @@ Lampac/ ├── config/ │ ├── init.conf # Конфигурация сервера (JSON, hot-reload) │ └── passwd # Пароль root (СЕКРЕТ, не коммитить) -├── modules/ # Overridе-файлы, монтируемые поверх файлов образа -│ ├── Tracks/manifest.json -│ ├── Music/manifest.json -│ ├── Tg-notify.bot/manifest.json -│ ├── AdminPanel/manifest.json -│ └── LampaWeb/plugins/extensions.json # каталог магазина плагинов (+DLNA) +├── modules/ # Резервные копии manifest-файлов (НЕ монтируются, см. ниже) +├── mods/ # Копии модулей с enable: true (том ./mods:/lampac/mods) ├── cache/ # Кеш (монтируется в /lampac/cache) ├── database/ # SQLite-БД: Sync, TimeCode, SISI (→ /lampac/database) ├── dlna/ # Данные DLNA (медиа/превью, → /lampac/data/dlna) @@ -40,6 +36,8 @@ Lampac/ └── .gitignore ``` +**Важно:** том `./mods:/lampac/mods` в `docker-compose.yaml` **обязателен** — без него копии модулей не попадают в контейнер, и 10 модулей (Tracks, Music, Tg-notify.bot, WatchTogether, AdminPanel, ExternalBind, LogUserRequest-Lite, Telemetry, Transcoding, DatabaseEditor) не загружаются (в логах нет `loaded module: `, роуты вроде `/tracks.js` отдают 404, в Lampa при установке из магазина/по URL — ошибка «модуль нет»). + ## Быстрый старт Требования: Docker 24+ с Compose v2 (проверка: `docker compose version`). @@ -65,7 +63,6 @@ docker compose ps | Плагин Online для Lampa | http://localhost:9118/online.js | | Плагин SISI (18+) | http://localhost:9118/sisi.js | | Плагин DLNA для Lampa | http://localhost:9118/dlna.js | -| AdminPanel (вход) | http://localhost:9118/adminpanel/auth | | Проверка версии | http://localhost:9118/version?type=hash | Чтобы подключить сервер к Lampa: откройте Lampa → Настройки → «Другие сервисы» (или вставьте адрес вручную) → `http://IP_МАШИНЫ:9118`. @@ -86,7 +83,7 @@ docker compose ps "BaseModule": { "SkipModules": [/* выключенные модули, см. ниже */] }, - "LampaWeb": { "initPlugins": { "dlna": true } }, // попадание плагина в список клиента Lampa + "LampaWeb": { "initPlugins": { "dlna": true } }, // флаги клиентских плагинов в /lampainit.js (online, sisi, torrserver, jacred, cubProxy и др. уже true в base.conf) "TorrServer": { "enable": true, "tsport": 9085, "releases": "latest" }, "TelegramBot": { "enable": true, "bot_token": "", "lampac_host": "http://127.0.0.1:9118" }, "chromium": { "enable": true }, // Playwright для обхода JS-защит @@ -97,14 +94,18 @@ docker compose ps ### Текущий набор модулей -**Выключены** (`BaseModule.SkipModules`): `OnlineAnime`, `OnlineUKR`, `OnlinePaid`, `OnlineGEO`, `Transcoding`, `WebLog`. +**Включены все модули** (проверка: `docker logs lampac | grep -c "loaded module"` → 117): -**Включены дополнительно к стартовому набору:** +- `BaseModule.SkipModules` в `init.conf` — **пуст** (`[]`): ни один модуль и ни одна группа (`OnlineAnime`, `OnlinePaid`, `OnlineGEO`...) не исключён. +- 10 модулей, которые в образе идут с `"enable": false` (`AdminPanel`, `DatabaseEditor`, `ExternalBind`, `LogUserRequest-Lite`, `Music`, `Telemetry`, `Tg-notify.bot`, `Tracks`, `Transcoding`, `WatchTogether`), подняты штатным способом по docs: копии их каталогов с `"enable": true` лежат в `mods/` на хосте и смонтированы **одним томом** `./mods:/lampac/mods`. -- `Catalog`, `JacRed`, `Sync` (вместе с `Storage`, `SyncEvents`, `TimeCode`), `TorrServer`, `DLNA`, `SISI` — убраны из `SkipModules`. -- `Tracks`, `Music`, `Tg-notify.bot`, `AdminPanel` — включены через manifest-оверрайды в `modules/*/manifest.json` (в образе у них `enable: false`; файлы монтируются поверх `/lampac/module//manifest.json`). +Клиентские плагины (что подключается в Lampa автоматически через `/lampainit.js`) управляются флагами `LampaWeb.initPlugins` в `init.conf`. Сейчас подключены: `dlna` (флаг в `init.conf`), а из `base.conf`: `online`, `sisi`, `torrserver` (`/ts.js`), `jacred` (записывает `jackett_url` — отдельного `/jacred.js` у модуля нет), `cubProxy`, `tmdbProxy`, `timecode`, `pirate_store`. -Чтобы выключить/включить любой другой модуль — добавьте или уберите его имя в `SkipModules` (имя = имя папки в `/lampac/module/`, включая группы вроде `OnlineAnime`). Изменение применяется после `docker compose restart lampac` (SkipModules не hot-reload). Убрать из списка группу `Sync` целиком отключит и `TimeCode`/`Storage`/`SyncEvents`. +### Установка плагинов через веб (Настройки → Плагины) + +- **По URL (работает всегда):** «Добавить вручную» → `http://IP:9118/tracks.js`, `http://IP:9118/music.js`, `http://IP:9118/tg-notify.js`, `http://IP:9118/watchtogether.js` и т.д. Полный список плагинов модулей: https://docs.lampac.dev/modules/overview +- **Внешние URL** из каталога магазина — работают. +- **Дефект образа:** 15 записей каталога магазина ссылаются на `127.0.0.1:9118/plugins/*.js` (pubtorr, checker, hotkeys, torrents...) — таких файлов не существует (HTTP-роута `/plugins/` нет в коде). Такие записи из магазина не устанавливайте — используйте прямой внешний URL (для PubTorr это `https://lampame.github.io/main/pubtorr.js`, подключён через `LampaWeb.customPlugins`). Список всех модулей и маршрутов: https://docs.lampac.dev/modules/overview @@ -120,7 +121,7 @@ docker compose ps ### Tg-notify (Telegram-бот) -Модуль `Tg-notify.bot` загружен, но long polling не стартует, пока не задан токен: впишите `bot_token` (от [@BotFather](https://t.me/BotFather)) в секцию `TelegramBot` файла `init.conf` — конфиг подхватится автоматически. Опционально: `tmdb_api_key`, `trakt_client_id`, `kp_api_key` (описание полей — в README модуля). +Модуль `Tg-notify.bot` загружен (включён через `mods/Tg-notify.bot`), но long polling не стартует, пока не задан токен: впишите `bot_token` (от [@BotFather](https://t.me/BotFather)) в секцию `TelegramBot` файла `init.conf` — конфиг подхватится автоматически. Опционально: `tmdb_api_key`, `trakt_client_id`, `kp_api_key` (описание полей — в README модуля). Клиентский плагин — `http://IP:9118/tg-notify.js`. ### DLNA в интерфейсе Lampa @@ -137,11 +138,11 @@ docker compose ps После правки перезагрузите страницу Lampa — `lampainit.js` сам добавит плагин (`Lampa.Plugins.add`) и он появится в Настройки → Плагины (со статусом включён). Ручное добавление по ссылке тоже работает: `http://IP_МАШИНЫ:9118/dlna.js`. Прямой UI модуля — `http://IP_МАШИНЫ:9118/dlna`. -**В магазине плагинов** (Настройки → Плагины/«Магазин») каталог отдаётся из `/extensions` — файла `LampaWeb/plugins/extensions.json` внутри образа, где DLNA по умолчанию нет. Добавлена запись через override: `modules/LampaWeb/plugins/extensions.json` (секция «Lampac» в начале каталога) монтируется поверх образного файла. Магазин кешируется (`Staticache`) — после правки этого файла кеш надо сбросить: удалить `cache/static/63/*` (каталог по хешу URL) или перезапустить контейнер. +**В магазине плагинов** (Настройки → Плагины/«Магазин») каталог отдаётся из `/extensions` — образного файла `LampaWeb/plugins/extensions.json` (DLNA по умолчанию нет, но плагин и так подключается автодобавлением из `initPlugins`). Магазин кешируется (`Staticache`) — после любых правок образного файла кеш надо сбросить: удалить `cache/static/63/*` (каталог по хешу URL) или весь `cache/static/`. ### AdminPanel -Веб-админка Lampac: правка `init.conf`/`current.conf`, пользователи `users.json`. +Веб-админка Lampac: правка `init.conf`/`current.conf`, пользователи `users.json`. Модуль включён (через `mods/AdminPanel`). - Вход: `http://IP_МАШИНЫ:9118/adminpanel/auth` — пароль из `config/passwd` (cookie `accspasswd`, живёт 180 дней). - Панель: `http://IP_МАШИНЫ:9118/adminpanel` (без входа — 302 на страницу auth). @@ -158,7 +159,7 @@ docker compose pull docker compose up -d ``` -Данные (`init.conf`, `passwd`, `cache/`, `database/`, `modules/`, `dlna/`) переживают обновление, т.к. смонтированы томами. +Данные (`init.conf`, `passwd`, `mods/`, `cache/`, `database/`, `dlna/`) переживают обновление, т.к. смонтированы томами. Внимание: содержимое `mods/` — копии из образа, при обновлении образа его вручную не переносят (при расхождении версий модуль может не скомпилироваться — тогда пересоздать копию из свежего контейнера, см. AGENT.md). ## Перенос на другой сервер @@ -170,7 +171,7 @@ docker compose up -d docker-compose.yaml config/init.conf # вся конфигурация config/passwd # СЕКРЕТ — переносить вне git (scp/rsync) -modules/ # override-файлы (manifest ×4, extensions.json) +mods/ # копии модулей с enable: true (иначе 10 модулей не загрузятся) README.md AGENT.md .gitignore ``` @@ -247,7 +248,7 @@ git commit -m "описание" git push origin main ``` -В git попадают только: `docker-compose.yaml`, `config/init.conf`, `modules/**`, `README.md`, `AGENT.md`, `.gitignore`. Секреты (`passwd`), `cache/`, `database/`, `dlna/` — в `.gitignore`. +В git попадают только: `docker-compose.yaml`, `config/init.conf`, `mods/**` (включённые модули), `modules/**` (резервные копии манифестов, не монтируются), `README.md`, `AGENT.md`, `.gitignore`. Секреты (`passwd`), `cache/`, `database/`, `dlna/` — в `.gitignore`. ## Ссылки diff --git a/config/init.conf b/config/init.conf index f31641f..76b9164 100644 --- a/config/init.conf +++ b/config/init.conf @@ -7,13 +7,7 @@ "localhost": "127.0.0.1" }, "BaseModule": { - "SkipModules": [ - "OnlineAnime", - "OnlineUKR", - "OnlinePaid", - "OnlineGEO", - "Transcoding" - ] + "SkipModules": [] }, "TorrServer": { "enable": true, @@ -27,8 +21,20 @@ }, "LampaWeb": { "initPlugins": { - "dlna": true - } + "dlna": true, + "tracks": true, + "catalog": true, + "dorama": true, + "sync": true, + "backup": true, + "watch_together": true + }, + "customPlugins": [ + { "url": "https://lampame.github.io/main/pubtorr.js", "status": 1, "name": "PubTorr", "author": "lampame" }, + { "url": "{localhost}/music.js", "status": 1, "name": "Music", "author": "lampac" }, + { "url": "{localhost}/tg-notify.js", "status": 1, "name": "Tg-notify", "author": "lampac" }, + { "url": "{localhost}/gst.js", "status": 1, "name": "GStreamer", "author": "lampac" } + ] }, "chromium": { "enable": true, diff --git a/docker-compose.yaml b/docker-compose.yaml index cf710c9..56cc0f0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,9 +17,5 @@ services: - ./config/init.conf:/lampac/init.conf - ./cache:/lampac/cache - ./database:/lampac/database - - ./modules/Tracks/manifest.json:/lampac/module/Tracks/manifest.json:ro - - ./modules/Music/manifest.json:/lampac/module/Music/manifest.json:ro - - ./modules/Tg-notify.bot/manifest.json:/lampac/module/Tg-notify.bot/manifest.json:ro - - ./modules/AdminPanel/manifest.json:/lampac/module/AdminPanel/manifest.json:ro - - ./modules/LampaWeb/plugins/extensions.json:/lampac/module/LampaWeb/plugins/extensions.json:ro - ./dlna:/lampac/data/dlna + - ./mods:/lampac/mods diff --git a/mods/AdminPanel/AdminPanel.csproj b/mods/AdminPanel/AdminPanel.csproj new file mode 100644 index 0000000..7c53670 --- /dev/null +++ b/mods/AdminPanel/AdminPanel.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + library + true + AdminPanel + + + + + + + diff --git a/mods/AdminPanel/AdminPanelController.cs b/mods/AdminPanel/AdminPanelController.cs new file mode 100644 index 0000000..e389c2d --- /dev/null +++ b/mods/AdminPanel/AdminPanelController.cs @@ -0,0 +1,366 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using Shared; +using Shared.Attributes; +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AdminPanel; + +[Authorization(redirectUri: "/adminpanel/auth")] +public class AdminPanelController : BaseController +{ + const string InitFile = "init.conf"; + const string CurrentFile = "current.conf"; + const string UsersFile = "users.json"; + + [HttpGet] + [AllowAnonymous] + [Route("/adminpanel/auth")] + public ActionResult Auth() + { + var path = Path.Combine(ModInit.modpath, "auth.html"); + var html = System.IO.File.ReadAllText(path, Encoding.UTF8); + return Content(html, "text/html; charset=utf-8"); + } + + [HttpGet] + [Route("/adminpanel")] + public ActionResult Index() + { + var path = Path.Combine(ModInit.modpath, "index.html"); + var html = System.IO.File.ReadAllText(path, Encoding.UTF8); + return Content(html, "text/html; charset=utf-8"); + } + + [HttpGet] + [Route("/adminpanel/api/groups")] + public ActionResult Groups() + { + var current = LoadCurrentRoot(); + var built = ConfigSectionGroups.Build(current); + var json = JsonConvert.SerializeObject(built, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore + }); + return Content(json, "application/json; charset=utf-8"); + } + + [HttpGet] + [Route("/adminpanel/api/groups/catalog")] + public ActionResult GroupsCatalog() + { + var built = ConfigSectionGroups.BuildCatalog(); + var current = LoadCurrentRoot(); + var inCatalog = ConfigSectionGroups.CatalogRootKeys; + var orphans = current.Properties() + .Select(p => p.Name) + .Where(k => !inCatalog.Contains(k)) + .OrderBy(k => k, StringComparer.Ordinal) + .ToArray(); + if (orphans.Length > 0) + built.Add(new GroupDto("other", "Прочее", "Ключи из current, не из каталога групп.", orphans)); + + var json = JsonConvert.SerializeObject(built, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore + }); + return Content(json, "application/json; charset=utf-8"); + } + + static JObject LoadCurrentRoot() + { + try + { + if (System.IO.File.Exists(CurrentFile)) + return JObject.Parse(System.IO.File.ReadAllText(CurrentFile, Encoding.UTF8)); + } + catch (JsonException) + { + } + + if (CoreInit.CurrentConf != null) + { + try + { + return (JObject)CoreInit.CurrentConf.DeepClone(); + } + catch + { + } + } + + return new JObject(); + } + + [HttpGet] + [Route("/adminpanel/api/init")] + public ActionResult GetInit() + { + if (!System.IO.File.Exists(InitFile)) + return Content("{}", "application/json; charset=utf-8"); + + var text = System.IO.File.ReadAllText(InitFile, Encoding.UTF8); + return Content(NormalizeJsonText(text), "application/json; charset=utf-8"); + } + + [HttpGet] + [Route("/adminpanel/api/current")] + public ActionResult GetCurrent() + { + if (!System.IO.File.Exists(CurrentFile)) + { + if (CoreInit.CurrentConf != null) + return Content(CoreInit.CurrentConf.ToString(Formatting.Indented), "application/json; charset=utf-8"); + return Content("{}", "application/json; charset=utf-8"); + } + + var text = System.IO.File.ReadAllText(CurrentFile, Encoding.UTF8); + return Content(NormalizeJsonText(text), "application/json; charset=utf-8"); + } + + [HttpPost] + [Route("/adminpanel/api/init")] + public async Task SaveInit() + { + string body; + using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + body = await reader.ReadToEndAsync().ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(body)) + return AdminJsonError(400, "empty body"); + + try + { + var parsed = JToken.Parse(body); + if (parsed.Type != JTokenType.Object) + return AdminJsonError(400, "root must be a JSON object"); + + var formatted = ((JObject)parsed).ToString(Formatting.Indented); + await WriteInitAtomicAsync(formatted).ConfigureAwait(false); + return AdminJsonOk(); + } + catch (JsonException ex) + { + return AdminJsonError(400, "invalid json", ex.Message); + } + catch (IOException ex) + { + return AdminJsonError(500, "failed to write init.conf", ex.Message); + } + } + + [HttpPost] + [Route("/adminpanel/api/init/section/{key}")] + public async Task SaveInitSection(string key) + { + if (string.IsNullOrWhiteSpace(key) || key.Contains('/') || key.Contains('\\')) + return AdminJsonError(400, "invalid section key"); + + string body; + using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + body = await reader.ReadToEndAsync().ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(body)) + return AdminJsonError(400, "empty body"); + + JToken sectionToken; + try + { + sectionToken = JToken.Parse(body); + } + catch (JsonException ex) + { + return AdminJsonError(400, "invalid json", ex.Message); + } + + try + { + JObject root; + if (System.IO.File.Exists(InitFile)) + { + try + { + root = JObject.Parse(System.IO.File.ReadAllText(InitFile, Encoding.UTF8)); + } + catch (JsonException) + { + root = new JObject(); + } + } + else + root = new JObject(); + + root[key] = sectionToken.DeepClone(); + var formatted = root.ToString(Formatting.Indented); + await WriteInitAtomicAsync(formatted).ConfigureAwait(false); + return AdminJsonOk(); + } + catch (IOException ex) + { + return AdminJsonError(500, "failed to write init.conf", ex.Message); + } + } + + // tmp+Move — атомарно; на bind mount Move(..., overwrite) часто EBUSY → пишем в init.conf напрямую. + static async Task WriteInitAtomicAsync(string formatted) + { + var tmp = InitFile + ".tmp"; + await System.IO.File.WriteAllTextAsync(tmp, formatted, Encoding.UTF8).ConfigureAwait(false); + try + { + try + { + System.IO.File.Move(tmp, InitFile, overwrite: true); + } + catch (IOException ex) when (IsReplaceTargetBusy(ex)) + { + await System.IO.File.WriteAllTextAsync(InitFile, formatted, Encoding.UTF8).ConfigureAwait(false); + } + } + finally + { + try + { + if (System.IO.File.Exists(tmp)) + System.IO.File.Delete(tmp); + } + catch + { + } + } + } + + static bool IsReplaceTargetBusy(IOException ex) + { + for (Exception e = ex; e != null; e = e.InnerException) + { + if (e.Message != null && e.Message.Contains("busy", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + static ContentResult AdminJsonOk() + { + return new ContentResult + { + Content = "{\"ok\":true}", + ContentType = "application/json; charset=utf-8", + StatusCode = 200 + }; + } + + static ContentResult AdminJsonError(int status, string error, string detail = null) + { + var o = new JObject { ["error"] = error }; + if (!string.IsNullOrEmpty(detail)) + o["detail"] = detail; + return new ContentResult + { + Content = o.ToString(Formatting.None), + ContentType = "application/json; charset=utf-8", + StatusCode = status + }; + } + + static string NormalizeJsonText(string raw) + { + try + { + return JToken.Parse(raw).ToString(Formatting.Indented); + } + catch + { + return raw; + } + } + + [HttpGet] + [Route("/adminpanel/api/users-json")] + public ActionResult GetUsersJson() + { + if (!System.IO.File.Exists(UsersFile)) + return Content("[]", "application/json; charset=utf-8"); + + var text = System.IO.File.ReadAllText(UsersFile, Encoding.UTF8); + if (string.IsNullOrWhiteSpace(text)) + return Content("[]", "application/json; charset=utf-8"); + + return Content(NormalizeJsonText(text), "application/json; charset=utf-8"); + } + + [HttpPost] + [Route("/adminpanel/api/users-json")] + public async Task SaveUsersJson() + { + string body; + using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + body = await reader.ReadToEndAsync().ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(body)) + return AdminJsonError(400, "empty body"); + + try + { + var parsed = JToken.Parse(body); + if (parsed.Type != JTokenType.Array) + return AdminJsonError(400, "root must be a JSON array", "users.json must be a list of AccsUser objects"); + + foreach (var item in (JArray)parsed) + { + if (item.Type != JTokenType.Object) + return AdminJsonError(400, "invalid array item", "each element must be a JSON object"); + } + + var formatted = ((JArray)parsed).ToString(Formatting.Indented); + await WriteUsersAtomicAsync(formatted).ConfigureAwait(false); + return AdminJsonOk(); + } + catch (JsonException ex) + { + return AdminJsonError(400, "invalid json", ex.Message); + } + catch (IOException ex) + { + return AdminJsonError(500, "failed to write users.json", ex.Message); + } + } + + static async Task WriteUsersAtomicAsync(string formatted) + { + var tmp = UsersFile + ".tmp"; + await System.IO.File.WriteAllTextAsync(tmp, formatted, Encoding.UTF8).ConfigureAwait(false); + try + { + try + { + System.IO.File.Move(tmp, UsersFile, overwrite: true); + } + catch (IOException ex) when (IsReplaceTargetBusy(ex)) + { + await System.IO.File.WriteAllTextAsync(UsersFile, formatted, Encoding.UTF8).ConfigureAwait(false); + } + } + finally + { + try + { + if (System.IO.File.Exists(tmp)) + System.IO.File.Delete(tmp); + } + catch + { + } + } + } +} diff --git a/mods/AdminPanel/ConfigSectionGroups.cs b/mods/AdminPanel/ConfigSectionGroups.cs new file mode 100644 index 0000000..18e8888 --- /dev/null +++ b/mods/AdminPanel/ConfigSectionGroups.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace AdminPanel; + +public static class ConfigSectionGroups +{ + public sealed record GroupSpec(string Id, string Title, string Hint, string[] Keys); + + public static readonly GroupSpec[] Catalog = + { + new("runtime", "Рантайм", "Поля снимка; обычно приходят из current, в init не обязательны.", + new[] { "guid", "freeDiskSpace" }), + new("listen", "HTTP-сервер (listen)", "Адрес, порт, схема, таймауты, compression.", + new[] { "listen" }), + new("security", "Безопасность и доступ", "WAF, accsdb, список модулей и middleware ядра.", + new[] { "WAF", "accsdb", "BaseModule" }), + new("network", "Сеть и прокси", "Прокси исходящих запросов, CORS, доверенные сети.", + new[] { "serverproxy", "proxy", "globalproxy", "corsehost", "KnownProxies" }), + + new("pools", "Пулы и служебное", "Буферы, apn, kit.", + new[] { "pool", "apn", "kit" }), + new("cache-gc", "Кэш и память", "Гибридный кэш, Staticache, настройки GC.", + new[] { "cache", "Staticache", "GC" }), + new("media", "Изображения и постеры", "Движок картинок, Poster API.", + new[] { "imagelibrary", "posterApi" }), + + new("realtime", "WebSocket и RCH", "Нативные сокеты и удалённый хаб.", + new[] { "WebSocket", "rch" }), + new("browser", "Браузеры (Playwright)", "Chromium / Firefox для автоматизации.", + new[] { "chromium", "firefox" }), + new("diagnostics", "Логи и диагностика", "Serilog, обработчик исключений, openstat.", + new[] { "serilog", "useDeveloperExceptionPage", "exceptionHandlerLogTarget", "exceptionHandlerLogFile", "watcherInit", "openstat" }), + + new("app", "Приложение и оболочка", "online, cub, sisi, реклама, дефолты, omdb.", + new[] { "online", "cub", "sisi", "vast", "disableEng", "defaultOn", "omdbapi_key", "overrideResponse" }), + new("client", "Клиент Lampa и API", "Оболочка Lampa, cookie, PidTor, TMDB.", + new[] { "tmdb", "LampaWeb", "Cookie", "PidTor" }), + new("modules", "Модули расширения", "Секции подключаемых модулей в корне конфига.", + new[] { "Catalog", "DLNA", "JacRed", "Sync", "TimeCode", "TorrServer", "Tracks", "transcoding", "TmdbProxy", "CubProxy", "WebLog" }), + + new("src-anime", "Источники · аниме", "Онлайн-балансеры аниме и смежные (в т.ч. Kodik).", + new[] { "AniLiberty", "AniLibria", "Animebesst", "AnimeGo", "AnimeLib", "AnimeON", "Animevost", "AniMedia", "Dreamerscast", "Kodik", "Mikai", "MoonAnime" }), + new("src-embed", "Источники · встраиваемые плееры", "Embed и агрегаторы сторонних плееров.", + new[] { "Autoembed", "Hydraflix", "MovPI", "Playembed", "Rgshows", "Smashystream", "Twoembed", "VidLink", "Videasy", "Vidsrc" }), + new("src-vod", "Источники · VOD и CDN", "Кино, сериалы, региональные и CDN-провайдеры.", + new[] + { + "Alloha", "Ashdi", "AsiaGe", "BamBoo", "CDNvideohub", "Collaps", "Eneyida", "FanCDN", "Filmix", "FilmixPartner", "FilmixTV", "FlixCDN", + "Geosaitebi", "GetsTV", "HDVB", "HdvbUA", "IptvOnline", "iRemux", "Kinobase", "Kinoflix", "Kinogo", "Kinotochka", "Kinoukr", "KinoPub", + "LeProduction", "Mirage", "Rezka", "RezkaPrem", "RutubeMovie", "Tortuga", "UaKino", "VideoDB", "Videoseed", "VeoVeo", "Vibix", "VkMovie", "VoKino" + }), + new("src-adult", "Источники · 18+", "SISI / взрослые сайты.", + new[] + { + "BongaCams", "Chaturbate", "Ebalovo", "Eporner", "HQporner", "PornHub", "PornHubPremium", "Porntrex", "Runetki", "Spankbang", "Tizam", + "Xhamster", "Xnxx", "Xvideos", "XvideosRED" + }), + }; + + public static List Build(JObject currentRoot) + { + if (currentRoot == null) + currentRoot = new JObject(); + + var inFile = new HashSet( + currentRoot.Properties().Select(p => p.Name), + StringComparer.Ordinal); + + var assigned = new HashSet(StringComparer.Ordinal); + var result = new List(); + + foreach (var g in Catalog) + { + var keys = g.Keys.Where(inFile.Contains).OrderBy(k => k, StringComparer.Ordinal).ToArray(); + foreach (var k in keys) + assigned.Add(k); + + if (keys.Length == 0) + continue; + + result.Add(new GroupDto(g.Id, g.Title, g.Hint, keys)); + } + + var orphans = inFile.Where(k => !assigned.Contains(k)).OrderBy(k => k, StringComparer.Ordinal).ToArray(); + if (orphans.Length > 0) + result.Add(new GroupDto("other", "Прочее", "Ключи из current.conf, не попавшие в каталог (новые модули).", orphans)); + + return result; + } + + public static List BuildCatalog() + { + var list = new List(Catalog.Length); + foreach (var g in Catalog) + list.Add(new GroupDto(g.Id, g.Title, g.Hint, g.Keys.ToArray())); + return list; + } + + public static HashSet CatalogRootKeys { get; } = new( + Catalog.SelectMany(g => g.Keys), + StringComparer.Ordinal); +} + +public sealed record GroupDto(string Id, string Title, string Hint, string[] Keys); diff --git a/mods/AdminPanel/ModInit.cs b/mods/AdminPanel/ModInit.cs new file mode 100644 index 0000000..b95da77 --- /dev/null +++ b/mods/AdminPanel/ModInit.cs @@ -0,0 +1,18 @@ +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; + +namespace AdminPanel; + +public class ModInit : IModuleLoaded +{ + public static string modpath; + + public void Loaded(InitspaceModel baseconf) + { + modpath = baseconf.path; + } + + public void Dispose() + { + } +} diff --git a/mods/AdminPanel/README.md b/mods/AdminPanel/README.md new file mode 100644 index 0000000..1fde86a --- /dev/null +++ b/mods/AdminPanel/README.md @@ -0,0 +1,98 @@ +# AdminPanel + +Веб-**админка** Lampac: статический UI (**`auth.html`**, **`index.html`**) и JSON API для просмотра и правки **`init.conf`**, снимка **`current.conf`** и пользователей **`users.json`** (режим **accsdb**). Доступ к закрытым маршрутам — после ввода **root-пароля** из файла **`passwd`** (cookie **`accspasswd`**; см. ниже); при отказе **`[Authorization]`** перенаправляет на **`/adminpanel/auth`**. + +## Быстрый старт + +1. Включите модуль в **`manifest.json`** (`"enable": true`). По умолчанию в репозитории стоит **`false`**. +2. Соберите и задеплойте сборку так же, как остальные модули (динамический модуль; список файлов — поле **`tree`** в **`manifest.json`**). +3. Откройте **`http(s)://<хост>:<порт>/adminpanel/auth`** и войдите **root-паролем из файла `passwd`** в рабочей директории сервера (см. [Вход](#вход)). +4. Рабочая панель: **`/adminpanel`**. + +Удобный порт в dev-сборке через Docker часто задаёт compose (например **9118**); точный адрес зависит от **`listen`** в конфигурации Lampac. + +## Вход и сессия + +Страница **`/adminpanel/auth`** сохраняет пароль в cookie **`accspasswd`** (срок **180 дней**, **`path=/`**, **`SameSite=Lax`**, в HTTPS добавляется **`Secure`**). + +Панель дергает API с **`credentials: 'same-origin'`**, поэтому cookie должна быть установлена для того же origin, что и сервер Lampac. + +### Файл `passwd` (root-пароль) + +Вход в админку **не** берётся из **`users.json`** и не совпадает с **`accsdb.shared_passwd`** в **`init.conf`**. Для маршрутов с **`[Authorization]`** ядро сравнивает cookie **`accspasswd`** с **`CoreInit.rootPasswd`**, который читается из файла **`passwd`** в **рабочей директории** процесса (рядом с **`init.conf`**): + +- если **`passwd`** есть при старте — содержимое файла загружается в память, пробелы и переводы строк снимаются; +- если файла нет при старте — генерируется случайная строка и записывается в **`passwd`** (имеет смысл сразу задать свой пароль и при необходимости перезапустить процесс). + +В форме входа нужно указывать **именно эту** root-строку. + +Дополнительно ядро ограничивает число **разных** попыток пароля в cookie с одного IP за сутки; после порога ответы к защищённым маршрутам выглядят как **404** (см. **`Core/Middlewares/Accsdb.cs`**). + +Тот же **`rootPasswd`** используется для внутренних вызовов с HTTP-заголовком **`lcrqpasswd`** (это не форма входа в браузере). + +Остальная политика (локальный доступ без проверки, **accsdb** для клиентов Lampa, WAF и т.д.) накладывается **поверх** этой проверки — см. конфигурацию ядра. Не выкладывайте **`passwd`** в Git и не публикуйте админку в открытый интернет без осознанной защиты. В Docker/Kubernetes **`passwd`** обычно монтируют вместе с **`init.conf`** (см. комментарии в **`docker-compose.yaml`**, чарт **`charts/lampac`**). + +## Где лежат файлы + +Пути **`passwd`**, **`init.conf`**, **`current.conf`**, **`users.json`** задаются относительно **текущей рабочей директории процесса** Lampac (не от каталога модуля). + +- Если **`current.conf`** на диске отсутствует или повреждён как JSON, при чтении подставляется снимок из **`CoreInit.CurrentConf`** (если доступен хостом). +- HTML читаются из **`ModInit.modpath`** (каталог загруженного модуля): **`auth.html`**, **`index.html`**. + +В Docker проверьте, что том с конфигом смонтирован туда же, откуда процесс видит те же имена файлов, что вы правите через UI. + +### Запись на диск + +Сохранение **`init.conf`** и **`users.json`** делается атомарно: сначала запись во временный файл, затем **`Move`** с перезаписью. Если на bind mount **`Move`** даёт ошибку занятости цели (**EBUSY**), выполняется прямая запись в целевой файл — это осознанный fallback для некоторых сценариев Docker. + +Ответ успешной записи: **`{"ok":true}`**. Ошибки: **`400`/`500`** с телом **`{"error":"...", "detail":"..."}`** (поле **`detail`** опционально). + +## Различие «init» и «current» в UI + +| Файл | Роль | +|------|------| +| **`init.conf`** | То, что можно **сохранять** через API: целиком (**`POST /adminpanel/api/init`**) или по корневым ключам (**`POST .../init/section/{key}`**). | +| **`current.conf`** | **Только чтение** в админке (**`GET /adminpanel/api/current`**): актуальный снимок/мердж конфигурации для просмотра и группировки секций в интерфейсе. Прямой POST для **`current`** в контроллере не предусмотрен. | + +Если нужно изменить один корневой ключ в **`init.conf`**, тело **`POST /adminpanel/api/init/section/{key}`** — валидный JSON **значения** секции (объект, массив, примитив — как в корне конфига). Ключ секции не должен содержать **`/`** или **`\`**. + +## HTTP API + +Подробности тел запросов и валидации — в **`AdminPanelController.cs`**. + +| Метод и маршрут | Описание | +|-----------------|----------| +| **`GET /adminpanel/auth`** | Страница входа (**`AllowAnonymous`**). | +| **`GET /adminpanel`** | Главная панель (**`index.html`**). | +| **`GET /adminpanel/api/groups`** | Группы секций по ключам, **присутствующим** в **`current.conf`** (или в памяти, если файл не прочитан), плюс блок **«Прочее»** для ключей без каталога. | +| **`GET /adminpanel/api/groups/catalog`** | Полный **каталог** групп из **`ConfigSectionGroups.Catalog`**; если в актуальном конфиге есть ключи вне каталога — добавляется **«Прочее»** для них (подписи ориентированы на ключи из current). | +| **`GET /adminpanel/api/init`** | Содержимое **`init.conf`** (пустой объект, если файла нет); JSON приводится к отформатированному виду когда возможно. | +| **`POST /adminpanel/api/init`** | Тело — один JSON **объект** (корень **`init.conf`**). Целиком перезаписывает файл. | +| **`POST /adminpanel/api/init/section/{key}`** | Обновить один корневой ключ в **`init.conf`**. | +| **`GET /adminpanel/api/current`** | **`current.conf`** или fallback из **`CoreInit.CurrentConf`**. | +| **`GET /adminpanel/api/users-json`** | Массив пользователей; если файла нет — **`[]`**. | +| **`POST /adminpanel/api/users-json`** | Тело — JSON **массив** объектов пользователей (схема **accsdb**); перезаписывает **`users.json`**. | + +## Группы в редакторе + +Класс **`ConfigSectionGroups`** задаёт человекочитаемые группы и подсказки для бокового каталога: какие корневые ключи конфига к какой группе относятся (рантайм, listen, модули, источники и т.д.). + +При добавлении нового модуля с секцией в корне JSON имеет смысл дописать её ключ в **`Catalog`** нужной группы или в блок **«modules»**, чтобы ключ не попадал только в **«Прочее»**. + +Публичный **`GroupDto`** (см. низ **`ConfigSectionGroups.cs`**): **`id`**, **`title`**, **`hint`**, массив **`keys`**. + +## Конфигурация модуля + +Отдельной секции настроек в **`ModInit`** нет — только **`manifest.json`** (**`enable`**, **`dynamic`**, **`tree`**). + +Статические ассеты панели лежат рядом с кодом модуля (**`index.html`**, **`auth.html`**); при изменении их в репозитории нужна перезагрузка/пересборка в соответствии с тем, как хост подхватывает динамические модули. + +## Файлы модуля + +| Файл | Роль | +|------|------| +| **`AdminPanelController.cs`** | Маршруты UI и API, работа с файлами конфигурации. | +| **`ConfigSectionGroups.cs`** | Каталог групп и билдер **`GroupDto`**. | +| **`ModInit.cs`** | **`IModuleLoaded`**: сохраняет **`modpath`** для раздачи HTML. | +| **`manifest.json`** | Включение модуля и список подгружаемых **`.cs`**. | +| **`auth.html`**, **`index.html`** | Интерфейс входа и панели. | diff --git a/mods/AdminPanel/auth.html b/mods/AdminPanel/auth.html new file mode 100644 index 0000000..40911e7 --- /dev/null +++ b/mods/AdminPanel/auth.html @@ -0,0 +1,401 @@ + + + + + + + + Вход — Lampac Admin + + + + + + + + + +
+
+ +
+ +
+ +
+
+

Вход

+

Введите пароль доступа к панели. Сессия сохраняется в cookie на 180 дней.

+
+
+ + +
+ +
+
+
+
+ + + + + diff --git a/mods/AdminPanel/index.html b/mods/AdminPanel/index.html new file mode 100644 index 0000000..f1946dd --- /dev/null +++ b/mods/AdminPanel/index.html @@ -0,0 +1,3538 @@ + + + + + + + + Admin — Lampac + + + + + + + +
+
+ +
+ + + + +
+ + + +
+ + + + + +
+ +
+ +
+
+ + + +
+
+ + +
+ + + + + +
+
+ +
+ + + + +
+ +
+ + +
+
+
+

Группы (JSON)

+

Ключи из current.conf, сгруппированные по секциям. Правки сохраняются в + init.conf. Удалить ключ: используйте null или редактируйте весь файл. +

+
+
+ +
+
+
+
+
+
+ + +
+
+
+

Простой редактор

+

Поля формы по структуре current.conf + init.conf. Значения берутся из init, + подсказки — из current. Массивы и вложенные объекты — JSON-блоки.

+
+
+ +
+
+
+
+
+ + +
+
+
+ + + + Ctrl+S сохранить + +
+ +
+
+ + +
+
+
+ Только просмотр + Полный снимок после слияния дефолтов и + модулей + + +
+ +
+
+ + +
+
+
+ + + + + Ctrl+S на этой вкладке тоже сохраняет users.json + +
+
+ Режим +
+ + +
+
+
+
+ + +
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ + + + + + + + diff --git a/mods/AdminPanel/manifest.json b/mods/AdminPanel/manifest.json new file mode 100644 index 0000000..f7f0034 --- /dev/null +++ b/mods/AdminPanel/manifest.json @@ -0,0 +1,9 @@ +{ + "enable": true, + "dynamic": true, + "tree": [ + "AdminPanelController.cs", + "ConfigSectionGroups.cs", + "ModInit.cs" + ] +} diff --git a/mods/DatabaseEditor/DatabaseEditor.csproj b/mods/DatabaseEditor/DatabaseEditor.csproj new file mode 100644 index 0000000..4e40c28 --- /dev/null +++ b/mods/DatabaseEditor/DatabaseEditor.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + library + true + DatabaseEditor + + + + + + + diff --git a/mods/DatabaseEditor/DatabaseEditorController.cs b/mods/DatabaseEditor/DatabaseEditorController.cs new file mode 100644 index 0000000..f0687c8 --- /dev/null +++ b/mods/DatabaseEditor/DatabaseEditorController.cs @@ -0,0 +1,328 @@ +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Attributes; +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace DatabaseEditor; + +[Authorization(redirectUri: "/weblog/auth")] +public class DatabaseEditorPageController : BaseController +{ + [HttpGet] + [Route("/database-editor")] + public ActionResult Index() + { + return EditorFile("index.html", "text/html; charset=utf-8"); + } + + [HttpGet] + [Route("/database-editor/app.js")] + public ActionResult Script() + { + return EditorFile("app.js", "application/javascript; charset=utf-8"); + } + + [HttpGet] + [Route("/database-editor/style.css")] + public ActionResult Style() + { + return EditorFile("style.css", "text/css; charset=utf-8"); + } + + ActionResult EditorFile(string fileName, string contentType) + { + string path = Path.Combine(ModInit.modpath, fileName); + string content = System.IO.File.ReadAllText(path, Encoding.UTF8); + Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; + return Content(content, contentType); + } +} + +[Authorization(accessDeniedMessage: "{\"success\":false,\"error\":\"unauthorized\"}")] +public class DatabaseEditorApiController : BaseController +{ + [HttpGet] + [Route("/database-editor/api/summary")] + public async Task Summary() + { + try + { + return Json(new { success = true, databases = await DatabaseStore.GetSummaryAsync() }); + } + catch (Exception ex) + { + return Unexpected(ex, "summary"); + } + } + + [HttpGet] + [Route("/database-editor/api/records")] + public async Task Records(string database, string query = null, int page = 1, int pageSize = 25, string user = null) + { + try + { + var result = await DatabaseStore.GetRecordsAsync(database, query, page, pageSize, user); + return Json(new + { + success = true, + result.database, + result.page, + result.pageSize, + result.total, + result.pages, + result.records + }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "records"); + } + } + + [HttpGet] + [Route("/database-editor/api/users")] + public async Task Users(string database) + { + try + { + return Json(new { success = true, users = await DatabaseStore.GetUsersAsync(database) }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "users"); + } + } + + [HttpGet] + [Route("/database-editor/api/record")] + public async Task Record(string database, long id) + { + try + { + var record = await DatabaseStore.GetRecordAsync(database, id); + return record == null + ? NotFound(new { success = false, error = "record_not_found" }) + : Json(new { success = true, record }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "record"); + } + } + + [HttpGet] + [Route("/database-editor/api/sync-user")] + public async Task SyncUser(long id) + { + try + { + var user = await DatabaseStore.GetSyncUserAsync(id); + return user == null + ? NotFound(new { success = false, error = "record_not_found" }) + : Json(new + { + success = true, + user, + categories = DatabaseStore.SyncEditorCategories, + statuses = DatabaseStore.SyncStatusCategories + }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "sync-user"); + } + } + + [HttpPost] + [Route("/database-editor/api/save")] + public async Task Save([FromBody] SaveRecordRequest request) + { + if (!IsEditorRequest()) + return BadRequest(new { success = false, error = "invalid_editor_request" }); + + try + { + var record = await DatabaseStore.SaveAsync(request); + return Json(new { success = true, record }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "save"); + } + } + + [HttpPost] + [Route("/database-editor/api/delete")] + public async Task Delete([FromBody] DeleteRecordRequest request) + { + if (!IsEditorRequest()) + return BadRequest(new { success = false, error = "invalid_editor_request" }); + + try + { + bool deleted = await DatabaseStore.DeleteAsync(request?.database, request?.id ?? 0); + return deleted + ? Json(new { success = true }) + : NotFound(new { success = false, error = "record_not_found" }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "delete"); + } + } + + [HttpPost] + [Route("/database-editor/api/rename-user")] + public async Task RenameUser([FromBody] RenameUserRequest request) + { + if (!IsEditorRequest()) + return BadRequest(new { success = false, error = "invalid_editor_request" }); + + try + { + var result = await DatabaseStore.RenameUserAsync(request); + return Json(new { success = true, result }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "rename-user"); + } + } + + [HttpPost] + [Route("/database-editor/api/delete-user")] + public async Task DeleteUser([FromBody] DeleteUserRequest request) + { + if (!IsEditorRequest()) + return BadRequest(new { success = false, error = "invalid_editor_request" }); + + try + { + var result = await DatabaseStore.DeleteUserAsync(request); + return Json(new { success = true, result }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "delete-user"); + } + } + + [HttpPost] + [Route("/database-editor/api/sync-item/save")] + public async Task SaveSyncItem([FromBody] SaveSyncItemRequest request) + { + if (!IsEditorRequest()) + return BadRequest(new { success = false, error = "invalid_editor_request" }); + + try + { + var categories = await DatabaseStore.SaveSyncItemAsync(request); + return Json(new { success = true, categories }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "sync-item-save"); + } + } + + [HttpPost] + [Route("/database-editor/api/sync-item/delete")] + public async Task DeleteSyncItem([FromBody] DeleteSyncItemRequest request) + { + if (!IsEditorRequest()) + return BadRequest(new { success = false, error = "invalid_editor_request" }); + + try + { + bool deleted = await DatabaseStore.DeleteSyncItemAsync(request); + return deleted + ? Json(new { success = true }) + : NotFound(new { success = false, error = "card_not_found" }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "sync-item-delete"); + } + } + + [HttpPost] + [Route("/database-editor/api/backup")] + public async Task Backup([FromBody] BackupRequest request) + { + if (!IsEditorRequest()) + return BadRequest(new { success = false, error = "invalid_editor_request" }); + + try + { + if (string.IsNullOrWhiteSpace(request?.database) || string.Equals(request.database, "all", StringComparison.OrdinalIgnoreCase)) + { + var backups = await DatabaseStore.BackupAllAsync(); + return Json(new { success = true, backups }); + } + + string path = await DatabaseStore.BackupAsync(request?.database); + return Json(new { success = true, path, database = request.database }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "backup"); + } + } + + [HttpGet] + [Route("/database-editor/api/backups")] + public ActionResult Backups(string database) + { + try + { + return Json(new { success = true, backups = DatabaseStore.GetBackups(database) }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "backups"); + } + } + + [HttpPost] + [Route("/database-editor/api/restore")] + public async Task Restore([FromBody] RestoreBackupRequest request) + { + if (!IsEditorRequest()) + return BadRequest(new { success = false, error = "invalid_editor_request" }); + + try + { + var result = await DatabaseStore.RestoreAsync(request); + return Json(new { success = true, result }); + } + catch (Exception ex) + { + return KnownOrUnexpected(ex, "restore"); + } + } + + bool IsEditorRequest() => string.Equals(Request.Headers["X-Database-Editor"], "1", StringComparison.Ordinal); + + ActionResult KnownOrUnexpected(Exception ex, string operation) + { + if (ex is DatabaseEditorValidationException) + return BadRequest(new { success = false, error = ex.Message }); + + if (ex is DatabaseEditorConflictException) + return Conflict(new { success = false, error = ex.Message }); + + if (ex is DatabaseEditorBusyException) + return StatusCode(503, new { success = false, error = ex.Message }); + + return Unexpected(ex, operation); + } + + ActionResult Unexpected(Exception ex, string operation) + { + Serilog.Log.Error(ex, "DatabaseEditor {Operation} failed", operation); + return StatusCode(500, new { success = false, error = "internal_error" }); + } +} diff --git a/mods/DatabaseEditor/DatabaseStore.cs b/mods/DatabaseEditor/DatabaseStore.cs new file mode 100644 index 0000000..1b6c2c7 --- /dev/null +++ b/mods/DatabaseEditor/DatabaseStore.cs @@ -0,0 +1,1512 @@ +using Microsoft.Data.Sqlite; +using Shared.Services; +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +namespace DatabaseEditor; + +public sealed class SaveRecordRequest +{ + public string database { get; set; } + public long? id { get; set; } + public string user { get; set; } + public string card { get; set; } + public string item { get; set; } + public string data { get; set; } +} + +public sealed class DeleteRecordRequest +{ + public string database { get; set; } + public long id { get; set; } +} + +public sealed class BackupRequest +{ + public string database { get; set; } +} + +public sealed class RestoreBackupRequest +{ + public string database { get; set; } + public string file { get; set; } +} + +public sealed class RenameUserRequest +{ + public string oldUser { get; set; } + public string newUser { get; set; } +} + +public sealed class RenameUserResult +{ + public string oldUser { get; set; } + public string newUser { get; set; } + public int syncRecords { get; set; } + public int timecodeRecords { get; set; } +} + +public sealed class DeleteUserRequest +{ + public string user { get; set; } +} + +public sealed class DeleteUserResult +{ + public string user { get; set; } + public int syncRecords { get; set; } + public int timecodeRecords { get; set; } +} + +public sealed class SaveSyncItemRequest +{ + public long recordId { get; set; } + public string cardId { get; set; } + public string[] categories { get; set; } +} + +public sealed class DeleteSyncItemRequest +{ + public long recordId { get; set; } + public string cardId { get; set; } +} + +public sealed class DatabaseSummary +{ + public string database { get; set; } + public string title { get; set; } + public string file { get; set; } + public bool available { get; set; } + public long records { get; set; } + public long bytes { get; set; } + public string updated { get; set; } +} + +public sealed class DatabaseUserOption +{ + public string user { get; set; } + public long records { get; set; } +} + +public sealed class DatabaseRecord +{ + public long id { get; set; } + public string user { get; set; } + public string card { get; set; } + public string item { get; set; } + public string data { get; set; } + public string preview { get; set; } + public long dataLength { get; set; } + public string updated { get; set; } + public string title { get; set; } + public string poster { get; set; } + public string year { get; set; } + public string mediaType { get; set; } + public int? season { get; set; } + public int? episode { get; set; } + public double? position { get; set; } + public double? duration { get; set; } + public double? percent { get; set; } +} + +public sealed class SyncUserItem +{ + public string cardId { get; set; } + public string title { get; set; } + public string poster { get; set; } + public string year { get; set; } + public string mediaType { get; set; } + public List categories { get; set; } + public int order { get; set; } + public Dictionary categoryOrder { get; set; } +} + +public sealed class SyncUserDetails +{ + public long id { get; set; } + public string user { get; set; } + public string updated { get; set; } + public int total { get; set; } + public List items { get; set; } +} + +public sealed class DatabaseBackupResult +{ + public string database { get; set; } + public string path { get; set; } +} + +public sealed class DatabaseBackupFile +{ + public string database { get; set; } + public string file { get; set; } + public string path { get; set; } + public long bytes { get; set; } + public string created { get; set; } +} + +public sealed class DatabaseRestoreResult +{ + public string database { get; set; } + public string restoredFrom { get; set; } + public string safetyBackup { get; set; } +} + +public sealed class RecordsPage +{ + public string database { get; set; } + public int page { get; set; } + public int pageSize { get; set; } + public long total { get; set; } + public int pages { get; set; } + public List records { get; set; } +} + +public sealed class DatabaseEditorValidationException : Exception +{ + public DatabaseEditorValidationException(string message) : base(message) { } +} + +public sealed class DatabaseEditorConflictException : Exception +{ + public DatabaseEditorConflictException(string message) : base(message) { } +} + +public sealed class DatabaseEditorBusyException : Exception +{ + public DatabaseEditorBusyException(string message) : base(message) { } +} + +static class DatabaseStore +{ + const int MaxDataBytes = 8 * 1024 * 1024; + const int MaxKeyLength = 512; + + public static readonly string[] SyncCategories = + { + "history", "like", "watch", "wath", "book", "look", + "viewed", "scheduled", "continued", "thrown" + }; + + public static readonly string[] SyncEditorCategories = + { + "book", "like", "wath", "history", + "look", "viewed", "scheduled", "continued", "thrown" + }; + + public static readonly string[] SyncStatusCategories = + { + "look", "viewed", "scheduled", "continued", "thrown" + }; + + sealed class DatabaseSpec + { + public string key; + public string title; + public string path; + public string table; + public string semaphore; + public bool timecode; + + /// Колонка, по которой сортируем и берём MAX. + public string updatedColumn = "updated"; + + /// Как показать её строкой: у TimeCode это мс, а не ISO-текст. + public Func updatedText = expression => expression; + } + + /// + /// TimeCode хранит таймкод типизированными колонками, а редактор правит road-объект Lampa — + /// тот же контракт, что у легаси `/timecode/all`. Собираем его на стороне SQLite. + /// + const string TimeCodeRoad = "json_object('duration', duration, 'time', position, 'percent', percent, 'profile', profile, 'updated', watched_at)"; + + static string TimeCodeStamp(string expression) => $"strftime('%Y-%m-%dT%H:%M:%SZ', {expression} / 1000, 'unixepoch')"; + + /// Обратная операция к : road из редактора → колонки. + const string TimeCodeColumns = "position, duration, percent, profile, watched_at"; + + const string TimeCodeValues = + "COALESCE(json_extract(@data, '$.time'), 0), " + + "COALESCE(json_extract(@data, '$.duration'), 0), " + + "COALESCE(json_extract(@data, '$.percent'), 0), " + + "COALESCE(json_extract(@data, '$.profile'), 0), " + + "COALESCE(json_extract(@data, '$.updated'), 0)"; + + const string TimeCodeAssignments = + "position = COALESCE(json_extract(@data, '$.time'), 0), " + + "duration = COALESCE(json_extract(@data, '$.duration'), 0), " + + "percent = COALESCE(json_extract(@data, '$.percent'), 0), " + + "profile = COALESCE(json_extract(@data, '$.profile'), 0), " + + "watched_at = COALESCE(json_extract(@data, '$.updated'), 0)"; + + sealed class MediaMetadata + { + public string title; + public string poster; + public string year; + public string mediaType; + public List hashTitles; + public int seasons; + public int episodes; + } + + static readonly DatabaseSpec Sync = new() + { + key = "sync", + title = "Sync / закладки", + path = Path.Combine("database", "Sync.sql"), + table = "bookmarks", + semaphore = "Sync" + }; + + static readonly DatabaseSpec TimeCode = new() + { + key = "timecode", + title = "TimeCode / позиции", + path = Path.Combine("database", "TimeCode.sql"), + table = "timecodes", + semaphore = "TimeCode", + timecode = true, + updatedColumn = "updated_at", + updatedText = TimeCodeStamp + }; + + public static async Task> GetSummaryAsync() + { + var result = new List(2); + result.Add(await ReadSummaryAsync(TimeCode)); + result.Add(await ReadSummaryAsync(Sync)); + return result; + } + + public static async Task> GetUsersAsync(string database) + { + DatabaseSpec spec = GetSpec(database); + await using var connection = await OpenAsync(spec); + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT user, COUNT(*) FROM {spec.table} WHERE user IS NOT NULL AND TRIM(user) <> '' GROUP BY user COLLATE NOCASE ORDER BY user COLLATE NOCASE;"; + + var users = new List(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + users.Add(new DatabaseUserOption + { + user = ReadString(reader, 0), + records = reader.IsDBNull(1) ? 0 : reader.GetInt64(1) + }); + } + return users; + } + + public static async Task GetRecordsAsync(string database, string query, int page, int pageSize, string user = null) + { + DatabaseSpec spec = GetSpec(database); + page = Math.Max(1, page); + pageSize = pageSize is 25 or 50 or 100 ? pageSize : 25; + query = (query ?? string.Empty).Trim(); + if (query.Length > 256) + throw new DatabaseEditorValidationException("search_too_long"); + user = (user ?? string.Empty).Trim(); + if (user.Length > MaxKeyLength) + throw new DatabaseEditorValidationException("key_too_long"); + + await using var connection = await OpenAsync(spec); + string where = BuildWhere(spec, query, user, out string searchValue, out string selectedUser); + + long total; + await using (var count = connection.CreateCommand()) + { + count.CommandText = $"SELECT COUNT(*) FROM {spec.table}{where};"; + AddFilters(count, searchValue, selectedUser); + total = Convert.ToInt64(await count.ExecuteScalarAsync()); + } + + int pages = Math.Max(1, (int)Math.Ceiling(total / (double)pageSize)); + page = Math.Min(page, pages); + int offset = (page - 1) * pageSize; + var records = new List(pageSize); + + await using (var command = connection.CreateCommand()) + { + command.CommandText = spec.timecode + ? $"SELECT Id, user, card, item, length({TimeCodeRoad}), {TimeCodeRoad}, {spec.updatedText(spec.updatedColumn)} FROM {spec.table}{where} ORDER BY {spec.updatedColumn} DESC, Id DESC LIMIT @limit OFFSET @offset;" + : $"SELECT Id, user, length(data), substr(data, 1, 420), {spec.updatedText(spec.updatedColumn)} FROM {spec.table}{where} ORDER BY {spec.updatedColumn} DESC, Id DESC LIMIT @limit OFFSET @offset;"; + AddFilters(command, searchValue, selectedUser); + command.Parameters.AddWithValue("@limit", pageSize); + command.Parameters.AddWithValue("@offset", offset); + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + int index = 0; + var record = new DatabaseRecord + { + id = reader.GetInt64(index++), + user = ReadString(reader, index++) + }; + + if (spec.timecode) + { + record.card = ReadString(reader, index++); + record.item = ReadString(reader, index++); + } + + record.dataLength = reader.IsDBNull(index) ? 0 : reader.GetInt64(index); + index++; + string dataValue = ReadString(reader, index++); + record.preview = BuildPreview(dataValue); + record.updated = ReadString(reader, index); + if (spec.timecode) + ReadPlaybackData(record, dataValue); + records.Add(record); + } + } + + if (spec.timecode && records.Count > 0) + await EnrichTimeCodeRecordsAsync(records); + + return new RecordsPage + { + database = spec.key, + page = page, + pageSize = pageSize, + total = total, + pages = pages, + records = records + }; + } + + public static async Task GetRecordAsync(string database, long id) + { + DatabaseSpec spec = GetSpec(database); + if (id <= 0) + throw new DatabaseEditorValidationException("invalid_id"); + + await using var connection = await OpenAsync(spec); + await using var command = connection.CreateCommand(); + command.CommandText = spec.timecode + ? $"SELECT Id, user, card, item, {TimeCodeRoad}, {spec.updatedText(spec.updatedColumn)} FROM {spec.table} WHERE Id = @id LIMIT 1;" + : $"SELECT Id, user, data, {spec.updatedText(spec.updatedColumn)} FROM {spec.table} WHERE Id = @id LIMIT 1;"; + command.Parameters.AddWithValue("@id", id); + + await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow); + if (!await reader.ReadAsync()) + return null; + + int index = 0; + var record = new DatabaseRecord + { + id = reader.GetInt64(index++), + user = ReadString(reader, index++) + }; + + if (spec.timecode) + { + record.card = ReadString(reader, index++); + record.item = ReadString(reader, index++); + } + + record.data = ReadString(reader, index++); + record.dataLength = Encoding.UTF8.GetByteCount(record.data ?? string.Empty); + record.updated = ReadString(reader, index); + return record; + } + + public static async Task GetSyncUserAsync(long id) + { + if (id <= 0) + throw new DatabaseEditorValidationException("invalid_id"); + + await using var connection = await OpenAsync(Sync); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT Id, user, data, updated FROM bookmarks WHERE Id = @id LIMIT 1;"; + command.Parameters.AddWithValue("@id", id); + + await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow); + if (!await reader.ReadAsync()) + return null; + + string data = ReadString(reader, 2); + var details = BuildSyncUserDetails(reader.GetInt64(0), ReadString(reader, 1), ReadString(reader, 3), data); + return details; + } + + public static async Task> SaveSyncItemAsync(SaveSyncItemRequest request) + { + if (request == null || request.recordId <= 0) + throw new DatabaseEditorValidationException("invalid_id"); + + string cardId = ValidateKey(request.cardId, "card_required"); + var selected = new HashSet(request.categories ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + foreach (string category in selected) + { + if (Array.IndexOf(SyncEditorCategories, category) < 0) + throw new DatabaseEditorValidationException("unknown_category"); + } + + int selectedStatuses = 0; + foreach (string category in SyncStatusCategories) + { + if (selected.Contains(category)) + selectedStatuses++; + } + if (selectedStatuses > 1) + throw new DatabaseEditorValidationException("multiple_statuses"); + + await UpdateSyncJsonAsync(request.recordId, root => + { + JsonObject card = FindSyncCard(root, cardId); + if (card == null) + throw new DatabaseEditorValidationException("card_not_found"); + + foreach (string category in SyncCategories) + { + JsonArray array = EnsureArray(root, category); + bool isSelected = selected.Contains(category); + bool isPresent = ContainsCardId(array, cardId); + if (isSelected && !isPresent) + array.Insert(0, CreateCardIdNode(cardId)); + else if (!isSelected && isPresent) + RemoveCardId(array, cardId); + } + }); + + Serilog.Log.Information("DatabaseEditor updated Sync card {CardId} in record {RecordId}", cardId, request.recordId); + var result = new List(); + foreach (string category in SyncEditorCategories) + { + if (selected.Contains(category)) + result.Add(category); + } + return result; + } + + public static async Task DeleteSyncItemAsync(DeleteSyncItemRequest request) + { + if (request == null || request.recordId <= 0) + throw new DatabaseEditorValidationException("invalid_id"); + + string cardId = ValidateKey(request.cardId, "card_required"); + bool removed = false; + await UpdateSyncJsonAsync(request.recordId, root => + { + if (root["card"] is JsonArray cards) + { + for (int index = cards.Count - 1; index >= 0; index--) + { + if (cards[index] is JsonObject card && string.Equals(NodeText(card["id"]), cardId, StringComparison.Ordinal)) + { + cards.RemoveAt(index); + removed = true; + } + } + } + + foreach (string category in SyncCategories) + { + if (root[category] is JsonArray array) + removed |= RemoveCardId(array, cardId); + } + }); + + if (removed) + Serilog.Log.Information("DatabaseEditor deleted Sync card {CardId} from record {RecordId}", cardId, request.recordId); + return removed; + } + + public static async Task SaveAsync(SaveRecordRequest request) + { + if (request == null) + throw new DatabaseEditorValidationException("request_required"); + + DatabaseSpec spec = GetSpec(request.database); + string user = ValidateKey(request.user, "user_required"); + string card = spec.timecode ? ValidateKey(request.card, "card_required") : null; + string item = spec.timecode ? ValidateKey(request.item, "item_required") : null; + string data = NormalizeJson(request.data); + long id = request.id.GetValueOrDefault(); + if (id < 0) + throw new DatabaseEditorValidationException("invalid_id"); + + var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); + bool acquired = await semaphore.WaitAsync(); + if (!acquired) + throw new DatabaseEditorBusyException("database_busy"); + + try + { + await using var connection = await OpenAsync(spec); + using var transaction = connection.BeginTransaction(); + string updated = DateTime.UtcNow.ToString("O"); + + // Правка из админки обязана поднять курсор выше всех строк пользователя, иначе + // клиенты её не увидят: дельты они спрашивают по `updated_at > since`. + string stamp = $"MAX(strftime('%s', 'now') * 1000, (SELECT COALESCE(MAX(updated_at), 0) + 1 FROM {spec.table} WHERE user = @user))"; + + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + if (id == 0) + { + command.CommandText = spec.timecode + ? $"INSERT INTO {spec.table} (user, card, item, {TimeCodeColumns}, updated_at) VALUES (@user, @card, @item, {TimeCodeValues}, {stamp}); SELECT last_insert_rowid();" + : $"INSERT INTO {spec.table} (user, data, updated) VALUES (@user, @data, @updated); SELECT last_insert_rowid();"; + } + else + { + command.CommandText = spec.timecode + ? $"UPDATE {spec.table} SET user = @user, card = @card, item = @item, {TimeCodeAssignments}, updated_at = {stamp} WHERE Id = @id;" + : $"UPDATE {spec.table} SET user = @user, data = @data, updated = @updated WHERE Id = @id;"; + command.Parameters.AddWithValue("@id", id); + } + + command.Parameters.AddWithValue("@user", user); + if (spec.timecode) + { + command.Parameters.AddWithValue("@card", card); + command.Parameters.AddWithValue("@item", item); + } + command.Parameters.AddWithValue("@data", data); + if (!spec.timecode) + command.Parameters.AddWithValue("@updated", updated); + + if (id == 0) + id = Convert.ToInt64(await command.ExecuteScalarAsync()); + else if (await command.ExecuteNonQueryAsync() == 0) + throw new DatabaseEditorValidationException("record_not_found"); + + transaction.Commit(); + Serilog.Log.Information("DatabaseEditor saved {Database} record {RecordId}", spec.key, id); + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 19) + { + throw new DatabaseEditorConflictException("duplicate_record_key"); + } + finally + { + semaphore.Release(); + } + + return await GetRecordAsync(spec.key, id); + } + + public static async Task DeleteAsync(string database, long id) + { + DatabaseSpec spec = GetSpec(database); + if (id <= 0) + throw new DatabaseEditorValidationException("invalid_id"); + + var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); + bool acquired = await semaphore.WaitAsync(); + if (!acquired) + throw new DatabaseEditorBusyException("database_busy"); + + try + { + await using var connection = await OpenAsync(spec); + await using var command = connection.CreateCommand(); + command.CommandText = $"DELETE FROM {spec.table} WHERE Id = @id;"; + command.Parameters.AddWithValue("@id", id); + bool deleted = await command.ExecuteNonQueryAsync() > 0; + if (deleted) + Serilog.Log.Information("DatabaseEditor deleted {Database} record {RecordId}", spec.key, id); + return deleted; + } + finally + { + semaphore.Release(); + } + } + + public static async Task RenameUserAsync(RenameUserRequest request) + { + if (request == null) + throw new DatabaseEditorValidationException("request_required"); + + string oldUser = ValidateKey(request.oldUser, "old_user_required"); + string newUser = ValidateKey(request.newUser, "new_user_required"); + if (string.Equals(oldUser, newUser, StringComparison.Ordinal)) + throw new DatabaseEditorValidationException("user_name_unchanged"); + if (!File.Exists(Sync.path) || !File.Exists(TimeCode.path)) + throw new DatabaseEditorValidationException("database_not_found"); + + var syncSemaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20)); + var timecodeSemaphore = new SemaphorManager(TimeCode.semaphore, TimeSpan.FromSeconds(20)); + bool syncAcquired = await syncSemaphore.WaitAsync(); + if (!syncAcquired) + throw new DatabaseEditorBusyException("database_busy"); + + bool timecodeAcquired = false; + try + { + timecodeAcquired = await timecodeSemaphore.WaitAsync(); + if (!timecodeAcquired) + throw new DatabaseEditorBusyException("database_busy"); + + await using var connection = await OpenAsync(TimeCode, pooling: false); + await using (var attach = connection.CreateCommand()) + { + attach.CommandText = "ATTACH DATABASE @syncPath AS syncdb;"; + attach.Parameters.AddWithValue("@syncPath", Path.GetFullPath(Sync.path)); + await attach.ExecuteNonQueryAsync(); + } + + using var transaction = connection.BeginTransaction(); + await EnsureRenameTargetAvailableAsync(connection, transaction, "main.timecodes", oldUser, newUser); + await EnsureRenameTargetAvailableAsync(connection, transaction, "syncdb.bookmarks", oldUser, newUser); + + int timecodeRecords = await RenameUserRowsAsync(connection, transaction, "main.timecodes", oldUser, newUser); + int syncRecords = await RenameUserRowsAsync(connection, transaction, "syncdb.bookmarks", oldUser, newUser); + if (timecodeRecords == 0 && syncRecords == 0) + throw new DatabaseEditorValidationException("user_not_found"); + + transaction.Commit(); + Serilog.Log.Information("DatabaseEditor renamed user {OldUser} to {NewUser}: {SyncRecords} Sync, {TimecodeRecords} TimeCode records", oldUser, newUser, syncRecords, timecodeRecords); + return new RenameUserResult + { + oldUser = oldUser, + newUser = newUser, + syncRecords = syncRecords, + timecodeRecords = timecodeRecords + }; + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 19) + { + throw new DatabaseEditorConflictException("rename_user_conflict"); + } + finally + { + if (timecodeAcquired) + timecodeSemaphore.Release(); + syncSemaphore.Release(); + } + } + + static async Task EnsureRenameTargetAvailableAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string oldUser, string newUser) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = $"SELECT 1 FROM {table} WHERE user = @newUser COLLATE NOCASE AND user <> @oldUser COLLATE NOCASE LIMIT 1;"; + command.Parameters.AddWithValue("@oldUser", oldUser); + command.Parameters.AddWithValue("@newUser", newUser); + if (await command.ExecuteScalarAsync() != null) + throw new DatabaseEditorConflictException("rename_user_conflict"); + } + + static async Task RenameUserRowsAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string oldUser, string newUser) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = $"UPDATE {table} SET user = @newUser WHERE user = @oldUser COLLATE NOCASE;"; + command.Parameters.AddWithValue("@oldUser", oldUser); + command.Parameters.AddWithValue("@newUser", newUser); + return await command.ExecuteNonQueryAsync(); + } + + public static async Task DeleteUserAsync(DeleteUserRequest request) + { + if (request == null) + throw new DatabaseEditorValidationException("request_required"); + + string user = ValidateKey(request.user, "user_required"); + if (!File.Exists(Sync.path) || !File.Exists(TimeCode.path)) + throw new DatabaseEditorValidationException("database_not_found"); + + var syncSemaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20)); + var timecodeSemaphore = new SemaphorManager(TimeCode.semaphore, TimeSpan.FromSeconds(20)); + bool syncAcquired = await syncSemaphore.WaitAsync(); + if (!syncAcquired) + throw new DatabaseEditorBusyException("database_busy"); + + bool timecodeAcquired = false; + try + { + timecodeAcquired = await timecodeSemaphore.WaitAsync(); + if (!timecodeAcquired) + throw new DatabaseEditorBusyException("database_busy"); + + await using var connection = await OpenAsync(TimeCode, pooling: false); + await using (var attach = connection.CreateCommand()) + { + attach.CommandText = "ATTACH DATABASE @syncPath AS syncdb;"; + attach.Parameters.AddWithValue("@syncPath", Path.GetFullPath(Sync.path)); + await attach.ExecuteNonQueryAsync(); + } + + using var transaction = connection.BeginTransaction(); + int timecodeRecords = await DeleteUserRowsAsync(connection, transaction, "main.timecodes", user); + int syncRecords = await DeleteUserRowsAsync(connection, transaction, "syncdb.bookmarks", user); + if (timecodeRecords == 0 && syncRecords == 0) + throw new DatabaseEditorValidationException("user_not_found"); + + transaction.Commit(); + Serilog.Log.Information("DatabaseEditor deleted user {User}: {SyncRecords} Sync, {TimecodeRecords} TimeCode records", user, syncRecords, timecodeRecords); + return new DeleteUserResult { user = user, syncRecords = syncRecords, timecodeRecords = timecodeRecords }; + } + finally + { + if (timecodeAcquired) + timecodeSemaphore.Release(); + syncSemaphore.Release(); + } + } + + static async Task DeleteUserRowsAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string user) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = $"DELETE FROM {table} WHERE user = @user COLLATE NOCASE;"; + command.Parameters.AddWithValue("@user", user); + return await command.ExecuteNonQueryAsync(); + } + + public static async Task BackupAsync(string database) + { + DatabaseSpec spec = GetSpec(database); + var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); + bool acquired = await semaphore.WaitAsync(); + if (!acquired) + throw new DatabaseEditorBusyException("database_busy"); + + try + { + string directory = Path.Combine("database", "backup", "database-editor"); + Directory.CreateDirectory(directory); + string fileName = $"{Path.GetFileNameWithoutExtension(spec.path)}-{DateTime.Now:yyyyMMdd-HHmmssfff}.sql"; + string destinationPath = Path.Combine(directory, fileName); + if (File.Exists(destinationPath)) + destinationPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(spec.path)}-{DateTime.Now:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.sql"); + + await using var source = await OpenAsync(spec); + var destinationBuilder = new SqliteConnectionStringBuilder + { + DataSource = destinationPath, + Mode = SqliteOpenMode.ReadWriteCreate, + Pooling = false + }; + await using var destination = new SqliteConnection(destinationBuilder.ToString()); + await destination.OpenAsync(); + source.BackupDatabase(destination); + Serilog.Log.Information("DatabaseEditor backed up {Database} to {BackupPath}", spec.key, destinationPath); + return destinationPath.Replace('\\', '/'); + } + finally + { + semaphore.Release(); + } + } + + public static async Task> BackupAllAsync() + { + var result = new List(2) + { + new() { database = TimeCode.key, path = await BackupAsync(TimeCode.key) }, + new() { database = Sync.key, path = await BackupAsync(Sync.key) } + }; + return result; + } + + public static List GetBackups(string database) + { + DatabaseSpec spec = GetSpec(database); + string directory = Path.Combine("database", "backup", "database-editor"); + var result = new List(); + if (!Directory.Exists(directory)) + return result; + + string prefix = Path.GetFileNameWithoutExtension(spec.path) + "-"; + foreach (string path in Directory.GetFiles(directory, prefix + "*.sql")) + { + var file = new FileInfo(path); + result.Add(new DatabaseBackupFile + { + database = spec.key, + file = file.Name, + path = path.Replace('\\', '/'), + bytes = file.Length, + created = file.LastWriteTimeUtc.ToString("O") + }); + } + result.Sort((left, right) => string.CompareOrdinal(right.created, left.created)); + return result; + } + + public static async Task RestoreAsync(RestoreBackupRequest request) + { + if (request == null) + throw new DatabaseEditorValidationException("request_required"); + + DatabaseSpec spec = GetSpec(request.database); + string fileName = Path.GetFileName((request.file ?? string.Empty).Trim()); + if (string.IsNullOrEmpty(fileName) || !string.Equals(fileName, request.file?.Trim(), StringComparison.Ordinal)) + throw new DatabaseEditorValidationException("invalid_backup_file"); + + string prefix = Path.GetFileNameWithoutExtension(spec.path) + "-"; + if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || !fileName.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) + throw new DatabaseEditorValidationException("invalid_backup_file"); + + string directory = Path.GetFullPath(Path.Combine("database", "backup", "database-editor")); + string sourcePath = Path.GetFullPath(Path.Combine(directory, fileName)); + if (!sourcePath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(sourcePath)) + throw new DatabaseEditorValidationException("backup_not_found"); + + var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); + bool acquired = await semaphore.WaitAsync(); + if (!acquired) + throw new DatabaseEditorBusyException("database_busy"); + + try + { + await ValidateBackupAsync(spec, sourcePath); + string safetyPath = await CreateBackupLockedAsync(spec, "before-restore"); + + var sourceBuilder = new SqliteConnectionStringBuilder { DataSource = sourcePath, Mode = SqliteOpenMode.ReadOnly, Pooling = false }; + await using (var source = new SqliteConnection(sourceBuilder.ToString())) + { + await source.OpenAsync(); + await using var destination = await OpenAsync(spec); + source.BackupDatabase(destination); + } + SqliteConnection.ClearAllPools(); + + Serilog.Log.Warning("DatabaseEditor restored {Database} from {BackupPath}; safety backup: {SafetyPath}", spec.key, sourcePath, safetyPath); + return new DatabaseRestoreResult + { + database = spec.key, + restoredFrom = sourcePath.Replace('\\', '/'), + safetyBackup = safetyPath + }; + } + finally + { + semaphore.Release(); + } + } + + static async Task ValidateBackupAsync(DatabaseSpec spec, string sourcePath) + { + try + { + var builder = new SqliteConnectionStringBuilder { DataSource = sourcePath, Mode = SqliteOpenMode.ReadOnly, Pooling = false }; + await using var connection = new SqliteConnection(builder.ToString()); + await connection.OpenAsync(); + await using var integrity = connection.CreateCommand(); + integrity.CommandText = "PRAGMA quick_check;"; + if (!string.Equals(Convert.ToString(await integrity.ExecuteScalarAsync()), "ok", StringComparison.OrdinalIgnoreCase)) + throw new DatabaseEditorValidationException("backup_integrity_failed"); + + await using var schema = connection.CreateCommand(); + schema.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = @table LIMIT 1;"; + schema.Parameters.AddWithValue("@table", spec.table); + if (await schema.ExecuteScalarAsync() == null) + throw new DatabaseEditorValidationException("backup_schema_mismatch"); + } + catch (SqliteException) + { + throw new DatabaseEditorValidationException("invalid_backup_database"); + } + } + + static async Task CreateBackupLockedAsync(DatabaseSpec spec, string marker) + { + string directory = Path.Combine("database", "backup", "database-editor"); + Directory.CreateDirectory(directory); + string baseName = Path.GetFileNameWithoutExtension(spec.path); + string destinationPath = Path.Combine(directory, $"{baseName}-{marker}-{DateTime.Now:yyyyMMdd-HHmmssfff}.sql"); + await using var source = await OpenAsync(spec); + var builder = new SqliteConnectionStringBuilder { DataSource = destinationPath, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }; + await using var destination = new SqliteConnection(builder.ToString()); + await destination.OpenAsync(); + source.BackupDatabase(destination); + return destinationPath.Replace('\\', '/'); + } + + static async Task EnrichTimeCodeRecordsAsync(List records) + { + if (!File.Exists(Sync.path)) + return; + + var users = new HashSet(StringComparer.Ordinal); + foreach (DatabaseRecord record in records) + { + if (!string.IsNullOrEmpty(record.user)) + users.Add(record.user); + if (TryParseTimeCodeCard(record.card, out _, out string mediaType)) + record.mediaType = mediaType; + } + + if (users.Count == 0) + return; + + var metadata = new Dictionary(StringComparer.Ordinal); + await using var connection = await OpenAsync(Sync); + await using var command = connection.CreateCommand(); + var placeholders = new List(users.Count); + int parameterIndex = 0; + foreach (string user in users) + { + string parameter = "@user" + parameterIndex++; + placeholders.Add(parameter); + command.Parameters.AddWithValue(parameter, user); + } + command.CommandText = $"SELECT user, data FROM bookmarks WHERE user IN ({string.Join(",", placeholders)});"; + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + string user = ReadString(reader, 0); + JsonObject root = ParseRoot(ReadString(reader, 1)); + if (root?["card"] is not JsonArray cards) + continue; + + foreach (JsonNode node in cards) + { + if (node is not JsonObject card) + continue; + string cardId = NodeText(card["id"]); + if (string.IsNullOrEmpty(cardId)) + continue; + metadata[MediaKey(user, cardId)] = ReadMediaMetadata(card); + } + } + + foreach (DatabaseRecord record in records) + { + if (!TryParseTimeCodeCard(record.card, out string cardId, out string mediaType)) + continue; + + record.mediaType = mediaType; + if (!metadata.TryGetValue(MediaKey(record.user, cardId), out MediaMetadata media)) + continue; + + record.title = media.title; + record.poster = media.poster; + record.year = media.year; + if (string.IsNullOrEmpty(record.mediaType)) + record.mediaType = media.mediaType; + if (string.Equals(record.mediaType, "tv", StringComparison.OrdinalIgnoreCase) && + TryFindEpisode(media, record.item, out int season, out int episode)) + { + record.season = season; + record.episode = episode; + } + } + } + + static SyncUserDetails BuildSyncUserDetails(long id, string user, string updated, string data) + { + JsonObject root = ParseRoot(data); + var categoryMap = new Dictionary>(StringComparer.Ordinal); + var categoryOrderMap = new Dictionary>(StringComparer.Ordinal); + var allCategoryIds = new List(); + + foreach (string category in SyncCategories) + { + var ids = new HashSet(StringComparer.Ordinal); + var positions = new Dictionary(StringComparer.Ordinal); + if (root?[category] is JsonArray array) + { + foreach (JsonNode node in array) + { + string cardId = NodeText(node); + if (!string.IsNullOrEmpty(cardId) && ids.Add(cardId)) + { + positions[cardId] = positions.Count; + allCategoryIds.Add(cardId); + } + } + } + categoryMap[category] = ids; + categoryOrderMap[category] = positions; + } + + var items = new List(); + var seen = new HashSet(StringComparer.Ordinal); + if (root?["card"] is JsonArray cards) + { + foreach (JsonNode node in cards) + { + if (node is not JsonObject card) + continue; + string cardId = NodeText(card["id"]); + if (string.IsNullOrEmpty(cardId) || !seen.Add(cardId)) + continue; + MediaMetadata media = ReadMediaMetadata(card); + items.Add(new SyncUserItem + { + cardId = cardId, + title = media.title, + poster = media.poster, + year = media.year, + mediaType = media.mediaType, + categories = CategoriesFor(cardId, categoryMap), + order = items.Count, + categoryOrder = CategoryOrderFor(cardId, categoryOrderMap) + }); + } + } + + foreach (string cardId in allCategoryIds) + { + if (!seen.Add(cardId)) + continue; + items.Add(new SyncUserItem + { + cardId = cardId, + title = "Карточка #" + cardId, + categories = CategoriesFor(cardId, categoryMap), + order = items.Count, + categoryOrder = CategoryOrderFor(cardId, categoryOrderMap) + }); + } + + return new SyncUserDetails + { + id = id, + user = user, + updated = updated, + total = items.Count, + items = items + }; + } + + static async Task UpdateSyncJsonAsync(long recordId, Action update) + { + var semaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20)); + bool acquired = await semaphore.WaitAsync(); + if (!acquired) + throw new DatabaseEditorBusyException("database_busy"); + + try + { + await using var connection = await OpenAsync(Sync); + using var transaction = connection.BeginTransaction(); + string data; + await using (var select = connection.CreateCommand()) + { + select.Transaction = transaction; + select.CommandText = "SELECT data FROM bookmarks WHERE Id = @id LIMIT 1;"; + select.Parameters.AddWithValue("@id", recordId); + data = Convert.ToString(await select.ExecuteScalarAsync()); + } + if (string.IsNullOrEmpty(data)) + throw new DatabaseEditorValidationException("record_not_found"); + + JsonObject root = ParseRoot(data) ?? throw new DatabaseEditorValidationException("invalid_json"); + update(root); + string updatedData = root.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); + if (Encoding.UTF8.GetByteCount(updatedData) > MaxDataBytes) + throw new DatabaseEditorValidationException("data_too_large"); + + await using var save = connection.CreateCommand(); + save.Transaction = transaction; + save.CommandText = "UPDATE bookmarks SET data = @data, updated = @updated WHERE Id = @id;"; + save.Parameters.AddWithValue("@data", updatedData); + save.Parameters.AddWithValue("@updated", DateTime.UtcNow.ToString("O")); + save.Parameters.AddWithValue("@id", recordId); + if (await save.ExecuteNonQueryAsync() == 0) + throw new DatabaseEditorValidationException("record_not_found"); + transaction.Commit(); + } + finally + { + semaphore.Release(); + } + } + + static MediaMetadata ReadMediaMetadata(JsonObject card) + { + string mediaType = NodeText(card?["media_type"]); + if (string.IsNullOrEmpty(mediaType)) + mediaType = card?["name"] != null && card?["title"] == null ? "tv" : "movie"; + + string date = FirstNotEmpty(NodeText(card?["release_date"]), NodeText(card?["first_air_date"])); + return new MediaMetadata + { + title = FirstNotEmpty(NodeText(card?["title"]), NodeText(card?["name"]), NodeText(card?["original_title"]), NodeText(card?["original_name"])), + poster = FirstNotEmpty(NodeText(card?["img"]), NodeText(card?["poster_path"])), + year = !string.IsNullOrEmpty(date) && date.Length >= 4 ? date.Substring(0, 4) : date, + mediaType = mediaType, + hashTitles = DistinctValues( + NodeText(card?["original_name"]), + NodeText(card?["original_title"]), + NodeText(card?["name"]), + NodeText(card?["title"])), + seasons = ReadPositiveInt(card?["number_of_seasons"]), + episodes = ReadPositiveInt(card?["number_of_episodes"]) + }; + } + + static bool TryFindEpisode(MediaMetadata media, string item, out int season, out int episode) + { + season = 0; + episode = 0; + if (media?.hashTitles == null || media.hashTitles.Count == 0 || + !long.TryParse(item, NumberStyles.None, CultureInfo.InvariantCulture, out long target) || + target < 0 || target > 2147483648L) + return false; + + int maxSeasons = media.seasons > 0 ? Math.Min(media.seasons, 100) : 50; + int maxEpisodes = media.episodes > 0 ? Math.Min(media.episodes, 3000) : 500; + maxSeasons = Math.Max(maxSeasons, 10); + maxEpisodes = Math.Max(maxEpisodes, 100); + + if (FindEpisode(media.hashTitles, target, maxSeasons, maxEpisodes, out season, out episode)) + return true; + + // Long-running daily shows and anime can exceed the compact first pass. + if (maxSeasons < 100 || maxEpisodes < 3000) + return FindEpisode(media.hashTitles, target, 100, 3000, out season, out episode); + + return false; + } + + static bool FindEpisode(List titles, long target, int maxSeasons, int maxEpisodes, out int season, out int episode) + { + var titleHashes = new List<(int hash, int power)>(titles.Count); + foreach (string title in titles) + { + int power = 1; + for (int index = 0; index < title.Length; index++) + power = unchecked(power * 31); + titleHashes.Add((JsHash(title), power)); + } + + for (int seasonNumber = 0; seasonNumber <= maxSeasons; seasonNumber++) + { + string separator = seasonNumber > 10 ? ":" : string.Empty; + string seasonPrefix = seasonNumber.ToString(CultureInfo.InvariantCulture) + separator; + for (int episodeNumber = 0; episodeNumber <= maxEpisodes; episodeNumber++) + { + int prefixHash = JsHash(seasonPrefix + episodeNumber.ToString(CultureInfo.InvariantCulture)); + foreach ((int titleHash, int power) in titleHashes) + { + int hash = unchecked(prefixHash * power + titleHash); + if (Math.Abs((long)hash) != target) + continue; + season = seasonNumber; + episode = episodeNumber; + return true; + } + } + } + + season = 0; + episode = 0; + return false; + } + + static int JsHash(string value) + { + int hash = 0; + foreach (char character in value ?? string.Empty) + hash = unchecked(hash * 31 + character); + return hash; + } + + static List DistinctValues(params string[] values) + { + var result = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (string value in values) + { + if (!string.IsNullOrWhiteSpace(value) && seen.Add(value)) + result.Add(value); + } + return result; + } + + static int ReadPositiveInt(JsonNode node) + { + return int.TryParse(NodeText(node), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0 + ? value + : 0; + } + + static List CategoriesFor(string cardId, Dictionary> categoryMap) + { + var result = new List(); + foreach (string category in SyncEditorCategories) + { + if (categoryMap[category].Contains(cardId)) + result.Add(category); + } + return result; + } + + static Dictionary CategoryOrderFor(string cardId, Dictionary> categoryOrderMap) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (string category in SyncEditorCategories) + { + if (categoryOrderMap[category].TryGetValue(cardId, out int position)) + result[category] = position; + } + return result; + } + + static JsonObject FindSyncCard(JsonObject root, string cardId) + { + if (root?["card"] is not JsonArray cards) + return null; + foreach (JsonNode node in cards) + { + if (node is JsonObject card && string.Equals(NodeText(card["id"]), cardId, StringComparison.Ordinal)) + return card; + } + return null; + } + + static JsonArray EnsureArray(JsonObject root, string name) + { + if (root[name] is JsonArray array) + return array; + array = new JsonArray(); + root[name] = array; + return array; + } + + static bool RemoveCardId(JsonArray array, string cardId) + { + bool removed = false; + for (int index = array.Count - 1; index >= 0; index--) + { + if (string.Equals(NodeText(array[index]), cardId, StringComparison.Ordinal)) + { + array.RemoveAt(index); + removed = true; + } + } + return removed; + } + + static bool ContainsCardId(JsonArray array, string cardId) + { + foreach (JsonNode node in array) + { + if (string.Equals(NodeText(node), cardId, StringComparison.Ordinal)) + return true; + } + return false; + } + + static JsonNode CreateCardIdNode(string cardId) + { + if (long.TryParse(cardId, out long numericId)) + return JsonValue.Create(numericId); + return JsonValue.Create(cardId); + } + + static JsonObject ParseRoot(string data) + { + if (string.IsNullOrWhiteSpace(data)) + return null; + try + { + return JsonNode.Parse(data) as JsonObject; + } + catch (JsonException) + { + return null; + } + } + + static string NodeText(JsonNode node) + { + if (node == null) + return null; + if (node is JsonValue value && value.TryGetValue(out string text)) + return text; + return node.ToJsonString().Trim('"'); + } + + static string FirstNotEmpty(params string[] values) + { + foreach (string value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + return null; + } + + static string MediaKey(string user, string cardId) => (user ?? string.Empty) + "\u001f" + cardId; + + static bool TryParseTimeCodeCard(string value, out string cardId, out string mediaType) + { + cardId = null; + mediaType = null; + if (string.IsNullOrWhiteSpace(value)) + return false; + int separator = value.LastIndexOf('_'); + if (separator <= 0 || separator == value.Length - 1) + return false; + string suffix = value.Substring(separator + 1); + if (!string.Equals(suffix, "movie", StringComparison.OrdinalIgnoreCase) && + !string.Equals(suffix, "tv", StringComparison.OrdinalIgnoreCase)) + return false; + cardId = value.Substring(0, separator); + mediaType = suffix.ToLowerInvariant(); + return true; + } + + static void ReadPlaybackData(DatabaseRecord record, string data) + { + if (string.IsNullOrWhiteSpace(data)) + return; + try + { + using var document = JsonDocument.Parse(data); + JsonElement root = document.RootElement; + record.position = ReadDouble(root, "time"); + record.duration = ReadDouble(root, "duration"); + record.percent = ReadDouble(root, "percent"); + } + catch (JsonException) { } + } + + static double? ReadDouble(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out JsonElement value)) + return null; + if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out double number)) + return number; + if (value.ValueKind == JsonValueKind.String && double.TryParse(value.GetString(), out number)) + return number; + return null; + } + + static async Task ReadSummaryAsync(DatabaseSpec spec) + { + var summary = new DatabaseSummary + { + database = spec.key, + title = spec.title, + file = spec.path.Replace('\\', '/'), + available = File.Exists(spec.path), + bytes = File.Exists(spec.path) ? new FileInfo(spec.path).Length : 0 + }; + + if (!summary.available) + return summary; + + await using var connection = await OpenAsync(spec); + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*), {spec.updatedText($"MAX({spec.updatedColumn})")} FROM {spec.table};"; + await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow); + if (await reader.ReadAsync()) + { + summary.records = reader.GetInt64(0); + summary.updated = ReadString(reader, 1); + } + + return summary; + } + + static DatabaseSpec GetSpec(string database) + { + if (string.Equals(database, Sync.key, StringComparison.OrdinalIgnoreCase)) + return Sync; + if (string.Equals(database, TimeCode.key, StringComparison.OrdinalIgnoreCase)) + return TimeCode; + throw new DatabaseEditorValidationException("unknown_database"); + } + + static async Task OpenAsync(DatabaseSpec spec, bool pooling = true) + { + if (!File.Exists(spec.path)) + throw new DatabaseEditorValidationException("database_not_found"); + + var builder = new SqliteConnectionStringBuilder + { + DataSource = spec.path, + Mode = SqliteOpenMode.ReadWrite, + Cache = SqliteCacheMode.Shared, + DefaultTimeout = 10, + Pooling = pooling + }; + var connection = new SqliteConnection(builder.ToString()); + await connection.OpenAsync(); + return connection; + } + + static string BuildWhere(DatabaseSpec spec, string query, string user, out string searchValue, out string selectedUser) + { + var clauses = new List(); + if (string.IsNullOrEmpty(query)) + { + searchValue = null; + } + else + { + searchValue = "%" + query.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_") + "%"; + clauses.Add(spec.timecode + ? "(CAST(Id AS TEXT) LIKE @search ESCAPE '\\' OR user LIKE @search ESCAPE '\\' COLLATE NOCASE OR identity LIKE @search ESCAPE '\\' COLLATE NOCASE OR card LIKE @search ESCAPE '\\' COLLATE NOCASE OR item LIKE @search ESCAPE '\\' COLLATE NOCASE)" + : "(CAST(Id AS TEXT) LIKE @search ESCAPE '\\' OR user LIKE @search ESCAPE '\\' COLLATE NOCASE OR data LIKE @search ESCAPE '\\' COLLATE NOCASE)"); + } + + selectedUser = string.IsNullOrEmpty(user) ? null : user; + if (selectedUser != null) + clauses.Add("user = @selectedUser COLLATE NOCASE"); + + return clauses.Count == 0 ? string.Empty : " WHERE " + string.Join(" AND ", clauses); + } + + static void AddFilters(SqliteCommand command, string searchValue, string selectedUser) + { + if (searchValue != null) + command.Parameters.AddWithValue("@search", searchValue); + if (selectedUser != null) + command.Parameters.AddWithValue("@selectedUser", selectedUser); + } + + static string ValidateKey(string value, string error) + { + value = (value ?? string.Empty).Trim(); + if (value.Length == 0) + throw new DatabaseEditorValidationException(error); + if (value.Length > MaxKeyLength) + throw new DatabaseEditorValidationException("key_too_long"); + return value; + } + + static string NormalizeJson(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new DatabaseEditorValidationException("data_required"); + if (Encoding.UTF8.GetByteCount(value) > MaxDataBytes) + throw new DatabaseEditorValidationException("data_too_large"); + + try + { + using var document = JsonDocument.Parse(value); + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new DatabaseEditorValidationException("data_must_be_json_object"); + return JsonSerializer.Serialize(document.RootElement); + } + catch (JsonException) + { + throw new DatabaseEditorValidationException("invalid_json"); + } + } + + static string BuildPreview(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + string preview = value.Replace("\r", " ").Replace("\n", " ").Replace("\t", " "); + while (preview.Contains(" ", StringComparison.Ordinal)) + preview = preview.Replace(" ", " ", StringComparison.Ordinal); + return preview.Length <= 240 ? preview : preview.Substring(0, 240) + "…"; + } + + static string ReadString(SqliteDataReader reader, int index) => reader.IsDBNull(index) ? null : reader.GetString(index); +} diff --git a/mods/DatabaseEditor/ModInit.cs b/mods/DatabaseEditor/ModInit.cs new file mode 100644 index 0000000..91f53d1 --- /dev/null +++ b/mods/DatabaseEditor/ModInit.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Http; +using Shared.Models.Events; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace DatabaseEditor; + +public class ModInit : IModuleLoaded +{ + public static string modpath; + + static readonly Func> MiddlewareHandler = OnMiddleware; + + public void Loaded(InitspaceModel initspace) + { + modpath = initspace.path; + EventListener.MiddlewareAsync += MiddlewareHandler; + } + + public void Dispose() + { + EventListener.MiddlewareAsync -= MiddlewareHandler; + } + + static async Task OnMiddleware(bool first, EventMiddleware e) + { + if (first || !HttpMethods.IsGet(e.httpContext.Request.Method)) + return true; + + string path = (e.httpContext.Request.Path.Value ?? string.Empty).TrimEnd('/'); + if (!string.Equals(path, "/weblog", StringComparison.OrdinalIgnoreCase)) + return true; + + string weblogPath = Path.Combine(AppContext.BaseDirectory, "module", "WebLog", "index.html"); + if (!File.Exists(weblogPath)) + return true; + + string html = File.ReadAllText(weblogPath, Encoding.UTF8); + if (!html.Contains("href=\"/database-editor\"", StringComparison.OrdinalIgnoreCase)) + { + const string activeLink = "Weblog"; + const string databaseLink = activeLink + "\n Базы"; + + if (html.Contains(activeLink, StringComparison.Ordinal)) + html = html.Replace(activeLink, databaseLink, StringComparison.Ordinal); + else + { + const string navEnd = ""; + int navIndex = html.IndexOf(navEnd, StringComparison.OrdinalIgnoreCase); + if (navIndex >= 0) + html = html.Insert(navIndex, "Базы\n "); + } + } + + e.httpContext.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; + e.httpContext.Response.ContentType = "text/html; charset=utf-8"; + await e.httpContext.Response.WriteAsync(html, e.httpContext.RequestAborted); + return false; + } +} diff --git a/mods/DatabaseEditor/README.md b/mods/DatabaseEditor/README.md new file mode 100644 index 0000000..a3f71cf --- /dev/null +++ b/mods/DatabaseEditor/README.md @@ -0,0 +1,42 @@ +# DatabaseEditor + +Защищённый root-паролем WebLog редактор баз `database/TimeCode.sql` и +`database/Sync.sql`. + +## Маршруты + +- `/database-editor` — интерфейс редактора. +- `/database-editor/api/summary` — состояние и размер баз. +- `/database-editor/api/records` — поиск и постраничный список. +- `/database-editor/api/record` — чтение одной записи. +- `/database-editor/api/sync-user` — карточки и категории одного Sync-пользователя. +- `/database-editor/api/sync-item/save` — точечное изменение категорий карточки Sync. +- `/database-editor/api/sync-item/delete` — удаление одной карточки из Sync. +- `/database-editor/api/save` — создание или изменение записи. +- `/database-editor/api/delete` — удаление записи. +- `/database-editor/api/rename-user` — атомарное переименование пользователя во всех строках `bookmarks` (Sync) и `timecodes` (TimeCode). +- `/database-editor/api/delete-user` — атомарное удаление пользователя и всех его строк из `bookmarks` (Sync) и `timecodes` (TimeCode). +- `/database-editor/api/backup` — online-backup одной базы или обеих баз при + `database: "all"` в `database/backup/database-editor`. +- `/database-editor/api/backups` — список доступных резервных копий выбранной базы. +- `/database-editor/api/restore` — проверка и восстановление выбранной SQLite-копии; перед заменой автоматически создаётся страховочная копия текущей базы с маркером `before-restore`. + +Изменяющие запросы требуют заголовок `X-Database-Editor: 1`. Все SQL-запросы +параметризованы; запись синхронизируется штатными семафорами `TimeCode` и +`Sync`. В корне поля `data` допускается только JSON-объект. Список TimeCode +дополняется названиями и постерами из карточек Sync того же пользователя. + +TimeCode с версии типизированной схемы не хранит блоб: редактор по-прежнему +показывает и принимает road-объект Lampa (`time`, `duration`, `percent`, +`profile`, `updated`), но собирает его из колонок и раскладывает обратно. +Правка поднимает `updated_at` выше всех строк пользователя — иначе клиенты +не увидят её, они спрашивают дельты по `updated_at > since`. + +Редактор Sync повторяет категории Lampa: `book` (Закладки), `like` (Нравится), +`wath` (Позже), `history` (История просмотров). Поле `wath` является актуальным +именем Lampa, несмотря на опечатку в английском ключе. Статусы `look` (Смотрю), +`viewed`, `scheduled`, `continued`, `thrown` взаимоисключающие. Служебное поле +`watch` сохраняется в поддерживаемой схеме Lampac, но в редакторе не выводится. + +Мод также добавляет вкладку `Базы` в `/weblog` на лету, не изменяя файлы +штатного модуля WebLog. diff --git a/mods/DatabaseEditor/app.js b/mods/DatabaseEditor/app.js new file mode 100644 index 0000000..4658417 --- /dev/null +++ b/mods/DatabaseEditor/app.js @@ -0,0 +1,649 @@ +(function () { + 'use strict'; + + var categoryLabels = { + book: 'Закладки', like: 'Нравится', wath: 'Позже', history: 'История просмотров', + look: 'Смотрю', viewed: 'Просмотрено', scheduled: 'Запланировано', + continued: 'Продолжение следует', thrown: 'Брошено' + }; + var state = { + database: 'timecode', page: 1, pageSize: 25, pages: 1, total: 0, query: '', selectedUser: '', loading: false, + editingId: 0, editingDatabase: 'timecode', syncUser: null, syncItems: [], syncCategories: [], syncStatuses: [], + syncPage: 1, syncPageSize: 48, syncQuery: '', syncCategory: '', syncItem: null + }; + var $ = function (id) { return document.getElementById(id); }; + var els = { + listView: $('listView'), summary: $('summary'), loading: $('loading'), head: $('tableHead'), body: $('tableBody'), + empty: $('empty'), pagerInfo: $('pagerInfo'), pageLabel: $('pageLabel'), prev: $('prevBtn'), next: $('nextBtn'), + search: $('searchInput'), userFilter: $('timecodeUserFilter'), pageSize: $('pageSize'), recordModal: $('recordModal'), recordTitle: $('recordModalTitle'), + recordId: $('recordModalId'), user: $('userField'), card: $('cardField'), item: $('itemField'), data: $('dataField'), + jsonStatus: $('jsonStatus'), saveRecord: $('saveRecordBtn'), deleteRecord: $('deleteRecordBtn'), toasts: $('toasts'), + syncDetail: $('syncDetail'), syncUserName: $('syncUserName'), syncUserMeta: $('syncUserMeta'), syncSearch: $('syncSearch'), + syncCategory: $('syncCategory'), syncResultCount: $('syncResultCount'), syncGrid: $('syncGrid'), syncEmpty: $('syncEmpty'), + syncPrev: $('syncPrev'), syncNext: $('syncNext'), syncPageLabel: $('syncPageLabel'), categoryModal: $('categoryModal'), + categoryHead: $('categoryCardHead'), categoryOptions: $('categoryOptions'), backupModal: $('backupModal'), + backupResults: $('backupResults'), renameUserModal: $('renameUserModal'), oldUser: $('oldUserField'), newUser: $('newUserField'), + confirmRenameUser: $('confirmRenameUserBtn'), deleteUserModal: $('deleteUserModal'), deleteUser: $('deleteUserField'), + confirmDeleteUser: $('confirmDeleteUserBtn'), restoreModal: $('restoreModal'), restoreResults: $('restoreResults'), restoreEmpty: $('restoreEmpty') + }; + var errorMessages = { + unauthorized: 'Требуется повторный вход', unknown_database: 'Неизвестная база', database_not_found: 'Файл базы не найден', + database_busy: 'База занята, повторите попытку', invalid_json: 'JSON содержит ошибку', + data_must_be_json_object: 'В корне JSON должен быть объект', data_required: 'JSON не может быть пустым', + data_too_large: 'JSON превышает 8 МБ', user_required: 'Укажите пользователя', card_required: 'Укажите карточку', + item_required: 'Укажите элемент', duplicate_record_key: 'Запись с таким уникальным ключом уже существует', + record_not_found: 'Запись уже удалена', card_not_found: 'Карточка не найдена', unknown_category: 'Неизвестная категория', + multiple_statuses: 'Для карточки можно выбрать только один статус', old_user_required: 'Укажите текущее имя пользователя', + new_user_required: 'Укажите новое имя пользователя', user_name_unchanged: 'Новое имя совпадает с текущим', + user_not_found: 'Пользователь не найден ни в Sync, ни в TimeCode', rename_user_conflict: 'Новое имя уже занято или создаёт конфликт записей', + invalid_backup_file: 'Недопустимое имя файла резервной копии', backup_not_found: 'Резервная копия не найдена', + backup_integrity_failed: 'Проверка целостности резервной копии не пройдена', backup_schema_mismatch: 'Копия относится к другой базе', + invalid_backup_database: 'Файл не является исправной SQLite-базой', + internal_error: 'Внутренняя ошибка сервера', invalid_response: 'Сервер вернул неожиданный ответ' + }; + + function api(path, options) { + options = options || {}; + options.credentials = 'same-origin'; + options.headers = Object.assign({ Accept: 'application/json' }, options.headers || {}); + if (options.body) { + options.headers['Content-Type'] = 'application/json'; + options.headers['X-Database-Editor'] = '1'; + } + return fetch('/database-editor/api/' + path, options).then(function (response) { + var type = response.headers.get('content-type') || ''; + if (response.redirected && response.url.indexOf('/weblog/auth') >= 0) { + location.href = response.url; + throw new Error('unauthorized'); + } + if (type.indexOf('application/json') < 0) throw new Error(response.status === 401 ? 'unauthorized' : 'invalid_response'); + return response.json().then(function (data) { + if (!response.ok || data.success === false) throw new Error(data.error || 'request_failed'); + return data; + }); + }); + } + + function el(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; + } + function toast(message, bad) { + var node = el('div', 'toast ' + (bad ? 'bad' : 'good'), message); + els.toasts.appendChild(node); + setTimeout(function () { node.remove(); }, 4500); + } + function showError(error) { + var key = error && error.message || 'internal_error'; + toast(errorMessages[key] || key, true); + } + function setLoading(value) { + state.loading = value; + els.loading.classList.toggle('active', value); + els.prev.disabled = value || state.page <= 1; + els.next.disabled = value || state.page >= state.pages; + } + function formatBytes(bytes) { + if (!bytes) return '0 Б'; + var units = ['Б', 'КБ', 'МБ', 'ГБ']; + var index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), 3); + return (bytes / Math.pow(1024, index)).toLocaleString('ru-RU', { maximumFractionDigits: index ? 1 : 0 }) + ' ' + units[index]; + } + function formatDate(value) { + if (!value) return '—'; + var normalized = String(value).trim(); + var sqliteDateWithoutZone = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/; + if (sqliteDateWithoutZone.test(normalized)) { + normalized = normalized.replace(' ', 'T').replace(/(\.\d{3})\d+$/, '$1') + 'Z'; + } + var date = new Date(normalized); + return isNaN(date.getTime()) ? value : date.toLocaleString('ru-RU'); + } + function formatTime(seconds) { + seconds = Math.max(0, Math.round(Number(seconds) || 0)); + var hours = Math.floor(seconds / 3600); + var minutes = Math.floor((seconds % 3600) / 60); + var rest = seconds % 60; + return (hours ? hours + ':' + String(minutes).padStart(2, '0') : minutes) + ':' + String(rest).padStart(2, '0'); + } + function posterUrl(value) { + if (!value) return ''; + if (/^https?:\/\//i.test(value)) return value; + return '/tmdb/img/t/p/w300' + (value.charAt(0) === '/' ? value : '/' + value); + } + function posterNode(value, title) { + if (!value) return el('div', 'poster-placeholder', '◇'); + var image = el('img', 'poster'); + image.loading = 'lazy'; + image.alt = title || ''; + image.src = posterUrl(value); + image.addEventListener('error', function () { image.replaceWith(el('div', 'poster-placeholder', '◇')); }, { once: true }); + return image; + } + function typeLabel(type) { return type === 'tv' ? 'Сериал' : type === 'movie' ? 'Фильм' : ''; } + function fallbackTitle(record) { + if (record.title) return record.title; + return typeLabel(record.mediaType) || 'Карточка'; + } + + function loadSummary() { + return api('summary').then(function (data) { + els.summary.textContent = ''; + (data.databases || []).forEach(function (db) { + var card = el('article', 'summary-card'); + var head = el('div', 'summary-head'); + head.appendChild(el('span', '', db.title)); + head.appendChild(el('span', db.available ? 'available' : 'missing', db.available ? 'доступна' : 'нет файла')); + card.appendChild(head); + card.appendChild(el('div', 'summary-value', Number(db.records || 0).toLocaleString('ru-RU') + ' записей')); + var foot = el('div', 'summary-foot'); + foot.appendChild(el('div', 'summary-meta', formatBytes(db.bytes) + ' · ' + db.file + ' · ' + formatDate(db.updated))); + var backup = el('button', 'summary-backup', 'Backup этой базы'); + backup.addEventListener('click', function () { createBackup(db.database); }); + foot.appendChild(backup); + card.appendChild(foot); + els.summary.appendChild(card); + }); + }).catch(showError); + } + + function renderHead() { + els.head.textContent = ''; + var table = els.head.closest('table'); + table.classList.toggle('timecode-table', state.database === 'timecode'); + table.classList.toggle('sync-table', state.database === 'sync'); + var columns = state.database === 'timecode' + ? [['ID', 'col-id'], ['Пользователь', 'col-user'], ['Карточка', 'col-media'], ['Позиция', 'col-progress'], ['Обновлено', 'col-date'], ['', 'col-actions']] + : [['ID', 'col-id'], ['Пользователь', 'col-user'], ['Обновлено', 'col-date'], ['', 'col-actions']]; + columns.forEach(function (column) { els.head.appendChild(el('th', column[1], column[0])); }); + } + + function renderRows(records) { + els.body.textContent = ''; + els.empty.classList.toggle('show', !records.length); + records.forEach(function (record) { + var row = document.createElement('tr'); + row.appendChild(el('td', 'mono', '#' + record.id)); + var userCell = el('td'); + userCell.appendChild(el('div', 'record-user', record.user || '—')); + userCell.appendChild(el('div', 'record-sub', formatBytes(record.dataLength || 0))); + row.appendChild(userCell); + + if (state.database === 'timecode') { + var mediaCell = el('td'); + var media = el('div', 'media-cell'); + var title = fallbackTitle(record); + media.appendChild(posterNode(record.poster, title)); + var copy = el('div', 'media-copy'); + copy.appendChild(el('div', 'media-title', title)); + var meta = el('div', 'media-meta'); + if (record.mediaType) meta.appendChild(el('span', 'type-badge', typeLabel(record.mediaType))); + var mediaDetails = []; + if (record.mediaType === 'tv' && record.season != null && record.episode != null) { + mediaDetails.push('Сезон ' + record.season, 'Серия ' + record.episode); + } + if (record.year) mediaDetails.push(record.year); + if (mediaDetails.length) meta.appendChild(document.createTextNode(mediaDetails.join(' · '))); + copy.appendChild(meta); + media.appendChild(copy); + mediaCell.appendChild(media); + row.appendChild(mediaCell); + + var progressCell = el('td'); + var percent = Number(record.percent); + if (!isFinite(percent) && Number(record.duration) > 0) percent = Number(record.position) / Number(record.duration) * 100; + percent = Math.max(0, Math.min(100, isFinite(percent) ? percent : 0)); + progressCell.appendChild(el('div', 'record-user', Math.round(percent) + '%')); + var track = el('div', 'progress-track'); + var fill = el('div', 'progress-fill'); + fill.style.width = percent + '%'; + track.appendChild(fill); + progressCell.appendChild(track); + var progressCopy = el('div', 'progress-copy'); + progressCopy.appendChild(el('span', '', formatTime(record.position))); + progressCopy.appendChild(el('span', '', formatTime(record.duration))); + progressCell.appendChild(progressCopy); + row.appendChild(progressCell); + } + + row.appendChild(el('td', '', formatDate(record.updated))); + var actions = el('td', 'row-actions'); + if (state.database === 'sync') { + var open = el('button', 'mini-btn', 'Открыть'); + open.addEventListener('click', function () { openSyncUser(record.id); }); + actions.appendChild(open); + } + var edit = el('button', 'mini-btn', 'JSON'); + edit.addEventListener('click', function () { openRecord(record.id, state.database); }); + var remove = el('button', 'mini-btn delete', '×'); + remove.title = 'Удалить всю запись'; + remove.addEventListener('click', function () { deleteRecord(record.id, state.database); }); + actions.appendChild(edit); + actions.appendChild(remove); + row.appendChild(actions); + els.body.appendChild(row); + }); + } + + function loadRecords() { + if (state.loading) return; + setLoading(true); + var params = new URLSearchParams({ database: state.database, page: String(state.page), pageSize: String(state.pageSize) }); + if (state.query) params.set('query', state.query); + if (state.database === 'timecode' && state.selectedUser) params.set('user', state.selectedUser); + api('records?' + params.toString()).then(function (data) { + state.page = Number(data.page || 1); state.pages = Number(data.pages || 1); state.total = Number(data.total || 0); + renderHead(); renderRows(data.records || []); + var from = state.total ? (state.page - 1) * state.pageSize + 1 : 0; + var to = Math.min(state.page * state.pageSize, state.total); + els.pagerInfo.textContent = from + '–' + to + ' из ' + state.total.toLocaleString('ru-RU'); + els.pageLabel.textContent = state.page + ' / ' + state.pages; + }).catch(showError).finally(function () { setLoading(false); }); + } + + function loadTimeCodeUsers() { + return api('users?database=timecode').then(function (data) { + var selected = state.selectedUser; + els.userFilter.textContent = ''; + var all = document.createElement('option'); + all.value = ''; + all.textContent = 'Все пользователи TimeCode (' + (data.users || []).length.toLocaleString('ru-RU') + ')'; + els.userFilter.appendChild(all); + (data.users || []).forEach(function (entry) { + var option = document.createElement('option'); + option.value = entry.user; + option.textContent = entry.user + ' (' + Number(entry.records || 0).toLocaleString('ru-RU') + ')'; + els.userFilter.appendChild(option); + }); + if (selected && Array.from(els.userFilter.options).some(function (option) { return option.value === selected; })) { + els.userFilter.value = selected; + } else { + state.selectedUser = ''; + if (selected && state.database === 'timecode') { state.page = 1; loadRecords(); } + } + }).catch(showError); + } + + function switchDatabase(database) { + if (database === state.database) return; + state.database = database; state.page = 1; + document.querySelectorAll('.db-tab').forEach(function (tab) { tab.classList.toggle('active', tab.dataset.db === database); }); + els.userFilter.hidden = database !== 'timecode'; + loadRecords(); + } + + function openSyncUser(id, preserveFilters) { + var previousQuery = preserveFilters ? state.syncQuery : ''; + var previousCategory = preserveFilters ? state.syncCategory : ''; + api('sync-user?id=' + encodeURIComponent(id)).then(function (data) { + state.syncUser = data.user; + state.syncItems = data.user.items || []; + state.syncCategories = data.categories || []; + state.syncStatuses = data.statuses || []; + state.syncPage = 1; state.syncQuery = previousQuery; state.syncCategory = previousCategory; + els.syncSearch.value = previousQuery; + buildCategorySelect(); + if (Array.from(els.syncCategory.options).some(function (option) { return option.value === previousCategory; })) { + els.syncCategory.value = previousCategory; + } else { + state.syncCategory = ''; + } + els.syncUserName.textContent = data.user.user || '—'; + els.syncUserMeta.textContent = Number(data.user.total || 0).toLocaleString('ru-RU') + ' карточек · обновлено ' + formatDate(data.user.updated); + els.listView.hidden = true; + els.syncDetail.hidden = false; + renderSyncItems(); + window.scrollTo(0, 0); + }).catch(showError); + } + + function buildCategorySelect() { + els.syncCategory.textContent = ''; + var all = document.createElement('option'); all.value = ''; all.textContent = 'Все категории'; els.syncCategory.appendChild(all); + var generalGroup = document.createElement('optgroup'); generalGroup.label = 'Категории'; + var statusGroup = document.createElement('optgroup'); statusGroup.label = 'Статус'; + state.syncCategories.forEach(function (category) { + var option = document.createElement('option'); option.value = category; option.textContent = categoryLabels[category] || category; + (state.syncStatuses.indexOf(category) >= 0 ? statusGroup : generalGroup).appendChild(option); + }); + els.syncCategory.appendChild(generalGroup); + els.syncCategory.appendChild(statusGroup); + } + + function syncItemOrder(item, category) { + var value = item.categoryOrder && item.categoryOrder[category]; + return value == null ? Number.MAX_SAFE_INTEGER : Number(value); + } + + function filteredSyncItems() { + var query = state.syncQuery.toLowerCase(); + var filtered = state.syncItems.filter(function (item) { + var matchesText = !query || String(item.title || '').toLowerCase().indexOf(query) >= 0 || String(item.cardId || '').toLowerCase().indexOf(query) >= 0; + var matchesCategory = !state.syncCategory || (item.categories || []).indexOf(state.syncCategory) >= 0; + return matchesText && matchesCategory; + }); + return filtered.sort(function (left, right) { + if (state.syncCategory) return syncItemOrder(left, state.syncCategory) - syncItemOrder(right, state.syncCategory); + var leftHistory = syncItemOrder(left, 'history'); + var rightHistory = syncItemOrder(right, 'history'); + if (leftHistory !== rightHistory) return leftHistory - rightHistory; + return Number(left.order || 0) - Number(right.order || 0); + }); + } + + function renderSyncItems() { + var filtered = filteredSyncItems(); + var pages = Math.max(1, Math.ceil(filtered.length / state.syncPageSize)); + state.syncPage = Math.min(state.syncPage, pages); + var start = (state.syncPage - 1) * state.syncPageSize; + var visible = filtered.slice(start, start + state.syncPageSize); + els.syncGrid.textContent = ''; + els.syncEmpty.classList.toggle('show', !visible.length); + els.syncResultCount.textContent = filtered.length.toLocaleString('ru-RU') + ' из ' + state.syncItems.length.toLocaleString('ru-RU'); + els.syncPageLabel.textContent = state.syncPage + ' / ' + pages; + els.syncPrev.disabled = state.syncPage <= 1; + els.syncNext.disabled = state.syncPage >= pages; + + visible.forEach(function (item) { + var card = el('article', 'sync-card'); + card.appendChild(posterNode(item.poster, item.title)); + var body = el('div', 'sync-card-body'); + body.appendChild(el('div', 'sync-card-title', item.title || 'Карточка #' + item.cardId)); + body.appendChild(el('div', 'sync-card-meta', [typeLabel(item.mediaType), item.year, '#' + item.cardId].filter(Boolean).join(' · '))); + var tags = el('div', 'category-tags'); + if (item.categories && item.categories.length) item.categories.forEach(function (category) { tags.appendChild(el('span', 'category-tag', categoryLabels[category] || category)); }); + else tags.appendChild(el('span', 'category-tag empty-tag', 'Без категории')); + body.appendChild(tags); + var actions = el('div', 'sync-card-actions'); + var edit = el('button', 'btn', 'Изменить категории'); + edit.addEventListener('click', function () { openCategoryModal(item); }); + actions.appendChild(edit); body.appendChild(actions); card.appendChild(body); els.syncGrid.appendChild(card); + }); + } + + function openCategoryModal(item) { + state.syncItem = item; + els.categoryHead.textContent = ''; + els.categoryHead.appendChild(posterNode(item.poster, item.title)); + var copy = el('div'); + copy.appendChild(el('div', 'media-title', item.title || 'Карточка #' + item.cardId)); + copy.appendChild(el('div', 'media-meta mono', '#' + item.cardId)); + els.categoryHead.appendChild(copy); + els.categoryOptions.textContent = ''; + state.syncCategories.forEach(function (category, index) { + var isStatus = state.syncStatuses.indexOf(category) >= 0; + if (isStatus && category === state.syncStatuses[0]) { + els.categoryOptions.appendChild(el('div', 'category-section-title', 'Статус')); + } + var wrap = el('div', 'category-option' + (isStatus ? ' status-option' : '')); + var input = document.createElement('input'); + input.type = 'checkbox'; input.id = 'sync-category-' + index; input.value = category; + if (isStatus) input.dataset.status = '1'; + input.checked = (item.categories || []).indexOf(category) >= 0; + if (isStatus) input.addEventListener('change', function () { + if (!input.checked) return; + els.categoryOptions.querySelectorAll('input[data-status="1"]').forEach(function (other) { + if (other !== input) other.checked = false; + }); + }); + var label = document.createElement('label'); label.htmlFor = input.id; label.textContent = categoryLabels[category] || category; + wrap.appendChild(input); wrap.appendChild(label); els.categoryOptions.appendChild(wrap); + }); + openModal('categoryModal'); + } + + function saveSyncItem() { + if (!state.syncUser || !state.syncItem) return; + var categories = Array.from(els.categoryOptions.querySelectorAll('input:checked')).map(function (input) { return input.value; }); + $('saveSyncItemBtn').disabled = true; + api('sync-item/save', { method: 'POST', body: JSON.stringify({ recordId: state.syncUser.id, cardId: state.syncItem.cardId, categories: categories }) }) + .then(function (data) { + closeModal('categoryModal'); toast('Категории и статус карточки сохранены'); loadSummary(); + openSyncUser(state.syncUser.id, true); + }).catch(showError).finally(function () { $('saveSyncItemBtn').disabled = false; }); + } + + function deleteSyncItem() { + if (!state.syncUser || !state.syncItem) return; + if (!confirm('Удалить карточку «' + (state.syncItem.title || state.syncItem.cardId) + '» у этого пользователя?')) return; + api('sync-item/delete', { method: 'POST', body: JSON.stringify({ recordId: state.syncUser.id, cardId: state.syncItem.cardId }) }) + .then(function () { + state.syncItems = state.syncItems.filter(function (item) { return item.cardId !== state.syncItem.cardId; }); + state.syncUser.total = state.syncItems.length; + closeModal('categoryModal'); renderSyncItems(); toast('Карточка удалена'); loadSummary(); + }).catch(showError); + } + + function prettyJson(value) { return JSON.stringify(JSON.parse(value), null, 2); } + function validateJson() { + try { + var parsed = JSON.parse(els.data.value); + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('object'); + els.jsonStatus.textContent = 'JSON корректен'; els.jsonStatus.className = 'json-status ok'; return true; + } catch (_) { + els.jsonStatus.textContent = 'Ошибка JSON'; els.jsonStatus.className = 'json-status bad'; return false; + } + } + function setRecordDatabase(database) { + document.querySelectorAll('.timecode-field').forEach(function (node) { node.style.display = database === 'timecode' ? 'flex' : 'none'; }); + } + function openRecord(id, database) { + api('record?database=' + encodeURIComponent(database) + '&id=' + id).then(function (data) { + var record = data.record; state.editingId = record.id; state.editingDatabase = database; setRecordDatabase(database); + els.recordTitle.textContent = 'Редактирование ' + (database === 'sync' ? 'Sync' : 'TimeCode'); els.recordId.textContent = '#' + record.id; + els.user.value = record.user || ''; els.card.value = record.card || ''; els.item.value = record.item || ''; + try { els.data.value = prettyJson(record.data || '{}'); } catch (_) { els.data.value = record.data || ''; } + els.deleteRecord.hidden = false; validateJson(); openModal('recordModal'); + }).catch(showError); + } + function saveRecord() { + if (!validateJson()) return; + var payload = { database: state.editingDatabase, id: state.editingId || null, user: els.user.value, card: els.card.value, item: els.item.value, data: els.data.value }; + els.saveRecord.disabled = true; + api('save', { method: 'POST', body: JSON.stringify(payload) }).then(function () { + toast('Запись сохранена'); closeModal('recordModal'); state.page = 1; loadRecords(); loadSummary(); + if (state.editingDatabase === 'timecode') loadTimeCodeUsers(); + if (state.syncUser && state.editingDatabase === 'sync') openSyncUser(state.syncUser.id); + }).catch(showError).finally(function () { els.saveRecord.disabled = false; }); + } + function deleteRecord(id, database) { + if (!confirm('Удалить всю запись #' + id + ' из ' + database + '?')) return; + api('delete', { method: 'POST', body: JSON.stringify({ database: database, id: id }) }).then(function () { + toast('Запись удалена'); closeModal('recordModal'); loadRecords(); loadSummary(); + if (database === 'timecode') loadTimeCodeUsers(); + }).catch(showError); + } + + function createBackup(database) { + $('backupAllBtn').disabled = true; + api('backup', { method: 'POST', body: JSON.stringify({ database: database }) }).then(function (data) { + var backups = data.backups || [{ database: data.database || database, path: data.path }]; + els.backupResults.textContent = ''; + backups.forEach(function (backup) { + var result = el('div', 'backup-result'); + result.appendChild(el('strong', '', backup.database === 'timecode' ? 'TimeCode.sql' : 'Sync.sql')); + result.appendChild(el('div', 'backup-path', backup.path)); + els.backupResults.appendChild(result); + }); + openModal('backupModal'); toast(backups.length === 2 ? 'Созданы копии TimeCode и Sync' : 'Резервная копия создана'); + }).catch(showError).finally(function () { $('backupAllBtn').disabled = false; }); + } + var restoreDatabase = 'sync'; + function openRestore() { + restoreDatabase = 'sync'; + document.querySelectorAll('[data-restore-db]').forEach(function (tab) { tab.classList.toggle('active', tab.dataset.restoreDb === restoreDatabase); }); + openModal('restoreModal'); loadBackups(); + } + function loadBackups() { + els.restoreResults.textContent = ''; els.restoreEmpty.classList.remove('show'); + els.restoreResults.appendChild(el('div', 'muted', 'Загрузка…')); + api('backups?database=' + encodeURIComponent(restoreDatabase)).then(function (data) { + els.restoreResults.textContent = ''; + var backups = data.backups || []; + els.restoreEmpty.classList.toggle('show', !backups.length); + backups.forEach(function (backup) { + var row = el('div', 'backup-result restore-result'); + var info = el('div', 'restore-info'); + info.appendChild(el('strong', '', backup.file)); + info.appendChild(el('div', 'backup-path', formatBytes(backup.bytes) + ' · ' + formatDate(backup.created))); + var button = el('button', 'btn danger', 'Восстановить'); + button.addEventListener('click', function () { restoreBackup(backup, button); }); + row.appendChild(info); row.appendChild(button); els.restoreResults.appendChild(row); + }); + }).catch(showError); + } + function restoreBackup(backup, button) { + var title = backup.database === 'sync' ? 'Sync' : 'TimeCode'; + if (!confirm('Восстановить базу ' + title + ' из файла «' + backup.file + '»?\n\nТекущее состояние сначала будет сохранено автоматически. Данные, записанные после даты этой копии, будут заменены.')) return; + button.disabled = true; + api('restore', { method: 'POST', body: JSON.stringify({ database: backup.database, file: backup.file }) }).then(function (data) { + closeModal('restoreModal'); + toast(title + ' восстановлена. Страховочная копия: ' + data.result.safetyBackup); + setTimeout(function () { location.reload(); }, 1600); + }).catch(showError).finally(function () { button.disabled = false; }); + } + function openRenameUser() { + var selected = state.syncUser ? state.syncUser.user || '' : state.selectedUser || ''; + els.oldUser.textContent = ''; + var loadingOption = document.createElement('option'); loadingOption.value = ''; loadingOption.textContent = 'Загрузка пользователей…'; + els.oldUser.appendChild(loadingOption); + els.newUser.value = ''; + els.confirmRenameUser.disabled = true; + openModal('renameUserModal'); + Promise.all([api('users?database=sync'), api('users?database=timecode')]).then(function (responses) { + var users = new Map(); + responses.forEach(function (response, databaseIndex) { + (response.users || []).forEach(function (entry) { + var key = (entry.user || '').toLowerCase(); + if (!key) return; + var current = users.get(key) || { user: entry.user, sync: 0, timecode: 0 }; + current[databaseIndex === 0 ? 'sync' : 'timecode'] += Number(entry.records || 0); + users.set(key, current); + }); + }); + var list = Array.from(users.values()).sort(function (left, right) { return left.user.localeCompare(right.user, 'ru', { sensitivity: 'base' }); }); + els.oldUser.textContent = ''; + var placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = list.length ? 'Выберите пользователя' : 'Пользователей нет'; + els.oldUser.appendChild(placeholder); + list.forEach(function (entry) { + var option = document.createElement('option'); option.value = entry.user; + option.textContent = entry.user + ' · Sync: ' + entry.sync + ', TimeCode: ' + entry.timecode; + els.oldUser.appendChild(option); + }); + var match = list.find(function (entry) { return entry.user.toLowerCase() === selected.toLowerCase(); }); + if (match) els.oldUser.value = match.user; + els.confirmRenameUser.disabled = !list.length; + setTimeout(function () { (els.oldUser.value ? els.newUser : els.oldUser).focus(); }, 0); + }).catch(function (error) { + els.oldUser.textContent = ''; + var failed = document.createElement('option'); failed.value = ''; failed.textContent = 'Не удалось загрузить пользователей'; els.oldUser.appendChild(failed); + showError(error); + }); + } + function openDeleteUser() { + var selected = state.syncUser ? state.syncUser.user || '' : state.selectedUser || ''; + els.deleteUser.textContent = ''; + var loading = document.createElement('option'); loading.value = ''; loading.textContent = 'Загрузка пользователей…'; els.deleteUser.appendChild(loading); + els.confirmDeleteUser.disabled = true; + openModal('deleteUserModal'); + Promise.all([api('users?database=sync'), api('users?database=timecode')]).then(function (responses) { + var users = new Map(); + responses.forEach(function (response, databaseIndex) { + (response.users || []).forEach(function (entry) { + var key = (entry.user || '').toLowerCase(); + if (!key) return; + var current = users.get(key) || { user: entry.user, sync: 0, timecode: 0 }; + current[databaseIndex === 0 ? 'sync' : 'timecode'] += Number(entry.records || 0); users.set(key, current); + }); + }); + var list = Array.from(users.values()).sort(function (left, right) { return left.user.localeCompare(right.user, 'ru', { sensitivity: 'base' }); }); + els.deleteUser.textContent = ''; + var placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = list.length ? 'Выберите пользователя' : 'Пользователей нет'; els.deleteUser.appendChild(placeholder); + list.forEach(function (entry) { var option = document.createElement('option'); option.value = entry.user; option.textContent = entry.user + ' · Sync: ' + entry.sync + ', TimeCode: ' + entry.timecode; els.deleteUser.appendChild(option); }); + var match = list.find(function (entry) { return entry.user.toLowerCase() === selected.toLowerCase(); }); + if (match) els.deleteUser.value = match.user; + els.confirmDeleteUser.disabled = !list.length; + setTimeout(function () { els.deleteUser.focus(); }, 0); + }).catch(function (error) { + els.deleteUser.textContent = ''; var failed = document.createElement('option'); failed.value = ''; failed.textContent = 'Не удалось загрузить пользователей'; els.deleteUser.appendChild(failed); showError(error); + }); + } + function deleteUser() { + var user = els.deleteUser.value.trim(); + if (!user) return showError(new Error('user_required')); + if (!confirm('Безвозвратно удалить пользователя «' + user + '», все его закладки Sync и позиции TimeCode?')) return; + els.confirmDeleteUser.disabled = true; + api('delete-user', { method: 'POST', body: JSON.stringify({ user: user }) }).then(function (data) { + var result = data.result; closeModal('deleteUserModal'); state.selectedUser = ''; + if (state.syncUser && state.syncUser.user.toLowerCase() === user.toLowerCase()) state.syncUser = null; + els.syncDetail.hidden = true; els.listView.hidden = false; + loadRecords(); loadSummary(); loadTimeCodeUsers(); + toast('Пользователь удалён: Sync — ' + result.syncRecords + ', TimeCode — ' + result.timecodeRecords); + }).catch(showError).finally(function () { els.confirmDeleteUser.disabled = false; }); + } + function renameUser() { + var oldUser = els.oldUser.value.trim(); + var newUser = els.newUser.value.trim(); + if (!oldUser) return showError(new Error('old_user_required')); + if (!newUser) return showError(new Error('new_user_required')); + if (!confirm('Перед продолжением выйдите из учётной записи «' + oldUser + '» на всех устройствах или измените её логин/account_email в Lampa и init/AccsDB.\n\nАктивная сессия со старым именем создаст пользователя повторно.\n\nПереименовать «' + oldUser + '» в «' + newUser + '»?')) return; + els.confirmRenameUser.disabled = true; + api('rename-user', { method: 'POST', body: JSON.stringify({ oldUser: oldUser, newUser: newUser }) }).then(function (data) { + var result = data.result; + closeModal('renameUserModal'); + state.selectedUser = ''; + if (state.syncUser && state.syncUser.user.toLowerCase() === oldUser.toLowerCase()) state.syncUser = null; + els.syncDetail.hidden = true; els.listView.hidden = false; + loadRecords(); loadSummary(); loadTimeCodeUsers(); + toast('Пользователь переименован: Sync — ' + result.syncRecords + ', TimeCode — ' + result.timecodeRecords); + }).catch(showError).finally(function () { els.confirmRenameUser.disabled = false; }); + } + function openModal(id) { $(id).classList.add('open'); } + function closeModal(id) { $(id).classList.remove('open'); } + function applyTheme(theme) { + document.documentElement.dataset.theme = theme; + var metaTheme = document.getElementById('metaTheme'); + if (metaTheme) metaTheme.setAttribute('content', theme === 'dark' ? '#0c0e12' : '#f4f5f8'); + localStorage.setItem('lampac-theme', theme); + } + + document.querySelectorAll('.db-tab').forEach(function (tab) { tab.addEventListener('click', function () { switchDatabase(tab.dataset.db); }); }); + var searchTimer; + els.search.addEventListener('input', function () { clearTimeout(searchTimer); searchTimer = setTimeout(function () { state.query = els.search.value.trim(); state.page = 1; loadRecords(); }, 280); }); + els.userFilter.addEventListener('change', function () { state.selectedUser = els.userFilter.value; state.page = 1; loadRecords(); }); + els.pageSize.addEventListener('change', function () { state.pageSize = Number(els.pageSize.value); state.page = 1; loadRecords(); }); + els.prev.addEventListener('click', function () { if (state.page > 1) { state.page--; loadRecords(); } }); + els.next.addEventListener('click', function () { if (state.page < state.pages) { state.page++; loadRecords(); } }); + $('refreshBtn').addEventListener('click', function () { loadRecords(); loadSummary(); if (state.database === 'timecode') loadTimeCodeUsers(); }); + $('backupAllBtn').addEventListener('click', function () { createBackup('all'); }); + $('restoreBtn').addEventListener('click', openRestore); + document.querySelectorAll('[data-restore-db]').forEach(function (tab) { tab.addEventListener('click', function () { restoreDatabase = tab.dataset.restoreDb; document.querySelectorAll('[data-restore-db]').forEach(function (item) { item.classList.toggle('active', item === tab); }); loadBackups(); }); }); + $('renameUserBtn').addEventListener('click', openRenameUser); + els.confirmRenameUser.addEventListener('click', renameUser); + $('deleteUserBtn').addEventListener('click', openDeleteUser); + els.confirmDeleteUser.addEventListener('click', deleteUser); + $('backToUsers').addEventListener('click', function () { els.syncDetail.hidden = true; els.listView.hidden = false; state.syncUser = null; loadRecords(); loadSummary(); }); + $('rawSyncBtn').addEventListener('click', function () { if (state.syncUser) openRecord(state.syncUser.id, 'sync'); }); + $('refreshSyncBtn').addEventListener('click', function () { if (state.syncUser) openSyncUser(state.syncUser.id, true); }); + els.syncSearch.addEventListener('input', function () { state.syncQuery = els.syncSearch.value.trim(); state.syncPage = 1; renderSyncItems(); }); + els.syncCategory.addEventListener('change', function () { state.syncCategory = els.syncCategory.value; state.syncPage = 1; renderSyncItems(); }); + els.syncPrev.addEventListener('click', function () { if (state.syncPage > 1) { state.syncPage--; renderSyncItems(); window.scrollTo(0, 0); } }); + els.syncNext.addEventListener('click', function () { state.syncPage++; renderSyncItems(); window.scrollTo(0, 0); }); + $('saveSyncItemBtn').addEventListener('click', saveSyncItem); + $('deleteSyncItemBtn').addEventListener('click', deleteSyncItem); + $('formatBtn').addEventListener('click', function () { try { els.data.value = prettyJson(els.data.value); } catch (_) { } validateJson(); }); + els.data.addEventListener('input', validateJson); + els.saveRecord.addEventListener('click', saveRecord); + els.deleteRecord.addEventListener('click', function () { deleteRecord(state.editingId, state.editingDatabase); }); + document.querySelectorAll('[data-close]').forEach(function (button) { button.addEventListener('click', function () { closeModal(button.dataset.close); }); }); + document.querySelectorAll('.modal-backdrop').forEach(function (modal) { modal.addEventListener('click', function (event) { if (event.target === modal) closeModal(modal.id); }); }); + document.addEventListener('keydown', function (event) { + if (event.key === 'Escape') document.querySelectorAll('.modal-backdrop.open').forEach(function (modal) { closeModal(modal.id); }); + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's' && els.recordModal.classList.contains('open')) { event.preventDefault(); saveRecord(); } + }); + $('themeBtn').addEventListener('click', function () { applyTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light'); }); + applyTheme(localStorage.getItem('lampac-theme') || 'dark'); + loadSummary(); loadTimeCodeUsers(); renderHead(); loadRecords(); +})(); diff --git a/mods/DatabaseEditor/index.html b/mods/DatabaseEditor/index.html new file mode 100644 index 0000000..e7f80c4 --- /dev/null +++ b/mods/DatabaseEditor/index.html @@ -0,0 +1,151 @@ + + + + + + + Базы — Lampac + + + + + + +
+
+ +
+ +
+ +
+ +
+
+
+

Редактор TimeCode и Sync

Пользователи, карточки, позиции просмотра и безопасные копии SQLite.

+
+
+ +
+ +
+
+
+ + +
+ + +
+
+
+
+
⌕
Записей не найдено
Измените поиск или выберите другую базу.
+
+
—
1 / 1
+
+
+ + +
+
+ + + + + + + + + + + + + +
+ + + diff --git a/mods/DatabaseEditor/manifest.json b/mods/DatabaseEditor/manifest.json new file mode 100644 index 0000000..25d4848 --- /dev/null +++ b/mods/DatabaseEditor/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "references": [ + "Microsoft.Data.Sqlite" + ] +} diff --git a/mods/DatabaseEditor/style.css b/mods/DatabaseEditor/style.css new file mode 100644 index 0000000..74c9cbc --- /dev/null +++ b/mods/DatabaseEditor/style.css @@ -0,0 +1,439 @@ +:root { + --font-body: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace; + --text-xs: .6875rem; + --text-sm: .8125rem; + --text-base: .875rem; + --text-lg: 1rem; + --text-xl: 1.25rem; + --sp-1: .25rem; + --sp-2: .5rem; + --sp-3: .75rem; + --sp-4: 1rem; + --sp-5: 1.25rem; + --sp-6: 1.5rem; + --sp-8: 2rem; + --r-sm: 6px; + --r-md: 8px; + --r-lg: 12px; + --r-xl: 16px; + --ease: cubic-bezier(.16, 1, .3, 1); + --dur: 180ms; +} + +[data-theme="dark"] { + color-scheme: dark; + --bg: #0c0e12; + --surface: #13161d; + --surface-2: #1a1e28; + --surface-3: #222733; + --surface-hover: #2a3142; + --border: #2a3040; + --border-subtle: rgba(255, 255, 255, .06); + --text: #e2e6ef; + --text-muted: #7c849a; + --text-faint: #4d5568; + --accent: #3ec9d1; + --accent-soft: #2ea8b0; + --accent-dim: rgba(62, 201, 209, .12); + --accent-hover: rgba(62, 201, 209, .22); + --accent-glow: rgba(62, 201, 209, .06); + --ok: #4ade80; + --ok-dim: rgba(74, 222, 128, .12); + --warn: #facc15; + --warn-dim: rgba(250, 204, 21, .12); + --err: #f87171; + --err-dim: rgba(248, 113, 113, .12); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, .3); + --shadow-md: 0 4px 16px rgba(0, 0, 0, .35); + --shadow-lg: 0 12px 40px rgba(0, 0, 0, .45); + --overlay: rgba(0, 0, 0, .6); +} + +[data-theme="light"] { + color-scheme: light; + --bg: #f4f5f8; + --surface: #fff; + --surface-2: #f0f1f5; + --surface-3: #e8eaf0; + --surface-hover: #e2e4ec; + --border: #d4d8e3; + --border-subtle: rgba(0, 0, 0, .06); + --text: #1a1e28; + --text-muted: #6b7280; + --text-faint: #9ca3af; + --accent: #0d9499; + --accent-soft: #0b7d82; + --accent-dim: rgba(13, 148, 153, .1); + --accent-hover: rgba(13, 148, 153, .18); + --accent-glow: rgba(13, 148, 153, .04); + --ok: #16a34a; + --ok-dim: rgba(22, 163, 74, .1); + --warn: #ca8a04; + --warn-dim: rgba(202, 138, 4, .1); + --err: #dc2626; + --err-dim: rgba(220, 38, 38, .1); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, .06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, .08); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, .12); + --overlay: rgba(0, 0, 0, .3); +} + +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; } +html { background: var(--bg); } +body { + min-width: 320px; + background: var(--bg); + color: var(--text); + font: var(--text-base)/1.5 var(--font-body); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +button, input, select, textarea { font: inherit; } +button { cursor: pointer; } +a { color: inherit; text-decoration: none; } +[hidden] { display: none !important; } +.muted { color: var(--text-muted); } + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--surface-3); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; } +::-webkit-scrollbar-thumb:hover { background-color: var(--text-faint); } + +.app { min-height: 100dvh; } +.topbar { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + gap: var(--sp-4); + min-height: 52px; + padding: var(--sp-3) var(--sp-6); + padding-top: max(var(--sp-3), env(safe-area-inset-top, 0px)); + padding-left: max(var(--sp-6), env(safe-area-inset-left, 0px)); + padding-right: max(var(--sp-6), env(safe-area-inset-right, 0px)); + background: color-mix(in srgb, var(--surface) 94%, transparent); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(16px); +} +.topbar-logo { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--sp-2); + color: var(--text); + font-size: var(--text-lg); + font-weight: 700; + letter-spacing: -.02em; + white-space: nowrap; +} +.topbar-logo svg { flex-shrink: 0; } +.topbar-logo span { margin-left: 2px; color: var(--text-muted); font-weight: 400; } +.topbar-divider { width: 1px; height: 24px; flex-shrink: 0; background: var(--border); } +.topbar-app-nav { display: flex; flex-shrink: 0; align-items: center; gap: var(--sp-2); } +.topbar-app-nav a { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 5.75rem; + min-height: 34px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface-2); + color: var(--text-muted); + font-size: var(--text-xs); + font-weight: 600; + white-space: nowrap; + transition: background var(--dur) var(--ease), color var(--dur) var(--ease), border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); +} +.topbar-app-nav a:hover { background: var(--surface-3); color: var(--text); } +.topbar-app-nav a[aria-current="page"] { border-color: var(--accent); background: var(--surface); color: var(--accent); box-shadow: var(--shadow-sm); } +.topbar-spacer { flex: 1; min-width: var(--sp-2); } +.theme-icon { display: block; } +.theme-icon-sun { display: none; } +[data-theme="dark"] .theme-icon-moon { display: none; } +[data-theme="dark"] .theme-icon-sun { display: block; } + +.main { width: 100%; max-width: 1500px; margin: 0 auto; padding: var(--sp-6) var(--sp-6) 3rem; } +.hero { display: flex; align-items: center; gap: var(--sp-6); margin-bottom: var(--sp-5); } +.hero > div:first-child { min-width: 260px; } +.hero h1, .detail-head h1 { margin: 0 0 var(--sp-1); font-size: var(--text-xl); line-height: 1.25; letter-spacing: -.025em; } +.hero p { max-width: 720px; margin: 0; color: var(--text-muted); font-size: var(--text-sm); } +.hero-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--sp-2); margin-left: auto; } + +.icon-btn, .btn, .mini-btn { + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface-2); + color: var(--text-muted); + font-size: var(--text-xs); + font-weight: 600; + transition: transform var(--dur) var(--ease), background var(--dur) var(--ease), color var(--dur) var(--ease), border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); +} +.icon-btn, .btn { display: inline-flex; min-height: 34px; align-items: center; justify-content: center; gap: var(--sp-2); } +.icon-btn { width: 34px; min-width: 34px; padding: 0; } +.btn { padding: 6px 14px; white-space: nowrap; } +.icon-btn:hover, .btn:hover, .mini-btn:hover { border-color: var(--accent); background: var(--accent-dim); color: var(--accent); transform: translateY(-1px); box-shadow: var(--shadow-sm); } +.btn.primary { border-color: var(--accent); background: var(--accent); color: #fff; } +.btn.primary:hover { border-color: var(--accent-soft); background: var(--accent-soft); color: #fff; } +.btn.danger { border-color: color-mix(in srgb, var(--err) 65%, var(--border)); background: var(--err-dim); color: var(--err); } +.btn.danger:hover { border-color: var(--err); background: color-mix(in srgb, var(--err) 18%, transparent); color: var(--err); } +.btn.small { min-height: 28px; padding: 4px 10px; } +.btn:disabled, .icon-btn:disabled, .mini-btn:disabled { cursor: not-allowed; opacity: .45; transform: none; box-shadow: none; } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-4); } +.summary-card { + position: relative; + overflow: hidden; + min-width: 0; + padding: var(--sp-4); + border: 1px solid var(--border); + border-radius: var(--r-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); +} +.summary-card::before { position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 72%); content: ""; opacity: .75; } +.summary-head { display: flex; justify-content: space-between; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); } +.summary-value { margin: var(--sp-2) 0 var(--sp-1); color: var(--text); font-size: 1.45rem; font-weight: 700; letter-spacing: -.03em; } +.summary-foot { display: flex; align-items: center; gap: var(--sp-3); margin-top: var(--sp-2); } +.summary-meta { flex: 1; overflow: hidden; color: var(--text-faint); font: var(--text-xs) var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } +.summary-backup { padding: 0; border: 0; background: transparent; color: var(--accent); font-size: var(--text-xs); font-weight: 600; } +.summary-backup:hover { color: var(--accent-soft); } +.available { color: var(--ok); } +.missing { color: var(--err); } + +.workspace, .sync-detail { overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface); box-shadow: var(--shadow-md); } +.toolbar, .detail-toolbar { display: flex; min-height: 58px; align-items: center; gap: var(--sp-3); padding: 10px var(--sp-3); border-bottom: 1px solid var(--border); background: var(--surface); } +.db-tabs, .restore-tabs { display: flex; gap: 2px; padding: 3px; border: 1px solid var(--border-subtle); border-radius: var(--r-md); background: var(--surface-2); } +.db-tab { padding: 5px 12px; border: 0; border-radius: 5px; background: transparent; color: var(--text-muted); font-size: var(--text-xs); font-weight: 600; white-space: nowrap; transition: all var(--dur) var(--ease); } +.db-tab:hover { color: var(--text); } +.db-tab.active { background: var(--surface-3); color: var(--accent); box-shadow: var(--shadow-sm); } +.search { position: relative; flex: 1; max-width: 560px; } +.search input, .select, .field input, .field textarea { + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface-2); + color: var(--text); + outline: none; + transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease), box-shadow var(--dur) var(--ease); +} +.search input { width: 100%; height: 34px; padding: 0 12px 0 35px; font-size: var(--text-sm); } +.search input::placeholder, .field input::placeholder, .field textarea::placeholder { color: var(--text-faint); } +.search > span { position: absolute; top: 6px; left: 11px; z-index: 1; color: var(--text-faint); font-size: 17px; pointer-events: none; } +.search input:focus, .select:focus, .field input:focus, .field textarea:focus { border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-dim); } +.toolbar-spacer { flex: 1; } +.select { height: 34px; padding: 0 30px 0 10px; color: var(--text-muted); font-size: var(--text-xs); } +.user-filter { width: clamp(210px, 22vw, 330px); } + +.loading { height: 2px; overflow: hidden; background: transparent; } +.loading::after { display: block; width: 35%; height: 100%; background: var(--accent); content: ""; transform: translateX(-110%); } +.loading.active::after { animation: load 1s infinite var(--ease); } +@keyframes load { to { transform: translateX(310%); } } + +.table-wrap { min-height: 370px; overflow: auto; } +.data-table { width: 100%; border-collapse: collapse; table-layout: fixed; } +.data-table th { position: sticky; top: 0; z-index: 2; height: 38px; padding: 0 var(--sp-3); border-bottom: 1px solid var(--border); background: var(--surface-2); color: var(--text-faint); font-size: var(--text-xs); font-weight: 600; letter-spacing: .05em; text-align: left; text-transform: uppercase; } +.data-table td { padding: var(--sp-3); border-bottom: 1px solid var(--border-subtle); vertical-align: middle; } +.data-table tbody tr { transition: background var(--dur) var(--ease); } +.data-table tbody tr:hover { background: var(--accent-glow); } +.col-id { width: 70px; } +.col-user { width: 250px; } +.col-media { width: 340px; } +.col-progress { width: 240px; } +.col-date { width: 170px; } +.col-actions { width: 190px; } +.mono { font-family: var(--font-mono); } +.record-user { overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.record-sub { overflow: hidden; margin-top: 3px; color: var(--text-faint); font: var(--text-xs) var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } +.preview { display: -webkit-box; overflow: hidden; color: var(--text-muted); font: var(--text-xs)/1.5 var(--font-mono); overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.row-actions { display: flex; justify-content: flex-end; gap: 5px; } +.mini-btn { display: inline-flex; height: 31px; align-items: center; justify-content: center; padding: 0 9px; } +.mini-btn.delete:hover { border-color: var(--err); background: var(--err-dim); color: var(--err); } +.media-cell { display: flex; min-width: 0; align-items: center; gap: 11px; } +.poster, .poster-placeholder { width: 46px; height: 68px; flex: 0 0 46px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-3); object-fit: cover; } +.poster-placeholder { display: grid; place-items: center; color: var(--text-faint); font-size: 20px; } +.media-copy { min-width: 0; } +.media-title { overflow: hidden; color: var(--text); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.media-meta { overflow: hidden; margin-top: 3px; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; } +.type-badge, .category-tag { display: inline-flex; border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); border-radius: 4px; background: var(--accent-dim); color: var(--accent); font-size: .625rem; } +.type-badge { margin-right: 5px; padding: 1px 5px; text-transform: uppercase; } +.progress-track { height: 5px; overflow: hidden; margin: 7px 0 5px; border-radius: 999px; background: var(--surface-3); } +.progress-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent-soft), var(--accent)); } +.progress-copy { display: flex; justify-content: space-between; color: var(--text-muted); font: var(--text-xs) var(--font-mono); } + +.empty { display: none; min-height: 360px; place-items: center; padding: var(--sp-6); color: var(--text-muted); text-align: center; } +.empty.show { display: grid; } +.empty-icon { display: grid; width: 52px; height: 52px; margin: 0 auto var(--sp-3); place-items: center; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface-2); color: var(--text-faint); } +.pager, .detail-pager { display: flex; min-height: 54px; align-items: center; gap: var(--sp-2); padding: 9px var(--sp-3); border-top: 1px solid var(--border); background: var(--surface); } +.pager-info, .page-label { color: var(--text-muted); font-size: var(--text-xs); } +.pager-buttons { display: flex; gap: 6px; margin-left: auto; } +.page-label { display: flex; align-items: center; padding: 0 9px; font-family: var(--font-mono); } + +.detail-head { display: flex; align-items: center; gap: var(--sp-5); padding: var(--sp-4); border-bottom: 1px solid var(--border); background: var(--surface); } +.detail-identity { min-width: 0; } +.detail-identity h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.eyebrow { margin-bottom: var(--sp-1); color: var(--accent); font-size: var(--text-xs); font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.detail-actions { display: flex; gap: var(--sp-2); margin-left: auto; } +.detail-toolbar .result-count { margin-left: auto; color: var(--text-muted); font: var(--text-xs) var(--font-mono); white-space: nowrap; } +.media-grid { display: grid; min-height: 380px; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: var(--sp-3); padding: var(--sp-3); } +.sync-card { display: flex; min-width: 0; min-height: 132px; gap: var(--sp-3); padding: 10px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); transition: transform var(--dur) var(--ease), border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); } +.sync-card:hover { border-color: var(--accent); transform: translateY(-1px); box-shadow: var(--shadow-sm); } +.sync-card .poster, .sync-card .poster-placeholder { width: 74px; height: 110px; flex-basis: 74px; } +.sync-card-body { display: flex; min-width: 0; flex: 1; flex-direction: column; } +.sync-card-title { display: -webkit-box; overflow: hidden; font-weight: 600; line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.sync-card-meta { margin-top: var(--sp-1); color: var(--text-muted); font: var(--text-xs) var(--font-mono); } +.category-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: var(--sp-2) 0; } +.category-tag { padding: 2px 6px; } +.category-tag.empty-tag { border-color: var(--border); background: var(--surface-3); color: var(--text-faint); } +.sync-card-actions { display: flex; gap: 5px; margin-top: auto; } +.sync-card-actions .btn { min-height: 28px; padding: 4px 9px; } +.sync-empty { min-height: 360px; } +.detail-pager { justify-content: center; gap: var(--sp-4); } + +.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: none; align-items: center; justify-content: center; padding: var(--sp-5); background: var(--overlay); backdrop-filter: blur(5px); } +.modal-backdrop.open { display: flex; } +.modal { display: flex; overflow: hidden; width: min(850px, 100%); max-height: calc(100dvh - 40px); flex-direction: column; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface); box-shadow: var(--shadow-lg); } +.modal-compact { width: min(600px, 100%); } +.modal-head, .modal-foot { display: flex; align-items: center; gap: 10px; padding: var(--sp-3) var(--sp-4); background: var(--surface-2); } +.modal-head { border-bottom: 1px solid var(--border); } +.modal-foot { justify-content: flex-end; border-top: 1px solid var(--border); } +.modal-title { color: var(--text); font-size: var(--text-lg); font-weight: 700; } +.modal-id { color: var(--text-faint); font: var(--text-xs) var(--font-mono); } +.modal-close { margin-left: auto; } +.modal-body { overflow: auto; padding: var(--sp-4); } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); } +.field { display: flex; min-width: 0; flex-direction: column; gap: 6px; } +.field.full { grid-column: 1 / -1; } +.field label { color: var(--text-muted); font-size: var(--text-xs); font-weight: 600; } +.field input, .field textarea { width: 100%; padding: 9px 10px; } +.field textarea { min-height: 340px; resize: vertical; font: var(--text-xs)/1.55 var(--font-mono); tab-size: 2; } +.json-tools { display: flex; align-items: center; gap: var(--sp-2); } +.json-status { color: var(--text-faint); font-size: var(--text-xs); } +.json-status.ok { color: var(--ok); } +.json-status.bad { color: var(--err); } +.category-card-head { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); padding-bottom: var(--sp-3); border-bottom: 1px solid var(--border); } +.category-card-head .poster, .category-card-head .poster-placeholder { width: 54px; height: 80px; flex-basis: 54px; } +.category-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2); } +.category-section-title { grid-column: 1 / -1; margin-top: 7px; padding-top: var(--sp-3); border-top: 1px solid var(--border); color: var(--text-muted); font-size: var(--text-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } +.category-option { position: relative; } +.category-option input { position: absolute; opacity: 0; } +.category-option label { display: flex; align-items: center; gap: 9px; padding: 10px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); color: var(--text-muted); cursor: pointer; transition: all var(--dur) var(--ease); } +.category-option label::before { width: 15px; height: 15px; flex-shrink: 0; border: 1px solid var(--text-faint); border-radius: 4px; content: ""; } +.category-option.status-option label::before { border-radius: 50%; } +.category-option input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; } +.category-option input:checked + label { border-color: var(--accent); background: var(--accent-dim); color: var(--text); } +.category-option input:checked + label::before { border-color: var(--accent); background: var(--accent); box-shadow: inset 0 0 0 3px var(--surface-2); } +.modal-hint { margin: var(--sp-3) 0 0; color: var(--text-muted); font-size: var(--text-xs); } +.backup-results { display: flex; flex-direction: column; gap: 10px; } +.backup-result { padding: var(--sp-3); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); } +.backup-result strong { display: block; margin-bottom: 5px; } +.backup-path { color: var(--text-muted); font: var(--text-xs)/1.5 var(--font-mono); overflow-wrap: anywhere; } +.restore-warning { margin: 0 0 var(--sp-3); padding: var(--sp-3); border: 1px solid color-mix(in srgb, var(--err) 45%, var(--border)); border-radius: var(--r-md); background: var(--err-dim); color: var(--err); } +.restore-tabs { width: max-content; margin-bottom: var(--sp-3); } +.restore-result { display: flex; align-items: center; gap: var(--sp-3); } +.restore-info { min-width: 0; flex: 1; } +.restore-result .btn { flex: 0 0 auto; } +.restore-empty { min-height: 160px; } +.restore-empty:not(.show) { display: none; } + +.toast-wrap { position: fixed; right: 18px; bottom: 18px; z-index: 200; display: flex; flex-direction: column; gap: var(--sp-2); } +.toast { width: min(390px, calc(100vw - 36px)); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); color: var(--text); box-shadow: var(--shadow-lg); animation: toast-in var(--dur) var(--ease); } +.toast.good { border-left: 3px solid var(--ok); } +.toast.bad { border-left: 3px solid var(--err); } +@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } } + +@media (max-width: 1120px) { + .hero { align-items: flex-start; } + .hero-actions { max-width: 620px; } + .toolbar { flex-wrap: wrap; } + .toolbar .search { order: 3; max-width: none; flex-basis: 100%; } + .toolbar .user-filter { flex: 1; width: auto; } + .toolbar-spacer { display: none; } + .data-table { min-width: 1060px; } + .detail-head { align-items: flex-start; flex-wrap: wrap; } + .detail-actions { margin-left: 0; } + .detail-toolbar { flex-wrap: wrap; } + .detail-toolbar .search { max-width: none; flex-basis: 100%; } + .detail-toolbar .result-count { margin-left: 0; } +} + +@media (max-width: 900px) { + .topbar { display: grid; grid-template-columns: auto 1fr auto; gap: var(--sp-2) var(--sp-3); padding: var(--sp-2) var(--sp-3); } + .topbar-logo { grid-column: 1; grid-row: 1; } + .topbar-spacer { display: block; grid-column: 2; grid-row: 1; } + .theme-btn { grid-column: 3; grid-row: 1; } + .topbar-divider { display: none; } + .topbar-app-nav { grid-column: 1 / -1; grid-row: 2; width: 100%; min-width: 0; overflow-x: auto; padding-top: var(--sp-2); border-top: 1px solid var(--border-subtle); scrollbar-width: none; } + .topbar-app-nav::-webkit-scrollbar { display: none; } + .topbar-app-nav a { min-width: 5.25rem; min-height: 42px; padding: 9px 13px; font-size: var(--text-sm); } + .main { padding: var(--sp-4) var(--sp-3) 2.25rem; } + .hero { display: block; } + .hero-actions { justify-content: flex-start; max-width: none; margin: var(--sp-3) 0 0; } + .summary { grid-template-columns: 1fr; } +} + +@media (max-width: 720px) { + .toolbar .db-tabs { order: 1; flex: 1 0 100%; } + .toolbar .db-tab { flex: 1; } + .toolbar .user-filter { order: 2; width: 100%; min-width: 0; flex: 1 0 100%; } + .toolbar .search { order: 3; flex: 1 0 100%; } + .toolbar #pageSize { order: 4; flex: 1; } + .toolbar #refreshBtn { order: 4; } + .data-table.timecode-table, .data-table.sync-table { display: block; width: 100%; min-width: 0; border-collapse: separate; table-layout: auto; } + .timecode-table thead, .sync-table thead { display: none; } + .timecode-table tbody, .sync-table tbody { display: grid; gap: 10px; padding: 10px; } + .timecode-table tr, .sync-table tr { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto; gap: 10px 12px; padding: var(--sp-3); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); } + .timecode-table td, .sync-table td { display: block; min-width: 0; padding: 0; border: 0; } + .timecode-table td:nth-child(1), .sync-table td:nth-child(1) { grid-column: 2; grid-row: 1; color: var(--text-faint); font: var(--text-xs) var(--font-mono); text-align: right; } + .timecode-table td:nth-child(2), .sync-table td:nth-child(2) { grid-column: 1; grid-row: 1; } + .timecode-table td:nth-child(3) { grid-column: 1 / -1; grid-row: 2; padding-top: 10px; border-top: 1px solid var(--border); } + .timecode-table td:nth-child(4) { grid-column: 1 / -1; grid-row: 3; } + .timecode-table td:nth-child(5) { grid-column: 1; grid-row: 4; align-self: center; color: var(--text-muted); font-size: var(--text-xs); } + .timecode-table td:nth-child(5)::before, .sync-table td:nth-child(3)::before { content: "Обновлено: "; } + .timecode-table td:nth-child(6) { display: flex; grid-column: 2; grid-row: 4; align-items: center; justify-content: flex-end; } + .sync-table td:nth-child(3) { grid-column: 1 / -1; grid-row: 2; padding-top: 10px; border-top: 1px solid var(--border); color: var(--text-muted); font-size: var(--text-xs); } + .sync-table td:nth-child(4) { display: flex; grid-column: 1 / -1; grid-row: 3; align-items: center; justify-content: flex-end; } + .sync-table td:nth-child(4) .mini-btn:not(.delete) { flex: 1; } + .sync-table td:nth-child(4) .mini-btn.delete { flex: 0 0 40px; } + .timecode-table .poster, .timecode-table .poster-placeholder { width: 52px; height: 78px; flex-basis: 52px; } + .timecode-table .media-title { display: -webkit-box; white-space: normal; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } + .timecode-table .media-meta { white-space: normal; } + .timecode-table .progress-copy { gap: var(--sp-3); } + .timecode-table tbody tr:hover, .sync-table tbody tr:hover { background: var(--surface-2); } +} + +@media (max-width: 600px) { + .topbar-logo span { display: none; } + .hero-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .hero-actions .btn { min-width: 0; white-space: normal; text-align: center; } + .media-grid { grid-template-columns: 1fr; padding: 9px; } + .form-grid, .category-options { grid-template-columns: 1fr; } + .field.full { grid-column: auto; } + .modal-backdrop { padding: 0; } + .modal { width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; } + .modal-foot { flex-wrap: wrap; } + .detail-head { gap: 10px; } + .detail-identity { order: 3; flex-basis: 100%; } + .detail-actions { margin-left: auto; } + .detail-actions .btn { padding: 6px 9px; } + .restore-result { align-items: flex-start; flex-direction: column; } + .restore-result .btn { width: 100%; } +} + +@media (max-width: 430px) { + .topbar { padding-right: var(--sp-2); padding-left: var(--sp-2); } + .topbar-app-nav a { min-width: 5rem; } + .main { padding-right: var(--sp-2); padding-left: var(--sp-2); } + .hero-actions { grid-template-columns: 1fr; } + .summary-card { padding: var(--sp-3); } + .pager { flex-wrap: wrap; } + .pager-buttons { margin-left: 0; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} diff --git a/mods/ExternalBind/ExternalBind.csproj b/mods/ExternalBind/ExternalBind.csproj new file mode 100644 index 0000000..ed087f3 --- /dev/null +++ b/mods/ExternalBind/ExternalBind.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + library + true + + + + + + + diff --git a/mods/ExternalBind/ModInit.cs b/mods/ExternalBind/ModInit.cs new file mode 100644 index 0000000..8c5d8e5 --- /dev/null +++ b/mods/ExternalBind/ModInit.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; +using Shared.Models.Base; +using Shared.Models.Events; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace ExternalBind +{ + public class ModInit : IModuleLoaded + { + static readonly HashSet ExternalBindPaths = new(StringComparer.OrdinalIgnoreCase) + { + "/lite/filmixpro", + "/lite/rhs/bind", + "/lite/kinopubpro", + "/lite/getstv/bind", + "/lite/sakhtv/bind", + "/lite/vokinotk", + "/lite/iptvonline/bind", + }; + + static readonly Func MiddlewareHandler = OnMiddleware; + + public void Loaded(InitspaceModel initspace) + { + EventListener.Middleware += MiddlewareHandler; + } + + public void Dispose() + { + EventListener.Middleware -= MiddlewareHandler; + } + + static bool OnMiddleware(bool first, EventMiddleware e) + { + var requestInfo = e.httpContext.Features.Get(); + var path = e.httpContext.Request.Path.Value; + if (requestInfo != null && path != null && ExternalBindPaths.Contains(path)) + requestInfo.IsLocalIp = true; + + return true; + } + } +} diff --git a/mods/ExternalBind/README.md b/mods/ExternalBind/README.md new file mode 100644 index 0000000..6d441cb --- /dev/null +++ b/mods/ExternalBind/README.md @@ -0,0 +1,45 @@ +# ExternalBind + +Опциональный модуль в **`Modules/ExternalBind/`**: для **заданных URL привязки Lite / Online** запрос считается «локальным» при классификации (`RequestModel.IsLocalIp`). + +В [`manifest.json`](manifest.json) по умолчанию **`"enable": false`**. В [корневом README](../../README.md) модуль перечислен в таблице модулей. + +## Зачем нужен + +Несколько контроллеров Online (Filmix Pro, KinoPub, GetsTV и аналоги) завершают привязку устройства / выдачу токена только если `requestInfo.IsLocalIp == true`. Обычно флаг выставляется лишь для адреса клиента из локальной сети (см. `IPNetwork.IsLocalIp`). + +Если ссылку привязки открыть с другого компьютера, через обратный прокси или в любой схеме, где браузер **не** определяется как локальный IP, эти эндпоинты отвечают ошибкой вроде **«is not local ip»**, и сценарий не завершается. + +ExternalBind подключается рано в цепочке middleware (`EventListener.Middleware`) и **только для перечисленных ниже путей** принудительно выставляет `IsLocalIp = true`, чтобы шаг привязки мог выполниться. + +## Затронутые пути + +Точное совпадение пути (без учёта регистра): + +| Путь | Назначение | +|------|------------| +| `/lite/filmixpro` | Привязка Filmix Pro | +| `/lite/rhs/bind` | Привязка Rezka | +| `/lite/kinopubpro` | Привязка KinoPub Pro | +| `/lite/getstv/bind` | Привязка GetsTV | +| `/lite/vokinotk` | Привязка Vokino | +| `/lite/iptvonline/bind` | Привязка IPTV Online | + +Чтобы добавить или изменить пути, правьте `ExternalBindPaths` в `ModInit.cs`. + +## Включение + +В `manifest.json` установите `"enable": true` и перезагрузите хост, чтобы модуль подхватился. + +По умолчанию **`enable: false`** — URL привязки остаются «только для локальной сети», пока вы явно не включите модуль. + +## WAF и безопасность + +При `IsLocalIp == true` поведение WAF может отличаться от обычного удалённого клиента (например, в `init.conf` параметр `bypassLocalIP` может ослаблять часть проверок WAF для таких запросов). Включение ExternalBind расширяет круг тех, кто может завершить привязку **по перечисленным путям**, до любого, кто достучится до сервера по этим URL. + +Включайте модуль только если нужны удалённая или проксированная привязка; сохраняйте обычные меры: HTTPS, контроль доступа, файрвол. + +## Зависимости + +- Реализует `IModuleLoaded`; отдельных ключей конфигурации нет. +- Зависит только от `Shared`; контроллеров и статики в модуле нет. diff --git a/mods/ExternalBind/manifest.json b/mods/ExternalBind/manifest.json new file mode 100644 index 0000000..d0180c1 --- /dev/null +++ b/mods/ExternalBind/manifest.json @@ -0,0 +1,7 @@ +{ + "enable": true, + "dynamic": true, + "tree": [ + "ModInit.cs" + ] +} diff --git a/mods/LogUserRequest-Lite/AppDbContext.cs b/mods/LogUserRequest-Lite/AppDbContext.cs new file mode 100644 index 0000000..4c8d042 --- /dev/null +++ b/mods/LogUserRequest-Lite/AppDbContext.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; + +namespace LogUserRequest; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + public AppDbContext() : base() { } + + public DbSet jurnal { get; set; } + public DbSet unfo { get; set; } + public DbSet headers { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { + var dbPath = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest", "userlog.db"); + var dbDir = Path.GetDirectoryName(dbPath); + if (!Directory.Exists(dbDir)) Directory.CreateDirectory(dbDir!); + optionsBuilder.UseSqlite($"Data Source={dbPath};Cache=Shared"); + optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasIndex(x => x.uid); + modelBuilder.Entity().HasIndex(x => x.time); + modelBuilder.Entity().HasIndex(x => x.balancer); + modelBuilder.Entity().HasIndex(x => new { x.uid, x.time }); + } +} + +public class LogModelSql +{ + public int Id { get; set; } + public DateTime time { get; set; } + public string uri { get; set; } = ""; + public string uid { get; set; } = ""; + public string unfo { get; set; } = ""; + public string header { get; set; } = ""; + public int duration_ms { get; set; } + public string? balancer { get; set; } + public int? status_code { get; set; } +} + +public class UserInfoModelSql +{ + [Key] public string Id { get; set; } = ""; + public string IP { get; set; } = ""; + public string Country { get; set; } = ""; + public string UserAgent { get; set; } = ""; +} + +public class HeaderModelSql +{ + [Key] public string Id { get; set; } = ""; + public string HeadersJson { get; set; } = ""; + + [NotMapped] + public Dictionary Headers + { + get + { + if (string.IsNullOrEmpty(HeadersJson)) return new(); + try { return JsonSerializer.Deserialize>(HeadersJson) ?? new(); } + catch { return new(); } + } + set + { + var safe = (value ?? new Dictionary()) + .Where(x => !string.IsNullOrWhiteSpace(x.Key)) + .Take(50) + .ToDictionary( + x => x.Key.Length > 100 ? x.Key[..100] : x.Key, + x => + { + var v = x.Value ?? ""; + return v.Length > 500 ? v[..500] : v; + }); + HeadersJson = JsonSerializer.Serialize(safe); + } + } +} diff --git a/mods/LogUserRequest-Lite/Controllers/ApiController.cs b/mods/LogUserRequest-Lite/Controllers/ApiController.cs new file mode 100644 index 0000000..ecda333 --- /dev/null +++ b/mods/LogUserRequest-Lite/Controllers/ApiController.cs @@ -0,0 +1,229 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using System.Text; +using Microsoft.EntityFrameworkCore; +using LogUserRequest.Models.DTO; + +namespace LogUserRequest.Controllers; + +[ApiController] +[Route("lite/logrequest")] +public class ApiController : ControllerBase +{ + private static readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IDbContextFactory _contextFactory; + + public ApiController(IDbContextFactory contextFactory) + { + _contextFactory = contextFactory; + } + + private static string GetAttemptsKey(string ip) => $"LogUserRequest:auth:IP:{ip}"; + + private bool IsAuthorized() + { + var sessionToken = Request.Cookies["loguser_session"]; + if (!string.IsNullOrEmpty(sessionToken) && ModInit.ValidateSessionToken(sessionToken)) + return true; + return false; + } + + [HttpGet] + public IActionResult Index() + { + if (!IsAuthorized()) return Redirect("/lite/logrequest/auth"); + var htmlPath = Path.Combine(ModInit.init.path, "index.html"); + if (!System.IO.File.Exists(htmlPath)) return Content("UI missing", "text/plain"); + var html = System.IO.File.ReadAllText(htmlPath, Encoding.UTF8); + return Content(html, "text/html; charset=utf-8"); + } + + [HttpGet("auth")] + public IActionResult Auth() + { + var sessionToken = Request.Cookies["loguser_session"]; + + if (!string.IsNullOrEmpty(sessionToken) && ModInit.ValidateSessionToken(sessionToken)) + return Redirect("/lite/logrequest"); + + var htmlPath = Path.Combine(ModInit.init.path, "auth.html"); + if (!System.IO.File.Exists(htmlPath)) return Content("Auth page missing", "text/plain"); + + Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"; + Response.Headers["Pragma"] = "no-cache"; + Response.Headers["Expires"] = "0"; + + var html = System.IO.File.ReadAllText(htmlPath, Encoding.UTF8); + return Content(html, "text/html; charset=utf-8"); + } + + [HttpPost("login")] + public async Task Login([FromForm] string password) + { + var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + var cacheKey = GetAttemptsKey(ip); + var attempts = _cache.Get(cacheKey) ?? 0; + + if (attempts > 0) await Task.Delay(attempts * 200); + if (attempts >= 5) return Redirect("/lite/logrequest/auth?blocked=1"); + + if (password == ModInit.conf.adminPassword) + { + _cache.Remove(cacheKey); + var sessionToken = ModInit.CreateSessionToken(); + + Response.Cookies.Append("loguser_session", sessionToken, new CookieOptions + { + HttpOnly = true, + Secure = Request.IsHttps, + SameSite = SameSiteMode.Strict, + MaxAge = TimeSpan.FromDays(30), + Path = "/lite/logrequest", + IsEssential = true + }); + + var script = @" + + + + "; + return Content(script, "text/html; charset=utf-8"); + } + + attempts++; + _cache.Set(cacheKey, attempts, new MemoryCacheEntryOptions + { + AbsoluteExpiration = DateTime.Today.AddDays(1), + SlidingExpiration = TimeSpan.FromHours(1) + }); + await Task.Delay(Random.Shared.Next(300, 800)); + return Redirect($"/lite/logrequest/auth?error=1&remaining={5 - attempts}"); + } + + [HttpGet("logout")] + public IActionResult Logout() + { + var sessionToken = Request.Cookies["loguser_session"]; + if (!string.IsNullOrEmpty(sessionToken)) ModInit.RevokeSessionToken(sessionToken); + Response.Cookies.Delete("loguser_session", new CookieOptions + { + Path = "/lite/logrequest" + }); + return Redirect("/lite/logrequest/auth"); + } + + [HttpGet("api")] + public async Task Api(string? uid, int skip = 0, int take = 200) + { + if (!IsAuthorized()) return UnauthorizedResponse(); + take = Math.Min(take, 500); + + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var query = dbContext.jurnal.AsNoTracking(); + if (!string.IsNullOrEmpty(uid)) query = query.Where(j => j.uid == uid); + + var jurnal = await query.OrderByDescending(x => x.Id).Skip(skip).Take(take).ToListAsync(); + if (jurnal.Count == 0) return Ok(ApiResponse>.Fail("Empty")); + + var unfoIds = jurnal.Select(j => j.unfo).Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList(); + var unfoDict = await dbContext.unfo.Where(u => unfoIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id); + + var result = jurnal.Select(j => + { + unfoDict.TryGetValue(j.unfo ?? "", out var unfo); + return new JournalItemDto + { + Id = j.Id, + Time = j.time, + Uri = j.uri, + UserUid = j.uid, + Ip = unfo?.IP ?? "unknown", + Country = unfo?.Country ?? "", + UserAgent = unfo?.UserAgent ?? "unknown", + DurationMs = j.duration_ms, + Balancer = j.balancer + }; + }).ToList(); + + return Ok(ApiResponse>.Ok(result)); + } + + [HttpGet("premium-chart")] + public async Task PremiumChart(int days = 7) + { + if (!IsAuthorized()) return UnauthorizedResponse(); + + days = Math.Min(days, 3); + + var startDate = DateTime.UtcNow.AddDays(-days); + + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var data = await dbContext.jurnal + .Where(j => j.time >= startDate) + .GroupBy(j => j.time.Date) + .Select(g => new { date = g.Key, count = g.Count() }) + .OrderBy(x => x.date) + .ToListAsync(); + + var result = new + { + data = data, + message = "В Lite-версии доступно только 2 дня. Premium — до 90 дней.", + premiumAvailable = true + }; + + return Ok(ApiResponse.Ok(result)); + } + + [HttpGet("stats")] + public async Task Stats(string? uid) + { + if (!IsAuthorized()) return UnauthorizedResponse(); + + await using var dbContext = await _contextFactory.CreateDbContextAsync(); + var query = dbContext.jurnal.AsNoTracking(); + if (!string.IsNullOrEmpty(uid)) query = query.Where(j => j.uid == uid); + + var userLogs = await query.OrderByDescending(j => j.time).Take(50000).ToListAsync(); + if (userLogs.Count == 0) return Ok(ApiResponse.Fail("Empty")); + + var unfoIds = userLogs.Select(j => j.unfo).Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList(); + var unfoList = await dbContext.unfo.Where(u => unfoIds.Contains(u.Id)).ToListAsync(); + + var uniqueIp = unfoList.Select(u => u.IP).Where(ip => !string.IsNullOrEmpty(ip)).Distinct().Count(); + var uniqueUserAgent = unfoList.Select(u => u.UserAgent).Where(ua => !string.IsNullOrEmpty(ua)).Distinct().Count(); + + var now = DateTime.UtcNow; + var today = userLogs.Count(l => l.time.Day == now.Day && l.time.Month == now.Month && l.time.Year == now.Year); + var month = userLogs.Count(l => l.time.Month == now.Month && l.time.Year == now.Year); + + var topBalancers = userLogs + .GroupBy(l => l.balancer) + .Where(g => !string.IsNullOrEmpty(g.Key)) + .Select(g => new TopItemDto { Name = g.Key ?? "", Count = g.Count() }) + .OrderByDescending(x => x.Count).Take(50).ToArray(); + + var topUsers = userLogs + .GroupBy(j => j.uid) + .Select(g => new TopItemDto { Name = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count).Take(20).ToArray(); + + return Ok(ApiResponse.Ok(new StatsDto + { + Today = today, + Month = month, + UniqueIp = uniqueIp, + UniqueUserAgent = uniqueUserAgent, + TopUsers = topUsers, + TopBalancers = topBalancers + })); + } + + private IActionResult UnauthorizedResponse() => StatusCode(401, ApiResponse.Fail("Unauthorized")); + private IActionResult Ok(ApiResponse response) => new JsonResult(response); +} diff --git a/mods/LogUserRequest-Lite/GlobalUsings.cs b/mods/LogUserRequest-Lite/GlobalUsings.cs new file mode 100644 index 0000000..3298cc4 --- /dev/null +++ b/mods/LogUserRequest-Lite/GlobalUsings.cs @@ -0,0 +1,24 @@ +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Caching.Memory; +global using Microsoft.Extensions.DependencyInjection; +global using Shared; +global using Shared.Attributes; +global using Shared.Models; +global using Shared.Models.Base; +global using Shared.Models.Events; +global using Shared.Models.Module; +global using Shared.Models.Module.Interfaces; +global using System; +global using System.Collections.Concurrent; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Security.Cryptography; +global using System.Text; +global using System.Text.Json; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/mods/LogUserRequest-Lite/LogUserRequest.csproj b/mods/LogUserRequest-Lite/LogUserRequest.csproj new file mode 100644 index 0000000..3450539 --- /dev/null +++ b/mods/LogUserRequest-Lite/LogUserRequest.csproj @@ -0,0 +1,13 @@ + + + net10.0 + Library + true + LogUserRequest + enable + enable + + + + + diff --git a/mods/LogUserRequest-Lite/LogUserRequestListener.cs b/mods/LogUserRequest-Lite/LogUserRequestListener.cs new file mode 100644 index 0000000..3101b1d --- /dev/null +++ b/mods/LogUserRequest-Lite/LogUserRequestListener.cs @@ -0,0 +1,182 @@ +using Microsoft.AspNetCore.Http; +using System.Security.Cryptography; +using Microsoft.Extensions.Caching.Memory; + +namespace LogUserRequest; + +public static class LogUserRequestListener +{ + public static readonly ConcurrentQueue<(LogModelSql jurnal, UserInfoModelSql unfo, HeaderModelSql header)> Queue = new(); + private static int _queueSize = 0; + private const int MaxQueueSize = 20000; + private static long _lastQueueOverflowLog = 0; + private static long _lastErrorLog = 0; + private static readonly MemoryCache _rateLimitCache = new(new MemoryCacheOptions { SizeLimit = 100_000 }); + + private static readonly string[] _skipPrefixes = + { + "/.well-known", + "/admin/health", + "/admin/ping", + "/testaccsdb", + "/nws", + "/lifeevents", + "/proxyimg", + "/lite/logrequest", + "/lite/events", + "/nexthub", + "/externalids", + "/lampa-main", + "/lite/withsearch", + "/timecode", + "/bookmark", + "/storage", + "/cub", + "/cub/", + "/sisi/bookmarks", + "/proxy/" + }; + + private static readonly string[] _skipExtensions = + { + ".js", ".css", ".svg", ".png", ".jpg", ".jpeg", + ".woff", ".woff2", ".ogg", ".ico", ".map" + }; + + public static void DequeueItem() => Interlocked.Decrement(ref _queueSize); + + private static bool ShouldLogError() + { + var nowTicks = DateTime.UtcNow.Ticks; + var last = Interlocked.Read(ref _lastErrorLog); + if (nowTicks - last > TimeSpan.FromMinutes(1).Ticks) + { + Interlocked.Exchange(ref _lastErrorLog, nowTicks); + return true; + } + return false; + } + + public static Task InvokeAsync(bool first, EventMiddleware e) + { + if (first) + return Task.FromResult(true); + + var httpContext = e.httpContext; + var requestInfo = httpContext.Features.Get(); + + if (requestInfo == null) + return Task.FromResult(true); + + if (requestInfo.IsLocalRequest || requestInfo.IsAnonymousRequest) + return Task.FromResult(true); + + try + { + var path = httpContext.Request.Path.Value ?? ""; + + // Отсев статики по расширениям + foreach (var ext in _skipExtensions) + { + if (path.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(true); + } + + // Отсев по префиксам + foreach (var prefix in _skipPrefixes) + { + if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(true); + } + + var realIP = requestInfo.IP; + + if (string.IsNullOrEmpty(realIP) || realIP == "127.0.0.1" || realIP == "::1") + return Task.FromResult(true); + + if (_rateLimitCache.TryGetValue(realIP, out _)) + return Task.FromResult(true); + + _rateLimitCache.Set(realIP, DateTime.UtcNow, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(33), + Size = 1 + }); + + string userAgent = httpContext.Request.Headers["User-Agent"].FirstOrDefault() ?? "unknown"; + if (userAgent.Length > 512) userAgent = userAgent[..512] + "...[truncated]"; + + string userUid = httpContext.Request.Query["uid"].FirstOrDefault() ?? ""; + if (string.IsNullOrEmpty(userUid)) userUid = httpContext.Request.Cookies["uid"] ?? ""; + if (string.IsNullOrEmpty(userUid)) userUid = "anonymous"; + if (userUid.Length > 256) userUid = userUid[..256]; + + string country = ""; + if (httpContext.Request.Headers.TryGetValue("CF-IPCountry", out var cfCountry)) + country = cfCountry.ToString(); + + static string GetHash(string input) + { + var bytes = Encoding.UTF8.GetBytes(input); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLower(); + } + + var unfo = new UserInfoModelSql + { + Id = GetHash($"{realIP}:{country}:{userAgent}"), + IP = realIP, + Country = country, + UserAgent = userAgent + }; + + var header = new HeaderModelSql { Headers = new Dictionary() }; + header.Id = ""; + + var fullUri = path + httpContext.Request.QueryString; + if (fullUri.Length > 2048) fullUri = fullUri[..2048]; + + string balancer = ""; + if (path.StartsWith("/lite/")) + balancer = path[6..].Split('/', '?')[0]; + else if (path.StartsWith("/rc/")) + balancer = path[4..].Split('/', '?')[0]; + + var jurnal = new LogModelSql + { + time = DateTime.UtcNow, + uri = fullUri, + uid = userUid, + duration_ms = 0, + balancer = balancer, + status_code = httpContext.Response?.StatusCode ?? 0 + }; + + if (Interlocked.Increment(ref _queueSize) <= MaxQueueSize) + { + try { Queue.Enqueue((jurnal, unfo, header)); } + catch { Interlocked.Decrement(ref _queueSize); } + } + else + { + Interlocked.Decrement(ref _queueSize); + var nowTicks = DateTime.UtcNow.Ticks; + var last = Interlocked.Read(ref _lastQueueOverflowLog); + if (nowTicks - last > TimeSpan.FromMinutes(1).Ticks) + { + if (Interlocked.CompareExchange(ref _lastQueueOverflowLog, nowTicks, last) == last) + { + Console.WriteLine($"[LogUserRequest] Queue overflow, dropping logs. Size: {_queueSize}"); + } + } + } + } + catch (Exception ex) + { + if (ShouldLogError()) + Console.WriteLine($"[LogUserRequest] Listener error: {ex}"); + } + + return Task.FromResult(true); + } +} diff --git a/mods/LogUserRequest-Lite/ModInit.cs b/mods/LogUserRequest-Lite/ModInit.cs new file mode 100644 index 0000000..ebb9f17 --- /dev/null +++ b/mods/LogUserRequest-Lite/ModInit.cs @@ -0,0 +1,375 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using System.Security.Cryptography; +using Microsoft.Extensions.Caching.Memory; + +namespace LogUserRequest; + +public class ModInit : IModuleLoaded, IModuleConfigure +{ + public static InitspaceModel init { get; set; } = null!; + public static (int logDay, string adminPassword) conf = (90, ""); + public static object? stats = null; + static Timer? _statsTimer, _clearJurnalTimer, _updateDbTimer; + + private static int _updatingStats = 0; + private static int _updatingDb = 0; + private static readonly MemoryCache _sessionTokens = new(new MemoryCacheOptions { SizeLimit = 10000 }); + private static readonly TimeSpan _sessionLifetime = TimeSpan.FromDays(30); + + private static string _workPath = AppContext.BaseDirectory; + private static string _dbDirectory = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest"); + private static string _dbPath = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest", "userlog.db"); + private static string _passwdPath = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest", "passlogreg"); + + // === IModuleConfigure === + public void Configure(ConfigureModel app) + { + app.services.AddDbContextFactory(options => + { + options.UseSqlite($"Data Source={_dbPath};Cache=Shared"); + options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + }); + } + + public static bool ValidateSessionToken(string token) + { + if (string.IsNullOrEmpty(token)) return false; + return _sessionTokens.TryGetValue(token, out _); + } + + public static string CreateSessionToken() + { + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + _sessionTokens.Set(token, true, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _sessionLifetime, + SlidingExpiration = TimeSpan.FromDays(7), + Size = 1 + }); + return token; + } + + public static void RevokeSessionToken(string token) => _sessionTokens.Remove(token); + + private static string GenerateRandomPassword(int length = 36) + { + using var rng = RandomNumberGenerator.Create(); + var bytes = new byte[length]; + rng.GetBytes(bytes); + var base64 = Convert.ToBase64String(bytes); + return base64.Replace('+', 'A').Replace('/', 'B')[..length]; + } + + private static string GetOrCreateAdminPassword() + { + var envPassword = Environment.GetEnvironmentVariable("LOGUSER_ADMIN_PASSWORD"); + if (!string.IsNullOrEmpty(envPassword)) + return envPassword; + + if (File.Exists(_passwdPath)) + { + try + { + var filePassword = File.ReadAllText(_passwdPath).Trim(); + if (!string.IsNullOrEmpty(filePassword)) + return filePassword; + } + catch { } + } + + var oldPasswdPath = Path.Combine(_workPath, "passlogreg"); + if (File.Exists(oldPasswdPath)) + { + try + { + var filePassword = File.ReadAllText(oldPasswdPath).Trim(); + if (!string.IsNullOrEmpty(filePassword)) + { + Directory.CreateDirectory(Path.GetDirectoryName(_passwdPath)!); + File.Move(oldPasswdPath, _passwdPath); + return filePassword; + } + } + catch { } + } + + var newPassword = GenerateRandomPassword(36); + try + { + var passwdDir = Path.GetDirectoryName(_passwdPath); + if (!string.IsNullOrEmpty(passwdDir)) + Directory.CreateDirectory(passwdDir); + + File.WriteAllText(_passwdPath, newPassword); + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD()) + { + try { File.SetUnixFileMode(_passwdPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { } + } + } + catch + { + Console.WriteLine("[LogUserRequest-Lite] Warning: Cannot save password to file. Using memory-only password."); + } + + return newPassword; + } + + public void Loaded(InitspaceModel initspace) + { + init = initspace; + + _workPath = AppContext.BaseDirectory; + _dbDirectory = Path.Combine(_workPath, "database", "LogUserRequest"); + _dbPath = Path.Combine(_dbDirectory, "userlog.db"); + _passwdPath = Path.Combine(_dbDirectory, "passlogreg"); + + Console.WriteLine($"[LogUserRequest-Lite] Work path: {_workPath}"); + Console.WriteLine($"[LogUserRequest-Lite] DB path: {_dbPath}"); + Console.WriteLine($"[LogUserRequest-Lite] Password path: {_passwdPath}"); + + try + { + Directory.CreateDirectory(_dbDirectory); + } + catch (IOException ex) + { + Console.WriteLine($"[LogUserRequest-Lite] Warning: Cannot create directory {_dbDirectory}: {ex.Message}"); + } + + using (var sqlDb = new AppDbContext()) + { + sqlDb.Database.EnsureCreated(); + try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN balancer TEXT;"); } catch { } + try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN status_code INTEGER NULL;"); } catch { } + try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN duration_ms INTEGER DEFAULT 0;"); } catch { } + try { sqlDb.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_jurnal_uid ON jurnal(uid);"); } catch { } + try { sqlDb.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_jurnal_time ON jurnal(time);"); } catch { } + + try + { + sqlDb.Database.ExecuteSqlRaw("PRAGMA journal_mode = WAL;"); + sqlDb.Database.ExecuteSqlRaw("PRAGMA synchronous = NORMAL;"); + sqlDb.Database.ExecuteSqlRaw("PRAGMA cache_size = -64000;"); + sqlDb.Database.ExecuteSqlRaw("PRAGMA temp_store = MEMORY;"); + sqlDb.Database.ExecuteSqlRaw("PRAGMA mmap_size = 33554432;"); + } + catch { } + } + + var manifestPath = Path.Combine(init.path, "manifest.json"); + JObject manifest = new(); + if (File.Exists(manifestPath)) + { + manifest = JsonConvert.DeserializeObject(File.ReadAllText(manifestPath)) ?? new(); + conf.logDay = manifest["logDay"]?.Value() ?? 90; + } + + conf.adminPassword = GetOrCreateAdminPassword(); + + _clearJurnalTimer = new Timer(ClearJurnal, null, TimeSpan.FromMinutes(1), TimeSpan.FromHours(24)); + _statsTimer = new Timer(UpdateStatsCallback, null, TimeSpan.FromSeconds(10), TimeSpan.FromMinutes(5)); + _updateDbTimer = new Timer(UpdateDbCallback, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(30)); + + // === Подписка на EventListener вместо app.UseMiddleware === + EventListener.MiddlewareAsync -= LogUserRequestListener.InvokeAsync; + EventListener.MiddlewareAsync += LogUserRequestListener.InvokeAsync; + + Console.WriteLine($"[LogUserRequest-Lite] Module loaded (logDay={conf.logDay})"); + } + + public void Dispose() + { + // === Отписка === + EventListener.MiddlewareAsync -= LogUserRequestListener.InvokeAsync; + + _clearJurnalTimer?.Dispose(); + _statsTimer?.Dispose(); + _updateDbTimer?.Dispose(); + _sessionTokens.Dispose(); + } + + // ... остальные методы (ClearJurnal, UpdateStats, UpdateDb) без изменений + static void ClearJurnal(object? state) + { + try + { + using var sqlDb = new AppDbContext(); + var cutoff = DateTime.UtcNow.AddDays(-conf.logDay); + + const int batchSize = 5000; + int totalDeleted = 0; + + while (true) + { + var idsToDelete = sqlDb.jurnal + .Where(j => j.time < cutoff) + .OrderBy(j => j.Id) + .Select(j => j.Id) + .Take(batchSize) + .ToList(); + + if (idsToDelete.Count == 0) break; + + sqlDb.jurnal.Where(j => idsToDelete.Contains(j.Id)).ExecuteDelete(); + totalDeleted += idsToDelete.Count; + + if (idsToDelete.Count < batchSize) break; + } + + if (totalDeleted > 0) + { + var usedUnfo = sqlDb.jurnal.Select(j => j.unfo).Distinct().Take(50000).ToHashSet(); + var usedHeaders = sqlDb.jurnal.Select(j => j.header).Distinct().Take(50000).ToHashSet(); + + sqlDb.unfo.Where(u => !usedUnfo.Contains(u.Id)).Take(batchSize).ExecuteDelete(); + sqlDb.headers.Where(h => !usedHeaders.Contains(h.Id)).Take(batchSize).ExecuteDelete(); + + Console.WriteLine($"[LogUserRequest-Lite] Cleaned {totalDeleted} old records"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[LogUserRequest-Lite] ClearJurnal error: {ex.Message}"); + } + } + + private static void UpdateStatsCallback(object? state) => UpdateStats(); + + public static void UpdateStats() + { + if (Interlocked.Exchange(ref _updatingStats, 1) == 1) return; + try + { + using var sqlDb = new AppDbContext(); + var now = DateTime.UtcNow; + + var monthStart = new DateTime(now.Year, now.Month, 1); + var todayStart = new DateTime(now.Year, now.Month, now.Day); + var tomorrowStart = todayStart.AddDays(1); + + var monthQuery = sqlDb.jurnal.Where(j => j.time >= monthStart); + + int today = monthQuery.Count(j => j.time >= todayStart && j.time < tomorrowStart); + int month = monthQuery.Count(); + + var unfoIds = monthQuery.Select(j => j.unfo).Distinct().Take(10000).ToList(); + + if (unfoIds.Count > 0) + { + var unfoData = sqlDb.unfo + .Where(u => unfoIds.Contains(u.Id)) + .Select(u => new { u.IP, u.UserAgent }) + .Take(10000) + .ToList(); + + int uniqueUserAgent = unfoData.Select(u => u.UserAgent).Distinct().Count(); + int uniqueIp = unfoData.Select(u => u.IP).Where(ip => !string.IsNullOrEmpty(ip)).Distinct().Count(); + + var topUsers = monthQuery + .GroupBy(j => j.uid) + .Select(g => new { uid = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count) + .Take(20) + .ToArray(); + + var topBalancers = monthQuery + .Where(j => j.balancer != null) + .GroupBy(j => j.balancer) + .Select(g => new { balancer = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count) + .Take(50) + .ToArray(); + + stats = new { today, month, uniqueUserAgent, uniqueIp, topUsers, topBalancers }; + } + else + { + stats = new + { + today = 0, + month = 0, + uniqueUserAgent = 0, + uniqueIp = 0, + topUsers = Array.Empty(), + topBalancers = Array.Empty() + }; + } + } + catch (Exception ex) + { + Console.WriteLine($"[LogUserRequest-Lite] UpdateStats error: {ex.Message}"); + } + finally { Interlocked.Exchange(ref _updatingStats, 0); } + } + + private static void UpdateDbCallback(object? state) => UpdateDb(); + + static void UpdateDb() + { + if (Interlocked.Exchange(ref _updatingDb, 1) == 1) return; + try + { + using var sqlDb = new AppDbContext(); + sqlDb.ChangeTracker.AutoDetectChangesEnabled = false; + + var batch = new List<(LogModelSql jurnal, UserInfoModelSql unfo, HeaderModelSql header)>(); + + int batchSize = 1000; + while (LogUserRequestListener.Queue.TryDequeue(out var item) && batch.Count < batchSize) + { + batch.Add(item); + LogUserRequestListener.DequeueItem(); + } + + if (batch.Count == 0) return; + + var unfoIds = batch.Select(b => b.unfo.Id).Distinct().ToList(); + var headerIds = batch.Select(b => b.header.Id).Distinct().ToList(); + + var existingUnfo = sqlDb.unfo + .Where(u => unfoIds.Contains(u.Id)) + .Select(u => u.Id) + .ToHashSet(); + + var existingHeaders = sqlDb.headers + .Where(h => headerIds.Contains(h.Id)) + .Select(h => h.Id) + .ToHashSet(); + + foreach (var item in batch.OrderBy(i => i.jurnal.time)) + { + if (!existingUnfo.Contains(item.unfo.Id)) + { + sqlDb.unfo.Add(item.unfo); + existingUnfo.Add(item.unfo.Id); + } + if (!existingHeaders.Contains(item.header.Id)) + { + sqlDb.headers.Add(item.header); + existingHeaders.Add(item.header.Id); + } + sqlDb.jurnal.Add(new LogModelSql + { + time = item.jurnal.time, + uri = item.jurnal.uri, + uid = item.jurnal.uid, + unfo = item.unfo.Id, + header = item.header.Id, + duration_ms = item.jurnal.duration_ms, + balancer = item.jurnal.balancer, + status_code = item.jurnal.status_code + }); + } + + sqlDb.SaveChanges(); + } + catch (Exception ex) + { + Console.WriteLine($"[LogUserRequest-Lite] UpdateDb error: {ex.Message}"); + } + finally { Interlocked.Exchange(ref _updatingDb, 0); } + } +} diff --git a/mods/LogUserRequest-Lite/Models/DTO/ApiResponse.cs b/mods/LogUserRequest-Lite/Models/DTO/ApiResponse.cs new file mode 100644 index 0000000..6fc4119 --- /dev/null +++ b/mods/LogUserRequest-Lite/Models/DTO/ApiResponse.cs @@ -0,0 +1,11 @@ +namespace LogUserRequest.Models.DTO; + +public class ApiResponse +{ + public bool Success { get; set; } = true; + public string? Error { get; set; } + public T? Data { get; set; } + + public static ApiResponse Ok(T data) => new() { Success = true, Data = data }; + public static ApiResponse Fail(string error) => new() { Success = false, Error = error }; +} diff --git a/mods/LogUserRequest-Lite/Models/DTO/JournalItemDto.cs b/mods/LogUserRequest-Lite/Models/DTO/JournalItemDto.cs new file mode 100644 index 0000000..9f8b52b --- /dev/null +++ b/mods/LogUserRequest-Lite/Models/DTO/JournalItemDto.cs @@ -0,0 +1,14 @@ +namespace LogUserRequest.Models.DTO; + +public class JournalItemDto +{ + public int Id { get; set; } + public DateTime Time { get; set; } + public string Uri { get; set; } = ""; + public string UserUid { get; set; } = ""; + public string Ip { get; set; } = "unknown"; + public string Country { get; set; } = ""; + public string UserAgent { get; set; } = "unknown"; + public int DurationMs { get; set; } + public string? Balancer { get; set; } +} diff --git a/mods/LogUserRequest-Lite/Models/DTO/StatsDto.cs b/mods/LogUserRequest-Lite/Models/DTO/StatsDto.cs new file mode 100644 index 0000000..83c0be8 --- /dev/null +++ b/mods/LogUserRequest-Lite/Models/DTO/StatsDto.cs @@ -0,0 +1,17 @@ +namespace LogUserRequest.Models.DTO; + +public class StatsDto +{ + public int Today { get; set; } + public int Month { get; set; } + public int UniqueIp { get; set; } + public int UniqueUserAgent { get; set; } + public TopItemDto[] TopUsers { get; set; } = Array.Empty(); + public TopItemDto[] TopBalancers { get; set; } = Array.Empty(); +} + +public class TopItemDto +{ + public string Name { get; set; } = ""; + public int Count { get; set; } +} diff --git a/mods/LogUserRequest-Lite/README.md b/mods/LogUserRequest-Lite/README.md new file mode 100644 index 0000000..21c28b1 --- /dev/null +++ b/mods/LogUserRequest-Lite/README.md @@ -0,0 +1,336 @@ +## Установка + +1. Скачайте архив с модулем +2. Распакуйте папку `LogUserRequest-Lite` в `/lampac/mods/` +3. Отредактируйте init.conf. Разрешите доступ к странице админки LogUserRequest-Lite: + "accsdb": { + "enable": true, + "whitepattern": "^/lite/logrequest" + }, +4. Перезапустите Lampac +5. Админка: http://ваш_сервер/lite/logrequest + +## Настройка модуля: +1. Конфигурация в manifest.json: + +{ + "enable": true, + "version": 4, + "namespace": "LogUserRequest", + "logDay": 90 +} +| Параметр | Описание | Значение по умолчанию | +|---|---|---| +| enable | Включить модуль | true | +| logDay | Сколько дней хранить логи | 90 | + +Пароль администратора: +1. Переменная окружения (LOGUSER_ADMIN_PASSWORD=my-super-super-secret-key) +2. Пароль генерируется автоматически при первом запуске и сохраняется в файл: /lampac/database/LogUserRequest/passlogreg +(Посмотреть пароль: docker exec lampac cat /lampac/database/LogUserRequest/passlogreg) + +Что произойдёт при первом запуске + +| Ситуация | Результат | +|---|---| +| ENV задан | Используется ENV, файл не создаётся | +| ENV пуст, файла нет | Создаётся /lampac/database/LogUserRequest/passlogreg со случайным паролем (36 символов) | +| ENV пуст, файл есть | Используется пароль из файла | + +⚠️ Важно: +Если переменная окружения задана - она имеет наивысший приоритет, и остальные источники игнорируются. + +Блокировка при входе в LogUserRequest +Как работает блокировка: + +| Параметр | Значение | +|---|---| +| Максимум попыток | 5 неверных паролей в день | +| Блокировка | После 5 неудачных попыток | +| Сброс счётчика | В полночь (по UTC) | +| Блокируется | Только IP нарушителя (остальные пользователи не страдают) | + +Что делать при блокировке +1. Подождать до полуночи +Счётчик сбрасывается автоматически в 00:00 UTC. (в коде `DateTime.Today.AddDays(1)` использует локальное время сервера, убедитесь что часовой пояс UTC) +2. Сменить IP (если нужно срочно) +Переподключиться к VPN +Использовать другой интернет +Перезагрузить роутер + +3. Проверить правильность пароля +docker exec lampac cat /lampac/database/LogUserRequest/passlogreg + +4. Сбросить блокировку вручную (требуется root доступ) +Перезапустить контейнер (сбросит кэш попыток) +docker restart lampac + +Сброс счётчика в полночь: +Изменения можно внести в Controllers/ApiController.cs +// В коде используется +AbsoluteExpiration = DateTime.Today.AddDays(1) + +## Инструкция по настройке фильтрации в Lite-версии + +### Где находятся фильтры + +Все фильтры находятся в файле `LogUserRequestListener.cs` в начале класса `LogUserRequestListener`. + +### Как работает фильтрация + +Модуль использует **три уровня фильтрации**: + +1. **Автоматическая (EventListener lampac)** — отсеивает анонимные запросы, локальные IP, служебные эндпоинты ядра +2. **Список префиксов (`_skipPrefixes`)** — пути, которые начинаются с указанных строк +3. **Список расширений (`_skipExtensions`)** — статические файлы по расширению + +Попадание в любой из фильтров — запрос не логируется. + +--- + +### 1. Список пропускаемых префиксов (`_skipPrefixes`) + +private static readonly string[] _skipPrefixes = +{ + "/.well-known", + "/admin/health", + "/admin/ping", + "/testaccsdb", + "/nws", + "/lifeevents", + "/proxyimg", + "/lite/logrequest", + "/lite/events", + "/nexthub", + "/externalids", + "/lampa-main", + "/lite/withsearch", + "/timecode", + "/bookmark", + "/storage", + "/cub", + "/cub/", + "/sisi/bookmarks", + "/proxy/" +}; + +#### Как добавить новый путь: + +private static readonly string[] _skipPrefixes = +{ + // ... существующие ... + "/ваш_путь", // Блокирует /ваш_путь и /ваш_путь/что-угодно + "/api/internal", // Ещё один пример +}; + +### 2. Список расширений (_skipExtensions) + +private static readonly string[] _skipExtensions = +{ + ".js", ".css", ".svg", ".png", ".jpg", ".jpeg", + ".woff", ".woff2", ".ogg", ".ico", ".map" +}; + +**Как добавить новое расширение:** + +private static readonly string[] _skipExtensions = +{ + // ... существующие ... + ".php", // Блокирует все .php файлы + ".webp", // Блокирует .webp изображения +}; + +### 3. Rate Limit (ограничение частоты запросов) + +_rateLimitCache.Set(realIP, DateTime.UtcNow, new MemoryCacheEntryOptions +{ + AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(33), // ← интервал + Size = 1 +}); + +**Как изменить интервал:** + +TimeSpan.FromMilliseconds(33) — ~30 запросов/сек с одного IP +TimeSpan.FromMilliseconds(100) — 10 запросов/сек +TimeSpan.FromSeconds(1) — 1 запрос/сек + +### 4. Максимальный размер очереди + +private const int MaxQueueSize = 20000; + +При превышении лимита новые записи отбрасываются с throttled-логом (не чаще раза в минуту). + +#### 📋 Примеры фильтрации + +| Что хотите заблокировать | Куда добавить | Код | +|---|---|---| +| Конкретный путь /api/test | _skipPrefixes | "/api/test" | +| Все пути с префиксом /bot/ | _skipPrefixes | "/bot" | +| Все .php файлы | _skipExtensions | ".php" | +| Слишком частые запросы | Rate Limit | Увеличить FromMilliseconds(33) до FromMilliseconds(100) | + +#### Что фильтруется автоматически (EventListener lampac) + +Это не нужно добавлять в списки — lampac делает это сам: +Статические файлы (js, css, png, svg, woff) +Анонимные запросы +Локальные IP (127.0.0.1, ::1) +Служебные эндпоинты lampac +Запросы до авторизации + +#### ⚠️ Важные замечания +После изменения файла нужна перекомпиляция модуля +Синтаксис C# чувствителен к регистру и запятым +Не удаляйте /lite/logrequest из _skipPrefixes — иначе страница модуля будет логировать саму себя +При ошибке в синтаксисе модуль не загрузится + +## База данных +Путь: /lampac/database/LogUserRequest/userlog.db + +Очистка старых записей +В Lite-версии реализована **очистка по дням** через параметр logDay в manifest.json. +{ + "logDay": 90 +} + +### Как работает: +Записи старше logDay дней удаляются раз в сутки +Проверка запускается через 1 минуту после старта модуля и затем каждые 24 часа +Удаление происходит батчами по 5000 записей для защиты от блокировки SQLite при больших объёмах +После удаления старых записей также удаляются неиспользуемые unfo и headers + +**Код в ModInit.cs:** + +Код находится в ModInit.cs, метод ClearJurnal. Удаление происходит батчами по 5000 записей. + +**Почему батчами:** +SQLite блокирует всю таблицу при удалении +При большом количестве записей (100 000+) одиночный DELETE может занять секунды +Батчи по 5000 позволяют другим операциям (запись логов, чтение админки) работать между удалениями + +### Рекомендуемые значения + +| Сценарий | logDay: | +|---|---| +| Домашнее использование (1-5 пользователей) | 90 дней | +| Небольшой сервер (5-20 пользователей) | 60 дней | +| Крупный сервер (20+ пользователей) | 30 дней | + +Оптимизация SQLite (уже настроено) +В модуле применены: +WAL — ускорение записи +cache_size = -64000 — кэш 64 МБ +temp_store = MEMORY — временные таблицы в памяти +mmap_size = 33554432 — memory-mapped I/O (32 МБ) + +### Ручная очистка и редактирование базы данных +Для редактирования базы данных используйте **DB Browser for SQLite** — бесплатный визуальный редактор. + +🔗 Скачать: [https://sqlitebrowser.org/](https://sqlitebrowser.org/) + +Пошаговая инструкция + +1. **Скопируйте** файл `userlog.db` из папки `lampac-docker/database/LogUserRequest/` в любое удобное место (это будет ваш бэкап) + +2. **Отредактируйте копию** в DB Browser for SQLite: + - Откройте скопированный файл + - Перейдите на вкладку **"Execute SQL"** + - Вставьте и выполните запросы: + + DELETE FROM jurnal WHERE time < datetime('now', '-30 days'); + VACUUM; + +Сохраните изменения (Ctrl+S) + +Замените оригинал — скопируйте отредактированный файл обратно в lampac-docker/database/LogUserRequest/ с заменой +Перезапустите контейнер: + +docker restart lampac + +✅ Готово! База очищена, старые записи удалены. + +📋 Альтернативные запросы + +| Задача | SQL-запрос | +|---|---| +| Удалить старше 7 дней | DELETE FROM jurnal WHERE time < datetime('now', '-7 days'); | +| Удалить старше 90 дней | DELETE FROM jurnal WHERE time < datetime('now', '-90 days'); | +| Удалить ВСЕ записи | DELETE FROM jurnal; | +| Посмотреть количество записей | SELECT COUNT(*) FROM jurnal; | + +## 🕐 Время записей не совпадает с местным +По умолчанию модуль сохраняет время запросов в **UTC**. Если в админке время отличается от вашего локального — это нормально, но можно настроить. + +Варианты решения +1. Сменить часовой пояс контейнера (рекомендуется) +Добавьте переменную окружения TZ при запуске контейнера: +docker run -e TZ=Europe/… +docker run -e TZ=Asia/… +Или в docker-compose.yml: +environment: + - TZ=Europe/…. +После изменения — перезапустите контейнер. +2. Если TZ не поддерживается нужно изменить код модуля (⚠️Не рекомендуется) +Если нужно хранить локальное время вместо UTC, измените во всех файлах модуля +// Было +DateTime.UtcNow +DateTime.UtcNow.AddDays(-X) +DateTime.UtcNow.Date +// Стало +DateTime.Now +DateTime.Now.AddDays(-X) +DateTime.Now.Date + +Не заменяйте: +DateTime.Now.Ticks +В именах файлов экспорта ($"export_{DateTime.Now:yyyyMMdd}.zip") + +### ⚠️ Важно: последствия перехода на локальное время +| Проблема | Описание | +|---|---| +| **Переход на летнее/зимнее время** | Дважды в год время сдвигается на час. Записи могут дублироваться или нарушится сортировка | +| **Смена часового пояса сервера** | При переезде на другой сервер все исторические данные будут показывать неверное время | +| **Сравнение логов с разных серверов** | Если у вас несколько серверов в разных часовых поясах — логи невозможно сопоставить | +| **Очистка по `logDay`** | Может удалить на час больше или меньше записей при переходе времени | + +#### 💡 Рекомендация: Оставьте UTC. Браузер сам покажет локальное время. Это стандарт для логирования. + +## ❓ Частые проблемы + +### Модуль не загружается +- Проверьте синтаксис в `manifest.json` (запятые, кавычки) +- Посмотрите логи: `docker logs lampac | grep LogUserRequest` + +### Не открывается админка (404) +- Убедитесь, что путь `/lite/logrequest` не заблокирован в чёрном списке +- Проверьте, что модуль включён: `"enable": true` + +### Пароль не подходит +- ENV переменная имеет приоритет: `docker exec lampac env | grep LOGUSER` +- Проверьте файл: `docker exec lampac cat /lampac/database/LogUserRequest/passlogreg` + +### База данных растёт слишком быстро +- Уменьшите `logDay` до 30 +- Добавьте в чёрный список часто вызываемые служебные пути + +### После входа в админку белый экран +- Проверьте консоль браузера (F12) на ошибки +- Убедитесь, что файлы `index.html` и `auth.html` не повреждены +- Очистите кэш браузера + +### Не работает кнопка "Premium" +- Это не баг 😄 — кнопка открывает пасхалку и информацию о Premium-версии +- Lite-версия полностью бесплатна и функциональна + +💡 **Premium-версия** добавляет графики, экспорт, Telegram-уведомления и гибкие фильтры через UI. +Подробнее — в модальном окне по кнопке "Premium" в админке или у разработчика. + +## 📞 Поддержка + +По вопросам работы модуля, предложениям и багам: + +- Telegram: [@Viacheslav_Sh1](https://t.me/Viacheslav_Sh1) + +--- + +**LogUserRequest Lite** — бесплатный модуль для логирования запросов в Lampac NG. diff --git a/mods/LogUserRequest-Lite/auth.html b/mods/LogUserRequest-Lite/auth.html new file mode 100644 index 0000000..8d4d5cd --- /dev/null +++ b/mods/LogUserRequest-Lite/auth.html @@ -0,0 +1,110 @@ + + + + + + + LogUserRequest — Вход + + + + + + + +
+ +

🔐 Вход

+
+
+ + +
+
+ + + \ No newline at end of file diff --git a/mods/LogUserRequest-Lite/index.html b/mods/LogUserRequest-Lite/index.html new file mode 100644 index 0000000..5383bb5 --- /dev/null +++ b/mods/LogUserRequest-Lite/index.html @@ -0,0 +1,487 @@ + + + + + + LogUserRequest Lite + + + + + + + +
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+ + + +
+
Загрузка...
+
+
+ + + + + + + \ No newline at end of file diff --git a/mods/LogUserRequest-Lite/manifest.json b/mods/LogUserRequest-Lite/manifest.json new file mode 100644 index 0000000..3e02531 --- /dev/null +++ b/mods/LogUserRequest-Lite/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 1, + "namespace": "LogUserRequest", + "logDay": 90 +} diff --git a/mods/Music/Controllers/ApiController.cs b/mods/Music/Controllers/ApiController.cs new file mode 100644 index 0000000..ec2c553 --- /dev/null +++ b/mods/Music/Controllers/ApiController.cs @@ -0,0 +1,295 @@ +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 new file mode 100644 index 0000000..5f0bb43 --- /dev/null +++ b/mods/Music/Controllers/AuthController.cs @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..242d670 --- /dev/null +++ b/mods/Music/Controllers/PlaybackController.cs @@ -0,0 +1,169 @@ +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 new file mode 100644 index 0000000..92be12a --- /dev/null +++ b/mods/Music/Controllers/SearchController.cs @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..480296d --- /dev/null +++ b/mods/Music/GlobalUsings.cs @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..600adf9 --- /dev/null +++ b/mods/Music/ModInit.cs @@ -0,0 +1,136 @@ +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 new file mode 100644 index 0000000..cad4811 --- /dev/null +++ b/mods/Music/Models/Contracts.cs @@ -0,0 +1,309 @@ +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 new file mode 100644 index 0000000..91c981f --- /dev/null +++ b/mods/Music/Models/MusicIsrc.cs @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..330faba --- /dev/null +++ b/mods/Music/ModuleConf.cs @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..c5b2704 --- /dev/null +++ b/mods/Music/Music.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + library + true + + + + + + + + + Always + + + + diff --git a/mods/Music/Providers/Abstractions/Contracts.cs b/mods/Music/Providers/Abstractions/Contracts.cs new file mode 100644 index 0000000..a0edcdb --- /dev/null +++ b/mods/Music/Providers/Abstractions/Contracts.cs @@ -0,0 +1,62 @@ +namespace Music; + +public sealed class MusicAudioTransientException : Exception +{ + public string ProviderId { get; } + + public MusicAudioTransientException(string providerId, string operation, Exception innerException) + : base($"{providerId} {operation} temporarily failed.", innerException) + { + ProviderId = providerId; + } +} + +public interface IMusicMetadataProvider +{ + string Id { get; } + string Name { get; } + bool Enabled { get; } + + Task 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 new file mode 100644 index 0000000..d32561a --- /dev/null +++ b/mods/Music/Providers/AppleMusic/AppleMusicSupport.cs @@ -0,0 +1,481 @@ +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; +using static Music.MusicPlaylistImportHelpers; + +namespace Music; + +// Импорт публичных плейлистов/альбомов Apple Music БЕЗ ключей и логина: +// анонимный developer-JWT из JS-бандла music.apple.com (живёт ~месяц) +// + amp-api catalog — тот же путь, что у веб-плеера. Стримов нет (DRM): +// треки приезжают метаданными, аудио резолвит обычный конвейер (YouTube-матчер). +public static class AppleMusicSupport +{ + public const string ProviderId = "applemusic"; + public const string PlaylistSourceType = "applemusic_playlist"; + public const string AlbumSourceType = "applemusic_album"; + + const string ApiBaseUrl = "https://amp-api.music.apple.com"; + const int PageLimit = 100; + const int MaxImportTracks = 2000; + const int MaxPages = 30; + const string BrowserUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"; + + static readonly HttpClient httpClient = MusicHttp.CreateClient("applemusic"); + // storefront (us/ru/ua/...), тип, id: pl.xxx / pl.u-xxx у плейлистов, цифры у альбомов + static readonly Regex entityUrlRegex = new(@"^https?://music\.apple\.com/([a-z]{2})/(playlist|album)/(?:[^/]+/)?(pl\.[A-Za-z0-9\.\-]+|\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + static readonly Regex catalogAlbumIdRegex = new(@"^applemusic:album:([a-z]{2}):(\d+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + static readonly Regex assetBundleRegex = new("src=\"(/assets/index~[^\"]+\\.js)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled); + static readonly Regex developerTokenRegex = new("\"(eyJ0[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+)\"", RegexOptions.Compiled); + static readonly SemaphoreSlim developerTokenLock = new(1, 1); + static string developerToken; + static DateTime developerTokenExpiresAt; + + static AppleMusicSupport() + { + httpClient.Timeout = TimeSpan.FromSeconds(20); + } + + public static bool CanHandleUrl(string url) => IsAppleMusicUrl(url); + + public static bool IsCatalogAlbum(string provider, string id) + => string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase) + || catalogAlbumIdRegex.IsMatch(id ?? string.Empty); + + public static Task 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 new file mode 100644 index 0000000..47c4cfe --- /dev/null +++ b/mods/Music/Providers/Audio/Sefon/SefonAudioProvider.cs @@ -0,0 +1,62 @@ +namespace Music; + +public class SefonAudioProvider : IMusicAudioProvider +{ + public string Id => SefonSupport.ProviderId; + public string Name => "Sefon"; + public bool Enabled => ModInit.conf?.sefon_audio_enabled == true; + public bool RequiresAuth => false; + public bool CacheMissingMatches => true; + + public async Task> 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 new file mode 100644 index 0000000..ddbd2d2 --- /dev/null +++ b/mods/Music/Providers/Audio/Sefon/SefonSupport.cs @@ -0,0 +1,672 @@ +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +using Shared.Services.HTML; + +namespace Music; + +internal sealed class SefonMatchPayload +{ + public string stream_url { get; set; } + public string stream_direct_url { get; set; } + public string track_url { get; set; } + public string referer { get; set; } +} + +internal static class SefonSupport +{ + public const string ProviderId = "sefonaudio"; + public const string BaseUrl = "https://sefon.org"; + const int MaxSearchQueries = 3; + + static readonly HttpClient httpClient = MusicHttp.CreateClient("sefon"); + + static readonly string[] SoftPenaltyWords = + { + "nightcore", "remix", "cover", "karaoke", "instrumental", "sped up", "slowed", "speed up", + "bass boosted", "deep", "car and bass", "edit", "mashup", "bootleg", "live", "demo" + }; + + static readonly string[] HardPenaltyWords = + { + "рингтон", "ringtone", "припев", "pripev", "припiв", "snippet", "тизер", "teaser", + "минус", "acapella", "акапелла" + }; + + static readonly string[] AlbumNoiseWords = + { + "www.", ".ru", ".com", "muzmix", "mfm", "freshmp3music", "not entered tracks" + }; + + static SefonSupport() + { + httpClient.Timeout = TimeSpan.FromSeconds(3); + httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0"); + httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7"); + httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Referer", $"{BaseUrl}/"); + httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Origin", BaseUrl); + } + + public static string BuildTrackQuery(MusicTrack track) + { + return BuildTrackQueries(track).FirstOrDefault() ?? string.Empty; + } + + public static async Task> 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 new file mode 100644 index 0000000..bd565cb --- /dev/null +++ b/mods/Music/Providers/Audio/SoundCloud/SoundCloudAudioProvider.cs @@ -0,0 +1,87 @@ +namespace Music; + +public class SoundCloudAudioProvider : IMusicAudioProvider +{ + public string Id => SoundCloudSupport.AudioProviderId; + public string Name => "SoundCloud"; + public bool Enabled => SoundCloudSupport.IsAudioEnabled; + public bool RequiresAuth => false; + public bool CacheMissingMatches => false; + + public async Task> 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 new file mode 100644 index 0000000..c206d70 --- /dev/null +++ b/mods/Music/Providers/Audio/YouTube/YouTubeAudioProvider.cs @@ -0,0 +1,361 @@ +using System.Collections.Concurrent; +using YoutubeExplode; +using YoutubeExplode.Search; +using YoutubeExplode.Videos.Streams; + +namespace Music; + +public class YouTubeAudioProvider : IMusicAudioProvider +{ + // спекулятивный манифест: после первой пачки поиска манифест + // промежуточного топ-кандидата тянется ПАРАЛЛЕЛЬНО со второй пачкой — + // победитель ранжирования после неё почти никогда не меняется, и + // GetStreamsAsync забирает готовый результат вместо ещё одного ~1.5s + // запроса. Промах (победитель сменился/спекуляция упала) — обычный путь. + static readonly ConcurrentDictionary 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 new file mode 100644 index 0000000..25cfa0b --- /dev/null +++ b/mods/Music/Providers/Audio/YouTube/YouTubeAudioSupport.cs @@ -0,0 +1,708 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using YoutubeExplode.Search; +using YoutubeExplode.Videos.Streams; + +namespace Music; + +internal static class YouTubeAudioSupport +{ + static readonly string[] GenericTrackTitles = + { + "intro", "outro", "skit", "interlude", "theme", "song", "track" + }; + + static readonly string[] PenaltyWords = + { + "live", "karaoke", "cover", "reaction", "remix", "sped up", "slowed", "nightcore", "8d", "instrumental", "fan made", "tribute", "parody", "concert", "festival", "demo", "teaser", "choreo", "choreography", "dance" + }; + + static readonly string[] StrongVariantWords = + { + "instrumental", "karaoke", "drumless", "acapella", "a cappella" + }; + + static readonly string[] LiveVariantWords = + { + "live", "concert", "festival", "live performance", "performance", + "live session", "session", "sessions", "acoustic", "unplugged", + "tiny desk", "live lounge", "kexp", "boiler room", "city hall", + "royal albert hall", "madison square garden", "wembley" + }; + + static readonly string[] BonusWords = + { + "official audio", "official video", "audio", "topic", "provided to youtube", "auto-generated by youtube", "visualizer", "lyrics" + }; + + static readonly string[] EarlyExitPenaltyWords = + { + "live", "karaoke", "cover", "reaction", "remix", "sped up", "slowed", "nightcore", "8d", "instrumental", "fan made", "tribute", "parody", "concert", "festival", "demo", "teaser", "choreo", "choreography", "dance", "alternate", "version", "drumless", "acoustic", "unplugged", "remaster", "remastered", "edit", "sped", "reverb" + }; + + public static IReadOnlyList 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 new file mode 100644 index 0000000..0a3d593 --- /dev/null +++ b/mods/Music/Providers/Audio/Z3Fm/Z3FmAudioProvider.cs @@ -0,0 +1,51 @@ +namespace Music; + +public class Z3FmAudioProvider : IMusicAudioProvider +{ + public string Id => Z3FmSupport.AudioProviderId; + public string Name => "Z3.FM"; + public bool Enabled => Z3FmSupport.IsAudioEnabled; + public bool RequiresAuth => false; + public bool CacheMissingMatches => false; + + public Task> 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 new file mode 100644 index 0000000..f7c4b95 --- /dev/null +++ b/mods/Music/Providers/Audio/Z3Fm/Z3FmSupport.cs @@ -0,0 +1,626 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Playwright; +using Shared.Models.Base; +using Shared.PlaywrightCore; +using Shared.Services.Utilities; +using System.Net; +using System.Text.RegularExpressions; + +namespace Music; + +public static class Z3FmSupport +{ + public const string AudioProviderId = "z3fmaudio"; + public const string BaseUrl = "https://z3.fm"; + public const string SearchPath = "/mp3/search"; + + static readonly MemoryCache cookieCache = new(new MemoryCacheOptions()); + static readonly TimeSpan cookieLifetime = TimeSpan.FromMinutes(20); + static readonly Regex resultBlockRegex = new( + "
(?.*?)]*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 new file mode 100644 index 0000000..c981ce0 --- /dev/null +++ b/mods/Music/Providers/Auth/SoundCloud/SoundCloudAuthProvider.cs @@ -0,0 +1,56 @@ +namespace Music; + +public class SoundCloudAuthProvider : IMusicAuthProvider +{ + public string Id => SoundCloudSupport.AuthProviderId; + public string Name => "SoundCloud"; + public bool Enabled => SoundCloudSupport.IsAuthEnabled; + + public async Task 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 new file mode 100644 index 0000000..e0c6ed1 --- /dev/null +++ b/mods/Music/Providers/Discovery/AppleMusic/AppleMusicDiscoveryProvider.cs @@ -0,0 +1,390 @@ +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace Music; + +public class AppleMusicDiscoveryProvider : IMusicDiscoveryProvider +{ + static readonly HttpClient httpClient = MusicHttp.CreateClient("applemusic"); + static readonly TimeSpan cacheTtl = TimeSpan.FromHours(6); + + public const string ProviderId = "applemusiccharts"; + const string defaultCountry = "us"; + const string userAgent = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)"; + + public string Id => ProviderId; + public string Name => "Apple Music Charts"; + public bool Enabled => true; + + static AppleMusicDiscoveryProvider() + { + httpClient.Timeout = TimeSpan.FromSeconds(20); + } + + public async Task> 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 new file mode 100644 index 0000000..62a17a2 --- /dev/null +++ b/mods/Music/Providers/Discovery/SoundCloud/SoundCloudDiscoveryProvider.cs @@ -0,0 +1,63 @@ +namespace Music; + +public class SoundCloudDiscoveryProvider : IMusicDiscoveryProvider +{ + public string Id => SoundCloudSupport.DiscoveryProviderId; + public string Name => "SoundCloud Charts"; + public bool Enabled => SoundCloudSupport.IsDiscoveryEnabled; + + public Task> 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 new file mode 100644 index 0000000..ae77b46 --- /dev/null +++ b/mods/Music/Providers/Discovery/Spotify/SpotifyDiscoveryProvider.cs @@ -0,0 +1,314 @@ +using System.Text.RegularExpressions; + +namespace Music; + +public class SpotifyDiscoveryProvider : IMusicDiscoveryProvider +{ + sealed class Playlist + { + public string title { get; init; } + public string playlistId { get; init; } + } + + static readonly TimeSpan cacheTtl = TimeSpan.FromHours(1); + static readonly IReadOnlyList 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 new file mode 100644 index 0000000..b017b62 --- /dev/null +++ b/mods/Music/Providers/Discovery/VkTopChart/VkTopChartDiscoveryProvider.cs @@ -0,0 +1,183 @@ +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; + +namespace Music; + +public class VkTopChartDiscoveryProvider : IMusicDiscoveryProvider +{ + static readonly HttpClient httpClient = MusicHttp.CreateClient("vktop200"); + static readonly TimeSpan cacheTtl = TimeSpan.FromHours(1); + + const string providerId = "vktop200"; + const string baseUrl = "https://www.top200chart.ru/"; + const string userAgent = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)"; + + public string Id => providerId; + public string Name => "VK Top 200"; + public bool Enabled => true; + + static VkTopChartDiscoveryProvider() + { + httpClient.Timeout = TimeSpan.FromSeconds(20); + } + + public async Task> 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 new file mode 100644 index 0000000..16194da --- /dev/null +++ b/mods/Music/Providers/Discovery/YouTubeMusic/YouTubeMusicSearchSupport.cs @@ -0,0 +1,707 @@ +using System.Collections; +using System.Net.Http; +using System.Text.RegularExpressions; +using YoutubeExplode; +using YoutubeExplode.Channels; +using YoutubeExplode.Playlists; +using YoutubeExplode.Search; + +namespace Music; + +internal static class YouTubeMusicSearchSupport +{ + public const string SearchSectionId = "search:youtubemusic"; + public const string TracksSectionId = "search:youtubemusic:tracks"; + public const string PlaylistsSectionId = "search:youtubemusic:playlists"; + public const string ArtistsSectionId = "search:youtubemusic:artists"; + public const string ProviderId = "youtubeaudio"; + + const string playlistPrefix = "youtube:playlist:"; + const string channelPrefix = "youtube:channel:"; + const string channelUploadsPrefix = "youtube:channeluploads:"; + const int maxSearchQueries = 2; + static readonly string[] titleSeparators = { " - ", " – ", " — " }; + static readonly HttpClient youtubeHttp = MusicHttp.CreateClient(ProviderId); + + public static bool IsSearchEnabled => ModInit.conf?.youtube_audio_enabled == true; + + public static async Task> 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 new file mode 100644 index 0000000..55ef091 --- /dev/null +++ b/mods/Music/Providers/Metadata/MusicBrainz/MusicBrainzMetadataProvider.cs @@ -0,0 +1,1553 @@ +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 new file mode 100644 index 0000000..c070c12 --- /dev/null +++ b/mods/Music/Providers/MusicMap/MusicMapSupport.cs @@ -0,0 +1,298 @@ +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; + +namespace Music; + +// Похожие артисты с music-map.com (проект Gnod/Gnoosic): один GET на артиста, +// парс якорей #gnodMap, отсортированных по музыкальной близости (краудсорсная +// коллаборативная фильтрация). Используется ТОЛЬКО как additive-слой «специи» +// в «Миксах недели»: не источник в UI, не участник радио, любой сбой — пустой +// список и микс собирается без внешнего слоя. Живой прототип 2026-07-16 +// подтвердил стабильность парсинга и RU/транслит-покрытие (10/10 сидов по 48 +// артистов, включая кириллические запросы и выдачу). +public static class MusicMapSupport +{ + const string BrowserUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"; + const string cacheVersion = "mm-v1"; + + // похожесть артистов почти статична; 14 дней — быстро самовосстановится, + // если сайт/парсер временно отдал мусор (совет Кодекса) + static readonly TimeSpan similarCacheTtl = TimeSpan.FromDays(14); + + static readonly HttpClient httpClient = MusicHttp.CreateClient("musicmap"); + static readonly Regex mapBlockRegex = new(@"
(.*?)
", 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 new file mode 100644 index 0000000..a695eaa --- /dev/null +++ b/mods/Music/Providers/SoundCloud/SoundCloudDtos.cs @@ -0,0 +1,108 @@ +namespace Music; + +public class SoundCloudCredentials +{ + public string access_token { get; set; } + public string refresh_token { get; set; } + public string token_type { get; set; } + public string scope { get; set; } + public string username { get; set; } + public string user_urn { get; set; } + public long? expires_at_unix { get; set; } +} + +public class SoundCloudTokenResponse +{ + public string access_token { get; set; } + public string refresh_token { get; set; } + public string token_type { get; set; } + public string scope { get; set; } + public int? expires_in { get; set; } +} + +public class SoundCloudCollectionDto +{ + 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 new file mode 100644 index 0000000..89a9b6d --- /dev/null +++ b/mods/Music/Providers/SoundCloud/SoundCloudSupport.Audio.cs @@ -0,0 +1,748 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Music; + +// Audio capability: поиск и ранжирование матчей под трек, скоринг, +// выбор transcoding и построение stream-источников. +public static partial class SoundCloudSupport +{ + public static async Task> 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 new file mode 100644 index 0000000..5d7bcce --- /dev/null +++ b/mods/Music/Providers/SoundCloud/SoundCloudSupport.Auth.cs @@ -0,0 +1,87 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Music; + +// OAuth credentials: парсинг payload, нормализация и статус токена. +public static partial class SoundCloudSupport +{ + public static Dictionary 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 new file mode 100644 index 0000000..ab94a22 --- /dev/null +++ b/mods/Music/Providers/SoundCloud/SoundCloudSupport.Catalog.cs @@ -0,0 +1,1378 @@ +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 new file mode 100644 index 0000000..b4c6db7 --- /dev/null +++ b/mods/Music/Providers/SoundCloud/SoundCloudSupport.Import.cs @@ -0,0 +1,276 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; +using static Music.MusicPlaylistImportHelpers; + +namespace Music; + +// Импорт пользовательских плейлистов и лайков по ссылке +// (профиль → лайки, плейлист → треки) для MusicUserPlaylistService. +public static partial class SoundCloudSupport +{ + static async Task 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 new file mode 100644 index 0000000..0cc5fa7 --- /dev/null +++ b/mods/Music/Providers/SoundCloud/SoundCloudSupport.cs @@ -0,0 +1,662 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Music; + +// Транспорт и общая инфраструктура SoundCloud: client_id (конфиг или скрап с кэшем), +// построение URL/заголовков, resolve, постраничные коллекции api-v2, JSON-хелперы. +// Остальные части класса: .Catalog.cs (discovery), .Audio.cs (матчинг/стримы), +// .Import.cs (импорт плейлистов/лайков), .Auth.cs (OAuth credentials). +public static partial class SoundCloudSupport +{ + public const string AuthProviderId = "soundcloud"; + public const string AudioProviderId = "soundcloudaudio"; + public const string DiscoveryProviderId = "soundcloudcharts"; + public const string ApiBaseUrl = "https://api.soundcloud.com"; + public const string ApiV2BaseUrl = "https://api-v2.soundcloud.com"; + public const string OAuthTokenUrl = "https://secure.soundcloud.com/oauth/token"; + public const string ChartsSectionId = "browse:soundcloud"; + public const string SearchSectionId = "search:soundcloud"; + public const string SearchTracksSectionId = "search:soundcloud:tracks"; + public const string SearchAlbumsSectionId = "search:soundcloud:albums"; + public const string SearchPlaylistsSectionId = "search:soundcloud:playlists"; + public const string SearchArtistsSectionId = "search:soundcloud:artists"; + public const string PlaylistIdPrefix = "soundcloud:playlist:"; + public const string UserIdPrefix = "soundcloud:user:"; + public const string UserTracksAlbumPrefix = "soundcloud:usertracks:"; + public const string UserSectionPrefix = "soundcloud:usersection:"; + + static readonly HttpClient httpClient = MusicHttp.CreateClient("soundcloud"); + static readonly Regex apiClientIdRegex = new("\"apiClient\".*?\"id\":\"([^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + static readonly Regex publicClientIdRegex = new("(?:client_id|clientId)\\s*[:=]\\s*[\"']?([A-Za-z0-9_-]{20,})", RegexOptions.IgnoreCase | RegexOptions.Compiled); + static readonly Regex publicAssetRegex = new("https://a-v2\\.sndcdn\\.com/assets/[^\"'<>\\s]+\\.js", RegexOptions.IgnoreCase | RegexOptions.Compiled); + static readonly Regex titleArtistSeparatorRegex = new(@"^\s*(?.+?)\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 new file mode 100644 index 0000000..eab79f8 --- /dev/null +++ b/mods/Music/Providers/Spotify/SpotifySupport.cs @@ -0,0 +1,1395 @@ +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 new file mode 100644 index 0000000..552fd95 --- /dev/null +++ b/mods/Music/Providers/Yandex/YandexMusicProviders.cs @@ -0,0 +1,266 @@ +using System.Text.Json; + +namespace Music; + +public class YandexMusicAudioProvider : IMusicAudioProvider +{ + public string Id => "yandexmusic"; + public string Name => "Yandex Music"; + public bool Enabled => true; + public bool RequiresAuth => true; + public bool CacheMissingMatches => false; + + public async Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) + { + if (track == null) + return Array.Empty<MusicAudioMatch>(); + + var credentials = await MusicAuthStorageService.GetAsync<YandexMusicCredentials>(profileId, Id, cancellationToken); + if (!YandexMusicSupport.HasApiToken(credentials)) + return Array.Empty<MusicAudioMatch>(); + + string query = YandexMusicSupport.BuildTrackQuery(track); + if (string.IsNullOrWhiteSpace(query)) + return Array.Empty<MusicAudioMatch>(); + + try + { + using var http = MusicHttp.CreateClient("yandexmusic"); + YandexMusicSupport.ApplyHeaders(http, YandexMusicSupport.CreateApiHeaders(credentials)); + + string url = $"https://api.music.yandex.net/search?text={HttpUtility.UrlEncode(query)}&type=track&page=0&page-size=12&nocorrect=false"; + + using var response = await http.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + return Array.Empty<MusicAudioMatch>(); + + string json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("result", out var result) || + !result.TryGetProperty("tracks", out var tracks) || + !tracks.TryGetProperty("results", out var items) || + items.ValueKind != JsonValueKind.Array) + { + return Array.Empty<MusicAudioMatch>(); + } + + var matches = new List<MusicAudioMatch>(); + + foreach (var item in items.EnumerateArray()) + { + string trackId = item.TryGetProperty("id", out var idProp) ? idProp.ToString() : null; + string title = item.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null; + + if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(title)) + continue; + + var artists = new List<string>(); + if (item.TryGetProperty("artists", out var artistsProp) && artistsProp.ValueKind == JsonValueKind.Array) + { + foreach (var artist in artistsProp.EnumerateArray()) + { + if (artist.TryGetProperty("name", out var nameProp)) + { + var name = nameProp.GetString(); + if (!string.IsNullOrWhiteSpace(name)) + artists.Add(name); + } + } + } + + string albumTitle = null; + string albumId = null; + if (item.TryGetProperty("albums", out var albumsProp) && albumsProp.ValueKind == JsonValueKind.Array) + { + var album = albumsProp.EnumerateArray().FirstOrDefault(); + if (album.ValueKind != JsonValueKind.Undefined) + { + if (album.TryGetProperty("title", out var albumTitleProp)) + albumTitle = albumTitleProp.GetString(); + + if (album.TryGetProperty("id", out var albumIdProp)) + albumId = albumIdProp.ToString(); + } + } + + int? duration = null; + if (item.TryGetProperty("durationMs", out var durationProp)) + { + if (durationProp.ValueKind == JsonValueKind.Number && durationProp.TryGetInt32(out var dur)) + duration = dur; + else if (durationProp.ValueKind == JsonValueKind.String && int.TryParse(durationProp.GetString(), out dur)) + duration = dur; + } + + matches.Add(new MusicAudioMatch + { + provider_id = Id, + id = trackId, + title = title, + artists = artists, + album_title = albumTitle, + duration_ms = duration, + payload = MusicJson.Serialize(new YandexMusicMatchPayload + { + track_id = trackId, + album_id = albumId, + album_title = albumTitle + }) + }); + } + + return YandexMusicSupport.RankMatches(track, matches).Take(5).ToList(); + } + catch + { + return Array.Empty<MusicAudioMatch>(); + } + } + + public async Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) + { + if (match == null || string.IsNullOrWhiteSpace(match.id)) + return Array.Empty<MusicPlaybackSource>(); + + var credentials = await MusicAuthStorageService.GetAsync<YandexMusicCredentials>(profileId, Id, cancellationToken); + if (!YandexMusicSupport.HasApiToken(credentials)) + return Array.Empty<MusicPlaybackSource>(); + + try + { + using var http = MusicHttp.CreateClient("yandexmusic"); + YandexMusicSupport.ApplyHeaders(http, YandexMusicSupport.CreateApiHeaders(credentials)); + + using var response = await http.GetAsync($"https://api.music.yandex.net/tracks/{match.id}/download-info", cancellationToken); + if (!response.IsSuccessStatusCode) + return Array.Empty<MusicPlaybackSource>(); + + string json = await response.Content.ReadAsStringAsync(cancellationToken); + var downloads = YandexMusicSupport.ParseDownloadInfos(json); + var selected = downloads.FirstOrDefault(); + + if (selected == null || string.IsNullOrWhiteSpace(selected.download_info_url)) + return Array.Empty<MusicPlaybackSource>(); + + using var detailsResponse = await http.GetAsync(selected.download_info_url, cancellationToken); + if (!detailsResponse.IsSuccessStatusCode) + return Array.Empty<MusicPlaybackSource>(); + + string raw = await detailsResponse.Content.ReadAsStringAsync(cancellationToken); + if (!YandexMusicSupport.TryParseDownloadPayload(raw, out var host, out var path, out var ts, out var s)) + return Array.Empty<MusicPlaybackSource>(); + + string streamUrl = YandexMusicSupport.BuildStreamUrl(host, path, ts, s); + + return new List<MusicPlaybackSource> + { + new() + { + provider_id = Id, + url = streamUrl, + mime_type = string.Equals(selected.codec, "aac", StringComparison.OrdinalIgnoreCase) ? "audio/aac" : "audio/mpeg", + bitrate = selected.bitrate_kbps, + quality = selected.bitrate_kbps.HasValue ? $"{(selected.codec ?? "audio").ToUpperInvariant()} {selected.bitrate_kbps}kbps" : (selected.codec ?? "audio").ToUpperInvariant(), + headers = new Dictionary<string, string> + { + ["Referer"] = "https://music.yandex.ru/" + } + } + }; + } + catch + { + return Array.Empty<MusicPlaybackSource>(); + } + } + + public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default) + { + return Task.FromResult<MusicPlaybackSource>(null); + } + + public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default) + { + return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>()); + } + + public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match) + { + return track != null && match != null; + } + + public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match) + { + return false; + } + + public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track) + { + return Array.Empty<string>(); + } +} + +public class YandexMusicAuthProvider : IMusicAuthProvider +{ + public string Id => "yandexmusic"; + public string Name => "Yandex Music"; + public bool Enabled => true; + + public async Task<MusicAuthState> GetStateAsync(string profileId = null, CancellationToken cancellationToken = default) + { + var credentials = await MusicAuthStorageService.GetAsync<YandexMusicCredentials>(profileId, Id, cancellationToken); + credentials = YandexMusicSupport.Normalize(credentials); + + bool hasToken = YandexMusicSupport.HasApiToken(credentials); + bool hasWeb = YandexMusicSupport.HasWebAuth(credentials); + + return new MusicAuthState + { + provider_id = Id, + provider_name = Name, + authenticated = hasToken || hasWeb, + requires_auth = true, + token_ready = hasToken, + web_ready = hasWeb, + mode = "token+cookies", + message = YandexMusicSupport.BuildStateMessage(hasToken, hasWeb) + }; + } + + public async Task<bool> SaveAsync(string payload, string profileId = null, CancellationToken cancellationToken = default) + { + var parsed = YandexMusicSupport.ParseSavePayload(payload); + if (parsed.Count == 0) + return false; + + var credentials = await MusicAuthStorageService.GetAsync<YandexMusicCredentials>(profileId, Id, cancellationToken) ?? new YandexMusicCredentials(); + + if (parsed.TryGetValue("ym_token", out var ymToken)) + credentials.ym_token = ymToken; + if (parsed.TryGetValue("ym_session_id", out var ymSessionId)) + credentials.ym_session_id = ymSessionId; + if (parsed.TryGetValue("ym_sessionid2", out var ymSessionId2)) + credentials.ym_sessionid2 = ymSessionId2; + if (parsed.TryGetValue("ym_yandexuid", out var ymYandexUid)) + credentials.ym_yandexuid = ymYandexUid; + if (parsed.TryGetValue("ym_yandex_login", out var ymYandexLogin)) + credentials.ym_yandex_login = ymYandexLogin; + if (parsed.TryGetValue("ym_l_token", out var ymLToken)) + credentials.ym_l_token = ymLToken; + + credentials = YandexMusicSupport.Normalize(credentials); + + if (YandexMusicSupport.IsEmpty(credentials)) + { + await MusicAuthStorageService.DeleteAsync(profileId, Id, cancellationToken); + return true; + } + + await MusicAuthStorageService.SaveAsync(profileId, Id, credentials, cancellationToken); + return true; + } + + public Task LogoutAsync(string profileId = null, CancellationToken cancellationToken = default) + => MusicAuthStorageService.DeleteAsync(profileId, Id, cancellationToken); +} diff --git a/mods/Music/Providers/Yandex/YandexMusicSupport.cs b/mods/Music/Providers/Yandex/YandexMusicSupport.cs new file mode 100644 index 0000000..e22a43a --- /dev/null +++ b/mods/Music/Providers/Yandex/YandexMusicSupport.cs @@ -0,0 +1,378 @@ +using System.Net.Http; +using System.Text.Json; +using System.Xml.Linq; +using Shared.Services.Utilities; + +namespace Music; + +internal sealed class YandexDownloadInfo +{ + public string download_info_url { get; set; } + public int? bitrate_kbps { get; set; } + public string codec { get; set; } +} + +internal static class YandexMusicSupport +{ + public const string ProviderId = "yandexmusic"; + public const string Salt = "XGRlBW9FXlekgbPrRHuSiA"; + + static readonly Dictionary<string, string> KeyMap = new(StringComparer.OrdinalIgnoreCase) + { + ["ym_token"] = "ym_token", + ["token"] = "ym_token", + ["oauth"] = "ym_token", + + ["ym_session_id"] = "ym_session_id", + ["session_id"] = "ym_session_id", + ["Session_id"] = "ym_session_id", + + ["ym_sessionid2"] = "ym_sessionid2", + ["sessionid2"] = "ym_sessionid2", + + ["ym_yandexuid"] = "ym_yandexuid", + ["yandexuid"] = "ym_yandexuid", + + ["ym_yandex_login"] = "ym_yandex_login", + ["yandex_login"] = "ym_yandex_login", + ["login"] = "ym_yandex_login", + + ["ym_l_token"] = "ym_l_token", + ["l_token"] = "ym_l_token", + ["L"] = "ym_l_token" + }; + + public static bool HasApiToken(YandexMusicCredentials credentials) + => !string.IsNullOrWhiteSpace(credentials?.ym_token); + + public static bool HasWebAuth(YandexMusicCredentials credentials) + { + if (credentials == null) + return false; + + bool hasSession = !string.IsNullOrWhiteSpace(credentials.ym_session_id) || + !string.IsNullOrWhiteSpace(credentials.ym_sessionid2); + + return hasSession && + !string.IsNullOrWhiteSpace(credentials.ym_yandexuid) && + !string.IsNullOrWhiteSpace(credentials.ym_yandex_login) && + !string.IsNullOrWhiteSpace(credentials.ym_l_token); + } + + public static YandexMusicCredentials Normalize(YandexMusicCredentials credentials) + { + credentials ??= new YandexMusicCredentials(); + + credentials.ym_token = NormalizeValue(credentials.ym_token); + credentials.ym_session_id = NormalizeValue(credentials.ym_session_id); + credentials.ym_sessionid2 = NormalizeValue(credentials.ym_sessionid2); + credentials.ym_yandexuid = NormalizeValue(credentials.ym_yandexuid); + credentials.ym_yandex_login = NormalizeValue(credentials.ym_yandex_login); + credentials.ym_l_token = NormalizeValue(credentials.ym_l_token); + + return credentials; + } + + public static bool IsEmpty(YandexMusicCredentials credentials) + { + credentials = Normalize(credentials); + + return string.IsNullOrWhiteSpace(credentials.ym_token) && + string.IsNullOrWhiteSpace(credentials.ym_session_id) && + string.IsNullOrWhiteSpace(credentials.ym_sessionid2) && + string.IsNullOrWhiteSpace(credentials.ym_yandexuid) && + string.IsNullOrWhiteSpace(credentials.ym_yandex_login) && + string.IsNullOrWhiteSpace(credentials.ym_l_token); + } + + public static Dictionary<string, string> CreateApiHeaders(YandexMusicCredentials credentials) + { + credentials = Normalize(credentials); + + var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + ["Accept"] = "application/json, text/plain, */*", + ["Accept-Language"] = "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7", + ["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + ["Referer"] = "https://music.yandex.ru/" + }; + + if (HasApiToken(credentials)) + headers["Authorization"] = $"OAuth {credentials.ym_token}"; + + return headers; + } + + public static Dictionary<string, string> CreateWebHeaders(YandexMusicCredentials credentials) + { + credentials = Normalize(credentials); + if (!HasWebAuth(credentials)) + return null; + + var cookie = new List<string>(); + + if (!string.IsNullOrWhiteSpace(credentials.ym_session_id)) + cookie.Add($"Session_id={credentials.ym_session_id}"); + + if (!string.IsNullOrWhiteSpace(credentials.ym_sessionid2)) + cookie.Add($"sessionid2={credentials.ym_sessionid2}"); + + cookie.Add($"yandexuid={credentials.ym_yandexuid}"); + cookie.Add($"yandex_login={credentials.ym_yandex_login}"); + cookie.Add($"L={credentials.ym_l_token}"); + + return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + ["Accept"] = "*/*", + ["Accept-Language"] = "ru-RU,ru;q=0.9,en;q=0.8", + ["Cache-Control"] = "no-cache", + ["Pragma"] = "no-cache", + ["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + ["Referer"] = "https://music.yandex.ru/", + ["Cookie"] = string.Join("; ", cookie) + }; + } + + public static void ApplyHeaders(HttpClient http, Dictionary<string, string> headers) + { + http.DefaultRequestHeaders.Clear(); + + foreach (var header in headers) + http.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); + } + + public static string BuildTrackQuery(MusicTrack track) + { + var parts = new List<string>(); + + if (!string.IsNullOrWhiteSpace(track?.artist_name)) + parts.Add(track.artist_name); + + if (!string.IsNullOrWhiteSpace(track?.title)) + parts.Add(track.title); + + if (!string.IsNullOrWhiteSpace(track?.album_title)) + parts.Add(track.album_title); + + return string.Join(" ", parts.Where(i => !string.IsNullOrWhiteSpace(i))).Trim(); + } + + public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches) + { + string queryTitle = NormalizeText(query?.title); + string queryArtist = NormalizeText(query?.artist_name); + string queryAlbum = NormalizeText(query?.album_title); + + return matches + .Select(match => + { + int score = 0; + string title = NormalizeText(match.title); + string album = NormalizeText(match.album_title); + var artists = match.artists.Select(NormalizeText).ToList(); + + if (title == queryTitle) + score += 6; + else if (!string.IsNullOrWhiteSpace(queryTitle) && title.Contains(queryTitle)) + score += 4; + + if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a == queryArtist)) + score += 4; + else if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a.Contains(queryArtist) || queryArtist.Contains(a))) + score += 2; + + if (!string.IsNullOrWhiteSpace(queryAlbum) && album == queryAlbum) + score += 2; + + if (query.duration_ms.HasValue && match.duration_ms.HasValue) + { + int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value); + if (diff <= 3000) + score += 3; + else if (diff <= 10000) + score += 1; + } + + return new { match, score }; + }) + .OrderByDescending(i => i.score) + .ThenBy(i => i.match.title) + .Select(i => i.match) + .ToList(); + } + + public static IReadOnlyList<YandexDownloadInfo> ParseDownloadInfos(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return Array.Empty<YandexDownloadInfo>(); + + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Array) + return Array.Empty<YandexDownloadInfo>(); + + var infos = new List<YandexDownloadInfo>(); + + foreach (var item in result.EnumerateArray()) + { + string url = TryGetString(item, "downloadInfoUrl"); + if (string.IsNullOrWhiteSpace(url)) + continue; + + infos.Add(new YandexDownloadInfo + { + download_info_url = url, + bitrate_kbps = TryGetInt(item, "bitrateInKbps"), + codec = TryGetString(item, "codec") + }); + } + + return infos + .OrderByDescending(i => string.Equals(i.codec, "mp3", StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(i => i.bitrate_kbps ?? 0) + .ToList(); + } + + public static string BuildStreamUrl(string host, string path, string ts, string s) + { + string pathForSign = path?.TrimStart('/') ?? string.Empty; + string sign = CrypTo.md5(Salt + pathForSign + s); + return $"https://{host}/get-mp3/{sign}/{ts}{path}"; + } + + public static bool TryParseDownloadPayload(string raw, out string host, out string path, out string ts, out string s) + { + host = null; + path = null; + ts = null; + s = null; + + if (string.IsNullOrWhiteSpace(raw)) + return false; + + raw = raw.Trim(); + + try + { + if (raw.StartsWith("{")) + { + using var doc = JsonDocument.Parse(raw); + host = TryGetString(doc.RootElement, "host"); + path = TryGetString(doc.RootElement, "path"); + ts = TryGetString(doc.RootElement, "ts"); + s = TryGetString(doc.RootElement, "s"); + } + else + { + var xml = XDocument.Parse(raw); + host = xml.Root?.Element("host")?.Value; + path = xml.Root?.Element("path")?.Value; + ts = xml.Root?.Element("ts")?.Value; + s = xml.Root?.Element("s")?.Value; + } + } + catch + { + return false; + } + + return !string.IsNullOrWhiteSpace(host) && + !string.IsNullOrWhiteSpace(path) && + !string.IsNullOrWhiteSpace(ts) && + !string.IsNullOrWhiteSpace(s); + } + + public static Dictionary<string, string> ParseSavePayload(string payload) + { + var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(payload)) + return result; + + payload = payload.Trim(); + + try + { + if (payload.StartsWith("{")) + { + using var doc = JsonDocument.Parse(payload); + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (!KeyMap.TryGetValue(property.Name, out var key)) + continue; + + result[key] = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() + : property.Value.ToString(); + } + + return result; + } + } + catch + { + } + + var form = HttpUtility.ParseQueryString(payload + .Replace("\r", "&") + .Replace("\n", "&") + .Replace(";", "&")); + foreach (string rawKey in form.AllKeys) + { + if (string.IsNullOrWhiteSpace(rawKey) || !KeyMap.TryGetValue(rawKey, out var key)) + continue; + + result[key] = form[rawKey]; + } + + return result; + } + + public static string BuildStateMessage(bool hasToken, bool hasWeb) + { + if (hasToken && hasWeb) + return "Token и web cookies сохранены."; + + if (hasToken) + return "Token сохранён. Web cookies для подборок и web-разделов ещё не заданы."; + + if (hasWeb) + return "Web cookies сохранены. OAuth token для API и playback ещё не задан."; + + return "Token and cookies are required for full functionality."; + } + + static string NormalizeValue(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + static string NormalizeText(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + return new string(value + .Trim() + .ToLowerInvariant() + .Where(ch => char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch)) + .ToArray()); + } + + static string TryGetString(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var property)) + return null; + + return property.ValueKind == JsonValueKind.String ? property.GetString() : property.ToString(); + } + + static int? TryGetInt(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var property)) + return null; + + if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var number)) + return number; + + if (property.ValueKind == JsonValueKind.String && int.TryParse(property.GetString(), out number)) + return number; + + return null; + } +} diff --git a/mods/Music/README.md b/mods/Music/README.md new file mode 100644 index 0000000..696d8d2 --- /dev/null +++ b/mods/Music/README.md @@ -0,0 +1,359 @@ +# 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 new file mode 100644 index 0000000..c9770b2 --- /dev/null +++ b/mods/Music/SQL/MusicContext.cs @@ -0,0 +1,614 @@ +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 new file mode 100644 index 0000000..5555a7a --- /dev/null +++ b/mods/Music/Services/Auth/MusicAuthService.cs @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..58a62f0 --- /dev/null +++ b/mods/Music/Services/Auth/MusicAuthStorageService.cs @@ -0,0 +1,103 @@ +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 new file mode 100644 index 0000000..3596737 --- /dev/null +++ b/mods/Music/Services/Auth/MusicProfileIdentity.cs @@ -0,0 +1,38 @@ +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/Catalog/MusicCatalogService.cs b/mods/Music/Services/Catalog/MusicCatalogService.cs new file mode 100644 index 0000000..6c20168 --- /dev/null +++ b/mods/Music/Services/Catalog/MusicCatalogService.cs @@ -0,0 +1,797 @@ +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 new file mode 100644 index 0000000..00a7a36 --- /dev/null +++ b/mods/Music/Services/Catalog/MusicProviderRegistry.cs @@ -0,0 +1,111 @@ +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 new file mode 100644 index 0000000..54a0e99 --- /dev/null +++ b/mods/Music/Services/Http/MusicHttp.cs @@ -0,0 +1,121 @@ +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 new file mode 100644 index 0000000..9c85762 --- /dev/null +++ b/mods/Music/Services/Http/MusicProxyService.cs @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..f357fa0 --- /dev/null +++ b/mods/Music/Services/Images/DiscogsArtistImageService.cs @@ -0,0 +1,342 @@ +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 new file mode 100644 index 0000000..fafd7d2 --- /dev/null +++ b/mods/Music/Services/Images/MusicImageProxyService.cs @@ -0,0 +1,346 @@ +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 new file mode 100644 index 0000000..e9f6ea0 --- /dev/null +++ b/mods/Music/Services/Lyrics/MusicLyricsService.cs @@ -0,0 +1,284 @@ +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 new file mode 100644 index 0000000..b56b977 --- /dev/null +++ b/mods/Music/Services/Lyrics/YouTubeMusicLyricsFallback.cs @@ -0,0 +1,773 @@ +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 new file mode 100644 index 0000000..0bf2bca --- /dev/null +++ b/mods/Music/Services/Playback/MusicDailyMixService.cs @@ -0,0 +1,1598 @@ +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 new file mode 100644 index 0000000..6f418f0 --- /dev/null +++ b/mods/Music/Services/Playback/MusicPlaybackHistoryService.cs @@ -0,0 +1,197 @@ +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 new file mode 100644 index 0000000..74e169a --- /dev/null +++ b/mods/Music/Services/Playback/MusicPlaybackModeService.cs @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..56caa7f --- /dev/null +++ b/mods/Music/Services/Playback/MusicPlaybackService.cs @@ -0,0 +1,215 @@ +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 new file mode 100644 index 0000000..ea5f394 --- /dev/null +++ b/mods/Music/Services/Playback/MusicPlaylistService.cs @@ -0,0 +1,327 @@ +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 new file mode 100644 index 0000000..2f828fd --- /dev/null +++ b/mods/Music/Services/Playback/MusicRadioService.cs @@ -0,0 +1,753 @@ +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 new file mode 100644 index 0000000..c4bdc53 --- /dev/null +++ b/mods/Music/Services/Playback/MusicResolver.cs @@ -0,0 +1,493 @@ +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 new file mode 100644 index 0000000..37b5154 --- /dev/null +++ b/mods/Music/Services/Playback/MusicStatsService.cs @@ -0,0 +1,258 @@ +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 new file mode 100644 index 0000000..932cc9c --- /dev/null +++ b/mods/Music/Services/Playback/MusicStreamModeService.cs @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..9c81b27 --- /dev/null +++ b/mods/Music/Services/Playback/MusicStreamRelayService.cs @@ -0,0 +1,282 @@ +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 new file mode 100644 index 0000000..d35e1f2 --- /dev/null +++ b/mods/Music/Services/Playback/MusicStreamTicketService.cs @@ -0,0 +1,125 @@ +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 new file mode 100644 index 0000000..87d1889 --- /dev/null +++ b/mods/Music/Services/Playlists/MusicPlaylistImportHelpers.cs @@ -0,0 +1,77 @@ +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 new file mode 100644 index 0000000..aa42569 --- /dev/null +++ b/mods/Music/Services/Playlists/MusicUserPlaylistService.cs @@ -0,0 +1,637 @@ +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 new file mode 100644 index 0000000..3b64cfb --- /dev/null +++ b/mods/Music/Services/Utils/MusicJson.cs @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..b5f4019 --- /dev/null +++ b/mods/Music/docs/client-architecture.md @@ -0,0 +1,158 @@ +# Архитектура клиента (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 new file mode 100644 index 0000000..f70b741 --- /dev/null +++ b/mods/Music/docs/ios-embedded-lockscreen-seek.md @@ -0,0 +1,110 @@ +# 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 new file mode 100644 index 0000000..be20c26 --- /dev/null +++ b/mods/Music/manifest.json @@ -0,0 +1,17 @@ +{ + "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 new file mode 100644 index 0000000..137e8f0 --- /dev/null +++ b/mods/Music/plugin.js @@ -0,0 +1,17934 @@ +(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(); +})(); diff --git a/mods/Telemetry/AppDbContext.cs b/mods/Telemetry/AppDbContext.cs new file mode 100644 index 0000000..cc0f684 --- /dev/null +++ b/mods/Telemetry/AppDbContext.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using Microsoft.EntityFrameworkCore; +using Telemetry.Models; + +namespace Telemetry; + +public class AppDbContext : DbContext +{ + public DbSet<LogModelSql> Logs { get; set; } = null!; + public DbSet<UserInfoModelSql> Users { get; set; } = null!; + + public AppDbContext() : base() { } + public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { + var dbPath = ModInit.DbPath; + var dbDir = Path.GetDirectoryName(dbPath); + if (dbDir != null && !Directory.Exists(dbDir)) Directory.CreateDirectory(dbDir); + optionsBuilder.UseSqlite($"Data Source={dbPath};Cache=Shared"); + optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<LogModelSql>() + .HasIndex(l => l.Time); + + modelBuilder.Entity<LogModelSql>() + .HasIndex(l => new { l.Uid, l.Time }); + + modelBuilder.Entity<LogModelSql>() + .HasIndex(l => new { l.Balancer, l.Time }); + + modelBuilder.Entity<LogModelSql>() + .HasIndex(l => l.UnfoId); + + modelBuilder.Entity<UserInfoModelSql>() + .HasKey(u => u.Id); + + modelBuilder.Entity<UserInfoModelSql>() + .HasIndex(u => u.Ip); + } +} diff --git a/mods/Telemetry/Controllers/TelemetryController.cs b/mods/Telemetry/Controllers/TelemetryController.cs new file mode 100644 index 0000000..d775287 --- /dev/null +++ b/mods/Telemetry/Controllers/TelemetryController.cs @@ -0,0 +1,539 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Shared; +using Shared.Attributes; +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Telemetry.Models; +using System.Collections.Generic; + +namespace Telemetry.Controllers; + +[Authorization(redirectUri: "/adminpanel/auth")] +[Route("/telemetry")] +public class TelemetryController : BaseController +{ + private readonly IDbContextFactory<AppDbContext> _contextFactory; + + public TelemetryController(IDbContextFactory<AppDbContext> contextFactory) + { + _contextFactory = contextFactory; + } + + private class MovieRawDto + { + public string? Title { get; set; } + public string? Tmdb { get; set; } + public string? Kp { get; set; } + public string? Imdb { get; set; } + public bool IsTv { get; set; } + public int Count { get; set; } + } + + private static (DateTime start, DateTime end) GetPeriod(int days) + { + var now = DateTime.UtcNow.Date; + var periodStart = days == 0 ? now : (days == -1 ? now.AddDays(-1) : now.AddDays(-days)); + var periodEnd = days == -1 ? now : DateTime.UtcNow; + return (periodStart, periodEnd); + } + + private static List<object> MapMovies(List<MovieRawDto> raw) + { + return raw.Select(m => new + { + title = m.Title, + id = m.Tmdb ?? m.Kp ?? m.Imdb ?? m.Title, + tmdb = m.Tmdb, + kp = m.Kp, + imdb = m.Imdb, + count = m.Count, + source = m.Tmdb != null ? "tmdb" : m.Kp != null ? "kinopoisk" : m.Imdb != null ? "imdb" : "unknown", + isTv = m.IsTv + }).Cast<object>().ToList(); + } + + private IQueryable<LogModelSql> ApplySearchFilter(AppDbContext db, IQueryable<LogModelSql> query, string? search) + { + if (string.IsNullOrEmpty(search)) return query; + + var rawSearch = search; + var encodedSearch = Uri.EscapeDataString(search); + var encodedSearchPlus = encodedSearch.Replace("%20", "+"); + + return query.Where(l => + l.Uid.Contains(rawSearch) || + l.Uri.Contains(rawSearch) || + l.Uri.Contains(encodedSearch) || + l.Uri.Contains(encodedSearchPlus) || + (l.Balancer != null && l.Balancer.Contains(rawSearch)) || + db.Users.Any(u => u.Id == l.UnfoId && (u.Ip.Contains(rawSearch) || u.UserAgent.Contains(rawSearch))) + ); + } + + [HttpGet] + [Route("")] + public ActionResult Index() + { + var path = Path.Combine(ModInit.modpath, "index.html"); + if (!System.IO.File.Exists(path)) return Content("UI missing", "text/plain"); + var html = System.IO.File.ReadAllText(path, Encoding.UTF8); + return Content(html, "text/html; charset=utf-8"); + } + + [HttpGet("api/stats")] + public async Task<IActionResult> Stats(string? search, int days = 30) + { + await using var db = await _contextFactory.CreateDbContextAsync(); + var query = db.Logs.AsNoTracking(); + query = ApplySearchFilter(db, query, search); + + var (periodStart, periodEnd) = GetPeriod(days); + var now = DateTime.UtcNow.Date; + + int total; + int today; + if (days == 0) + { + today = await query.Where(l => l.Time >= now.Date && l.Time < periodEnd).CountAsync(); + total = today; + } + else if (days == -1) + { + total = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd).CountAsync(); + today = 0; + } + else + { + total = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd).CountAsync(); + today = await query.Where(l => l.Time >= now.Date && l.Time < periodEnd).CountAsync(); + } + + var uids = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd && l.Uid != "anonymous" && !string.IsNullOrEmpty(l.Uid)).Select(l => l.Uid).Distinct().CountAsync(); + + var validLogs = query.Where(l => l.Time >= periodStart && l.Time < periodEnd && l.UnfoId != null); + var ips = await validLogs.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip).Where(ip => ip != null && ip != "").Distinct().CountAsync(); + var uas = await validLogs.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().CountAsync(); + + return new JsonResult(new { success = true, data = new { today, total, uids, ips, uas } }); + } + + [HttpGet("api/logs")] + public async Task<IActionResult> Logs(string? search, int days = 30, int skip = 0, int take = 100) + { + take = Math.Min(take, 1000); + await using var db = await _contextFactory.CreateDbContextAsync(); + + var query = db.Logs.AsNoTracking(); + var (periodStart, periodEnd) = GetPeriod(days); + + query = query.Where(l => l.Time >= periodStart && l.Time < periodEnd); + query = ApplySearchFilter(db, query, search); + + var logs = await query.OrderByDescending(l => l.Time).Skip(skip).Take(take) + .GroupJoin(db.Users, l => l.UnfoId, u => u.Id, (l, users) => new { Log = l, Users = users }) + .SelectMany(x => x.Users.DefaultIfEmpty(), (x, u) => new + { + id = x.Log.Id, + time = x.Log.Time, + uri = x.Log.Uri, + uid = x.Log.Uid, + ip = u != null ? u.Ip : "unknown", + userAgent = u != null ? u.UserAgent : "unknown", + balancer = x.Log.Balancer, + statusCode = x.Log.StatusCode, + duration = x.Log.DurationMs + }) + .ToListAsync(); + + return new JsonResult(new { success = true, data = logs }); + } + + [HttpGet("api/chart")] + public async Task<IActionResult> Chart(string? search, int days = 30) + { + var (periodStart, periodEnd) = GetPeriod(days); + + await using var db = await _contextFactory.CreateDbContextAsync(); + var query = db.Logs.AsNoTracking(); + query = ApplySearchFilter(db, query, search); + + var data = await query + .Where(l => l.Time >= periodStart && l.Time < periodEnd) + .GroupBy(l => l.Time.Date) + .Select(g => new { date = g.Key, count = g.Count() }) + .OrderBy(x => x.date) + .ToListAsync(); + + return new JsonResult(new { success = true, data = data }); + } + + [HttpGet("api/tops")] + public async Task<IActionResult> GetTops(string? search, [FromQuery] int days = 7) + { + await using var db = await _contextFactory.CreateDbContextAsync(); + var (periodStart, periodEnd) = GetPeriod(days); + + var query = db.Logs.AsNoTracking(); + query = ApplySearchFilter(db, query, search); + var baseQuery = query.Where(l => l.Time >= periodStart && l.Time < periodEnd); + + var topBalancers = await baseQuery + .Where(l => l.Balancer != null && l.Balancer != "" && !l.Balancer.StartsWith("18+ ")) + .GroupBy(l => l.Balancer) + .Select(g => new { balancer = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count) + .Take(10) + .ToListAsync(); + + var topAdult = await baseQuery + .Where(l => l.Balancer != null && l.Balancer.StartsWith("18+ ")) + .GroupBy(l => l.Balancer) + .Select(g => new { balancer = g.Key!.Substring(4), count = g.Count() }) + .OrderByDescending(x => x.count) + .Take(10) + .ToListAsync(); + + var topUsers = await baseQuery + .Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "") + .GroupBy(l => l.Uid) + .Select(g => new { uid = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count) + .Take(10) + .ToListAsync(); + + var topMoviesRaw = await baseQuery + .Where(l => l.MovieTitle != null && l.MovieTitle != "") + .GroupBy(l => l.MovieTitle) + .Select(g => new MovieRawDto + { + Title = g.Key, + Tmdb = g.Max(l => l.TmdbId), + Kp = g.Max(l => l.KpId), + Imdb = g.Max(l => l.ImdbId), + IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1, + Count = g.Count() + }) + .OrderByDescending(x => x.Count) + .Take(10) + .ToListAsync(); + + var topMovies = MapMovies(topMoviesRaw); + + var userAgents = await baseQuery + .Where(l => l.UnfoId != null) + .Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent) + .GroupBy(ua => ua) + .Select(g => new { ua = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count) + .Take(1000) + .ToListAsync(); + + var devices = userAgents + .Select(x => new { name = ParseDevice(x.ua), count = x.count }) + .GroupBy(x => x.name) + .Select(g => new { name = g.Key, count = g.Sum(x => x.count) }) + .OrderByDescending(x => x.count) + .ToList(); + + var hours = await baseQuery + .GroupBy(l => l.Time.Hour) + .Select(g => new { hour = g.Key, count = g.Count() }) + .OrderBy(x => x.hour) + .ToListAsync(); + + var seriesCount = await baseQuery.Where(l => l.IsTv).CountAsync(); + var moviesCount = await baseQuery.Where(l => l.MovieTitle != null && l.MovieTitle != "" && !l.IsTv).CountAsync(); + var animeCount = await baseQuery.Where(l => l.Uri.Contains("anime=1") || l.Uri.Contains("anime=true")).CountAsync(); + + var contentTypes = new[] { + new { name = "Фильмы", count = moviesCount }, + new { name = "Сериалы", count = seriesCount }, + new { name = "Аниме", count = animeCount } + }.Where(x => x.count > 0).ToList(); + + return new JsonResult(new { success = true, data = new { balancers = topBalancers, adult = topAdult, users = topUsers, movies = topMovies, devices, hours, contentTypes } }); + } + + private string ParseDevice(string? ua) + { + if (ua == null) return "Другие"; + ua = ua.ToLower(); + if (ua.Contains("tizen") || ua.Contains("samsung")) return "Tizen"; + if (ua.Contains("web0s") || ua.Contains("webos")) return "WebOS"; + if (ua.Contains("android")) return "Android"; + if (ua.Contains("iphone") || ua.Contains("ipad") || ua.Contains("mac os")) return "Apple"; + if (ua.Contains("windows")) return "Windows"; + if (ua.Contains("linux")) return "Linux"; + return "Другие"; + } + + [HttpGet("api/user")] + public async Task<IActionResult> UserInfo(string uid, int days = 30) + { + if (string.IsNullOrEmpty(uid)) return BadRequest(); + + await using var db = await _contextFactory.CreateDbContextAsync(); + var (periodStart, periodEnd) = GetPeriod(days); + + var baseQuery = db.Logs.AsNoTracking().Where(l => l.Uid == uid && l.Time >= periodStart && l.Time < periodEnd); + var count = await baseQuery.CountAsync(); + + if (count == 0) return new JsonResult(new { success = false }); + + var totalRequests = count; + var firstSeen = await baseQuery.MinAsync(l => l.Time); + var lastSeen = await baseQuery.MaxAsync(l => l.Time); + + var ips = await baseQuery.Where(l => l.UnfoId != null).Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip).Where(ip => ip != null && ip != "").Distinct().ToListAsync(); + var userAgents = await baseQuery.Where(l => l.UnfoId != null).Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().ToListAsync(); + + var balancers = await baseQuery.Where(l => l.Balancer != null && l.Balancer != "") + .GroupBy(l => l.Balancer) + .Select(g => new { name = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count) + .ToListAsync(); + + var isHourly = days <= 1; + var timeline = await (isHourly + ? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync() + : baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()); + + var moviesRaw = await baseQuery + .Where(l => l.MovieTitle != null && l.MovieTitle != "") + .GroupBy(l => l.MovieTitle) + .Select(g => new MovieRawDto + { + Title = g.Key, + Tmdb = g.Max(l => l.TmdbId), + Kp = g.Max(l => l.KpId), + Imdb = g.Max(l => l.ImdbId), + IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1, + Count = g.Count() + }) + .OrderByDescending(x => x.Count) + .Take(10) + .ToListAsync(); + + var movies = MapMovies(moviesRaw); + + return new JsonResult(new + { + success = true, + data = new + { + totalRequests, + firstSeen, + lastSeen, + ips, + userAgents, + balancers, + timeline, + movies + } + }); + } + + [HttpGet("api/ip")] + public async Task<IActionResult> IpInfo(string ip, int days = 30) + { + if (string.IsNullOrEmpty(ip)) return BadRequest(); + + await using var db = await _contextFactory.CreateDbContextAsync(); + var (periodStart, periodEnd) = GetPeriod(days); + + var baseQuery = db.Logs.AsNoTracking().Where(l => l.Time >= periodStart && l.Time < periodEnd && l.UnfoId != null && db.Users.Any(u => u.Id == l.UnfoId && u.Ip == ip)); + var count = await baseQuery.CountAsync(); + + if (count == 0) return new JsonResult(new { success = false }); + + var totalRequests = count; + var firstSeen = await baseQuery.MinAsync(l => l.Time); + var lastSeen = await baseQuery.MaxAsync(l => l.Time); + + var uids = await baseQuery.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "").Select(l => l.Uid).Distinct().ToListAsync(); + var userAgents = await baseQuery.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().ToListAsync(); + + var balancers = await baseQuery.Where(l => l.Balancer != null && l.Balancer != "") + .GroupBy(l => l.Balancer) + .Select(g => new { name = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count) + .ToListAsync(); + + var isHourly = days <= 1; + var timeline = await (isHourly + ? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync() + : baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()); + + var moviesRaw = await baseQuery + .Where(l => l.MovieTitle != null && l.MovieTitle != "") + .GroupBy(l => l.MovieTitle) + .Select(g => new MovieRawDto + { + Title = g.Key, + Tmdb = g.Max(l => l.TmdbId), + Kp = g.Max(l => l.KpId), + Imdb = g.Max(l => l.ImdbId), + IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1, + Count = g.Count() + }) + .OrderByDescending(x => x.Count) + .Take(10) + .ToListAsync(); + + var movies = MapMovies(moviesRaw); + + return new JsonResult(new + { + success = true, + data = new + { + totalRequests, + firstSeen, + lastSeen, + uids, + userAgents, + balancers, + timeline, + movies + } + }); + } + + [HttpGet("api/settings")] + public IActionResult GetSettings() + { + return new JsonResult(new + { + success = true, + data = new + { + prefixes = string.Join("\n", RequestListener.SkipPrefixes), + extensions = string.Join("\n", RequestListener.SkipExtensions), + regexes = string.Join("\n", RequestListener.RegexFilters), + ips = string.Join("\n", RequestListener.BlacklistIps), + rateLimit = RequestListener.RateLimitMs + } + }); + } + + [HttpPost("api/settings")] + public IActionResult UpdateSettings([FromForm] string? prefixes, [FromForm] string? extensions, [FromForm] string? regexes, [FromForm] string? ips, [FromForm] int? rateLimit) + { + if (prefixes != null) RequestListener.SkipPrefixes = prefixes.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + if (extensions != null) RequestListener.SkipExtensions = extensions.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + if (regexes != null) + { + var patterns = regexes.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + var invalid = new List<string>(); + foreach (var p in patterns) + { + try { System.Text.RegularExpressions.Regex.Match("", p, System.Text.RegularExpressions.RegexOptions.None, TimeSpan.FromMilliseconds(50)); } + catch { invalid.Add(p); } + } + if (invalid.Count > 0) + return new JsonResult(new { success = false, error = $"Невалидные regex: {string.Join(", ", invalid)}" }); + RequestListener.RegexFilters = patterns; + } + if (ips != null) RequestListener.BlacklistIps = ips.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + if (rateLimit.HasValue) RequestListener.RateLimitMs = rateLimit.Value; + + RequestListener.SaveSettings(); + + return new JsonResult(new { success = true }); + } + + [HttpGet("api/balancer")] + public async Task<IActionResult> BalancerInfo(string balancer, int days = 30) + { + if (string.IsNullOrEmpty(balancer)) return BadRequest(); + + await using var db = await _contextFactory.CreateDbContextAsync(); + var (periodStart, periodEnd) = GetPeriod(days); + var now = DateTime.UtcNow.Date; + + var baseQuery = db.Logs.AsNoTracking().Where(l => l.Balancer == balancer && l.Time >= periodStart && l.Time < periodEnd); + + var total = await baseQuery.CountAsync(); + var today = await baseQuery.Where(l => l.Time >= now.Date).CountAsync(); + + var topUids = await baseQuery.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "") + .GroupBy(l => l.Uid) + .Select(g => new { uid = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count).Take(10).ToListAsync(); + + var topIps = await baseQuery.Where(l => l.UnfoId != null) + .Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip) + .Where(ip => ip != null && ip != "") + .GroupBy(ip => ip) + .Select(g => new { ip = g.Key, count = g.Count() }) + .OrderByDescending(x => x.count).Take(10).ToListAsync(); + + var isHourly = days <= 1; + var timeline = await (isHourly + ? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync() + : baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()); + + var moviesRaw = await baseQuery + .Where(l => l.MovieTitle != null && l.MovieTitle != "") + .GroupBy(l => l.MovieTitle) + .Select(g => new MovieRawDto + { + Title = g.Key, + Tmdb = g.Max(l => l.TmdbId), + Kp = g.Max(l => l.KpId), + Imdb = g.Max(l => l.ImdbId), + IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1, + Count = g.Count() + }) + .OrderByDescending(x => x.Count) + .Take(10) + .ToListAsync(); + + var movies = MapMovies(moviesRaw); + + return new JsonResult(new { success = true, data = new { total, today, uids = topUids, ips = topIps, timeline, movies } }); + } + + [HttpPost("api/logs/delete")] + public async Task<IActionResult> DeleteLogs([FromForm] string? search, [FromForm] int days = 30) + { + await using var db = await _contextFactory.CreateDbContextAsync(); + var now = DateTime.UtcNow.Date; + var periodStart = days == 0 ? now : (days == -1 ? now.AddDays(-1) : now.AddDays(-days)); + var periodEnd = days == -1 ? now : DateTime.UtcNow; + + var query = db.Logs.Where(l => l.Time >= periodStart && l.Time < periodEnd); + query = ApplySearchFilter(db, query, search); + + var deleted = await query.ExecuteDeleteAsync(); + return new JsonResult(new { success = true, deleted }); + } + + [HttpGet("api/export")] + public async Task<IActionResult> ExportData() + { + await using var db = await _contextFactory.CreateDbContextAsync(); + + var result = await db.Logs.AsNoTracking() + .OrderByDescending(l => l.Id) + .Take(50000) + .GroupJoin(db.Users, l => l.UnfoId, u => u.Id, (l, users) => new { Log = l, Users = users }) + .SelectMany(x => x.Users.DefaultIfEmpty(), (x, u) => new + { + time = x.Log.Time, + uri = x.Log.Uri, + uid = x.Log.Uid, + ip = u != null ? u.Ip : "", + userAgent = u != null ? u.UserAgent : "", + balancer = x.Log.Balancer, + statusCode = x.Log.StatusCode + }) + .ToListAsync(); + + return new JsonResult(result); + } +} diff --git a/mods/Telemetry/ModInit.cs b/mods/Telemetry/ModInit.cs new file mode 100644 index 0000000..f3cb8f1 --- /dev/null +++ b/mods/Telemetry/ModInit.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shared; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Events; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using Telemetry.Models; + +namespace Telemetry; + +public class ModInit : IModuleLoaded, IModuleConfigure +{ + public static string modpath { get; private set; } = string.Empty; + public static readonly string DbPath = Path.Combine(AppContext.BaseDirectory, "database", "Telemetry", "stats.db"); + private static Timer? _updateDbTimer; + private static Timer? _cleanupTimer; + private static int _updatingDb = 0; + + public void Configure(ConfigureModel app) + { + app.services.AddDbContextFactory<AppDbContext>(options => + { + options.UseSqlite($"Data Source={DbPath};Cache=Shared"); + options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + }); + } + + public void Loaded(InitspaceModel initspace) + { + modpath = initspace.path; + + var dbDir = Path.GetDirectoryName(DbPath); + if (dbDir != null) Directory.CreateDirectory(dbDir); + + using (var sqlDb = new AppDbContext()) + { + sqlDb.Database.EnsureCreated(); + try + { + sqlDb.Database.ExecuteSqlRaw("PRAGMA journal_mode = WAL;"); + sqlDb.Database.ExecuteSqlRaw("PRAGMA synchronous = NORMAL;"); + sqlDb.Database.ExecuteSqlRaw("PRAGMA cache_size = -64000;"); + sqlDb.Database.ExecuteSqlRaw("PRAGMA temp_store = MEMORY;"); + sqlDb.Database.ExecuteSqlRaw("PRAGMA mmap_size = 33554432;"); + } + catch { } + } + + _updateDbTimer?.Dispose(); + _cleanupTimer?.Dispose(); + + _updateDbTimer = new Timer(UpdateDb, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)); + _cleanupTimer = new Timer(CleanupDb, null, TimeSpan.FromMinutes(1), TimeSpan.FromHours(12)); + + RequestListener.LoadSettings(); + + EventListener.MiddlewareAsync -= RequestListener.InvokeAsync; + EventListener.MiddlewareAsync += RequestListener.InvokeAsync; + + Console.WriteLine("[Telemetry] Module loaded."); + } + + private static void UpdateDb(object? state) + { + if (Interlocked.Exchange(ref _updatingDb, 1) == 1) return; + try + { + using var sqlDb = new AppDbContext(); + sqlDb.ChangeTracker.AutoDetectChangesEnabled = false; + + var batch = new List<(LogModelSql log, UserInfoModelSql user)>(); + while (batch.Count < 2000 && RequestListener.Queue.TryDequeue(out var item)) + { + RequestListener.DequeueItem(); + batch.Add(item); + } + + if (batch.Count == 0) return; + + var userIds = batch.Select(b => b.user.Id).Distinct().ToList(); + var existingUsers = sqlDb.Users.Where(u => userIds.Contains(u.Id)).Select(u => u.Id).ToHashSet(); + + foreach (var item in batch.OrderBy(i => i.log.Time)) + { + if (!existingUsers.Contains(item.user.Id)) + { + sqlDb.Users.Add(item.user); + existingUsers.Add(item.user.Id); + } + sqlDb.Logs.Add(item.log); + } + sqlDb.SaveChanges(); + } + catch (Exception ex) + { + Console.WriteLine($"[Telemetry] DB Update Error: {ex.Message}"); + } + finally { Interlocked.Exchange(ref _updatingDb, 0); } + } + + private static void CleanupDb(object? state) + { + try + { + // Settings should be configurable, hardcoded for now: 90 days or max 500MB DB + int logDay = 90; + var cutoff = DateTime.UtcNow.AddDays(-logDay); + + using var sqlDb = new AppDbContext(); + int batchSize = 5000; + + // Delete old records + while (true) + { + var ids = sqlDb.Logs.Where(l => l.Time < cutoff).Select(l => l.Id).Take(batchSize).ToList(); + if (ids.Count == 0) break; + sqlDb.Logs.Where(l => ids.Contains(l.Id)).ExecuteDelete(); + if (ids.Count < batchSize) break; + } + + // Delete orphaned users who have no logs left + sqlDb.Users.Where(u => !sqlDb.Logs.Any(l => l.UnfoId == u.Id)).ExecuteDelete(); + + // Run VACUUM to reclaim deleted disk space + try { sqlDb.Database.ExecuteSqlRaw("VACUUM;"); } catch { } + } + catch (Exception ex) + { + Console.WriteLine($"[Telemetry] DB Cleanup Error: {ex.Message}"); + } + } + + public void Dispose() + { + EventListener.MiddlewareAsync -= RequestListener.InvokeAsync; + _updateDbTimer?.Dispose(); + _cleanupTimer?.Dispose(); + Console.WriteLine("[Telemetry] Module disposed."); + } +} diff --git a/mods/Telemetry/Models/SqlModels.cs b/mods/Telemetry/Models/SqlModels.cs new file mode 100644 index 0000000..b7f06a9 --- /dev/null +++ b/mods/Telemetry/Models/SqlModels.cs @@ -0,0 +1,56 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Telemetry.Models; + +public class LogModelSql +{ + [Key] + public long Id { get; set; } + + public DateTime Time { get; set; } + + [MaxLength(2048)] + public string Uri { get; set; } = string.Empty; + + [MaxLength(256)] + public string Uid { get; set; } = string.Empty; + + [MaxLength(64)] + public string? UnfoId { get; set; } + + public int DurationMs { get; set; } + + [MaxLength(128)] + public string? Balancer { get; set; } + + public int StatusCode { get; set; } + + [MaxLength(256)] + public string? MovieTitle { get; set; } + + [MaxLength(32)] + public string? TmdbId { get; set; } + + [MaxLength(32)] + public string? KpId { get; set; } + + [MaxLength(32)] + public string? ImdbId { get; set; } + + public bool IsTv { get; set; } +} + +public class UserInfoModelSql +{ + [Key] + [MaxLength(64)] + public string Id { get; set; } = string.Empty; + + [MaxLength(64)] + public string Ip { get; set; } = string.Empty; + + [MaxLength(1024)] + public string UserAgent { get; set; } = string.Empty; +} diff --git a/mods/Telemetry/RequestListener.cs b/mods/Telemetry/RequestListener.cs new file mode 100644 index 0000000..5cb892a --- /dev/null +++ b/mods/Telemetry/RequestListener.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Events; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using Telemetry.Models; + +namespace Telemetry; + +public static class RequestListener +{ + public static readonly ConcurrentQueue<(LogModelSql log, UserInfoModelSql user)> Queue = new(); + private static int _queueSize = 0; + private const int MaxQueueSize = 50000; + + private static readonly MemoryCache _rateLimitCache = new(new MemoryCacheOptions { SizeLimit = 100_000 }); + private static readonly MemoryCache _balancerDedupCache = new(new MemoryCacheOptions { SizeLimit = 50_000 }); + private static readonly MemoryCache _userIdCache = new(new MemoryCacheOptions { SizeLimit = 50_000 }); + + private static readonly string _settingsPath = Path.Combine(AppContext.BaseDirectory, "database", "Telemetry", "settings.json"); + + public static volatile string[] SkipPrefixes = { + "/.well-known", "/admin/health", "/admin/ping", + "/lite/telemetry", "/telemetry", "/adminpanel", "/stats", "/weblog", + "/storage/", "/timecode/", "/bookmark/", "/tmdb/api/", "/lite/events", "/externalids", "/sisi" + }; + public static volatile string[] SkipExtensions = { ".js", ".css", ".svg", ".png", ".jpg", ".woff", ".ico" }; + public static volatile string[] RegexFilters = { }; + private static volatile HashSet<string> _blacklistIps = new(); + public static volatile int RateLimitMs = 33; + + public static string[] BlacklistIps + { + get => _blacklistIps.ToArray(); + set => _blacklistIps = new HashSet<string>(value, StringComparer.OrdinalIgnoreCase); + } + + public static bool IsBlacklistedIp(string ip) => _blacklistIps.Contains(ip); + + public static void DequeueItem() => Interlocked.Decrement(ref _queueSize); + + public static Task<bool> InvokeAsync(bool first, EventMiddleware e) + { + if (first) + { + e.httpContext.Items["Telemetry_StartTime"] = DateTime.UtcNow; + return Task.FromResult(true); + } + + var httpContext = e.httpContext; + var requestInfo = httpContext.Features.Get<RequestModel>(); + + if (requestInfo == null || requestInfo.IsLocalRequest || requestInfo.IsAnonymousRequest) + return Task.FromResult(true); + + try + { + var path = httpContext.Request.Path.Value ?? ""; + + // Configurable Regex filters with ReDoS protection (timeout 100ms) + try + { + if (RegexFilters.Any(pattern => Regex.IsMatch(path, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(100)))) + return Task.FromResult(true); + } + catch (RegexMatchTimeoutException) + { + // ReDoS protection triggered, skip this request and log warning + return Task.FromResult(true); + } + + // Extension filter + if (SkipExtensions.Any(ext => path.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) + return Task.FromResult(true); + + // Prefix filter + if (SkipPrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) + return Task.FromResult(true); + + var realIP = requestInfo.IP; + if (string.IsNullOrEmpty(realIP) || realIP == "127.0.0.1" || realIP == "::1") + return Task.FromResult(true); + + // IP Blacklist check + if (IsBlacklistedIp(realIP)) + return Task.FromResult(true); + + // Rate limit + if (RateLimitMs > 0) + { + var rateLimitKey = $"{realIP}:{path}"; + if (_rateLimitCache.TryGetValue(rateLimitKey, out _)) + return Task.FromResult(true); + + _rateLimitCache.Set(rateLimitKey, true, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(RateLimitMs), + Size = 1 + }); + } + + string userAgent = httpContext.Request.Headers["User-Agent"].FirstOrDefault() ?? "unknown"; + if (userAgent.Length > 1024) userAgent = userAgent[..1024]; + + string userUid = httpContext.Request.Query["uid"].FirstOrDefault() ?? httpContext.Request.Cookies["uid"] ?? "anonymous"; + if (userUid.Length > 256) userUid = userUid[..256]; + + var cacheKey = $"{realIP}:{userAgent}"; + if (!_userIdCache.TryGetValue(cacheKey, out string? userId)) + { + userId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(cacheKey))).ToLower(); + _userIdCache.Set(cacheKey, userId, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(2), Size = 1 }); + } + + var user = new UserInfoModelSql + { + Id = userId!, + Ip = realIP, + UserAgent = userAgent + }; + + var fullUri = path + httpContext.Request.QueryString; + if (fullUri.Length > 2048) fullUri = fullUri[..2048]; + + string balancer = ""; + if (path.StartsWith("/lite/")) balancer = path[6..].Split('/', '?')[0]; + else if (path.StartsWith("/rc/")) balancer = path[4..].Split('/', '?')[0]; + else + { + // Parse 18+ (sisi) modules which sit at the root + string[] adultRoutes = { "bgs", "chu", "elo", "epr", "hqr", "phub", "phubgay", "phubsml", "phubprem", "ptx", "runetki", "sbg", "tizam", "xmr", "xmrgay", "xmrsml", "xnx", "xds", "xdsgay", "xdssml", "xdsred", "nexthub" }; + var rootSegment = path.TrimStart('/').Split('/', '?')[0].ToLowerInvariant(); + + if (adultRoutes.Contains(rootSegment)) + { + balancer = "18+ " + rootSegment; + } + } + + // Filter out system endpoints that are not actual movie balancers + string[] systemEndpoints = { "events", "sync", "auth", "ws", "subs", "vtt", "search", "suggest", "torrent", "tracker", "info", "mdl" }; + if (systemEndpoints.Contains(balancer.ToLowerInvariant())) + { + balancer = ""; + } + + // Deduplication for balancers (First + Last within 30 seconds) + if (!string.IsNullOrEmpty(balancer)) + { + var dedupKey = $"{userUid}:{balancer}"; + var lastSeen = _balancerDedupCache.Get<DateTime?>(dedupKey); + + if (lastSeen.HasValue && (DateTime.UtcNow - lastSeen.Value).TotalSeconds < 30) + { + return Task.FromResult(true); + } + else + { + _balancerDedupCache.Set(dedupKey, DateTime.UtcNow, new MemoryCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromSeconds(30), + Size = 1 + }); + } + } + + int durationMs = 0; + if (httpContext.Items.TryGetValue("Telemetry_StartTime", out var startTimeObj) && startTimeObj is DateTime startTime) + { + durationMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds; + } + + string? mTitle = null, mTmdb = null, mKp = null, mImdb = null; + bool mIsTv = false; + + var reqQuery = httpContext.Request.Query; + if (reqQuery.TryGetValue("title", out var titleVal)) mTitle = titleVal.ToString(); + if (reqQuery.TryGetValue("id", out var idVal)) mTmdb = idVal.ToString(); + if (reqQuery.TryGetValue("kinopoisk_id", out var kpVal)) mKp = kpVal.ToString(); + if (reqQuery.TryGetValue("imdb_id", out var imdbVal)) mImdb = imdbVal.ToString(); + if (reqQuery.TryGetValue("serial", out var serialVal)) mIsTv = serialVal.ToString() == "1" || serialVal.ToString().Equals("true", StringComparison.OrdinalIgnoreCase); + + var log = new LogModelSql + { + Time = DateTime.UtcNow, + Uri = fullUri, + Uid = userUid, + UnfoId = user.Id, + DurationMs = durationMs, + Balancer = balancer, + StatusCode = httpContext.Response?.StatusCode ?? 0, + MovieTitle = mTitle, + TmdbId = mTmdb, + KpId = mKp, + ImdbId = mImdb, + IsTv = mIsTv + }; + + if (Interlocked.Increment(ref _queueSize) <= MaxQueueSize) + { + try { Queue.Enqueue((log, user)); } + catch { Interlocked.Decrement(ref _queueSize); } + } + else + { + Interlocked.Decrement(ref _queueSize); + // Dropped + } + } + catch { } + + return Task.FromResult(true); + } + + public static void SaveSettings() + { + try + { + var dir = Path.GetDirectoryName(_settingsPath); + if (dir != null) Directory.CreateDirectory(dir); + + var data = new Dictionary<string, object> + { + ["prefixes"] = SkipPrefixes, + ["extensions"] = SkipExtensions, + ["regexes"] = RegexFilters, + ["ips"] = BlacklistIps, + ["rateLimit"] = RateLimitMs + }; + File.WriteAllText(_settingsPath, System.Text.Json.JsonSerializer.Serialize(data, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + } + catch (Exception ex) + { + Console.WriteLine($"[Telemetry] SaveSettings Error: {ex.Message}"); + } + } + + public static void LoadSettings() + { + try + { + if (!File.Exists(_settingsPath)) return; + var json = File.ReadAllText(_settingsPath); + var doc = System.Text.Json.JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("prefixes", out var p)) SkipPrefixes = p.EnumerateArray().Select(e => e.GetString()!).ToArray(); + if (root.TryGetProperty("extensions", out var e2)) SkipExtensions = e2.EnumerateArray().Select(e => e.GetString()!).ToArray(); + if (root.TryGetProperty("regexes", out var r)) RegexFilters = r.EnumerateArray().Select(e => e.GetString()!).ToArray(); + if (root.TryGetProperty("ips", out var i)) BlacklistIps = i.EnumerateArray().Select(e => e.GetString()!).ToArray(); + if (root.TryGetProperty("rateLimit", out var rl)) RateLimitMs = rl.GetInt32(); + + Console.WriteLine("[Telemetry] Settings loaded from disk."); + } + catch (Exception ex) + { + Console.WriteLine($"[Telemetry] LoadSettings Error: {ex.Message}"); + } + } +} diff --git a/mods/Telemetry/Telemetry.csproj b/mods/Telemetry/Telemetry.csproj new file mode 100644 index 0000000..bbd09a8 --- /dev/null +++ b/mods/Telemetry/Telemetry.csproj @@ -0,0 +1,13 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <OutputType>Library</OutputType> + <RootNamespace>Telemetry</RootNamespace> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\Shared\Shared.csproj" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" /> + </ItemGroup> +</Project> diff --git a/mods/Telemetry/index.html b/mods/Telemetry/index.html new file mode 100644 index 0000000..d5569bc --- /dev/null +++ b/mods/Telemetry/index.html @@ -0,0 +1,2337 @@ +<!doctype html> +<html lang="ru" data-theme="dark"> + <head> + <meta charset="utf-8" /> + <meta + name="viewport" + content="width=device-width, initial-scale=1, viewport-fit=cover" + /> + <meta name="theme-color" content="#0c0e12" id="metaTheme" /> + <title>Telemetry — Lampac + + + + + + + +
+
+
+ +
+ +
+
+ + + + +
+
+
+ +
+
+
+
Запросов сегодня
+
-
+
+
+
Запросов за период
+
-
+
+
+
Уникальных IP
+
-
+
+
+
Уникальных устройств
+
-
+
+
+ +
+ +
+ +
+
+ +
+ Нет данных +
+
+
+ +
+ Нет данных +
+
+
+ +
+ Нет данных +
+
+
+ +
+ +
+ Нет данных +
+
+ +
+
+ +
+ Нет данных +
+
+
+ +
+ Нет данных +
+
+
+ +
+ Нет данных +
+
+
+ +
+ +
+ + + +
+ +
+ + + + + + + + + + + + + + + + +
ВремяUIDIPПутьБалансирСтатус
+ Загрузка... +
+
+
+
+ + + + + + + + + + + + diff --git a/mods/Telemetry/manifest.json b/mods/Telemetry/manifest.json new file mode 100644 index 0000000..7a950f3 --- /dev/null +++ b/mods/Telemetry/manifest.json @@ -0,0 +1,11 @@ +{ + "enable": true, + "dynamic": true, + "tree": [ + "Controllers", + "Models", + "AppDbContext.cs", + "ModInit.cs", + "RequestListener.cs" + ] +} diff --git a/mods/Tg-notify.bot/Controllers/TgNotifyController.cs b/mods/Tg-notify.bot/Controllers/TgNotifyController.cs new file mode 100644 index 0000000..1452f5a --- /dev/null +++ b/mods/Tg-notify.bot/Controllers/TgNotifyController.cs @@ -0,0 +1,131 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; +using Shared; +using System.IO; +using System.Threading.Tasks; + +namespace TelegramBot.Controllers +{ + public class TgNotifyController : BaseController + { + [HttpGet] + [AllowAnonymous] + [Route("tg-notify.js")] + public ActionResult TgNotifyPlugin() + { + // Ищем плагин в возможных расположениях модуля (NextGen: module/ или mods/) + string file = null; + foreach (var p in new[] + { + "module/Tg-notify.bot/tg-notify.js", + "module/Community/Tg-notify.bot/tg-notify.js", + "mods/TelegramBot/tg-notify.js", + "module/TelegramBot/tg-notify.js", + "plugins/tg-notify.js", + "wwwroot/plugins/tg-notify.js" + }) + { + if (System.IO.File.Exists(p)) { file = p; break; } + } + + if (file == null) + return Content(string.Empty, "application/javascript; charset=utf-8"); + + var js = System.IO.File.ReadAllText(file); + js = js.Replace("{localhost}", host); + return Content(js, "application/javascript; charset=utf-8"); + } + + [HttpPost] + [Route("/api/tg/subscribe")] + public async Task Subscribe() + { + if (!ModInit.IsRunning) + return Json(new { success = false, msg = "bot_not_running" }); + + var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString()); + if (string.IsNullOrEmpty(uid)) + return Json(new { success = false, msg = "no_uid" }); + + using var reader = new StreamReader(HttpContext.Request.Body); + var json = JObject.Parse(await reader.ReadToEndAsync()); + + var result = await ModInit.SubscribeApi( + uid, + json.Value("tmdb_id"), + json.Value("title") ?? "", + json.Value("voice") ?? "", + json.Value("season"), + json.Value("episode"), + json.Value("mirage_orid") ?? "", + json.Value("mirage_voice_id"), + json.Value("voice_episode"), + json.Value("collaps_orid") ?? "", + json.Value("voice_source") ?? "", + json.Value("kp_id") + ); + return Json(result); + } + + [HttpPost] + [Route("/api/tg/unsubscribe")] + public async Task Unsubscribe() + { + if (!ModInit.IsRunning) + return Json(new { success = false, msg = "bot_not_running" }); + + var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString()); + if (string.IsNullOrEmpty(uid)) + return Json(new { success = false, msg = "no_uid" }); + + using var reader = new StreamReader(HttpContext.Request.Body); + var json = JObject.Parse(await reader.ReadToEndAsync()); + + return Json(ModInit.UnsubscribeApi(uid, json.Value("tmdb_id"), json.Value("voice"))); + } + + [HttpGet] + [Route("/api/tg/status")] + public ActionResult Status(int tmdb_id) + { + var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString()); + if (string.IsNullOrEmpty(uid)) + return Json(new { success = false, msg = "no_uid" }); + + return Json(ModInit.StatusApi(uid, tmdb_id)); + } + + [HttpGet] + [Route("/api/tg/voices")] + public async Task Voices(string title, int year, int season, int tmdb_id) + { + return Json(await ModInit.GetVoicesApi(title ?? "", year, season, tmdb_id)); + } + + [HttpGet] + [Route("/api/tg/link")] + public async Task GetLink() + { + var uid = requestInfo.user_uid; + if (string.IsNullOrEmpty(uid)) + return Json(new { success = false, msg = "no_uid" }); + + return Json(await ModInit.LinkApi(uid)); + } + + /// + /// Список всех подписок пользователя с метаданными (для страницы подписок в Lampa) + /// + [HttpGet] + [Route("/api/tg/subscriptions")] + public ActionResult Subscriptions() + { + var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString()); + if (string.IsNullOrEmpty(uid)) + return Json(new { success = false, msg = "no_uid" }); + + return Json(ModInit.SubscriptionsApi(uid)); + } + } +} diff --git a/mods/Tg-notify.bot/ModInit.cs b/mods/Tg-notify.bot/ModInit.cs new file mode 100644 index 0000000..f92dddf --- /dev/null +++ b/mods/Tg-notify.bot/ModInit.cs @@ -0,0 +1,1825 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Models.Events; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using Shared.Services; +using TelegramBot.Models; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace TelegramBot +{ + public class ModInit : IModuleLoaded + { + #region Static data + public static TelegramBotConf Config { get; private set; } = new(); + public static ConcurrentDictionary Users { get; private set; } = new(); + public static ConcurrentDictionary> Subs { get; private set; } = new(); + public static TelegramBotClient Bot { get; private set; } + public static bool IsRunning { get; private set; } + public static string modpath { get; private set; } + + static string DataDir => string.IsNullOrWhiteSpace(Config?.data_dir) ? "database/tgnotify" : Config.data_dir; + static string configPath => System.IO.Path.Combine(DataDir, "config.json"); + static string subscriptionsPath => System.IO.Path.Combine(DataDir, "subscriptions.json"); + static string usersPath => System.IO.Path.Combine(DataDir, "users.json"); + static readonly HttpClient http = CreateHttpClient(); + static Timer checkTimer; + + static HttpClient CreateHttpClient() + { + var client = new HttpClient(); + client.DefaultRequestHeaders.Add("User-Agent", "LampaNotifications/1.0"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.Timeout = TimeSpan.FromSeconds(30); + return client; + } + + // Быстрый клиент для балансеров (Mirage/Collaps/RHS) — короткий таймаут + static readonly HttpClient httpFast = CreateFastHttpClient(); + static HttpClient CreateFastHttpClient() + { + var client = new HttpClient(); + client.DefaultRequestHeaders.Add("User-Agent", "LampaNotifications/1.0"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.Timeout = TimeSpan.FromSeconds(8); + return client; + } + + // Кеш для озвучек и поиска (ключ → (результат, время кеширования)) + static readonly ConcurrentDictionary _cache = new(); + static readonly TimeSpan CacheTTL = TimeSpan.FromMinutes(30); + + static T GetCache(string key) where T : class + { + if (_cache.TryGetValue(key, out var entry) && DateTime.UtcNow - entry.cached_at < CacheTTL) + return entry.result as T; + return null; + } + + static void SetCache(string key, object value) + { + _cache[key] = (value, DateTime.UtcNow); + // Чистим старые записи (раз в 100 записей) + if (_cache.Count > 200) + foreach (var k in _cache.Where(kv => DateTime.UtcNow - kv.Value.cached_at > CacheTTL).Select(kv => kv.Key).ToList()) + _cache.TryRemove(k, out _); + } + #endregion + + #region Models + public class TgUser + { + public long chat_id { get; set; } + public string username { get; set; } + public string lampac_uid { get; set; } + public DateTime linked_at { get; set; } + } + + public class Subscription + { + public long chat_id { get; set; } + public int tmdb_id { get; set; } + public string media_type { get; set; } = "tv"; + public string title { get; set; } + public string voice { get; set; } // Озвучка: "LostFilm", "HDrezka Studio" и т.д. Пусто = любая + public string voice_source { get; set; } // "mirage" или "collaps" + public string mirage_orid { get; set; } // ID сериала в Mirage + public int mirage_voice_id { get; set; } // ID озвучки (параметр t=) + public string collaps_orid { get; set; } // ID сериала в Collaps + public int kp_id { get; set; } // kinopoisk_id для VideoHub (резолвится один раз и кешируется) + public int last_season { get; set; } + public int last_episode { get; set; } + public int last_voice_episode { get; set; } // Последняя серия в озвучке + public DateTime subscribed_at { get; set; } + } + #endregion + + public void Loaded(InitspaceModel initspace) + { + modpath = initspace.path; + + UpdateConfFromInit(); + EventListener.UpdateInitFile += UpdateConfFromInit; + + Directory.CreateDirectory(DataDir); + LoadUsers(); LoadSubscriptions(); + + if (!Config.enable) + { + Console.WriteLine("\n\t[TelegramBot] Модуль отключён (TelegramBot.enable=false в init.conf)\n"); + return; + } + + if (string.IsNullOrWhiteSpace(Config.bot_token) || Config.bot_token == "YOUR_BOT_TOKEN") + { + Console.WriteLine("\n\t[TelegramBot] Установите bot_token в секции \"TelegramBot\" файла init.conf\n"); + return; + } + + if (IsRunning) return; + + _ = Task.Run(async () => + { + try + { + var cts = new CancellationTokenSource(); + Bot = new TelegramBotClient(Config.bot_token, cancellationToken: cts.Token); + var me = await Bot.GetMe(); + IsRunning = true; + Console.WriteLine($"\n\t[TelegramBot] @{me.Username} запущен!\n"); + + // Устанавливаем команды в меню бота + try + { + await Bot.SetMyCommands(new[] + { + new BotCommand { Command = "list", Description = "📋 Мои подписки" }, + new BotCommand { Command = "check", Description = "🔍 Проверить сейчас" }, + new BotCommand { Command = "help", Description = "📖 Помощь" }, + new BotCommand { Command = "unlink", Description = "🔓 Отвязать аккаунт" } + }); + Console.WriteLine("[TelegramBot] Bot commands set"); + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] SetMyCommands error: {ex.Message}"); } + + checkTimer = new Timer(async _ => + { + try { await CheckAll(); } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] Timer error: {ex.Message}"); } + }, null, TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(Config.check_interval_minutes)); + + Console.WriteLine("[TelegramBot] Starting polling loop..."); + await HandleUpdates(cts.Token); + } + catch (Exception ex) + { + Console.WriteLine($"[TelegramBot] Fatal: {ex}\n"); + IsRunning = false; + } + }); + } + + void UpdateConfFromInit() + { + Config = ModuleInvoke.Init("TelegramBot", new TelegramBotConf + { + enable = true, + bot_token = "YOUR_BOT_TOKEN", + tmdb_api_key = "", + trakt_client_id = "", + lampac_host = "http://127.0.0.1:9118", + lampac_token = "", + check_interval_minutes = 60, + tmdb_lang = "ru-RU", + data_dir = "database/tgnotify" + }); + } + + #region Telegram polling + static async Task HandleUpdates(CancellationToken ct) + { + int offset = 0; + Console.WriteLine("[TelegramBot] HandleUpdates started"); + while (!ct.IsCancellationRequested) + { + try + { + var updates = await Bot.GetUpdates(offset, timeout: 30, cancellationToken: ct); + foreach (var update in updates) + { + offset = update.Id + 1; + try + { + if (update.Message?.Text != null) + { + Console.WriteLine($"[TelegramBot] Msg: {update.Message.Text}"); + await HandleMessage(update.Message); + } + else if (update.CallbackQuery != null) + await HandleCallback(update.CallbackQuery); + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] Handle error: {ex}"); } + } + } + catch (TaskCanceledException) { break; } + catch (Exception ex) + { + Console.WriteLine($"[TelegramBot] Poll error: {ex.Message}"); + await Task.Delay(5000, ct); + } + } + } + + static ReplyKeyboardMarkup MainKeyboard => new ReplyKeyboardMarkup(new[] + { + new KeyboardButton[] { "📋 Подписки", "🔍 Проверить" }, + new KeyboardButton[] { "📖 Помощь", "🔓 Отвязать" } + }) + { ResizeKeyboard = true }; + + static async Task HandleMessage(Message msg) + { + var chatId = msg.Chat.Id; + var text = msg.Text.Trim(); + + if (text.StartsWith("/start")) + { + var parts = text.Split(' '); + if (parts.Length > 1 && TryParseLinkPayload(parts[1], out var uid)) + { + Users[chatId] = new TgUser { chat_id = chatId, username = msg.From?.Username ?? "", lampac_uid = uid, linked_at = DateTime.UtcNow }; + SaveUsers(); + await Bot.SendMessage(chatId, "✅ *Аккаунт привязан!*\n\nИспользуйте кнопки ниже для управления.", parseMode: ParseMode.Markdown, replyMarkup: MainKeyboard); + return; + } + await Bot.SendMessage(chatId, "🎬 *Lampa Notifications Bot*\n\nУведомления о новых сериях и озвучках.", parseMode: ParseMode.Markdown, replyMarkup: MainKeyboard); + } + else if (text == "/list" || text == "📋 Подписки") await ShowSubscriptions(chatId); + else if (text == "/check" || text == "🔍 Проверить") + { + Console.WriteLine($"[TelegramBot] /check from {chatId}"); + // Запускаем в фоне чтобы не блокировать polling + _ = Task.Run(async () => + { + try + { + await Bot.SendMessage(chatId, "🔍 Проверяю..."); + await CheckAll(chatId); + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] /check error: {ex}"); } + }); + } + else if (text == "/help" || text == "📖 Помощь") + { + await Bot.SendMessage(chatId, + "📖 *Помощь*\n\n" + + "• Карточка сериала → 🔔 → выберите озвучку\n" + + "• Бот пришлёт уведомление когда появится серия в этой озвучке\n" + + "• Или подпишитесь на «Любая озвучка» — уведомление при выходе оригинала\n\n" + + "Кнопки:\n" + + "📋 — список подписок\n" + + "🔍 — проверить новые серии\n" + + "🔓 — отвязать аккаунт", + parseMode: ParseMode.Markdown, replyMarkup: MainKeyboard); + } + else if (text == "/unlink" || text == "🔓 Отвязать") + { + if (Users.TryRemove(chatId, out _)) + { + foreach (var kvp in Subs) kvp.Value.RemoveAll(s => s.chat_id == chatId); + SaveUsers(); SaveSubscriptions(); + await Bot.SendMessage(chatId, "🔓 Аккаунт отвязан.", replyMarkup: new ReplyKeyboardRemove()); + } + else + { + await Bot.SendMessage(chatId, "Аккаунт не привязан."); + } + } + } + + static async Task HandleCallback(CallbackQuery cb) + { + if (cb.Data.StartsWith("unsub_")) + { + var tmdbId = cb.Data.Substring(6); + if (Subs.TryGetValue(tmdbId, out var list) && list.RemoveAll(s => s.chat_id == cb.Message.Chat.Id) > 0) + { + SaveSubscriptions(); + await Bot.AnswerCallbackQuery(cb.Id, "Удалено ✅"); + await ShowSubscriptions(cb.Message.Chat.Id); + return; + } + await Bot.AnswerCallbackQuery(cb.Id, "Не найдено"); + } + } + + static async Task ShowSubscriptions(long chatId) + { + var userSubs = Subs.SelectMany(kvp => kvp.Value).Where(s => s.chat_id == chatId).OrderByDescending(s => s.subscribed_at).ToList(); + if (userSubs.Count == 0) { await Bot.SendMessage(chatId, "📋 Нет подписок."); return; } + + var text = "📋 *Ваши подписки:*\n\n"; + var buttons = new List>(); + foreach (var sub in userSubs) + { + var v = string.IsNullOrEmpty(sub.voice) ? "Любая озвучка" : sub.voice; + text += $"• *{EscapeMd(sub.title)}* — {v}\n S{sub.last_season:D2}E{sub.last_episode:D2}"; + if (sub.last_voice_episode > 0) text += $" (озв: E{sub.last_voice_episode:D2})"; + text += "\n"; + buttons.Add(new List { + InlineKeyboardButton.WithCallbackData($"❌ {sub.title} ({v})", $"unsub_{sub.tmdb_id}") + }); + } + await Bot.SendMessage(chatId, text, parseMode: ParseMode.Markdown, replyMarkup: new InlineKeyboardMarkup(buttons)); + } + #endregion + + #region Проверка — главная + public static async Task CheckAll(long? onlyChatId = null) + { + Console.WriteLine($"[TelegramBot] CheckAll started, onlyChatId={onlyChatId}"); + var allSubs = Subs.SelectMany(kvp => kvp.Value).ToList(); + if (onlyChatId.HasValue) allSubs = allSubs.Where(s => s.chat_id == onlyChatId.Value).ToList(); + + int notified = 0; + + foreach (var sub in allSubs.Where(s => s.media_type == "tv")) + { + try + { + bool hasVoice = !string.IsNullOrEmpty(sub.voice); + bool hasMirage = !string.IsNullOrEmpty(sub.mirage_orid); + bool hasCollaps = !string.IsNullOrEmpty(sub.collaps_orid); + + // 1. Проверить новые серии (Trakt/TMDB) + var prevSeason = sub.last_season; + var newEpisodes = await CheckNewEpisodes(sub); + foreach (var ep in newEpisodes) + { + if (!hasVoice) + { + await SendEpisodeNotification(sub, ep); + notified++; + } + sub.last_season = ep.season; + sub.last_episode = ep.episode; + } + + // Если сезон сменился — обнуляем счётчик озвучек + if (sub.last_season > prevSeason && sub.last_voice_episode > 0) + { + Console.WriteLine($"[TelegramBot] Season changed {prevSeason}→{sub.last_season} for {sub.title}, resetting voice episode {sub.last_voice_episode}→0"); + sub.last_voice_episode = 0; + } + + // 2. Проверить озвучки — все источники параллельно + if (hasVoice) + { + int voiceEps = sub.last_voice_episode; + + var voiceTasks = new List>(); + if (hasMirage) voiceTasks.Add(CheckMirageVoice(sub)); + voiceTasks.Add(CheckCollapsVoice(sub)); + voiceTasks.Add(CheckRHSVoice(sub)); + voiceTasks.Add(CheckVideoHubVoice(sub)); + + await Task.WhenAll(voiceTasks); + + foreach (var task in voiceTasks) + if (task.Result > voiceEps) + voiceEps = task.Result; + + if (voiceEps > sub.last_voice_episode) + { + int fromEp = sub.last_voice_episode + 1; + int toEp = voiceEps; + + for (int e = fromEp; e <= toEp; e++) + { + var (epTitle, epOverview, imgUrl) = await GetTmdbEpisodeInfo(sub.tmdb_id, sub.last_season, e); + + var msg = $"🎬 *{EscapeMd(sub.title)}*\n🎙 {EscapeMd(sub.voice)}\n📺 S{sub.last_season:D2}E{e:D2}"; + if (!string.IsNullOrEmpty(epTitle)) msg += $" — _{EscapeMd(epTitle)}_"; + if (!string.IsNullOrEmpty(epOverview)) + { + var desc = epOverview.Length > 300 ? epOverview.Substring(0, 300) + "..." : epOverview; + msg += $"\n\n{EscapeMd(desc)}"; + } + + try + { + if (!string.IsNullOrEmpty(imgUrl)) + await Bot.SendPhoto(sub.chat_id, InputFile.FromUri(imgUrl), caption: msg, parseMode: ParseMode.Markdown); + else + await Bot.SendMessage(sub.chat_id, msg, parseMode: ParseMode.Markdown); + notified++; + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] Send voice error: {ex.Message}"); } + } + + sub.last_voice_episode = voiceEps; + Console.WriteLine($"[TelegramBot] Voice update: {sub.title} {sub.voice} now E{voiceEps}"); + } + } + + await Task.Delay(500); + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] Check error {sub.tmdb_id}: {ex.Message}"); } + } + + SaveSubscriptions(); + Console.WriteLine($"[TelegramBot] CheckAll done, notified={notified}"); + + if (onlyChatId.HasValue && Bot != null) + await Bot.SendMessage(onlyChatId.Value, notified > 0 ? $"✅ Уведомлений: {notified}" : "Новых серий/озвучек не найдено."); + } + + static async Task SendEpisodeNotification(Subscription sub, EpisodeInfo ep) + { + var voice = string.IsNullOrEmpty(sub.voice) ? "" : $"\n🎙 {EscapeMd(sub.voice)}"; + var message = $"🎬 *{EscapeMd(ep.show_name)}*\n📺 S{ep.season:D2}E{ep.episode:D2}"; + if (!string.IsNullOrEmpty(ep.title)) message += $" — _{EscapeMd(ep.title)}_"; + message += voice + $"\n📅 {ep.air_date}"; + + if (!string.IsNullOrEmpty(ep.overview)) + { + var desc = ep.overview.Length > 300 ? ep.overview.Substring(0, 300) + "..." : ep.overview; + message += $"\n\n{EscapeMd(desc)}"; + } + + try + { + if (!string.IsNullOrEmpty(ep.image_url)) + await Bot.SendPhoto(sub.chat_id, InputFile.FromUri(ep.image_url), caption: message, parseMode: ParseMode.Markdown); + else + await Bot.SendMessage(sub.chat_id, message, parseMode: ParseMode.Markdown); + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] Send error: {ex.Message}"); } + } + #endregion + + #region Проверка новых эпизодов (Trakt → TMDB) + class EpisodeInfo + { + public string show_name { get; set; } + public int season { get; set; } + public int episode { get; set; } + public string title { get; set; } + public string air_date { get; set; } + public string overview { get; set; } + public string image_url { get; set; } + } + + static async Task> CheckNewEpisodes(Subscription sub) + { + bool useTrakt = !string.IsNullOrEmpty(Config.trakt_client_id); + if (useTrakt) + { + try + { + var (found, episodes) = await CheckViaTrakt(sub); + if (found) + return episodes; // Trakt нашёл шоу — верим только ему, даже если 0 новых серий + + // Trakt не нашёл шоу — fallback на TMDB + Console.WriteLine($"[TelegramBot] Trakt: show {sub.tmdb_id} not found, using TMDB"); + return await CheckViaTmdb(sub); + } + catch (Exception ex) + { + Console.WriteLine($"[TelegramBot] Trakt error for {sub.tmdb_id}: {ex.Message}, using TMDB"); + try { return await CheckViaTmdb(sub); } catch { } + return new List(); + } + } + + try { return await CheckViaTmdb(sub); } + catch { return new List(); } + } + + static async Task<(bool found, List episodes)> CheckViaTrakt(Subscription sub) + { + var result = new List(); + + var searchReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.trakt.tv/search/tmdb/{sub.tmdb_id}?type=show"); + searchReq.Headers.Add("trakt-api-key", Config.trakt_client_id); + searchReq.Headers.Add("trakt-api-version", "2"); + var searchResp = await http.SendAsync(searchReq); + + Console.WriteLine($"[TelegramBot] Trakt search tmdb/{sub.tmdb_id}: status={searchResp.StatusCode}"); + + if (!searchResp.IsSuccessStatusCode) + { + Console.WriteLine($"[TelegramBot] Trakt search failed: {await searchResp.Content.ReadAsStringAsync()}"); + return (false, result); + } + + var searchBody = await searchResp.Content.ReadAsStringAsync(); + var searchData = JArray.Parse(searchBody); + + Console.WriteLine($"[TelegramBot] Trakt search results: {searchData.Count} items"); + + if (searchData.Count == 0) return (false, result); + + var showName = searchData[0]?["show"]?.Value("title") ?? sub.title; + var traktSlug = searchData[0]?["show"]?["ids"]?.Value("slug"); + var traktId = searchData[0]?["show"]?["ids"]?.Value("trakt"); + var showId = traktSlug ?? traktId; + + Console.WriteLine($"[TelegramBot] Trakt found: {showName}, slug={traktSlug}, id={traktId}, using={showId}"); + + if (string.IsNullOrEmpty(showId)) return (false, result); + + // Шоу найдено — с этого момента верим только Trakt + var seasonsReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.trakt.tv/shows/{showId}/seasons?extended=full"); + seasonsReq.Headers.Add("trakt-api-key", Config.trakt_client_id); + seasonsReq.Headers.Add("trakt-api-version", "2"); + var seasonsResp = await http.SendAsync(seasonsReq); + if (!seasonsResp.IsSuccessStatusCode) return (true, result); // шоу найдено, но сезоны недоступны — не fallback + + var seasons = JArray.Parse(await seasonsResp.Content.ReadAsStringAsync()); + var seasonsToCheck = seasons + .Where(s => s.Value("number") >= sub.last_season && s.Value("number") > 0) + .Where(s => s.Value("aired_episodes") > 0 || s.Value("episode_count") > 0) + .Select(s => s.Value("number")).OrderBy(n => n).ToList(); + + foreach (var seasonNum in seasonsToCheck) + { + var epsReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.trakt.tv/shows/{showId}/seasons/{seasonNum}?extended=full"); + epsReq.Headers.Add("trakt-api-key", Config.trakt_client_id); + epsReq.Headers.Add("trakt-api-version", "2"); + var epsResp = await http.SendAsync(epsReq); + if (!epsResp.IsSuccessStatusCode) continue; + + foreach (JObject ep in JArray.Parse(await epsResp.Content.ReadAsStringAsync())) + { + int sNum = ep.Value("season"), eNum = ep.Value("number"); + var firstAired = ep.Value("first_aired"); + if (sNum < sub.last_season || (sNum == sub.last_season && eNum <= sub.last_episode)) continue; + if (string.IsNullOrEmpty(firstAired)) continue; + if (!DateTime.TryParse(firstAired, null, System.Globalization.DateTimeStyles.RoundtripKind, out var airDt)) continue; + if (airDt > DateTime.UtcNow) continue; + + // Получаем русское описание и картинку из TMDB + var (tmdbTitle, tmdbOverview, tmdbImage) = await GetTmdbEpisodeInfo(sub.tmdb_id, sNum, eNum); + + result.Add(new EpisodeInfo + { + show_name = sub.title, // Русское название из подписки + season = sNum, + episode = eNum, + title = !string.IsNullOrEmpty(tmdbTitle) ? tmdbTitle : (ep.Value("title") ?? ""), + air_date = airDt.ToString("yyyy-MM-dd HH:mm UTC"), + overview = !string.IsNullOrEmpty(tmdbOverview) ? tmdbOverview : (ep.Value("overview") ?? ""), + image_url = tmdbImage + }); + } + await Task.Delay(300); + } + return (true, result); + } + + static async Task> CheckViaTmdb(Subscription sub) + { + var result = new List(); + var show = JObject.Parse(await http.GetStringAsync($"https://api.themoviedb.org/3/tv/{sub.tmdb_id}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); + var lastSeason = show.Value("number_of_seasons"); + var showName = show.Value("name") ?? sub.title; + var season = JObject.Parse(await http.GetStringAsync($"https://api.themoviedb.org/3/tv/{sub.tmdb_id}/season/{lastSeason}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); + var episodes = season["episodes"] as JArray; + if (episodes == null) return result; + + foreach (JObject ep in episodes) + { + int sNum = ep.Value("season_number"), eNum = ep.Value("episode_number"); + var airDate = ep.Value("air_date"); + if (sNum < sub.last_season || (sNum == sub.last_season && eNum <= sub.last_episode)) continue; + if (string.IsNullOrEmpty(airDate) || !DateTime.TryParse(airDate, out var airDt) || airDt.Date > DateTime.UtcNow.Date) continue; + + var still = ep.Value("still_path"); + result.Add(new EpisodeInfo + { + show_name = showName, + season = sNum, + episode = eNum, + title = ep.Value("name") ?? "", + air_date = airDate, + image_url = !string.IsNullOrEmpty(still) ? $"https://image.tmdb.org/t/p/w500{still}" : null + }); + } + return result; + } + + static async Task GetTmdbStill(int tmdbId, int season, int episode) + { + try + { + var data = JObject.Parse(await http.GetStringAsync($"https://api.themoviedb.org/3/tv/{tmdbId}/season/{season}/episode/{episode}?api_key={Config.tmdb_api_key}")); + var still = data.Value("still_path"); + return !string.IsNullOrEmpty(still) ? $"https://image.tmdb.org/t/p/w500{still}" : null; + } + catch { return null; } + } + + // Получить полную информацию об эпизоде (название, описание, картинка) + static async Task<(string title, string overview, string imageUrl)> GetTmdbEpisodeInfo(int tmdbId, int season, int episode) + { + try + { + var data = JObject.Parse(await http.GetStringAsync( + $"https://api.themoviedb.org/3/tv/{tmdbId}/season/{season}/episode/{episode}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); + var still = data.Value("still_path"); + var title = data.Value("name") ?? ""; + var overview = data.Value("overview") ?? ""; + var imageUrl = !string.IsNullOrEmpty(still) ? $"https://image.tmdb.org/t/p/w500{still}" : null; + + // Если описание пустое на русском, попробуем английский + if (string.IsNullOrEmpty(overview)) + { + try + { + var dataEn = JObject.Parse(await http.GetStringAsync( + $"https://api.themoviedb.org/3/tv/{tmdbId}/season/{season}/episode/{episode}?api_key={Config.tmdb_api_key}&language=en-US")); + overview = dataEn.Value("overview") ?? ""; + if (string.IsNullOrEmpty(title)) title = dataEn.Value("name") ?? ""; + } + catch { } + } + + return (title, overview, imageUrl); + } + catch { return ("", "", null); } + } + #endregion + + #region Mirage — проверка озвучек + static async Task CheckMirageVoice(Subscription sub) + { + if (string.IsNullOrEmpty(sub.mirage_orid) || string.IsNullOrEmpty(Config.lampac_host)) + return sub.last_voice_episode; + + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + var url = $"{Config.lampac_host}/lite/mirage?rjson=true&s={sub.last_season}&t={sub.mirage_voice_id}&orid={sub.mirage_orid}{token}"; + + Console.WriteLine($"[TelegramBot] Mirage check: {sub.title} voice={sub.voice} s={sub.last_season} t={sub.mirage_voice_id}"); + + var body = await httpFast.GetStringAsync(url); + if (string.IsNullOrEmpty(body) || body == "null") return sub.last_voice_episode; + + int maxEp = 0; + try + { + var json = JObject.Parse(body); + var data = json["data"] as JArray; + if (data != null) + foreach (var ep in data) + { + var e = ep.Value("e") ?? 0; + if (e > maxEp) maxEp = e; + } + } + catch + { + // Fallback HTML парсинг + var matches = Regex.Matches(body, @"e=""(\d+)"""); + foreach (Match m in matches) + if (int.TryParse(m.Groups[1].Value, out var ep) && ep > maxEp) maxEp = ep; + } + + Console.WriteLine($"[TelegramBot] Mirage result: {sub.title} voice={sub.voice} episodes={maxEp} (was {sub.last_voice_episode})"); + return maxEp; + } + catch (Exception ex) + { + Console.WriteLine($"[TelegramBot] Mirage error: {ex.Message}"); + return sub.last_voice_episode; + } + } + + public static async Task> GetMirageVoices(string orid, int season) + { + var cacheKey = $"mirage_voices:{orid}:{season}"; + var cached = GetCache>(cacheKey); + if (cached != null) return cached; + + var result = new List(); + if (string.IsNullOrEmpty(orid) || string.IsNullOrEmpty(Config.lampac_host)) return result; + + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + var url = $"{Config.lampac_host}/lite/mirage?rjson=true&s={season}&orid={orid}{token}"; + var body = await httpFast.GetStringAsync(url); + if (string.IsNullOrEmpty(body) || body == "null") return result; + + try + { + var json = JObject.Parse(body); + var voices = json["voice"] as JArray; + if (voices != null) + foreach (var v in voices) + { + var name = v.Value("name") ?? ""; + if (!string.IsNullOrEmpty(name)) + { + int tid = 0; + var vUrl = v.Value("url") ?? ""; + var tm = Regex.Match(vUrl, @"t=(\d+)"); + if (tm.Success) int.TryParse(tm.Groups[1].Value, out tid); + result.Add(new VoiceInfo { id = tid, name = name, source = "mirage" }); + } + } + } + catch + { + // Fallback HTML + var matches = Regex.Matches(body, @"t=(\d+)&[^""]*""[^>]*>([^<]+)
"); + foreach (Match m in matches) + if (int.TryParse(m.Groups[1].Value, out var tid)) + result.Add(new VoiceInfo { id = tid, name = m.Groups[2].Value.Trim(), source = "mirage" }); + } + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetMirageVoices error: {ex.Message}"); } + + if (result.Count > 0) SetCache(cacheKey, result); + return result; + } + + public static async Task> SearchMirage(string title, int year) + { + var cacheKey = $"mirage_search:{title}:{year}"; + var cached = GetCache>(cacheKey); + if (cached != null) return cached; + + var result = new List(); + if (string.IsNullOrEmpty(Config.lampac_host)) return result; + + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + + // Помощник: запрос с проглатыванием 503/500/ошибок (возвращает null вместо исключения) + async Task TryGet(string u) + { + try { return await httpFast.GetStringAsync(u); } + catch { return null; } + } + + var url = $"{Config.lampac_host}/lite/mirage-search?rjson=true&title={Uri.EscapeDataString(title)}&year={year}{token}"; + var body = await TryGet(url); + + if (string.IsNullOrEmpty(body) || body == "null") + { + // Fallback 1: без rjson + url = $"{Config.lampac_host}/lite/mirage-search?title={Uri.EscapeDataString(title)}&year={year}{token}"; + body = await TryGet(url); + } + + if (string.IsNullOrEmpty(body) || body == "null") + { + // Fallback 2: очищенное название. Mirage отдаёт 503 на цифрах/апострофах/дефисах в запросе. + // "Люди Икс '97" -> "Люди Икс"; "Уидоус-Бэй" -> "Уидоус Бэй" + var simple = Regex.Replace(title, @"[\u2018\u2019\u02BC'`]", ""); + simple = Regex.Replace(simple, @"[-\u2013\u2014]", " "); + simple = Regex.Replace(simple, @"\d+", ""); + simple = Regex.Replace(simple, @"\s+", " ").Trim(); + if (simple.Length >= 3 && !simple.Equals(title, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"[TelegramBot] Mirage fallback simplified: '{title}' -> '{simple}'"); + url = $"{Config.lampac_host}/lite/mirage-search?rjson=true&title={Uri.EscapeDataString(simple)}&year={year}{token}"; + body = await TryGet(url); + } + } + + if (string.IsNullOrEmpty(body) || body == "null") return result; + + // Попробовать JSON + try + { + var json = JObject.Parse(body); + var data = json["data"] as JArray; + if (data != null) + foreach (var item in data) + { + var itemUrl = item.Value("url") ?? ""; + var itemTitle = item.Value("title") ?? ""; + var itemYear = item.Value("year") ?? 0; + var oridMatch = Regex.Match(itemUrl, @"orid=([a-f0-9]+)"); + if (oridMatch.Success) + result.Add(new MirageSearchResult { orid = oridMatch.Groups[1].Value, title = itemTitle, year = itemYear }); + } + } + catch + { + // Fallback HTML + var matches = Regex.Matches(body, @"data-json='(\{[^']+\})'"); + foreach (Match m in matches) + try + { + var jj = JObject.Parse(m.Groups[1].Value.Replace("&", "&")); + var itemUrl = jj.Value("url") ?? ""; + var itemTitle = jj.Value("title") ?? ""; + var itemYear = jj.Value("year") ?? 0; + var oridMatch = Regex.Match(itemUrl, @"orid=([a-f0-9]+)"); + if (oridMatch.Success) + result.Add(new MirageSearchResult { orid = oridMatch.Groups[1].Value, title = itemTitle, year = itemYear }); + } + catch { } + } + + // Сортировка по точности + if (result.Count > 1) + { + var searchTitle = title.Trim().ToLowerInvariant(); + result = result.OrderByDescending(r => + { + int score = 0; + var it = (r.title ?? "").Trim().ToLowerInvariant(); + if (it == searchTitle) score += 100; + else if (it.Length > 0 && searchTitle.Length > 0 && (it.Contains(searchTitle) || searchTitle.Contains(it))) + score += (int)(50.0 * Math.Min(it.Length, searchTitle.Length) / Math.Max(it.Length, searchTitle.Length)); + if (year > 0 && r.year == year) score += 20; + else if (year > 0 && Math.Abs(r.year - year) <= 1) score += 10; + return score; + }).ToList(); + } + + Console.WriteLine($"[TelegramBot] SearchMirage: '{title}' year={year} -> {result.Count} results, best: {result.FirstOrDefault()?.title} ({result.FirstOrDefault()?.year})"); + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] SearchMirage error: {ex.Message}"); } + + if (result.Count > 0) SetCache(cacheKey, result); + return result; + } + + public class VoiceInfo { public int id { get; set; } public string name { get; set; } public string source { get; set; } } + public class MirageSearchResult { public string orid { get; set; } public string title { get; set; } public int year { get; set; } } + #endregion + + #region Collaps — rjson + public static async Task SearchCollaps(string title, int year) + { + var cacheKey = $"collaps_search:{title}:{year}"; + var cached = GetCache(cacheKey); + if (cached != null) return cached; + + if (string.IsNullOrEmpty(Config.lampac_host)) return null; + + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + var url = $"{Config.lampac_host}/lite/collaps?rjson=true&title={Uri.EscapeDataString(title)}&year={year}{token}"; + var body = await httpFast.GetStringAsync(url); + if (string.IsNullOrEmpty(body) || body == "null") return null; + + var json = JObject.Parse(body); + var data = json["data"] as JArray; + if (data == null || data.Count == 0) return null; + + string bestOrid = null; + int bestScore = -1; + foreach (var item in data) + { + var itemUrl = item.Value("url") ?? ""; + var itemTitle = item.Value("title") ?? ""; + var itemYear = item.Value("year") ?? 0; + var oridMatch = Regex.Match(itemUrl, @"orid=(\d+)"); + if (!oridMatch.Success) continue; + + int score = 0; + if (itemYear == year) score += 10; + else if (Math.Abs(itemYear - year) <= 1) score += 5; + if (itemTitle.Equals(title, StringComparison.OrdinalIgnoreCase)) score += 5; + if (score > bestScore) { bestScore = score; bestOrid = oridMatch.Groups[1].Value; } + } + + Console.WriteLine($"[TelegramBot] SearchCollaps: '{title}' year={year} -> orid={bestOrid}"); + if (!string.IsNullOrEmpty(bestOrid)) SetCache(cacheKey, bestOrid); + return bestOrid; + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] SearchCollaps error: {ex.Message}"); return null; } + } + + public static async Task> GetCollapsVoices(string collapsOrid, int season) + { + var cacheKey = $"collaps_voices:{collapsOrid}:{season}"; + var cached = GetCache>(cacheKey); + if (cached != null) return cached; + + var result = new List(); + if (string.IsNullOrEmpty(collapsOrid) || string.IsNullOrEmpty(Config.lampac_host)) return result; + + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + var url = $"{Config.lampac_host}/lite/collaps?rjson=true&orid={collapsOrid}&s={season}{token}"; + var body = await httpFast.GetStringAsync(url); + if (string.IsNullOrEmpty(body) || body == "null") return result; + + var json = JObject.Parse(body); + var data = json["data"] as JArray; + if (data == null || data.Count == 0) return result; + + // Берём details из первого эпизода + var details = data[0]?.Value("details") ?? data[0]?.Value("voice_name") ?? ""; + if (!string.IsNullOrEmpty(details)) + { + int id = 1; + foreach (var v in details.Split(',')) + { + var name = v.Trim().Trim('"'); + if (!string.IsNullOrEmpty(name) && name.Length > 1) + result.Add(new VoiceInfo { id = id++, name = name, source = "collaps" }); + } + } + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetCollapsVoices error: {ex.Message}"); } + + if (result.Count > 0) SetCache(cacheKey, result); + return result; + } + + static async Task CheckCollapsVoice(Subscription sub) + { + if (string.IsNullOrEmpty(Config.lampac_host)) + return sub.last_voice_episode; + + var orid = sub.collaps_orid; + if (string.IsNullOrEmpty(orid)) + { + orid = await SearchCollaps(sub.title, 0); + if (!string.IsNullOrEmpty(orid)) + { + sub.collaps_orid = orid; + Console.WriteLine($"[TelegramBot] Collaps auto-found orid={orid} for {sub.title}"); + } + else return sub.last_voice_episode; + } + + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + var url = $"{Config.lampac_host}/lite/collaps?rjson=true&orid={orid}&s={sub.last_season}{token}"; + + Console.WriteLine($"[TelegramBot] Collaps check: {sub.title} voice={sub.voice} s={sub.last_season}"); + + var body = await httpFast.GetStringAsync(url); + if (string.IsNullOrEmpty(body) || body == "null") return sub.last_voice_episode; + + var json = JObject.Parse(body); + var data = json["data"] as JArray; + if (data == null) return sub.last_voice_episode; + + int maxEp = 0; + foreach (var ep in data) + { + var epNum = ep.Value("e") ?? 0; + var details = ep.Value("details") ?? ep.Value("voice_name") ?? ""; + if (details.Contains(sub.voice, StringComparison.OrdinalIgnoreCase) && epNum > maxEp) + maxEp = epNum; + } + + Console.WriteLine($"[TelegramBot] Collaps result: {sub.title} voice={sub.voice} episodes={maxEp} (was {sub.last_voice_episode})"); + return maxEp; + } + catch (Exception ex) + { + Console.WriteLine($"[TelegramBot] Collaps check error: {ex.Message}"); + return sub.last_voice_episode; + } + } + #endregion + + #region VideoHub (CDNvideohub) — поиск по kinopoisk_id + + // Резолвер tmdb_id -> kinopoisk_id. Порядок: кеш в подписке -> мост Lampac /externalids -> поиск по названию в KP API. + // Результат кешируется прямо в sub.kp_id, чтобы не дёргать KP API каждый прогон. -1 = резолв не удался (больше не пытаемся). + public static async Task GetKpId(Subscription sub) + { + if (sub.kp_id > 0) return sub.kp_id; + if (sub.kp_id == -1) return 0; // ранее не нашли — не долбим API повторно + + // 1) Мост Lampac /externalids (бесплатно, без квоты KP API) + string imdbId = null; + if (!string.IsNullOrEmpty(Config.lampac_host)) + { + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + var url = $"{Config.lampac_host}/externalids?id={sub.tmdb_id}&ts=tv{token}"; + var body = await httpFast.GetStringAsync(url); + if (!string.IsNullOrEmpty(body) && body != "null") + { + var j = JObject.Parse(body); + var kpStr = j.Value("kinopoisk_id"); + imdbId = j.Value("imdb_id"); + if (int.TryParse(kpStr, out var kp) && kp > 0) + { + sub.kp_id = kp; + Console.WriteLine($"[TelegramBot] KP resolved via externalids: {sub.title} -> kp={kp}"); + return kp; + } + } + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] externalids error: {ex.Message}"); } + } + + // 2) Поиск по названию через kinopoiskapiunofficial.tech (нужен ключ kp_api_key) + if (!string.IsNullOrEmpty(Config.kp_api_key)) + { + int year = 0; + string originalTitle = null; + try + { + var show = JObject.Parse(await http.GetStringAsync( + $"https://api.themoviedb.org/3/tv/{sub.tmdb_id}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); + originalTitle = show.Value("original_name"); + var firstAir = show.Value("first_air_date") ?? ""; + if (firstAir.Length >= 4) int.TryParse(firstAir.Substring(0, 4), out year); + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetKpId TMDB info error: {ex.Message}"); } + + var kp = await SearchKpByTitle(sub.title, year, originalTitle); + if (kp > 0) + { + sub.kp_id = kp; + Console.WriteLine($"[TelegramBot] KP resolved via search: {sub.title} -> kp={kp}"); + return kp; + } + } + + sub.kp_id = -1; // пометка «не нашли», чтобы не пытаться каждый раз + Console.WriteLine($"[TelegramBot] KP NOT resolved: {sub.title} (tmdb={sub.tmdb_id})"); + return 0; + } + + // Поиск kinopoisk_id по названию. Точное совпадение nameRu/nameEn + год + type=TV_SERIES имеет приоритет. + // Нормализация названия для сравнения: убирает апострофы (обычные и типографские), + // приводит дефисы/двоеточия/точки к пробелам, схлопывает пробелы, lower-case. + static string NormTitle(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + s = Regex.Replace(s, @"[\u2018\u2019\u02BC'`]", ""); + s = Regex.Replace(s, @"[-\u2013\u2014:.,]", " "); + s = Regex.Replace(s, @"\s+", " ").Trim().ToLowerInvariant(); + return s; + } + + // Поиск kinopoisk_id по названию. Сопоставление нормализованное ('97 vs '97, регистр). + // Приоритет: имя+TV_SERIES+год -> имя+TV_SERIES -> 0. Мусорный fallback (первый попавшийся) убран: + // лучше пропустить VideoHub, чем взять чужой фильм. + static async Task SearchKpByTitle(string title, int year, string originalTitle = null) + { + if (string.IsNullOrEmpty(Config.kp_api_key) || string.IsNullOrEmpty(title)) return 0; + + var cacheKey = $"kp_search:{title}:{originalTitle}:{year}"; + var cached = GetCache(cacheKey); + if (cached != null) return int.TryParse(cached, out var c) ? c : 0; + + var queries = new List { title }; + if (!string.IsNullOrEmpty(originalTitle) && !originalTitle.Equals(title, StringComparison.OrdinalIgnoreCase)) + queries.Add(originalTitle); + + var wantRu = NormTitle(title); + var wantEn = NormTitle(originalTitle); + + try + { + int exactSeriesYear = 0, exactSeries = 0, exactAny = 0; + + foreach (var q in queries) + { + var url = $"https://kinopoiskapiunofficial.tech/api/v2.1/films/search-by-keyword?keyword={Uri.EscapeDataString(q)}"; + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.Add("X-API-KEY", Config.kp_api_key); + req.Headers.Add("accept", "application/json"); + + var resp = await httpFast.SendAsync(req); + var body = await resp.Content.ReadAsStringAsync(); + if (string.IsNullOrEmpty(body)) continue; + + var films = JObject.Parse(body)["films"] as JArray; + if (films == null) continue; + + foreach (var f in films) + { + var id = f.Value("filmId") ?? 0; + if (id <= 0) continue; + var nRu = NormTitle(f.Value("nameRu")); + var nEn = NormTitle(f.Value("nameEn")); + int.TryParse(f.Value("year"), out var fy); + bool isSeries = (f.Value("type") ?? "") == "TV_SERIES"; + + bool nameMatch = + (wantRu.Length > 0 && (nRu == wantRu || (nRu.Length > 3 && wantRu.Length > 3 && (nRu.Contains(wantRu) || wantRu.Contains(nRu))))) || + (wantEn.Length > 0 && (nEn == wantEn || (nEn.Length > 3 && wantEn.Length > 3 && (nEn.Contains(wantEn) || wantEn.Contains(nEn))))); + + if (!nameMatch) continue; + + if (isSeries && year > 0 && fy == year && exactSeriesYear == 0) exactSeriesYear = id; + if (isSeries && exactSeries == 0) exactSeries = id; + if (exactAny == 0) exactAny = id; + } + + if (exactSeriesYear != 0) break; + } + + int result = exactSeriesYear != 0 ? exactSeriesYear + : exactSeries != 0 ? exactSeries + : exactAny; // 0, если имя нигде не совпало → VideoHub пропустится + + Console.WriteLine($"[TelegramBot] SearchKpByTitle: '{title}' (orig='{originalTitle}') year={year} -> kp={result}"); + SetCache(cacheKey, result.ToString()); + return result; + } + catch (Exception ex) + { + Console.WriteLine($"[TelegramBot] SearchKpByTitle error: {ex.Message}"); + return 0; + } + } + + // Список озвучек VideoHub для сезона (для меню плагина). Возвращает VoiceInfo с source="videohub". + public static async Task> GetVideoHubVoices(int kpId, int season) + { + var result = new List(); + if (kpId <= 0 || string.IsNullOrEmpty(Config.lampac_host)) return result; + + var cacheKey = $"vhub_voices:{kpId}:{season}"; + var cached = GetCache>(cacheKey); + if (cached != null) return cached; + + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + var url = $"{Config.lampac_host}/lite/cdnvideohub?rjson=true&kinopoisk_id={kpId}&s={season}{token}"; + var body = await httpFast.GetStringAsync(url); + if (string.IsNullOrEmpty(body) || body == "null") return result; + + var json = JObject.Parse(body); + var voices = json["voice"] as JArray; + if (voices != null) + foreach (var v in voices) + { + var name = v.Value("name"); + if (!string.IsNullOrEmpty(name)) + result.Add(new VoiceInfo { id = 0, name = name, source = "videohub" }); + } + + SetCache(cacheKey, result); + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetVideoHubVoices error: {ex.Message}"); } + + return result; + } + + // Проверка максимального вышедшего эпизода в нужной озвучке через VideoHub. + static async Task CheckVideoHubVoice(Subscription sub) + { + if (string.IsNullOrEmpty(Config.lampac_host)) return sub.last_voice_episode; + + int kpId = await GetKpId(sub); + if (kpId <= 0) return sub.last_voice_episode; + + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + // t = имя озвучки, обязательно URL-кодировать (кириллица иначе даёт HTTP 400) + var tParam = !string.IsNullOrEmpty(sub.voice) ? $"&t={Uri.EscapeDataString(sub.voice)}" : ""; + var url = $"{Config.lampac_host}/lite/cdnvideohub?rjson=true&kinopoisk_id={kpId}&s={sub.last_season}{tParam}{token}"; + + Console.WriteLine($"[TelegramBot] VideoHub check: {sub.title} voice={sub.voice} kp={kpId} s={sub.last_season}"); + + var body = await httpFast.GetStringAsync(url); + if (string.IsNullOrEmpty(body) || body == "null") return sub.last_voice_episode; + + var json = JObject.Parse(body); + var data = json["data"] as JArray; + if (data == null) return sub.last_voice_episode; + + int maxEp = 0; + foreach (var ep in data) + { + var epNum = ep.Value("e") ?? 0; + if (epNum > maxEp) maxEp = epNum; + } + + Console.WriteLine($"[TelegramBot] VideoHub result: {sub.title} voice={sub.voice} episodes={maxEp} (was {sub.last_voice_episode})"); + return maxEp; + } + catch (Exception ex) + { + Console.WriteLine($"[TelegramBot] VideoHub check error: {ex.Message}"); + return sub.last_voice_episode; + } + } + #endregion + + #region RedHeadSound (RHS) — rjson + public static async Task> GetRHSVoices(string title, int year, int season) + { + var cacheKey = $"rhs_voices:{title}:{year}:{season}"; + var cached = GetCache>(cacheKey); + if (cached != null) return cached; + + var result = new List(); + if (string.IsNullOrEmpty(Config.lampac_host)) return result; + + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + var url = $"{Config.lampac_host}/lite/redheadsound?rjson=true&title={Uri.EscapeDataString(title)}&year={year}&s={season}{token}"; + var body = await httpFast.GetStringAsync(url); + if (string.IsNullOrEmpty(body) || body == "null") return result; + + var json = JObject.Parse(body); + var voices = json["voice"] as JArray; + if (voices != null) + foreach (var v in voices) + { + var name = v.Value("name") ?? ""; + if (string.IsNullOrEmpty(name)) continue; + + int tid = 0; + var vUrl = v.Value("url") ?? ""; + var tm = Regex.Match(vUrl, @"t=(\d+)"); + if (tm.Success) int.TryParse(tm.Groups[1].Value, out tid); + result.Add(new VoiceInfo { id = tid, name = name, source = "rhs" }); + } + + Console.WriteLine($"[TelegramBot] GetRHSVoices: '{title}' s{season} -> {result.Count} voices"); + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetRHSVoices error: {ex.Message}"); } + + if (result.Count > 0) SetCache(cacheKey, result); + return result; + } + + static async Task CheckRHSVoice(Subscription sub) + { + if (string.IsNullOrEmpty(sub.voice) || string.IsNullOrEmpty(Config.lampac_host)) + return sub.last_voice_episode; + + try + { + var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; + var voiceUrl = $"{Config.lampac_host}/lite/redheadsound?rjson=true&title={Uri.EscapeDataString(sub.title)}&year=0&s={sub.last_season}{token}"; + + Console.WriteLine($"[TelegramBot] RHS check: {sub.title} voice={sub.voice} s={sub.last_season}"); + + var voiceBody = await httpFast.GetStringAsync(voiceUrl); + if (string.IsNullOrEmpty(voiceBody) || voiceBody == "null") return sub.last_voice_episode; + + var voiceJson = JObject.Parse(voiceBody); + + // Ищем озвучку по имени → получаем правильный t= + int rhsVoiceId = 0; + var voices = voiceJson["voice"] as JArray; + if (voices != null) + foreach (var v in voices) + { + var vName = v.Value("name") ?? ""; + if (vName.Equals(sub.voice, StringComparison.OrdinalIgnoreCase)) + { + var vUrl = v.Value("url") ?? ""; + var tm = Regex.Match(vUrl, @"t=(\d+)"); + if (tm.Success) int.TryParse(tm.Groups[1].Value, out rhsVoiceId); + break; + } + } + + if (rhsVoiceId == 0) + { + // Озвучка не найдена по имени — пробуем по details в эпизодах + var data0 = voiceJson["data"] as JArray; + if (data0 != null) + { + int maxEp = 0; + foreach (var ep in data0) + { + var details = ep.Value("details") ?? ep.Value("voice_name") ?? ""; + var e = ep.Value("e") ?? 0; + if (details.Contains(sub.voice, StringComparison.OrdinalIgnoreCase) && e > maxEp) + maxEp = e; + } + if (maxEp > 0) + { + Console.WriteLine($"[TelegramBot] RHS result (details): {sub.title} voice={sub.voice} episodes={maxEp} (was {sub.last_voice_episode})"); + return maxEp; + } + } + return sub.last_voice_episode; + } + + // Запрос с правильным t= + var url = $"{Config.lampac_host}/lite/redheadsound?rjson=true&title={Uri.EscapeDataString(sub.title)}&year=0&s={sub.last_season}&t={rhsVoiceId}{token}"; + var body = await httpFast.GetStringAsync(url); + if (string.IsNullOrEmpty(body) || body == "null") return sub.last_voice_episode; + + var json = JObject.Parse(body); + var data = json["data"] as JArray; + if (data == null) return sub.last_voice_episode; + + int max = 0; + foreach (var ep in data) + { + var e = ep.Value("e") ?? 0; + if (e > max) max = e; + } + + Console.WriteLine($"[TelegramBot] RHS result: {sub.title} voice={sub.voice} t={rhsVoiceId} episodes={max} (was {sub.last_voice_episode})"); + return max; + } + catch (Exception ex) + { + Console.WriteLine($"[TelegramBot] RHS check error: {ex.Message}"); + return sub.last_voice_episode; + } + } + #endregion + + #region API для контроллера + public static async Task SubscribeApi(string uid, int tmdbId, string title, string voice, + int season, int episode, string mirageOrid = null, int mirageVoiceId = 0, int voiceEpisode = 0, + string collapsOrid = null, string voiceSource = null, int kpId = 0) + { + var user = Users.Values.FirstOrDefault(u => u.lampac_uid == uid); + if (user == null) return new { success = false, msg = "not_linked" }; + + var key = tmdbId.ToString(); + if (!Subs.ContainsKey(key)) Subs[key] = new List(); + + // Удалить старую подписку с той же озвучкой + Subs[key].RemoveAll(s => s.chat_id == user.chat_id && s.voice == (voice ?? "")); + + Subs[key].Add(new Subscription + { + chat_id = user.chat_id, + tmdb_id = tmdbId, + title = title, + voice = voice ?? "", + voice_source = voiceSource ?? (string.IsNullOrEmpty(mirageOrid) ? "collaps" : "mirage"), + mirage_orid = mirageOrid ?? "", + mirage_voice_id = mirageVoiceId, + collaps_orid = collapsOrid ?? "", + kp_id = kpId, + last_season = season, + last_episode = episode, + last_voice_episode = voiceEpisode, + subscribed_at = DateTime.UtcNow + }); + SaveSubscriptions(); + + if (Bot != null) + { + var v = string.IsNullOrEmpty(voice) ? "любая озвучка" : voice; + try { await Bot.SendMessage(user.chat_id, $"🔔 Подписка!\n*{EscapeMd(title)}* — {v}", parseMode: ParseMode.Markdown); } catch { } + } + return new { success = true }; + } + + public static object UnsubscribeApi(string uid, int tmdbId, string voice = null) + { + var user = Users.Values.FirstOrDefault(u => u.lampac_uid == uid); + if (user == null) return new { success = false, msg = "not_linked" }; + + var key = tmdbId.ToString(); + if (Subs.TryGetValue(key, out var list)) + { + if (voice != null) + list.RemoveAll(s => s.chat_id == user.chat_id && s.voice == voice); + else + list.RemoveAll(s => s.chat_id == user.chat_id); + SaveSubscriptions(); + } + return new { success = true }; + } + + public static object StatusApi(string uid, int tmdbId) + { + var user = Users.Values.FirstOrDefault(u => u.lampac_uid == uid); + if (user == null) return new { success = true, subscribed = false, linked = false, voices = new string[0] }; + + var userSubs = Subs.TryGetValue(tmdbId.ToString(), out var list) + ? list.Where(s => s.chat_id == user.chat_id).ToList() + : new List(); + + return new + { + success = true, + linked = true, + subscribed = userSubs.Count > 0, + voices = userSubs.Select(s => s.voice).ToArray() + }; + } + + /// + /// Возвращает список подписок пользователя для страницы подписок в Lampa. + /// Каждая подписка содержит tmdb_id, title, last_season, last_episode, + /// last_voice_episode, voice, voice_source — всё необходимое для карточки. + /// + public static object SubscriptionsApi(string uid) + { + var user = Users.Values.FirstOrDefault(u => u.lampac_uid == uid); + if (user == null) + return new { success = true, linked = false, results = new object[0] }; + + var userSubs = Subs + .SelectMany(kvp => kvp.Value) + .Where(s => s.chat_id == user.chat_id) + .OrderByDescending(s => s.subscribed_at) + .Select(s => new + { + tmdb_id = s.tmdb_id, + title = s.title, + media_type = s.media_type ?? "tv", + last_season = s.last_season, + last_episode = s.last_episode, + last_voice_episode = s.last_voice_episode, + voice = s.voice ?? "", + voice_source = s.voice_source ?? "", + subscribed_at = s.subscribed_at.ToString("o") + }) + .ToArray(); + + return new { success = true, linked = true, results = userSubs }; + } + + public static async Task GetVoicesApi(string title, int year, int season, int tmdbId = 0) + { + // Проверяем кеш + var cacheKey = $"voices:{tmdbId}:{title}:{year}:{season}"; + var cached = GetCache(cacheKey); + if (cached != null) + { + Console.WriteLine($"[TelegramBot] GetVoices cache hit: {title}"); + return cached; + } + + // Определить актуальный сезон + альтернативное название — параллельно + int actualSeason = season > 0 ? season : 1; + var allTitles = new List(); + + if (tmdbId > 0) + { + // Одним запросом к TMDB получаем и сезон, и альтернативные названия + try + { + var show = JObject.Parse(await http.GetStringAsync( + $"https://api.themoviedb.org/3/tv/{tmdbId}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); + + var tmdbName = show.Value("name") ?? ""; + var tmdbOriginal = show.Value("original_name") ?? ""; + + // Определяем сезон из TMDB данных (быстро, без доп. запросов) + var tmdbSeasons = show["seasons"] as JArray; + if (tmdbSeasons != null) + { + int bestSeason = 1; + foreach (var s in tmdbSeasons) + { + var sNum = s.Value("season_number"); + var epCount = s.Value("episode_count"); + var airDate = s.Value("air_date"); + if (sNum <= 0 || epCount <= 0) continue; + if (!string.IsNullOrEmpty(airDate) && DateTime.TryParse(airDate, out var dt) && dt.Date <= DateTime.UtcNow.Date) + bestSeason = sNum; + } + if (bestSeason > 0) actualSeason = bestSeason; + } + else + { + var ns = show.Value("number_of_seasons"); + if (ns > 0) actualSeason = ns; + } + + Console.WriteLine($"[TelegramBot] TMDB info: {tmdbName} / {tmdbOriginal}, season={actualSeason}"); + + // Собираем все уникальные названия для поиска + // Порядок: Lampa title, TMDB name (ru), TMDB original_name + allTitles.Add(title); + if (!string.IsNullOrEmpty(tmdbName) && !allTitles.Any(t => t.Equals(tmdbName, StringComparison.OrdinalIgnoreCase))) + allTitles.Add(tmdbName); + if (!string.IsNullOrEmpty(tmdbOriginal) && !allTitles.Any(t => t.Equals(tmdbOriginal, StringComparison.OrdinalIgnoreCase))) + allTitles.Add(tmdbOriginal); + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] TMDB info error: {ex.Message}"); } + } + + // Список названий для поиска + var titles = allTitles.Count > 0 ? allTitles : new List { title }; + Console.WriteLine($"[TelegramBot] GetVoices titles: [{string.Join(", ", titles)}] season={actualSeason}"); + + string mirageOrid = ""; + string collapsOrid = ""; + var allVoices = new List(); + + // === ПАРАЛЛЕЛЬНЫЙ поиск по всем источникам === + var mirageTask = SearchMirageMulti(titles, year); + var collapsTask = SearchCollapsMulti(titles, year); + var rhsTask = SearchRHSMulti(titles, year, actualSeason); + + await Task.WhenAll(mirageTask, collapsTask, rhsTask); + + // Получаем озвучки из найденных источников — параллельно + var mirageResult = mirageTask.Result; + var cOrid = collapsTask.Result; + var rhsVoices = rhsTask.Result; + + if (mirageResult.orid != null) mirageOrid = mirageResult.orid; + if (!string.IsNullOrEmpty(cOrid)) collapsOrid = cOrid; + + // Запускаем получение озвучек параллельно + var mirageVoicesTask = !string.IsNullOrEmpty(mirageOrid) ? GetMirageVoices(mirageOrid, actualSeason) : Task.FromResult(new List()); + var collapsVoicesTask = !string.IsNullOrEmpty(collapsOrid) ? GetCollapsVoices(collapsOrid, actualSeason) : Task.FromResult(new List()); + + await Task.WhenAll(mirageVoicesTask, collapsVoicesTask); + + // Собираем результаты: Mirage → Collaps → RHS (без дубликатов) + foreach (var v in mirageVoicesTask.Result) { v.source = "mirage"; allVoices.Add(v); } + + var existingNames = new HashSet(allVoices.Select(v => v.name), StringComparer.OrdinalIgnoreCase); + foreach (var v in collapsVoicesTask.Result) + if (!existingNames.Contains(v.name)) { allVoices.Add(v); existingNames.Add(v.name); } + + foreach (var v in rhsVoices) + if (!existingNames.Contains(v.name)) { allVoices.Add(v); existingNames.Add(v.name); } + + // === VideoHub: резолвим kp_id по tmdb и добавляем озвучки === + int vhubKp = 0; + if (tmdbId > 0) + { + var probe = new Subscription { tmdb_id = tmdbId, title = title }; + vhubKp = await GetKpId(probe); + if (vhubKp > 0) + { + var vhubVoices = await GetVideoHubVoices(vhubKp, actualSeason); + foreach (var v in vhubVoices) + if (!existingNames.Contains(v.name)) { allVoices.Add(v); existingNames.Add(v.name); } + Console.WriteLine($"[TelegramBot] GetVoices VideoHub: kp={vhubKp} voices={vhubVoices.Count}"); + } + } + + Console.WriteLine($"[TelegramBot] GetVoices: {title} ({year}) s{actualSeason} — mirage:{mirageOrid} collaps:{collapsOrid} rhs:{rhsVoices.Count} voices:{allVoices.Count}"); + + var result = (object)new { success = true, voices = allVoices, orid = mirageOrid, collaps_orid = collapsOrid, kp_id = vhubKp, season = actualSeason }; + if (allVoices.Count > 0) SetCache(cacheKey, result); + return result; + } + + // Параллельный поиск по нескольким названиям — все сразу, берём лучший + static async Task<(string orid, string title)> SearchMirageMulti(List titles, int year) + { + var tasks = titles.Select(t => SearchMirage(t, year)).ToArray(); + await Task.WhenAll(tasks); + + // Собираем ВСЕХ кандидатов со всех названий (без дублей по orid) + var all = new List(); + var seen = new HashSet(); + foreach (var task in tasks) + foreach (var r in task.Result) + if (!string.IsNullOrEmpty(r.orid) && seen.Add(r.orid)) + all.Add(r); + + if (all.Count == 0) return (null, null); + + // Приоритет 1: точное совпадение года (главный критерий — локализованные названия часто расходятся) + if (year > 0) + { + var byYear = all.Where(r => r.year == year).ToList(); + if (byYear.Count > 0) + { + // среди совпавших по году — предпочесть совпадение названия с любым из искомых + var best = byYear.OrderByDescending(r => TitleScore(r.title, titles)).First(); + Console.WriteLine($"[TelegramBot] SearchMirageMulti: matched by year={year} -> {best.title} orid={best.orid}"); + return (best.orid, best.title); + } + } + + // Приоритет 2: года нет или не совпал — берём лучшего по названию + var fallback = all.OrderByDescending(r => TitleScore(r.title, titles)).First(); + return (fallback.orid, fallback.title); + } + + // Очки совпадения названия кандидата с любым из искомых названий + static int TitleScore(string candidate, List titles) + { + var c = (candidate ?? "").Trim().ToLowerInvariant(); + if (c.Length == 0) return 0; + int best = 0; + foreach (var t in titles) + { + var s = (t ?? "").Trim().ToLowerInvariant(); + if (s.Length == 0) continue; + if (c == s) best = Math.Max(best, 100); + else if (c.Contains(s) || s.Contains(c)) + best = Math.Max(best, (int)(50.0 * Math.Min(c.Length, s.Length) / Math.Max(c.Length, s.Length))); + } + return best; + } + + static async Task SearchCollapsMulti(List titles, int year) + { + if (titles.Count == 1) + return await SearchCollaps(titles[0], year); + + var tasks = titles.Select(t => SearchCollaps(t, year)).ToArray(); + await Task.WhenAll(tasks); + + foreach (var task in tasks) + if (!string.IsNullOrEmpty(task.Result)) + return task.Result; + + return null; + } + + static async Task> SearchRHSMulti(List titles, int year, int season) + { + if (titles.Count == 1) + return await GetRHSVoices(titles[0], year, season); + + var tasks = titles.Select(t => GetRHSVoices(t, year, season)).ToArray(); + await Task.WhenAll(tasks); + + foreach (var task in tasks) + if (task.Result.Count > 0) + return task.Result; + + return new List(); + } + + // Определить последний вышедший сезон + static async Task GetCurrentSeason(int tmdbId) + { + // Сначала Trakt + if (!string.IsNullOrEmpty(Config.trakt_client_id)) + { + try + { + var searchReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.trakt.tv/search/tmdb/{tmdbId}?type=show"); + searchReq.Headers.Add("trakt-api-key", Config.trakt_client_id); + searchReq.Headers.Add("trakt-api-version", "2"); + var searchResp = await http.SendAsync(searchReq); + + if (searchResp.IsSuccessStatusCode) + { + var searchData = JArray.Parse(await searchResp.Content.ReadAsStringAsync()); + if (searchData.Count > 0) + { + var slug = searchData[0]?["show"]?["ids"]?.Value("slug"); + if (!string.IsNullOrEmpty(slug)) + { + var sReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.trakt.tv/shows/{slug}/seasons?extended=full"); + sReq.Headers.Add("trakt-api-key", Config.trakt_client_id); + sReq.Headers.Add("trakt-api-version", "2"); + var sResp = await http.SendAsync(sReq); + + if (sResp.IsSuccessStatusCode) + { + var seasons = JArray.Parse(await sResp.Content.ReadAsStringAsync()); + var lastAired = seasons + .Where(s => s.Value("number") > 0 && s.Value("aired_episodes") > 0) + .OrderByDescending(s => s.Value("number")) + .FirstOrDefault(); + + if (lastAired != null) + { + var result = lastAired.Value("number"); + Console.WriteLine($"[TelegramBot] Trakt current season for tmdb {tmdbId}: {result} (total seasons: {seasons.Count})"); + return result; + } + else + { + Console.WriteLine($"[TelegramBot] Trakt: no aired seasons found for tmdb {tmdbId}"); + } + } + } + } + } + } + catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetCurrentSeason Trakt error: {ex.Message}"); } + } + + // Fallback — TMDB: проверяем каждый сезон на наличие вышедших эпизодов + try + { + Console.WriteLine($"[TelegramBot] GetCurrentSeason TMDB fallback for {tmdbId}"); + var show = JObject.Parse(await http.GetStringAsync( + $"https://api.themoviedb.org/3/tv/{tmdbId}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); + + var tmdbSeasons = show["seasons"] as JArray; + if (tmdbSeasons != null) + { + // Ищем последний сезон с episode_count > 0 и air_date <= сегодня + int bestSeason = 1; + foreach (var s in tmdbSeasons) + { + var sNum = s.Value("season_number"); + var epCount = s.Value("episode_count"); + var airDate = s.Value("air_date"); + + if (sNum <= 0 || epCount <= 0) continue; + if (!string.IsNullOrEmpty(airDate) && DateTime.TryParse(airDate, out var dt) && dt.Date <= DateTime.UtcNow.Date) + bestSeason = sNum; + } + Console.WriteLine($"[TelegramBot] TMDB current season for {tmdbId}: {bestSeason}"); + return bestSeason; + } + + return show.Value("number_of_seasons"); + } + catch { return 1; } + } + + public static async Task LinkApi(string uid) + { + if (Bot == null) return new { success = false, msg = "bot_not_running" }; + var me = await Bot.GetMe(); + return new { success = true, link = $"https://t.me/{me.Username}?start={LinkPayload(uid)}" }; + } + + // Telegram пропускает в параметре deep-link только A-Za-z0-9_- и не длиннее 64 символов, + // а uid бывает email-ом: «@» и «.» обрывают параметр, и бот получает голый /start. + // Такой uid уходит в base64url с префиксом link64_; безопасный — как раньше, link_{uid}. + const string LinkPrefix = "link_"; + const string LinkPrefix64 = "link64_"; + const int DeepLinkMaxLength = 64; + + static string LinkPayload(string uid) + { + if (Regex.IsMatch(uid, "^[A-Za-z0-9_-]+$")) + return LinkPrefix + uid; + + string encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(uid)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + // Длинный email не влезет и так; прежний формат хотя бы работает командой + // «/start link_{uid}», набранной боту вручную. + return LinkPrefix64.Length + encoded.Length <= DeepLinkMaxLength + ? LinkPrefix64 + encoded + : LinkPrefix + uid; + } + + static bool TryParseLinkPayload(string payload, out string uid) + { + uid = null; + + if (payload.StartsWith(LinkPrefix64)) + { + string encoded = payload.Substring(LinkPrefix64.Length).Replace('-', '+').Replace('_', '/'); + encoded = encoded.PadRight(encoded.Length + (4 - encoded.Length % 4) % 4, '='); + try + { + uid = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); + } + catch (FormatException) + { + return false; + } + } + else if (payload.StartsWith(LinkPrefix)) + { + uid = payload.Substring(LinkPrefix.Length); + } + + return !string.IsNullOrEmpty(uid); + } + + // Плагин передаёт account_email и uid устройства, сервер выбирает account_email. У кого бот + // привязан по uid устройства (прежний плагин слал только его), подписки не должны пропасть: + // если под основным id привязки нет, а под uid устройства есть — берём его. + public static string ResolveUid(string uid, string deviceUid) + { + if (string.IsNullOrEmpty(deviceUid) || deviceUid == uid) + return uid; + + if (Users.Values.Any(u => u.lampac_uid == uid)) + return uid; + + return Users.Values.Any(u => u.lampac_uid == deviceUid) ? deviceUid : uid; + } + #endregion + + #region Storage + static void LoadUsers() + { + try { if (System.IO.File.Exists(usersPath)) { var l = JsonConvert.DeserializeObject>(System.IO.File.ReadAllText(usersPath)); if (l != null) foreach (var u in l) Users[u.chat_id] = u; } } catch { } + } + public static void SaveUsers() { try { System.IO.File.WriteAllText(usersPath, JsonConvert.SerializeObject(Users.Values.ToList(), Formatting.Indented)); } catch { } } + static void LoadSubscriptions() + { + try { if (System.IO.File.Exists(subscriptionsPath)) { var d = JsonConvert.DeserializeObject>>(System.IO.File.ReadAllText(subscriptionsPath)); if (d != null) foreach (var kvp in d) Subs[kvp.Key] = kvp.Value; } } catch { } + } + public static void SaveSubscriptions() + { + try { System.IO.File.WriteAllText(subscriptionsPath, JsonConvert.SerializeObject(Subs.ToDictionary(k => k.Key, k => k.Value), Formatting.Indented)); } catch { } + } + #endregion + + static string EscapeMd(string text) => + string.IsNullOrEmpty(text) ? "" : text.Replace("_", "\\_").Replace("*", "\\*").Replace("[", "\\[").Replace("]", "\\]").Replace("`", "\\`"); + + public void Dispose() + { + EventListener.UpdateInitFile -= UpdateConfFromInit; + try { checkTimer?.Dispose(); } catch { } + IsRunning = false; + } + } +} diff --git a/mods/Tg-notify.bot/Models/TelegramBotConf.cs b/mods/Tg-notify.bot/Models/TelegramBotConf.cs new file mode 100644 index 0000000..7d5a35e --- /dev/null +++ b/mods/Tg-notify.bot/Models/TelegramBotConf.cs @@ -0,0 +1,30 @@ +using Shared.Models.Module; + +namespace TelegramBot.Models +{ + public class TelegramBotConf : ModuleBaseConf + { + public bool enable { get; set; } = true; + + public string bot_token { get; set; } = "YOUR_BOT_TOKEN"; + + public string tmdb_api_key { get; set; } = ""; + + public string trakt_client_id { get; set; } = ""; + + public string lampac_host { get; set; } = "http://127.0.0.1:9118"; + + public string lampac_token { get; set; } = ""; + + // Ключ kinopoiskapiunofficial.tech — резолвинг tmdb→kinopoisk_id по названию для источника VideoHub. + // Пусто → kp_id резолвится только через мост Lampac /externalids (покрытие ~40%). + public string kp_api_key { get; set; } = ""; + + public int check_interval_minutes { get; set; } = 60; + + public string tmdb_lang { get; set; } = "ru-RU"; + + // Каталог для users.json / subscriptions.json. Пусто → database/tgnotify + public string data_dir { get; set; } = "database/tgnotify"; + } +} diff --git a/mods/Tg-notify.bot/README.md b/mods/Tg-notify.bot/README.md new file mode 100644 index 0000000..a901f93 --- /dev/null +++ b/mods/Tg-notify.bot/README.md @@ -0,0 +1,163 @@ +# Tg-notify.bot + +Фоновый **Telegram-бот уведомлений о новых сериях и озвучках** (long polling). Пользователь подписывается на сериал прямо из карточки Lampa, а бот сам отслеживает выход новых эпизодов и появление выбранной озвучки и шлёт уведомление в Telegram с обложкой, номером серии и описанием из TMDB. + +Проект: **`Modules/Tg-notify.bot/`**. C#-namespace модуля — `TelegramBot`. + +Бот объединяет три подсистемы: + +1. **Трекер новых серий** — Trakt API (приоритет) с фоллбэком на TMDB. +2. **Трекер озвучек** — параллельный опрос балансеров **Mirage**, **Collaps** и **VideoHub** (поиск по названию или kinopoisk_id, мультиозвучка, чистый JSON). Берётся максимальный эпизод из всех источников — кто первым выложил серию в нужной озвучке, от того и уведомление. +3. **Клиентский плагин Lampa** (`tg-notify.js`) — вкладка «TG Подписки», перехват кнопки 🔔 в карточке, меню выбора озвучки. + +--- + +## Включение + +1. В шаблоне [`config/base.conf`](../../../config/base.conf) при необходимости проверьте, что **`TelegramBot`** не попал в **`BaseModule.SkipModules`** (если попал — уберите имя из списка, иначе модуль не загрузится). +2. [`manifest.json`](manifest.json): выставьте **`"enable": true`** (в репозитории по умолчанию `false`). +3. `init.conf`: добавьте секцию **`TelegramBot`**. Пример — [`init.merge.example.json`](init.merge.example.json). +4. Перезапустите сервис: `systemctl restart lampac` (или имя вашего systemd-юнита). + +При `enable: true` и пустом `bot_token` long polling **не стартует** — в лог пишется предупреждение. + +--- + +## Конфигурация (секция `init.conf`) + +```json +"TelegramBot": { + "enable": true, + "bot_token": "ТОКЕН_ОТ_BOTFATHER", + "tmdb_api_key": "ВАШ_КЛЮЧ_TMDB", + "trakt_client_id": "", + "lampac_host": "http://127.0.0.1:9118", + "lampac_token": "", + "kp_api_key": "", + "check_interval_minutes": 60, + "tmdb_lang": "ru-RU", + "data_dir": "database/tgnotify" +} +``` + +| Поле | Назначение | +|------|-----------| +| `bot_token` | Токен бота от [@BotFather](https://t.me/BotFather). **Обязателен.** | +| `tmdb_api_key` | Ключ TMDB v3 — обложки, описания эпизодов, определение актуального сезона. | +| `trakt_client_id` | Client ID приложения Trakt. Если задан — основной источник новых серий; если пуст — используется только TMDB. | +| `lampac_host` | Базовый URL самого Lampac для запросов к балансерам (`/lite/mirage`, `/lite/collaps`). Обычно локальный: `http://127.0.0.1:9118`. | +| `lampac_token` | Токен **accsdb**. Обязателен, если у вас включена защита `accsdb` — без него балансеры вернут `{"accsdb":true,"msg":"Войдите в аккаунт"}`. | +| `kp_api_key` | Ключ [kinopoiskapiunofficial.tech](https://kinopoiskapiunofficial.tech/) — резолвинг `tmdb_id → kinopoisk_id` по названию для источника **VideoHub**. Опционально: если пусто, `kinopoisk_id` берётся только из встроенного моста Lampac `/externalids` (покрытие ~40%); с ключом — фоллбэк через поиск по названию поднимает покрытие почти до полного. | +| `check_interval_minutes` | Период фоновой проверки подписок. По умолчанию 60. Первый прогон — через 2 минуты после старта. | +| `tmdb_lang` | Язык метаданных TMDB. По умолчанию `ru-RU` (с фоллбэком на `en-US` при пустом описании). | +| `data_dir` | Каталог хранения `users.json` / `subscriptions.json`. По умолчанию `database/tgnotify`. | + +--- + +## Зависимости + +- **`Telegram.Bot` 22.4.4** — кладётся в [`references/`](references/) (для рантайм-компиляции Roslyn) и подтягивается через `PackageReference` при локальной сборке. +- **TMDB API** — ключ v3 (`tmdb_api_key`). +- **Trakt API** (опционально) — `trakt_client_id` для более точного и быстрого трекинга выхода серий. +- Балансеры **Mirage**, **Collaps** и **VideoHub** (`/lite/cdnvideohub`) должны быть доступны по `lampac_host`. +- **kinopoiskapiunofficial.tech** (опционально) — `kp_api_key`. Нужен только для VideoHub: резолвит `kinopoisk_id` по названию, когда встроенный мост Lampac его не знает. + +--- + +## Структура модуля + +``` +Tg-notify.bot/ +├── manifest.json # dynamic:true, references+tree +├── Tg-notify.bot.csproj # только для локальной сборки; на сервере не используется +├── init.merge.example.json # пример секции init.conf +├── ModInit.cs # ядро: polling, трекинг серий, парсеры озвучек, HTTP API +├── Models/ +│ └── TelegramBotConf.cs # конфиг (наследует ModuleBaseConf) +├── Controllers/ +│ └── TgNotifyController.cs # HTTP API + раздача tg-notify.js +├── references/ +│ └── Telegram.Bot.dll # 22.4.4 +└── tg-notify.js # клиентский плагин Lampa +``` + +> На сервере модуль компилируется рантайм-движком **Roslyn** по `manifest.json` (`dynamic: true`) — `.csproj` нужен только для локальной разработки. Поля `references` и `tree` обязательны. + +--- + +## Как работает + +### Привязка аккаунта + +UID пользователя определяется так же, как для закладок и тайм-кодов: плагин передаёт `token`, `account_email` и `uid` устройства, сервер берёт первое непустое (`RequestInfo.getuid`). Вошёл в аккаунт CUB — подписки общие для всех его устройств. + +Из вкладки «TG Подписки» в Lampa пользователь получает deep-link на бота. Telegram пропускает в параметре только `A-Za-z0-9_-` (до 64 символов), поэтому UID вида email уходит как `link64_{base64url(uid)}`, остальные — как раньше, `link_{uid}`. Бот сохраняет связку `chat_id ↔ lampac_uid` (`users.json`), после чего подписки этого UID становятся «его». Привязка, сделанная раньше по `uid` устройства, продолжает работать: если под основным UID привязки нет, API ищет её по `uid` устройства. + +### Подписка + +В карточке сериала плагин перехватывает кнопку 🔔 и показывает меню озвучек (`/api/tg/voices` → Mirage + Collaps + VideoHub). Выбор отправляется в `/api/tg/subscribe` и сохраняется в `subscriptions.json`. + +### Фоновый цикл `CheckAll` + +Раз в `check_interval_minutes` (и вручную командой `/check`) по каждой подписке: + +1. **Новые серии** — `CheckNewEpisodes` (Trakt → TMDB). Если у подписки не выбрана конкретная озвучка — уведомление шлётся о самом факте выхода серии. +2. **Озвучки** (если озвучка выбрана) — **параллельно** (`Task.WhenAll`) опрашиваются Mirage, Collaps и VideoHub; берётся максимальный доступный эпизод; кто первый нашёл новую серию — от того уведомление. На каждую новую серию шлётся отдельное сообщение с инфой из TMDB. + +> **VideoHub и kinopoisk_id.** VideoHub ищет контент только по `kinopoisk_id`, тогда как бот работает по `tmdb_id`. Резолвинг выполняется лениво и кешируется в самой подписке: сначала через встроенный мост Lampac `/externalids`, при неудаче — поиском по названию через `kp_api_key`. Имя озвучки в запросе URL-кодируется (кириллица иначе даёт HTTP 400). Если `kinopoisk_id` не нашёлся, VideoHub для этой подписки молча пропускается — Mirage и Collaps продолжают работать. + +Формат уведомления: + +``` +🎬 Название +🎙 Озвучка +📺 S05E08 — Название эпизода + +Описание (до 300 символов) +``` + +С обложкой кадра (`SendPhoto`), если у эпизода есть `still_path`. + +--- + +## Команды бота + +| Команда / кнопка | Действие | +|------------------|----------| +| `/start` | Приветствие; `/start link_{uid}` или `/start link64_{base64url(uid)}` — привязка аккаунта | +| `/list` · «📋 Подписки» | Список активных подписок | +| `/check` · «🔍 Проверить» | Принудительная проверка (в фоне) | +| `/help` · «📖 Помощь» | Справка | +| `/unlink` · «🔓 Отвязать» | Отвязать аккаунт | + +--- + +## HTTP API (`TgNotifyController : BaseController`) + +| Метод | Маршрут | Назначение | +|-------|---------|-----------| +| `GET` | `/tg-notify.js` | Раздаёт клиентский плагин с автозаменой `{localhost}` → реальный хост. `[AllowAnonymous]` | +| `POST` | `/api/tg/subscribe` | Подписка на сериал/озвучку | +| `POST` | `/api/tg/unsubscribe` | Отписка | +| `GET` | `/api/tg/status?tmdb_id=` | Статус подписки текущего пользователя | +| `GET` | `/api/tg/voices?title=&year=&season=&tmdb_id=` | Список доступных озвучек | +| `GET` | `/api/tg/link` | Ссылка привязки бота | +| `GET` | `/api/tg/subscriptions` | Все подписки пользователя с метаданными | + +Все маршруты, кроме `/tg-notify.js` и `/api/tg/voices`, требуют `requestInfo.user_uid`. + +--- + +## Клиентский плагин (`tg-notify.js`) + +Подключается через `customPlugins` в `init.conf` либо раздаётся самим модулем по `/tg-notify.js`: + +```json +"LampaWeb": { + "customPlugins": [ + { "url": "{localhost}/tg-notify.js", "status": 1 } + ] +} +``` + +Возможности: вкладка «TG Подписки» (сетка карточек с бейджем `S5 E4`), перехват кнопки 🔔 в полной карточке, меню выбора озвучки (`Lampa.Select.show`, метки `Mirage [—]` / `Collaps [C]` / `VideoHub [V]`), загрузка метаданных через `Lampa.TMDB` / `Lampa.Network.silent` с кешем. Хост подставляется через `{localhost}` — хардкода адресов нет. diff --git a/mods/Tg-notify.bot/Tg-notify.bot.csproj b/mods/Tg-notify.bot/Tg-notify.bot.csproj new file mode 100644 index 0000000..379e3db --- /dev/null +++ b/mods/Tg-notify.bot/Tg-notify.bot.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + library + true + TelegramBot + TelegramBot + disable + disable + + + + + + + + + + + <_TelegramBotRef Include="@(ReferencePath)" Condition="'%(Filename)' == 'Telegram.Bot'" /> + + + + + + diff --git a/mods/Tg-notify.bot/init.merge.example.json b/mods/Tg-notify.bot/init.merge.example.json new file mode 100644 index 0000000..5d0c65d --- /dev/null +++ b/mods/Tg-notify.bot/init.merge.example.json @@ -0,0 +1,13 @@ +{ + "TelegramBot": { + "enable": true, + "bot_token": "ВАШ_ТОКЕН_ОТ_BOTFATHER", + "tmdb_api_key": "ВАШ_КЛЮЧ_TMDB", + "trakt_client_id": "", + "lampac_host": "http://127.0.0.1:9118", + "lampac_token": "", + "check_interval_minutes": 60, + "tmdb_lang": "ru-RU", + "data_dir": "database/tgnotify" + } +} diff --git a/mods/Tg-notify.bot/manifest.json b/mods/Tg-notify.bot/manifest.json new file mode 100644 index 0000000..e3974f5 --- /dev/null +++ b/mods/Tg-notify.bot/manifest.json @@ -0,0 +1,12 @@ +{ + "enable": true, + "dynamic": true, + "references": [ + "references/" + ], + "tree": [ + "Models", + "Controllers", + "ModInit.cs" + ] +} diff --git a/mods/Tg-notify.bot/references/Telegram.Bot.dll b/mods/Tg-notify.bot/references/Telegram.Bot.dll new file mode 100755 index 0000000000000000000000000000000000000000..addba0839f4dbd2ec382ccdb1af8ad5a802732b5 GIT binary patch literal 2542592 zcmeEv1(;;j(RS}`nI7F8>6sN^7a6$hvbcK)5Q00w-Q_Mj_;xjeySux)YjD@aAp}c8 zfpv$sv}i(&pr3f8k?M$N~cn(4F3Q5=Tz!W zApb3|pZ$OIg55Iq?v~W8_0P_F=djhEopt@4cJ3KBX>zpV=ErXZkz4L zP2PKtaXarZZpF3M8@GG3-S+b~H#f`?SH1R1snqJj(y99UvR{>3dp9-PxW-}gr&15i znM&pD+vDE_J&yET{1~BhV0!Zl>aYKn2Or<_UplqbVHD+m4&x%f!oQ_Z?^;UDmHKC; z68*wH#?6�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

~ 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

LAe79CZ}v8d#yoweF&`ySN?; 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�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

bKEh#u)9WPFBa1=o2d5yv~^VE?I17DV#7SZ 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 -$J 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