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 0000000..addba08 Binary files /dev/null and b/mods/Tg-notify.bot/references/Telegram.Bot.dll differ diff --git a/mods/Tg-notify.bot/tg-notify.js b/mods/Tg-notify.bot/tg-notify.js new file mode 100644 index 0000000..bf507ed --- /dev/null +++ b/mods/Tg-notify.bot/tg-notify.js @@ -0,0 +1,407 @@ +(function () { + 'use strict'; + + var lampac_host = '{localhost}'; + var network = new Lampa.Reguest(); + + // Пользователя выбирает сервер, как в account() остальных плагинов Lampac: token, + // account_email и uid передаются отдельными параметрами, RequestInfo.getuid берёт первое + // непустое. Иначе подписки живут под номером устройства, а закладки и тайм-коды того же + // человека — под его аккаунтом. + function apiUrl(path) { + var url = lampac_host + path; + var ids = { + token: Lampa.Storage.get('token', ''), + account_email: Lampa.Storage.get('account_email', ''), + uid: Lampa.Storage.get('lampac_unic_id', '') + }; + for (var name in ids) { + if (ids[name]) url = Lampa.Utils.addUrlComponent(url, name + '=' + encodeURIComponent(ids[name])); + } + return url; + } + + function pad(n) { return n < 10 ? '0' + n : '' + n; } + + var TMDB_KEY = ''; + + // TMDB image/api с поддержкой прокси — используем встроенные методы Lampa + function tmdbImage(url) { + try { return Lampa.TMDB.image(url); } catch(e) {} + return 'https://image.tmdb.org/' + url; + } + + function tmdbApi(path) { + try { return Lampa.TMDB.api(path + '&api_key=' + (Lampa.TMDB.key ? Lampa.TMDB.key() : TMDB_KEY)); } catch(e) {} + return 'https://api.themoviedb.org/3/' + path + '&api_key=' + TMDB_KEY; + } + + // ============================================= + // Компонент подписок — построен по образцу + // src/interaction/items/category/base.js + // ============================================= + function TgSubscribesComponent(object) { + var scroll = new Lampa.Scroll({ mask: true, over: true, step: 250, end_ratio: 2 }); + var items = []; + var html = document.createElement('div'); + var body = $(document.createElement('div')); // jQuery объект, как в Lampa + var last; + var active = 0; + + this.create = function () { + var self = this; + + // Как в base.js: сначала scroll.minus(), потом append body + scroll.minus(); + scroll.append(body); + html.appendChild(scroll.render(true)); + + // Layer.visible при скролле — для ленивой загрузки изображений + scroll.onScroll = function () { + Lampa.Layer.visible(scroll.render(true)); + }; + + self.activity.loader(true); + + var net = new Lampa.Reguest(); + net.timeout(15000); + + net['native'](apiUrl('/api/tg/subscriptions'), function (result) { + if (!result.linked || !result.results || !result.results.length) { + self.empty(); + return; + } + + var subs = result.results; + var lang = Lampa.Storage.field('tmdb_lang') || 'ru-RU'; + var total = subs.length; + var done = 0; + var tmdbData = {}; + + function buildCards() { + // Классы сетки из items.js: mapping--grid + cols--6 + body.addClass('mapping--grid'); + body.addClass('cols--6'); + + var frag = document.createDocumentFragment(); + + subs.forEach(function (sub) { + var t = tmdbData[sub.tmdb_id] || {}; + + var cardData = { + id: sub.tmdb_id, + tmdb_id: sub.tmdb_id, + tmdb: sub.tmdb_id, + name: t.name || sub.title, + title: t.name || sub.title, + original_name: t.original_name || sub.title, + poster_path: t.poster_path || '', + backdrop_path: t.backdrop_path || '', + vote_average: t.vote_average || 0, + first_air_date: t.first_air_date || '', + number_of_seasons: t.number_of_seasons || sub.last_season, + source: 'tmdb' + }; + + if (t.poster_path) + cardData.img = tmdbImage('t/p/w300' + t.poster_path); + + var card = new Lampa.Card(cardData, { object: object, card_category: true }); + card.create(); + + var season = parseInt(sub.last_season) || 0; + var episode = parseInt(sub.last_episode) || 0; + var voice = (sub.voice || '').trim(); + + // Бейдж подписки — без pad() как в стандартном Subscribe модуле Lampa + var badge = document.createElement('div'); + badge.className = 'card__subscribe'; + badge.innerHTML = + '' + + '' + + ''; + var view = card.render(true).querySelector('.card__view'); + if (view) view.after(badge); + + card.onFocus = function (target, data) { + last = target; + active = items.indexOf(card); + scroll.update($(target), true); + // Используем стандартную функцию Lampa — сама выбирает постер/backdrop по настройкам + Lampa.Background.change(Lampa.Utils.cardImgBackground(data)); + }; + + card.onEnter = function (target, data) { + Lampa.Activity.push({ + url: '', component: 'full', + id: data.tmdb_id || data.id, + method: 'tv', card: data, source: 'tmdb' + }); + }; + + frag.appendChild(card.render(true)); + items.push(card); + }); + + body.append(frag); + + // Устанавливаем фон на первую карточку при открытии + if (items.length > 0) { + Lampa.Background.change(Lampa.Utils.cardImgBackground(items[0].data)); + } + + // Layer.visible — запускает ленивую загрузку изображений в карточках + Lampa.Layer.visible(scroll.render(true)); + + self.activity.loader(false); + self.activity.toggle(); + } + + // Параллельная загрузка TMDB через Lampa.Network (с кешем и прокси) + subs.forEach(function (sub) { + var url = tmdbApi('tv/' + sub.tmdb_id + '?language=' + lang); + Lampa.Network.silent(url, function (t) { + if (t && !t.status_code) tmdbData[sub.tmdb_id] = t; + if (++done >= total) buildCards(); + }, + function () { if (++done >= total) buildCards(); }, + false, { cache: { life: 60 * 24 } } + ); + }); + + }, function () { self.empty(); }); + }; + + this.empty = function () { + var empty = new Lampa.Empty(); + html.appendChild(empty.render(true)); + this.start = empty.start.bind(empty); + this.activity.loader(false); + this.activity.toggle(); + }; + + // start() — точная копия base.js + this.start = function () { + var self = this; + Lampa.Controller.add('content', { + link: self, + invisible: true, + toggle: function () { + scroll.restorePosition ? scroll.restorePosition() : null; + Lampa.Controller.collectionSet(scroll.render(true)); + Lampa.Controller.collectionFocus(last || false, scroll.render(true)); + }, + left: function () { if (Navigator.canmove('left')) Navigator.move('left'); else Lampa.Controller.toggle('menu'); }, + right: function () { if (Navigator.canmove('right')) Navigator.move('right'); }, + up: function () { if (Navigator.canmove('up')) Navigator.move('up'); else Lampa.Controller.toggle('head'); }, + down: function () { if (Navigator.canmove('down')) Navigator.move('down'); }, + back: function () { Lampa.Activity.backward(); } + }); + Lampa.Controller.toggle('content'); + }; + + this.pause = function () {}; + this.stop = function () {}; + this.render = function (js) { return js ? html : $(html); }; + this.destroy = function () { + items.forEach(function (c) { try { c.destroy(); } catch(e) {} }); + scroll.destroy(); + html.remove(); + items = []; + }; + } + + // ============================================= + // Меню + // ============================================= + var MENU_ICON = ''; + var MENU_TITLE = 'TG Подписки'; + var MENU_ACTION = 'tg_subscribes'; + + function openSubscribesPage() { + Lampa.Activity.push({ url: '', title: MENU_TITLE, component: MENU_ACTION, page: 1 }); + } + + function addMenu() { + if ($('.menu__item[data-action="' + MENU_ACTION + '"]').length) return; + Lampa.Menu.addButton(MENU_ICON, MENU_TITLE, openSubscribesPage).attr('data-action', MENU_ACTION); + try { + var sort = Lampa.Storage.get('menu_sort', '[]'); + if (typeof sort === 'string') sort = JSON.parse(sort); + if (Array.isArray(sort) && sort.indexOf(MENU_TITLE) === -1) { + sort.push(MENU_TITLE); + Lampa.Storage.set('menu_sort', JSON.stringify(sort)); + } + } catch(e) {} + } + + function registerComponent() { + Lampa.Component.add(MENU_ACTION, TgSubscribesComponent); + setTimeout(addMenu, 500); + } + + // ============================================= + // Перехват кнопки 🔔 в карточке сериала + // ============================================= + function initSubscribeOverride() { + Lampa.Listener.follow('full', function (e) { + if (e.type === 'complite') + setTimeout(function () { overrideSubscribeButton(e.object); }, 300); + }); + } + + function overrideSubscribeButton(obj) { + if (!obj || !obj.card || !obj.card.number_of_seasons) return; + var card = obj.card; + var button; + try { button = obj.activity.render().find('.button--subscribe'); } catch(e) { return; } + if (!button || !button.length) return; + + button.removeClass('hide'); + button.off('hover:enter'); + + checkStatus(card.id, function (status) { + if (status.subscribed) button.addClass('active').find('path').attr('fill', 'currentColor'); + button.on('hover:enter', function () { + if (!status.linked) showLinkDialog(); + else showVoiceMenu(card, button, status); + }); + }); + } + + function showVoiceMenu(card, button, currentStatus) { + var title = card.name || card.title || ''; + var year = card.first_air_date ? parseInt(card.first_air_date) : (card.year || 0); + var tmdbId = card.id || 0; + + Lampa.Loading.start(function () { network.clear(); Lampa.Loading.stop(); }); + network.clear(); + network.timeout(30000); + network['native']( + apiUrl('/api/tg/voices?title=' + encodeURIComponent(title) + '&year=' + year + '&season=0&tmdb_id=' + tmdbId), + function (result) { + Lampa.Loading.stop(); + var season = result.season || card.number_of_seasons || 1; + var items = []; + + if (currentStatus && currentStatus.subscribed && currentStatus.voices) { + currentStatus.voices.forEach(function (v) { + items.push({ title: '❌ Отписаться: ' + (v || 'Любая озвучка'), unsubscribe: true, voice: v }); + }); + if (items.length) items.push({ title: '', separator: true }); + } + + items.push({ title: '🔔 Любая озвучка (оригинал)', subtitle: 'При выходе серии', voice: '', orid: '', voice_id: 0, collaps_orid: '', voice_source: '' }); + + if (result.success && result.voices && result.voices.length) { + items.push({ title: 'Озвучки:', separator: true }); + result.voices.forEach(function (v) { + var src = v.source || 'mirage'; + var badge = src === 'collaps' ? ' [C]' : src === 'rhs' ? ' [R]' : src === 'videohub' ? ' [V]' : ''; + items.push({ title: '🎙 ' + v.name + badge, voice: v.name, orid: result.orid || '', voice_id: v.id || 0, collaps_orid: result.collaps_orid || '', kp_id: result.kp_id || 0, voice_source: src }); + }); + } + + Lampa.Select.show({ + title: 'TG Уведомления', items: items, + onSelect: function (a) { + Lampa.Controller.toggle('content'); + if (a.unsubscribe) doUnsubscribe(card, button, a.voice); + else if (typeof a.voice !== 'undefined') doSubscribe(card, a.voice, season, button, a.orid, a.voice_id, a.collaps_orid, a.voice_source, a.kp_id); + }, + onBack: function () { Lampa.Controller.toggle('content'); } + }); + }, + function () { + Lampa.Loading.stop(); + var items = [{ title: '🔔 Подписаться (без озвучки)', voice: '', orid: '', voice_id: 0 }]; + if (currentStatus && currentStatus.subscribed) items.unshift({ title: '❌ Отписаться', unsubscribe: true, voice: '' }); + Lampa.Select.show({ + title: 'TG Уведомления', items: items, + onSelect: function (a) { + Lampa.Controller.toggle('content'); + if (a.unsubscribe) doUnsubscribe(card, button, null); + else doSubscribe(card, '', card.number_of_seasons || 1, button, '', 0); + }, + onBack: function () { Lampa.Controller.toggle('content'); } + }); + } + ); + } + + function doSubscribe(card, voice, season, button, orid, voiceId, collapsOrid, voiceSource, kpId) { + network.clear(); + network['native'](apiUrl('/api/tg/subscribe'), function (r) { + if (r.success) { + Lampa.Noty.show(voice ? '🔔 Подписка: ' + voice : '🔔 Подписка оформлена!'); + button.addClass('active').find('path').attr('fill', 'currentColor'); + } else if (r.msg === 'not_linked') { + showLinkDialog(); + } else { + Lampa.Noty.show('Ошибка: ' + (r.msg || '')); + } + }, function () { + Lampa.Noty.show('Ошибка подписки'); + }, JSON.stringify({ tmdb_id: card.id, title: card.name || card.title || '', voice: voice, season: season, episode: 0, mirage_orid: orid || '', mirage_voice_id: voiceId || 0, voice_episode: 0, collaps_orid: collapsOrid || '', voice_source: voiceSource || '', kp_id: (card.kinopoisk_id || card.kp_id || kpId || 0) }), + { dataType: 'json', contentType: 'application/json' }); + } + + function doUnsubscribe(card, button, voice) { + network.clear(); + network['native'](apiUrl('/api/tg/unsubscribe'), function (r) { + if (r.success) { + Lampa.Noty.show('Подписка отменена'); + checkStatus(card.id, function (st) { + if (!st.subscribed) button.removeClass('active').find('path').attr('fill', 'transparent'); + }); + } + }, function () { Lampa.Noty.show('Ошибка'); }, + JSON.stringify({ tmdb_id: card.id, voice: voice }), + { dataType: 'json', contentType: 'application/json' }); + } + + function checkStatus(tmdb_id, callback) { + var n = new Lampa.Reguest(); + n.timeout(5000); + n['native'](apiUrl('/api/tg/status?tmdb_id=' + tmdb_id), + function (r) { callback(r || { success: false, linked: false, subscribed: false, voices: [] }); }, + function () { callback({ success: false, linked: false, subscribed: false, voices: [] }); } + ); + } + + function showLinkDialog() { + var n = new Lampa.Reguest(); + n['native'](apiUrl('/api/tg/link'), function (r) { + if (!r.success || !r.link) { Lampa.Noty.show('Бот не запущен'); return; } + Lampa.Select.show({ + title: 'Привязка Telegram', + items: [ + { title: 'Открыть ссылку', subtitle: r.link, link: r.link }, + { title: 'Скопировать ссылку', show_link: r.link } + ], + onSelect: function (a) { + Lampa.Controller.toggle('content'); + if (a.link) { + if (typeof Android !== 'undefined' && Android.openBrowser) Android.openBrowser(a.link); + else if (window.open) window.open(a.link, '_blank'); + } + if (a.show_link) Lampa.Noty.show(a.show_link); + }, + onBack: function () { Lampa.Controller.toggle('content'); } + }); + }, function () { Lampa.Noty.show('Модуль TelegramBot недоступен'); }); + } + + // ============================================= + // Init + // ============================================= + function init() { + registerComponent(); + initSubscribeOverride(); + console.log('[TG-Notify] Plugin loaded v17'); + } + + if (window.appready) init(); + else Lampa.Listener.follow('app', function (e) { if (e.type === 'ready') init(); }); + +})(); diff --git a/mods/Tracks/Controller.cs b/mods/Tracks/Controller.cs new file mode 100644 index 0000000..202261f --- /dev/null +++ b/mods/Tracks/Controller.cs @@ -0,0 +1,148 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Attributes; +using Shared.Services; +using Shared.Services.Utilities; +using System; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; + +namespace Tracks; + +public class TracksController : BaseController +{ + [HttpGet, AllowAnonymous] + [Staticache(cacheMinutes: 10, always: true, setHeadersNoCache: true)] + [Route("tracks.js")] + [Route("tracks/js/{token}")] + public ActionResult Tracks(string token) + { + string plugin = FileCache.ReadAllText($"{ModInit.modpath}/plugin.js", "tracks.js") + .Replace("{localhost}", host) + .Replace("{token}", HttpUtility.UrlEncode(token)); + + return ContentTo(plugin, "application/javascript; charset=utf-8"); + } + + + [HttpGet] + [Route("ffprobe")] + async public Task Ffprobe(string media, bool showerror) + { + if (string.IsNullOrWhiteSpace(media) || !media.StartsWith("http") || media.Contains("/transcoding/")) + return ContentTo("{}"); + + string magnethash = null; + + if (media.Contains("/dlna/stream")) + { + string path = Regex.Match(media, "\\?path=([^&]+)").Groups[1].Value; + if (string.IsNullOrWhiteSpace(path)) + return ContentTo(showerror ? "path" : "{}"); + + magnethash = HttpUtility.UrlDecode(path); + } + else if (media.Contains("/stream/") || media.Contains("/lite/pidtor/")) + { + media = Regex.Replace(media, "[^a-z0-9_:\\-\\/\\.\\=\\?\\&\\%\\@]+", "", RegexOptions.IgnoreCase); + + if (media.Contains("/stream/") && ModInit.conf.tsuri != null) + media = Regex.Replace(media, "^https?://[^/]+", ModInit.conf.tsuri, RegexOptions.IgnoreCase); + } + else if (media.Contains("/proxy/") && media.Contains(".mkv")) + { + string hash = Regex.Match(media, "/proxy/([^\n\r]+\\.mkv)").Groups[1].Value; + media = ProxyLink.Decrypt(hash.AsSpan(), null)?.uri; + if (string.IsNullOrWhiteSpace(media)) + return ContentTo(showerror ? "media" : "{}"); + } + + string argumentList = string.Empty; + + string memKey = $"tracks:ffprobe:{media}"; + if (!hybridCache.TryGetValue(memKey, out string outPut)) + { + #region getFolder + static string getFolder(string magnethash) + { + return $"database/tracks/{magnethash}"; + } + #endregion + + if (media.Contains("/stream/")) + { + magnethash = Regex.Match(media, "link=([a-z0-9]+)").Groups[1].Value; + string index = Regex.Match(media, @"index=([0-9]+)", RegexOptions.IgnoreCase).Groups[1].Value; + + if (!string.IsNullOrWhiteSpace(magnethash)) + magnethash = $"{magnethash}_{index}"; + } + else if (media.Contains("/lite/pidtor/")) + { + magnethash = Regex.Match(media, "/lite/pidtor/s([a-z0-9]+)").Groups[1].Value; + string index = Regex.Match(media, @"tsid=([0-9]+)", RegexOptions.IgnoreCase).Groups[1].Value; + + if (!string.IsNullOrWhiteSpace(magnethash)) + magnethash = $"{magnethash}_{index}"; + } + + if (string.IsNullOrEmpty(magnethash)) + magnethash = media; + + // A cache key must never remain a user-controlled filesystem path. + magnethash = CrypTo.md5(magnethash); + + if (System.IO.File.Exists(getFolder(magnethash))) + outPut = await BrotliTo.DecompressAsync(getFolder(magnethash)); + + if (string.IsNullOrWhiteSpace(outPut)) + { + if (!Uri.TryCreate(media, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + return ContentTo(showerror ? "uri" : "{}"); + + var process = new System.Diagnostics.Process(); + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.StandardOutputEncoding = Encoding.UTF8; + process.StartInfo.FileName = CoreInit.Win32NT ? "data/ffprobe.exe" : System.IO.File.Exists("data/ffprobe") ? "data/ffprobe" : "ffprobe"; + + process.StartInfo.ArgumentList.Add("-v"); + process.StartInfo.ArgumentList.Add("quiet"); + process.StartInfo.ArgumentList.Add("-print_format"); + process.StartInfo.ArgumentList.Add("json"); + process.StartInfo.ArgumentList.Add("-show_format"); + process.StartInfo.ArgumentList.Add("-show_streams"); + process.StartInfo.ArgumentList.Add(AccsDbInvk.Args(uri.AbsoluteUri, HttpContext)); + + argumentList = process.StartInfo.FileName + " " + string.Join(" ", process.StartInfo.ArgumentList); + + process.Start(); + + outPut = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + + if (outPut == null) + outPut = string.Empty; + + if (Regex.Replace(outPut, "[\n\r\t ]+", "") == "{}") + outPut = string.Empty; + + if (!string.IsNullOrWhiteSpace(outPut) && !string.IsNullOrWhiteSpace(magnethash)) + { + await BrotliTo.CompressAsync(getFolder(magnethash), outPut); + } + else + { + // заглушка + hybridCache.Set(memKey, outPut, DateTime.Now.AddMinutes(5), inmemory: true); + } + } + } + + return ContentTo(string.IsNullOrEmpty(outPut) ? (showerror ? argumentList : "{}") : outPut); + } +} diff --git a/mods/Tracks/ModInit.cs b/mods/Tracks/ModInit.cs new file mode 100644 index 0000000..ac00e62 --- /dev/null +++ b/mods/Tracks/ModInit.cs @@ -0,0 +1,46 @@ +using Shared; +using Shared.Services; +using Shared.Models.AppConf; +using Shared.Models.Events; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using System.Collections.Generic; +using System.IO; + +namespace Tracks; + +public class ModInit : IModuleLoaded +{ + public static string modpath; + public static ModuleConf conf; + + public void Loaded(InitspaceModel initspace) + { + modpath = initspace.path; + + updateConf(); + EventListener.UpdateInitFile += updateConf; + + foreach (var m in conf.limit_map) + CoreInit.conf.WAF.limit_map.Insert(0, m); + + Directory.CreateDirectory("database/tracks"); + } + + public void Dispose() + { + EventListener.UpdateInitFile -= updateConf; + } + + void updateConf() + { + conf = ModuleInvoke.Init("Tracks", new ModuleConf() + { + tsuri = null, + limit_map = new List() + { + new("^/ffprobe", new WafLimitMap { limit = 10, second = 1 }) + } + }); + } +} diff --git a/mods/Tracks/ModuleConf.cs b/mods/Tracks/ModuleConf.cs new file mode 100644 index 0000000..d7d7680 --- /dev/null +++ b/mods/Tracks/ModuleConf.cs @@ -0,0 +1,8 @@ +using Shared.Models.Module; + +namespace Tracks; + +public class ModuleConf : ModuleBaseConf +{ + public string tsuri { get; set; } +} diff --git a/mods/Tracks/README.md b/mods/Tracks/README.md new file mode 100644 index 0000000..0c35a34 --- /dev/null +++ b/mods/Tracks/README.md @@ -0,0 +1,30 @@ +# Tracks + +Модуль **Tracks**: загружает **`ModuleConf`** и регистрирует лимиты WAF для тяжёлых endpoint’ов (например **`ffprobe`**). Путь модуля доступен как **`Tracks.modpath`**. + +## Интерфейс + +**`IModuleLoaded`** — без **`IModuleOnline`**. + +## Поведение (`Loaded`) + +- **`modpath = initspace.path`**; +- **`conf.limit_map`** вставляется в начало **`CoreInit.conf.WAF.limit_map`**; +- создаётся каталог **`database/tracks`**. + +## Конфигурация + +Секция в `init.conf`: **`Tracks`** (`ModuleConf`). + +По умолчанию в коде: **`tsuri = null`**, правило **`^/ffprobe`** — **`limit = 10`** запросов за **`1`** секунду. + +## HTTP + +| Маршрут | Назначение | +|---------|------------| +| **`tracks.js`**, **`tracks/js/{token}`** | Клиентский скрипт / токенизированная версия. | +| **`ffprobe`** | Вызов ffprobe (ограничен WAF). | + +## Файлы + +**`ModInit.cs`**, **`Controller.cs`**. diff --git a/mods/Tracks/Tracks.csproj b/mods/Tracks/Tracks.csproj new file mode 100644 index 0000000..c5b2704 --- /dev/null +++ b/mods/Tracks/Tracks.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + library + true + + + + + + + + + Always + + + + diff --git a/mods/Tracks/manifest.json b/mods/Tracks/manifest.json new file mode 100644 index 0000000..909561d --- /dev/null +++ b/mods/Tracks/manifest.json @@ -0,0 +1,9 @@ +{ + "enable": true, + "dynamic": true, + "tree": [ + "Controller.cs", + "ModInit.cs", + "ModuleConf.cs" + ] +} diff --git a/mods/Tracks/plugin.js b/mods/Tracks/plugin.js new file mode 100644 index 0000000..5181040 --- /dev/null +++ b/mods/Tracks/plugin.js @@ -0,0 +1,401 @@ +(function () { + 'use strict'; + + var connect_host = '{localhost}'; + var list_opened = false; + + var unic_id = Lampa.Storage.get('lampac_unic_id', ''); + if (!unic_id) { + unic_id = Lampa.Utils.uid(8).toLowerCase(); + Lampa.Storage.set('lampac_unic_id', unic_id); + } + + function reguest(params, callback) { + if (params.ffprobe) { + setTimeout(function () { + callback({ + streams: params.ffprobe + }); + }, 200); + } else { + var net = new Lampa.Reguest(); + net.timeout(1000 * 15); + var url = connect_host + '/ffprobe?media=' + encodeURIComponent(params.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)); + } + + if (url.indexOf('token=') == -1) { + var token = '{token}'; + if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token={token}'); + } + + net["native"](url, function (str) { + var json = {}; + + try { + json = JSON.parse(str); + } catch (e) {} + + if (json.streams) callback(json); + }, false, false, { + dataType: 'text' + }); + } + } + + function subscribeTracks(data) { + var inited = false; + var inited_parse = false; + var webos_replace = {}; + + function log() { + console.log.apply(console.log, arguments); + } + + function getTracks() { + var video = Lampa.PlayerVideo.video(); + return video.audioTracks || []; + } + + function getSubs() { + var video = Lampa.PlayerVideo.video(); + return video.textTracks || []; + } + + log('Tracks', 'start'); + + function setTracks() { + if (inited_parse) { + var new_tracks = []; + var video_tracks = getTracks(); + var parse_tracks = inited_parse.streams.filter(function (a) { + return a.codec_type == 'audio'; + }); + var minus = 1; + log('Tracks', 'set tracks:', video_tracks.length); + if (parse_tracks.length !== video_tracks.length) parse_tracks = parse_tracks.filter(function (a) { + return a.codec_name !== 'dts'; + }); + parse_tracks = parse_tracks.filter(function (a) { + return a.tags; + }); + log('Tracks', 'filtred tracks:', parse_tracks.length); + parse_tracks.forEach(function (track) { + var orig = video_tracks[track.index - minus]; + var elem = { + index: track.index - minus, + language: track.tags.language, + label: track.tags.title || track.tags.handler_name, + ghost: orig ? false : true, + selected: orig ? orig.selected == true || orig.enabled == true : false + }; + console.log('Tracks', 'tracks original', orig); + Object.defineProperty(elem, "enabled", { + set: function set(v) { + if (v) { + var aud = getTracks(); + var trk = aud[elem.index]; + + for (var i = 0; i < aud.length; i++) { + aud[i].enabled = false; + aud[i].selected = false; + } + + if (trk) { + trk.enabled = true; + trk.selected = true; + } + } + }, + get: function get() {} + }); + new_tracks.push(elem); + }); + if (parse_tracks.length) Lampa.PlayerPanel.setTracks(new_tracks); + } + } + + function setSubs() { + if (inited_parse) { + var new_subs = []; + var video_subs = getSubs(); + var parse_subs = inited_parse.streams.filter(function (a) { + return a.codec_type == 'subtitle'; + }); + var minus = inited_parse.streams.filter(function (a) { + return a.codec_type == 'audio'; + }).length + 1; + log('Tracks', 'set subs:', video_subs.length); + parse_subs = parse_subs.filter(function (a) { + return a.tags; + }); + log('Tracks', 'filtred subs:', parse_subs.length); + parse_subs.forEach(function (track) { + var orig = video_subs[track.index - minus]; + var elem = { + index: track.index - minus, + language: track.tags.language, + label: track.tags.title || track.tags.handler_name, + ghost: video_subs[track.index - minus] ? false : true, + selected: orig ? orig.selected == true || orig.mode == 'showing' : false + }; + console.log('Tracks', 'subs original', orig); + Object.defineProperty(elem, "mode", { + set: function set(v) { + if (v) { + var txt = getSubs(); + var sub = txt[elem.index]; + + for (var i = 0; i < txt.length; i++) { + txt[i].mode = 'disabled'; + txt[i].selected = false; + } + + if (sub) { + sub.mode = 'showing'; + sub.selected = true; + } + } + }, + get: function get() {} + }); + new_subs.push(elem); + }); + if (parse_subs.length) Lampa.PlayerPanel.setSubs(new_subs); + } + } + + function listenTracks() { + log('Tracks', 'tracks video event'); + setTracks(); + Lampa.PlayerVideo.listener.remove('tracks', listenTracks); + } + + function listenSubs() { + log('Tracks', 'subs video event'); + setSubs(); + Lampa.PlayerVideo.listener.remove('subs', listenSubs); + } + + function canPlay() { + log('Tracks', 'canplay video event'); + if (webos_replace.tracks) setWebosTracks(webos_replace.tracks);else setTracks(); + if (webos_replace.subs) setWebosSubs(webos_replace.subs);else setSubs(); + Lampa.PlayerVideo.listener.remove('canplay', canPlay); + } + + function setWebosTracks(video_tracks) { + if (inited_parse) { + var parse_tracks = inited_parse.streams.filter(function (a) { + return a.codec_type == 'audio'; + }); + log('Tracks', 'webos set tracks:', video_tracks.length); + + if (parse_tracks.length !== video_tracks.length) { + parse_tracks = parse_tracks.filter(function (a) { + return a.codec_name !== 'truehd'; + }); + + if (parse_tracks.length !== video_tracks.length) { + parse_tracks = parse_tracks.filter(function (a) { + return a.codec_name !== 'dts'; + }); + } + } + + parse_tracks = parse_tracks.filter(function (a) { + return a.tags; + }); + log('Tracks', 'webos tracks', video_tracks); + parse_tracks.forEach(function (track, i) { + if (video_tracks[i]) { + video_tracks[i].language = track.tags.language; + video_tracks[i].label = track.tags.title || track.tags.handler_name; + } + }); + } + } + + function setWebosSubs(video_subs) { + if (inited_parse) { + var parse_subs = inited_parse.streams.filter(function (a) { + return a.codec_type == 'subtitle'; + }); + log('Tracks', 'webos set subs:', video_subs.length); + if (parse_subs.length !== video_subs.length - 1) parse_subs = parse_subs.filter(function (a) { + return a.codec_name !== 'hdmv_pgs_subtitle'; + }); + parse_subs = parse_subs.filter(function (a) { + return a.tags; + }); + parse_subs.forEach(function (track, a) { + var i = a + 1; + + if (video_subs[i]) { + video_subs[i].language = track.tags.language; + video_subs[i].label = track.tags.title || track.tags.handler_name; + } + }); + } + } + + function listenWebosSubs(_data) { + log('Tracks', 'webos subs event'); + webos_replace.subs = _data.subs; + if (inited_parse) setWebosSubs(_data.subs); + } + + function listenWebosTracks(_data) { + log('Tracks', 'webos tracks event'); + webos_replace.tracks = _data.tracks; + if (inited_parse) setWebosTracks(_data.tracks); + } + + function listenStart() { + inited = true; + reguest(data, function (result) { + log('Tracks', 'parsed', inited_parse); + inited_parse = result; + + if (inited) { + if (webos_replace.subs) setWebosSubs(webos_replace.subs);else setSubs(); + if (webos_replace.tracks) setWebosTracks(webos_replace.tracks);else setTracks(); + } + }); + } + + function listenDestroy() { + inited = false; + Lampa.Player.listener.remove('destroy', listenDestroy); + Lampa.PlayerVideo.listener.remove('tracks', listenTracks); + Lampa.PlayerVideo.listener.remove('subs', listenSubs); + Lampa.PlayerVideo.listener.remove('webos_subs', listenWebosSubs); + Lampa.PlayerVideo.listener.remove('webos_tracks', listenWebosTracks); + Lampa.PlayerVideo.listener.remove('canplay', canPlay); + log('Tracks', 'end'); + } + + Lampa.Player.listener.follow('destroy', listenDestroy); + Lampa.PlayerVideo.listener.follow('tracks', listenTracks); + Lampa.PlayerVideo.listener.follow('subs', listenSubs); + Lampa.PlayerVideo.listener.follow('webos_subs', listenWebosSubs); + Lampa.PlayerVideo.listener.follow('webos_tracks', listenWebosTracks); + Lampa.PlayerVideo.listener.follow('canplay', canPlay); + listenStart(); + } + + function parseMetainfo(data) { + var loading = Lampa.Template.get('tracks_loading'); + data.item.after(loading); + reguest(data.element, function (result) { + if (list_opened) { + var append = function append(name, fields) { + if (fields.length) { + var block = Lampa.Template.get('tracks_metainfo_block', {}); + block.find('.tracks-metainfo__label').text(Lampa.Lang.translate(name == 'video' ? 'extensions_hpu_video' : name == 'audio' ? 'player_tracks' : 'player_' + name)); + fields.forEach(function (data) { + var item = $('
'); + item.on('hover:focus', function (e) { + Lampa.Modal.scroll().update(item); + }); + + for (var i in data) { + var div = $('
'); + div.text(data[i]); + item.append(div); + } + + block.find('.tracks-metainfo__info').append(item); + }); + html.append(block); + } + }; + + var video = []; + var audio = []; + var subs = []; + var codec_video = result.streams.filter(function (a) { + return a.codec_type == 'video'; + }); + var codec_audio = result.streams.filter(function (a) { + return a.codec_type == 'audio'; + }); + var codec_subs = result.streams.filter(function (a) { + return a.codec_type == 'subtitle'; + }); + codec_video.slice(0, 1).forEach(function (v) { + var line = {}; + if (v.width && v.height) line.video = v.width + 'х' + v.height; + if (v.codec_name) line.codec = v.codec_name.toUpperCase(); + if (Boolean(v.is_avc)) line.avc = 'AVC'; + if (Lampa.Arrays.getKeys(line).length) video.push(line); + }); + codec_audio.forEach(function (a, i) { + var line = { + num: i + 1 + }; + + if (a.tags) { + line.lang = (a.tags.language || '').toUpperCase(); + } + + line.name = a.tags ? a.tags.title || a.tags.handler_name : ''; + if (a.codec_name) line.codec = a.codec_name.toUpperCase(); + if (a.channel_layout) line.channels = a.channel_layout.replace('(side)', '').replace('stereo', '2.0'); + var bit = a.bit_rate ? a.bit_rate : a.tags && (a.tags.BPS || a.tags["BPS-eng"]) ? a.tags.BPS || a.tags["BPS-eng"] : '--'; + line.rate = bit == '--' ? bit : Math.round(bit / 1000) + ' ' + Lampa.Lang.translate('speed_kb'); + if (Lampa.Arrays.getKeys(line).length) audio.push(line); + }); + codec_subs.forEach(function (a, i) { + var line = { + num: i + 1 + }; + + if (a.tags) { + line.lang = (a.tags.language || '').toUpperCase(); + } + + line.name = a.tags ? a.tags.title || a.tags.handler_name : ''; + if (Lampa.Arrays.getKeys(line).length) subs.push(line); + }); + var html = Lampa.Template.get('tracks_metainfo', {}); + append('video', video); + append('audio', audio); + append('subs', subs); + loading.remove(); + + if (video.length || audio.length || subs.length) { + data.item.after(html); + } + + if (Lampa.Controller.enabled().name == 'modal') Lampa.Controller.toggle('modal'); + } + }); + } + + Lampa.Player.listener.follow('start', function (data) { + if (data.torrent_hash) subscribeTracks(data); + }); + Lampa.Listener.follow('torrent_file', function (data) { + if (data.type == 'list_open') list_opened = true; + if (data.type == 'list_close') list_opened = false; + + if (data.type == 'render' && data.items.length == 1 && list_opened) { + parseMetainfo(data); + } + }); + Lampa.Template.add('tracks_loading', "\n
\n #{loading}...\n
\n"); + Lampa.Template.add('tracks_metainfo', "\n
\n"); + Lampa.Template.add('tracks_metainfo_block', "\n
\n
\n
\n
\n"); + Lampa.Template.add('tracks_css', "\n \n"); + $('body').append(Lampa.Template.get('tracks_css', {}, true)); + +})(); diff --git a/mods/Transcoding/Controller.cs b/mods/Transcoding/Controller.cs new file mode 100644 index 0000000..0577747 --- /dev/null +++ b/mods/Transcoding/Controller.cs @@ -0,0 +1,594 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; +using Newtonsoft.Json; +using Shared; +using Shared.Services; +using Shared.Services.Pools; +using Shared.Models.Templates; +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using Shared.Services.Utilities; +using Shared.Services.Pools.Json; + +namespace Transcoding; + +[ApiController] +[Route("transcoding")] +public sealed class TranscodingController : Controller +{ + #region static + static readonly Serilog.ILogger Log = Serilog.Log.ForContext(); + + readonly TranscodingService _service = TranscodingService.Instance; + + static readonly FileExtensionContentTypeProvider provider = new FileExtensionContentTypeProvider() + { + Mappings = { + [".m4s"] = "video/mp4", + [".ts"] = "video/mp2t", + [".mp4"] = "video/mp4", + [".m2ts"] = "video/MP2T" + } + }; + #endregion + + #region transcoding.js + [AllowAnonymous] + [HttpGet("/transcoding.js")] + [HttpGet("js/{token}")] + public ActionResult TranscodingJs(string token) + { + HttpContext.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; // HTTP 1.1. + HttpContext.Response.Headers["Pragma"] = "no-cache"; // HTTP 1.0. + HttpContext.Response.Headers["Expires"] = "0"; // Proxies. + + var plugin = FileCache.ReadAllText($"{ModInit.modpath}/plugin.js", "transcoding.js") + .Replace("{localhost}", CoreInit.Host(HttpContext)) + .Replace("{token}", HttpUtility.UrlEncode(token)); + + return Content(plugin, "application/javascript; charset=utf-8"); + } + #endregion + + + #region Start + [HttpGet("start.m3u8")] + public async Task StartM3u8(string src, int a, int s, bool subtitles, bool live) + { + if (string.IsNullOrEmpty(src)) + return BadRequest(new { error = "src" }); + + if (src.Contains("/proxy/") && src.Contains(".mkv")) + { + string hash = Regex.Match(src, "/proxy/([^\n\r]+\\.mkv)").Groups[1].Value; + src = ProxyLink.Decrypt(hash.AsSpan(), null)?.uri; + if (string.IsNullOrWhiteSpace(src)) + return BadRequest(new { error = "src decrypt" }); + } + + var defaults = ModInit.conf; + + var (job, error) = await _service.Start(new TranscodingStartRequest() + { + src = src, + live = live, + subtitles = subtitles, + audio = new TranscodingAudioOptions() { index = a }, + hls = new TranscodingHlsOptions() { seek = s } + }); + + if (job == null) + return BadRequest(new { error }); + + string uri = $"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/{(live ? "live" : "main")}.m3u8"; + + return Redirect(AccsDbInvk.Args(uri, HttpContext)); + } + + [HttpPost("start")] + public async Task Start([FromBody] TranscodingStartRequest request) + { + if (request == null) + return BadRequest(new { error = "Request body is required" }); + + var (job, error) = await _service.Start(request); + if (job == null) + return BadRequest(new { error }); + + return Ok(new + { + job.StreamId, + playlistUrl = AccsDbInvk.Args($"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/{(job.Context.live ? "live" : "main")}.m3u8", HttpContext), + subtitlesUrl = job.Context.subtitles ? AccsDbInvk.Args($"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/subtitles", HttpContext) : null, + hls_timeout_seconds = 60 + }); + } + #endregion + + #region Seek + [HttpGet("{streamId}/seek/{ss}")] + public async Task Seek(string streamId, int ss) + { + if (!_service.TryResolveJob(streamId, out var job)) + return NotFound(); + + if (!job.Context.live) + return BadRequest(new { error = "Context not live" }); + + if (ss < 0) + return BadRequest(new { error = "ss must be greater or equal 0" }); + + var (success, error) = await _service.SeekAsync(streamId, ss); + if (!success) + return BadRequest(new { error }); + + return Ok(); + } + #endregion + + #region Live + [HttpGet("{streamId}/live.m3u8")] + public async Task Live(string streamId) + { + if (!_service.TryResolveJob(streamId, out var job)) + return NotFound(); + + if (!job.Context.live) + return BadRequest(new { error = "Context not live" }); + + _service.Touch(job); + + string path = Path.Combine(job.Context.OutputDirectory, "index.m3u8"); + + var fileExistsTimeout = TimeSpan.FromSeconds(60); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + bool fileExists = System.IO.File.Exists(path); + + while (!fileExists && sw.Elapsed < fileExistsTimeout) + { + await Task.Delay(250); + fileExists = System.IO.File.Exists(path); + if (fileExists) + break; + } + + if (!fileExists) + return NotFound(); + + string m3u8 = null; + + sw.Restart(); + while (sw.Elapsed < fileExistsTimeout) + { + try + { + m3u8 = System.IO.File.ReadAllText(path); + } + catch + { + m3u8 = null; + } + + if (!string.IsNullOrEmpty(m3u8) && Regex.IsMatch(m3u8, "seg_[0-9]+\\.(m4s|ts)")) + break; + + await Task.Delay(250); + } + + if (string.IsNullOrEmpty(m3u8)) + return NotFound(); + + m3u8 = Regex.Replace(m3u8, "#EXT-X-MAP:URI=[^\n\r]+", $"#EXT-X-MAP:URI=\"{AccsDbInvk.Args("init.mp4", HttpContext)}\""); + m3u8 = Regex.Replace(m3u8, "(seg_[0-9]+\\.(m4s|ts))", r => + { + string file = r.Groups[1].Value; + return AccsDbInvk.Args(file, HttpContext); + }); + + return Content(m3u8, "application/vnd.apple.mpegurl"); + } + #endregion + + #region Playlist + [HttpGet("{streamId}/main.m3u8")] + public async Task Playlist(string streamId) + { + if (!_service.TryResolveJob(streamId, out var job)) + return NotFound(); + + if (job.Context.live) + return BadRequest(new { error = "Context not playlist" }); + + if (!int.TryParse(job.Context.ffprobe["format"].Value("duration").Split('.')[0].Split(',')[0], out int duration) || duration == 0) + return BadRequest(new { error = "duration" }); + + _service.Touch(job); + + var fileExistsTimeout = TimeSpan.FromSeconds(60); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + while (sw.Elapsed < fileExistsTimeout) + { + if (Directory.GetFiles(job.Context.OutputDirectory).Length > 2) + break; + + await Task.Delay(250); + } + + int segDur = job.Context.HlsOptions.segDur; + + var builder = new StringBuilder(); + builder.AppendLine("#EXTM3U"); + builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); + builder.AppendLine($"#EXT-X-VERSION:{(job.Context.HlsOptions.fmp4 ? 7 : 3)}"); + builder.AppendLine($"#EXT-X-TARGETDURATION:{segDur}"); + builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); + + if (job.Context.HlsOptions.fmp4) + builder.AppendLine($"#EXT-X-MAP:URI=\"{AccsDbInvk.Args("init.mp4", HttpContext)}\""); + + for (int i = 0; i < (duration / segDur); i++) + { + builder.AppendLine($"#EXTINF:{segDur}.0,"); + builder.AppendLine(AccsDbInvk.Args($"seg_{i:d5}.{(job.Context.HlsOptions.fmp4 ? "m4s" : "ts")}", HttpContext)); + } + + builder.AppendLine("#EXT-X-ENDLIST"); + + return Content(builder.ToString(), "application/vnd.apple.mpegurl"); + } + #endregion + + #region Segment + [HttpGet("{streamId}/{file}")] + public async Task Segment(string streamId, string file) + { + if (!_service.TryResolveJob(streamId, out var job)) + return NotFound(); + + _service.Touch(job); + + int? segmentIndex = null; + if (!job.Context.live && file != null) + { + var matchSegment = Regex.Match(file, @"seg_(\d+)\.(m4s|ts)$", RegexOptions.IgnoreCase); + if (matchSegment.Success && int.TryParse(matchSegment.Groups[1].Value, out var idx)) + segmentIndex = idx; + } + + var fileExistsTimeout = TimeSpan.FromSeconds(60); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + string resolved = _service.GetFilePath(job, file); + + if (job.Context.live == false && resolved == null && !file.Contains(".vtt")) + { + #region SeekAsync + if (segmentIndex.HasValue) + { + int segDur = job.Context.HlsOptions.segDur; + int ss = segmentIndex.Value * segDur; + + if (job.Context.HlsOptions.seek == 0 && 30 > ss) + { + // первые 30 секунд без seek-а - не трогаем + } + else if (job.Context.HlsOptions.seek == ss) + { + // ffmpeg на текущем сегменте + } + else + { + // работает дальше чем текущий сегмент + bool goSeek = job.Context.HlsOptions.seek > ss; + + string extension = Path.GetExtension(file); + int segmentsPerMinute = (int)Math.Ceiling(30.0 / segDur); + int startIndex = Math.Max(0, segmentIndex.Value - segmentsPerMinute); + + if (goSeek == false) + { + goSeek = true; + + for (int i = startIndex; i < segmentIndex.Value; i++) + { + string candidate = $"seg_{i:d5}{extension}"; + if (_service.GetFilePath(job, candidate) != null) + { + // есть сегменты в пределах последних 30 секунд + goSeek = false; + break; + } + } + } + + if (goSeek) + await _service.SeekAsync(streamId, ss, segmentIndex.Value); + } + } + #endregion + + do + { + try + { + await Task.Delay(200); + resolved = _service.GetFilePath(job, file); + if (resolved != null) + break; + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_om7pdvok"); + } + } + while (sw.Elapsed < fileExistsTimeout); + } + + if (resolved == null) + return NotFound(); + + #region ждем появления следующего сегмента + while (segmentIndex.HasValue && sw.Elapsed < fileExistsTimeout) + { + try + { + string seg = Path.Combine(job.OutputDirectory, $"seg_{segmentIndex.Value + 1:d5}"); + if (System.IO.File.Exists($"{seg}.{(job.Context.HlsOptions.fmp4 ? "m4s" : "ts")}")) + break; + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_4pxipm5i"); + } + + await Task.Delay(200); + } + #endregion + + #region FileStream + FileStream fs = null; + do + { + try + { + fs = file.Contains(".vtt") + ? new FileStream(resolved, FileMode.Open, FileAccess.Read, FileShare.ReadWrite) + : System.IO.File.OpenRead(resolved); + break; + } + catch (IOException) + { + await Task.Delay(200); + } + catch (UnauthorizedAccessException) + { + await Task.Delay(200); + } + } + while (sw.Elapsed < fileExistsTimeout); + + if (fs == null) + return NotFound(); + #endregion + + if (!provider.TryGetContentType(resolved, out var contentType)) + contentType = "application/octet-stream"; + + if (segmentIndex.HasValue) + _service.ReportSegmentAccess(job, segmentIndex.Value); + + return File(fs, contentType, enableRangeProcessing: true); + } + #endregion + + #region Subtitles + [HttpGet("{streamId}/subtitles")] + public IActionResult Subtitles(string streamId) + { + if (!_service.TryResolveJob(streamId, out var job)) + return NotFound(); + + _service.Touch(job); + + var subsTpl = new SubtitleTpl(); + + if (job.Context.subtitles && job.Context.ffprobe.ContainsKey("streams")) + { + foreach (var s in job.Context.ffprobe["streams"]) + { + if (s.Value("codec_type") != "subtitle") + continue; + + if (s.Value("codec_name") is "subrip" or "webvtt" or "ass" or "ssa" or "mov_text" or "ttml" or "sami") + { + int subIndex = s.Value("index"); + if (subIndex == 0) + continue; + + string uri = $"{CoreInit.Host(HttpContext)}/transcoding/{streamId}/{AccsDbInvk.Args($"subs_{subIndex}.vtt", HttpContext)}"; + + string name = s["tags"].Value("title") + ?? s["language"].Value("title") + ?? $"sub_{subIndex}"; + + subsTpl.Append(name, uri); + } + } + } + + return Ok(subsTpl.ToObject()); + } + #endregion + + #region Heartbeat + [HttpGet("{streamId}/heartbeat")] + public IActionResult Heartbeat(string streamId) + { + if (!_service.TryResolveJob(streamId, out var job)) + return NotFound(); + + _service.Touch(job); + return Ok(); + } + #endregion + + #region StopAsync + [HttpGet("{streamId}/stop")] + public async Task StopAsync(string streamId) + { + var stopped = await _service.StopAsync(streamId); + return stopped ? Ok() : NotFound(); + } + #endregion + + #region Status + [HttpGet("{streamId}/status")] + public IActionResult Status(string streamId) + { + if (!_service.TryResolveJob(streamId, out var job)) + return NotFound(); + + var now = DateTime.UtcNow; + var state = job.Process.HasExited ? TranscodingJobState.Stopped : TranscodingJobState.Running; + + var uptime = now - job.StartedUtc; + + int? exitCode = null; + try + { + if (job.Process.HasExited) + exitCode = job.Process.ExitCode; + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_iuxl2cac"); + } + + var snapshotLog = job.SnapshotLog(); + + ulong time_ms = 0; + foreach (string line in snapshotLog.Reverse()) + { + if (!line.Contains("out_time_ms=")) + continue; + + if (!ulong.TryParse(Regex.Match(line, "out_time_ms=([0-9]+)").Groups[1].Value, out time_ms)) + continue; + + break; + } + + return Content(JsonConvertPool.SerializeObject(new + { + job.StreamId, + state = state.ToString(), + job.OutputDirectory, + ffmpeg = string.Join(" ", job.Process.StartInfo.ArgumentList), + startedUtc = job.StartedUtc, + lastAccessUtc = job.LastAccessUtc, + uptime = uptime.TotalSeconds, + positionSec = (ulong)(job.Context.HlsOptions.seek + (time_ms > 0 ? (time_ms / 1000000.0) : 0)), + exitCode, + job.Context.ffprobe, + log = snapshotLog + }), "application/json; charset=utf-8"); + } + #endregion + + + #region DOC + [HttpGet("")] + public IActionResult DOC() + { + var endpoints = new object[] + { + new { + path = "/transcoding/start.m3u8", + method = "GET", + query = new object[] { + new { name = "src", type = "string", required = true, description = "Source URL or local path to media" }, + new { name = "a", type = "int", required = false, description = "Audio index (optional)" }, + new { name = "s", type = "int", required = false, description = "Seek position in seconds (optional)" }, + new { name = "subtitles", type = "bool", required = false, description = "subtitles on/off" }, + new { name = "live", type = "bool", required = false, description = "Context live/playlist" } + }, + description = "Start transcoding with query parameters and redirect to the generated HLS playlist" + }, + new { + path = "/transcoding/start", + method = "POST", + contentType = "application/json", + body = new { + src = "https://example.com/media.mp4", + videoFormat = "", + live = false, + subtitles = false, + headers = new { referer = "https://example.com", userAgent = "HlsProxy/1.0" }, + audio = ModInit.conf.audioOptions, + hls = ModInit.conf.hlsOptions + }, + description = "Start transcoding by POSTing a JSON body. Returns StreamId and playlist URL" + }, + new { + path = "/transcoding/{streamId}/live.m3u8", + method = "GET", + route = new { name = "streamId", type = "string", required = true }, + description = "Returns the HLS live for the given transcoding job" + }, + new { + path = "/transcoding/{streamId}/main.m3u8", + method = "GET", + route = new { name = "streamId", type = "string", required = true }, + description = "Returns the HLS master/variant playlist for the given transcoding job" + }, + new { + path = "/transcoding/{streamId}/{file}", + method = "GET", + route = new object[] { + new { name = "streamId", type = "string", required = true }, + new { name = "file", type = "string", required = true, description = "Requested segment or asset (e.g. init.mp4, seg_1.m4s, index.m3u8)" } + }, + description = "Serves individual segment files, init files and playlists produced by the transcoder. Supports range requests." + }, + new { + path = "/transcoding/{streamId}/seek/{ss}", + method = "GET", + route = new object[] { + new { name = "streamId", type = "string", required = true }, + new { name = "ss", type = "int", required = true, description = "Seek position in seconds (>= 0)" } + }, + description = "Request the transcoder to seek to the specified position (async). Returns 200 on success." + }, + new { + path = "/transcoding/{streamId}/heartbeat", + method = "GET", + route = new { name = "streamId", type = "string", required = true }, + description = "Touch the job to keep it alive. Returns 200 if job exists." + }, + new { + path = "/transcoding/{streamId}/stop", + method = "GET", + route = new { name = "streamId", type = "string", required = true }, + description = "Stop the transcoding job. Returns 200 if stopped or 404 if job not found." + }, + new { + path = "/transcoding/{streamId}/status", + method = "GET", + query = new { name = "log", type = "bool", required = false, description = "Include job log snapshot when true" }, + route = new { name = "streamId", type = "string", required = true }, + description = "Return current job status, uptime, position and optional log snapshot." + } + }; + + return Ok(JsonConvert.SerializeObject(endpoints, Formatting.Indented)); + } + #endregion +} diff --git a/mods/Transcoding/ModInit.cs b/mods/Transcoding/ModInit.cs new file mode 100644 index 0000000..53c89f9 --- /dev/null +++ b/mods/Transcoding/ModInit.cs @@ -0,0 +1,54 @@ +using Shared; +using Shared.Services; +using Shared.Models.AppConf; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using System.Collections.Generic; +using System.IO; +using Shared.Models.Events; + +namespace Transcoding; + +public class ModInit : IModuleLoaded +{ + public static string modpath; + public static ModuleConf conf; + + + public void Loaded(InitspaceModel initspace) + { + modpath = initspace.path; + + updateConf(); + EventListener.UpdateInitFile += updateConf; + + foreach (var m in conf.limit_map) + CoreInit.conf.WAF.limit_map.Insert(0, m); + + Directory.CreateDirectory("database/tracks"); + Directory.CreateDirectory(conf.tempRoot); + + TranscodingService.Instance.Configure(conf); + } + + public void Dispose() + { + EventListener.UpdateInitFile -= updateConf; + TranscodingService.Instance.StopAll(); + } + + void updateConf() + { + conf = ModuleInvoke.Init("transcoding", new ModuleConf() + { + tempRoot = Path.Combine("cache", "transcoding"), + idleTimeoutSec = 60 * 5, + idleTimeoutSec_live = 120, + maxConcurrentJobs = 5, + limit_map = new List() + { + new("^/transcoding/", new WafLimitMap { limit = 50, second = 1 }) + } + }); + } +} diff --git a/mods/Transcoding/ModuleConf.cs b/mods/Transcoding/ModuleConf.cs new file mode 100644 index 0000000..277c333 --- /dev/null +++ b/mods/Transcoding/ModuleConf.cs @@ -0,0 +1,153 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Shared.Models.Module; +using System; +using System.Collections.Generic; + +namespace Transcoding; + +public class ModuleConf : ModuleBaseConf +{ + public string ffmpeg { get; set; } + + public string tsuri { get; set; } + + public string tempRoot { get; set; } + + public int idleTimeoutSec { get; set; } + + public int idleTimeoutSec_live { get; set; } + + public bool defaultSubtitles { get; set; } + + public int maxConcurrentJobs { get; set; } + + public string[] allowHosts { get; set; } = Array.Empty(); + + public TranscodingHlsOptions hlsOptions { get; set; } = new(); + + public TranscodingAudioOptions audioOptions { get; set; } = new(); + + public TranscodingSubtitleOptions subtitleOptions { get; set; } = new(); + + public TranscodingPlaylistOptions playlistOptions { get; set; } = new(); + + public TranscodingConvertOptions convertOptions { get; set; } = new(); + + [JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] + public Dictionary comand { get; set; } = new Dictionary() + { + ["demuxer"] = ["-threads 0", "-fflags +genpts"], + ["input"] = ["-avoid_negative_ts disabled"], + ["output"] = [ + "-map 0:v:0", "-map 0:a:{audio_index}", + "-dn", "-sn", + "-map_metadata -1", "-map_chapters -1", "-max_muxing_queue_size 2048" + ], + }; +} + +public class TranscodingHlsOptions +{ + [JsonIgnore] + public int seek { get; set; } + + /// + /// hls_time + /// + public int segDur { get; set; } = 6; + + /// + /// hls_list_size + /// + public int winSize { get; set; } = 10; + + /// + /// hls_segment_type fmp4 / mpegts + /// + public bool fmp4 { get; set; } = true; + + [JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] + public Dictionary comand { get; set; } = new Dictionary() + { + ["output"] = ["-max_delay 5000000"], + ["segment_mpegts"] = ["-bsf:v h264_mp4toannexb"], + }; +} + +public class TranscodingAudioOptions +{ + [JsonIgnore] + public int index { get; set; } + + public int bitrateKbps { get; set; } = 192; + + public bool stereo { get; set; } = true; + + [JsonProperty("codec_copy", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] + public string[] codec_copy { get; set; } = Array.Empty(); + + [JsonProperty("comand_transcode", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] + public Dictionary comand_transcode { get; set; } = new Dictionary() + { + ["default"] = ["-c:a aac", "-ac {stereo}", "-b:a {bitrateKbps}", "-profile:a aac_low"] + }; +} + +public class TranscodingPlaylistOptions +{ + /// + /// sped + /// + public double readrate { get; set; } = 1.6; + + /// + /// 10 MB + /// + public int burst { get; set; } = 10485760; + + public bool delete_segments { get; set; } = true; +} + +public class TranscodingConvertOptions +{ + public bool transcodeVideo { get; set; } + + [JsonProperty("codec", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] + public string[] codec { get; set; } = { "mpeg4", "msmpeg4v3", "flv1", "av1" }; + + [JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] + public Dictionary comand { get; set; } = new Dictionary() + { + ["default"] = ["-c:v libx264", "-preset veryfast", "-tune zerolatency", "-pix_fmt yuv420p"], + + ["h264_yuv420p10le"] = [ + "-vf", "scale=in_color_matrix=bt2020nc:out_color_matrix=bt709:in_range=pc:out_range=tv,format=yuv420p", + "-c:v libx264", "-preset veryfast", "-tune zerolatency", "-pix_fmt yuv420p", + "-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709", + "-color_primaries bt709", "-color_trc bt709", "-colorspace bt709", "-color_range tv" + ] + }; +} + +public class TranscodingSubtitleOptions +{ + [JsonProperty("codec", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] + public string[] codec { get; set; } = { "subrip", "webvtt", "ass", "ssa", "mov_text", "ttml", "sami" }; + + [JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] + public string[] comand { get; set; } = ["-map 0:{subIndex}", "-an -vn", "-c:s webvtt", "-flush_packets 1", "-max_interleave_delta 0", "-muxpreload 0", "-muxdelay 0", "-f webvtt", "subs_{subIndex}.vtt"]; +} + +public record TranscodingStartContext( + Uri Source, + string UserAgent, + string Referer, + TranscodingHlsOptions HlsOptions, + TranscodingAudioOptions Audio, + bool live, + bool subtitles, + string OutputDirectory, + int? startNumber, + JObject ffprobe +); diff --git a/mods/Transcoding/README.md b/mods/Transcoding/README.md new file mode 100644 index 0000000..de26310 --- /dev/null +++ b/mods/Transcoding/README.md @@ -0,0 +1,35 @@ +Актуальная реализация:
+https://github.com/lampac-nextgen/lampac/blob/main/Modules/GStreamer/README.md + + +# Transcoding (legacy) + +Серверное **перекодирование потоков** (FFmpeg и связанная логика): очередь задач, каталог временных файлов, ограничение параллельных заданий и HTTP API под **`/transcoding`**. + +## Назначение + +- Путь модуля сохраняется в **`Transcoding.modpath`**. +- **`TranscodingService`** конфигурируется при загрузке модуля и останавливает все задачи при выгрузке (`StopAll`). +- Создаются каталоги **`database/tracks`** (общий паттерн с Tracks) и **`tempRoot`** для промежуточных файлов (по умолчанию `cache/transcoding`). + +## Конфигурация + +Секция в `init.conf`: **`transcoding`** (имя ключа в нижнем регистре в `ModuleInvoke.Init`). + +Параметры по умолчанию в **`ModInit`**: + +| Параметр | Значение в коде | +|----------|-----------------| +| **`tempRoot`** | **`cache/transcoding`** | +| **`idleTimeoutSec`** | **300** с | +| **`idleTimeoutSec_live`** | **120** с | +| **`maxConcurrentJobs`** | **5** | +| **`limit_map`** | **`^/transcoding/`** — **50** запросов / **1** с | + +## HTTP + +Базовый префикс контроллера: **`transcoding`** — дочерние маршруты см. **`Controller.cs`**. + +## Зависимости + +- Доступность **ffmpeg** на машине (или иной исполнитель, зашитый в `TranscodingService`). diff --git a/mods/Transcoding/Services/TranscodingJob.cs b/mods/Transcoding/Services/TranscodingJob.cs new file mode 100644 index 0000000..22f95a0 --- /dev/null +++ b/mods/Transcoding/Services/TranscodingJob.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; + +namespace Transcoding; + +internal sealed class TranscodingJob : IDisposable +{ + static readonly Serilog.ILogger Log = Serilog.Log.ForContext(); + + private const int MaxLogLines = 200; + + private readonly LinkedList _log = new(); + private readonly object _logSync = new(); + private readonly CancellationTokenSource _cts = new(); + private int _lastSegmentIndex = -1; + + public TranscodingJob(string id, string streamId, string outputDirectory, Process process, TranscodingStartContext context) + { + Id = id; + StreamId = streamId; + OutputDirectory = outputDirectory; + Process = process; + Context = context; + StartedUtc = DateTime.UtcNow; + LastAccessUtc = StartedUtc; + } + + public string Id { get; } + + public string StreamId { get; } + + public string OutputDirectory { get; } + + public Process Process { get; } + + public TranscodingStartContext Context { get; } + + public DateTime StartedUtc { get; } + + public DateTime LastAccessUtc { get; private set; } + + public int? ExitCode { get; private set; } + + public bool HasExited => Process.HasExited; + + public CancellationToken CancellationToken => _cts.Token; + + public void UpdateLastAccess() => LastAccessUtc = DateTime.UtcNow; + + public int LastSegmentIndex => Volatile.Read(ref _lastSegmentIndex); + + public void UpdateLastSegmentIndex(int segmentIndex) + { + if (segmentIndex < 0) + return; + + while (true) + { + var current = Volatile.Read(ref _lastSegmentIndex); + if (segmentIndex <= current) + return; + + if (Interlocked.CompareExchange(ref _lastSegmentIndex, segmentIndex, current) == current) + return; + } + } + + + public void AppendLog(string line) + { + if (string.IsNullOrWhiteSpace(line)) + return; + + lock (_logSync) + { + foreach (var part in line.Split('\n')) + { + string trimmed = part.TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(trimmed)) + continue; + + if (trimmed.Length > 2000) + trimmed = trimmed[..2000]; + + _log.AddLast(trimmed); + if (_log.Count > MaxLogLines) + _log.RemoveFirst(); + } + } + } + + public string[] SnapshotLog() + { + lock (_logSync) + return _log.ToArray(); + } + + public void SignalExit() + { + if (Process.HasExited) + ExitCode = Process.ExitCode; + } + + public void StopBackground() + => _cts.Cancel(); + + public void Dispose() + { + try + { + StopBackground(); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_p5i63hde"); + } + + try + { + Process.Dispose(); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_6fb2ahbb"); + } + } +} diff --git a/mods/Transcoding/Services/TranscodingModels.cs b/mods/Transcoding/Services/TranscodingModels.cs new file mode 100644 index 0000000..8499d71 --- /dev/null +++ b/mods/Transcoding/Services/TranscodingModels.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Transcoding; + +public sealed class TranscodingStartRequest +{ + public string src { get; set; } = string.Empty; + + public TranscodingAudioOptions audio { get; set; } + + public TranscodingHlsOptions hls { get; set; } + + public Dictionary headers { get; set; } + + public bool live { get; set; } + + public bool? subtitles { get; set; } +} + +public enum TranscodingJobState +{ + Running, + Idle, + Stopped +} diff --git a/mods/Transcoding/Services/TranscodingService.cs b/mods/Transcoding/Services/TranscodingService.cs new file mode 100644 index 0000000..4f9e945 --- /dev/null +++ b/mods/Transcoding/Services/TranscodingService.cs @@ -0,0 +1,1003 @@ +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Services; +using Shared.Models.Base; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +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 System.Web; + +namespace Transcoding; + +internal sealed class TranscodingService +{ + static readonly Serilog.ILogger Log = Serilog.Log.ForContext(); + + static readonly Lazy _lazy = new(() => new TranscodingService()); + + readonly ConcurrentDictionary _jobs = new(); + readonly Regex _safeFileNameRegex = new("^[A-Za-z0-9_.-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + readonly Regex _segmentFileRegex = new("^seg_(\\d+)\\.(m4s|ts)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + int _segmentCleanupRunning; + readonly Timer _segmentCleanupTimer; + + byte[] _hmacKey = RandomNumberGenerator.GetBytes(32); + static string _ffmpegPath; + + public static TranscodingService Instance => _lazy.Value; + + private TranscodingService() + { + _segmentCleanupTimer = new Timer(_ => CleanupSegments(), null, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(20)); + } + + public void Configure(ModuleConf config) + { + if (config == null) + return; + + _ffmpegPath = string.IsNullOrWhiteSpace(config.ffmpeg) + ? (CoreInit.Win32NT ? "data/ffmpeg.exe" : (File.Exists("data/ffmpeg") ? "data/ffmpeg" : "ffmpeg")) + : config.ffmpeg; + + if (string.IsNullOrWhiteSpace(config.tempRoot)) + config.tempRoot = Path.Combine("cache", "transcoding"); + + try + { + if (!Directory.Exists(config.tempRoot)) + { + Directory.CreateDirectory(config.tempRoot); + } + else + { + foreach (var dir in Directory.GetDirectories(config.tempRoot)) + { + try + { + Directory.Delete(dir, true); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_g6yuafvi"); + } + } + } + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_quspivc7"); + } + } + + public ICollection Jobs => _jobs.Values; + + async public Task<(TranscodingJob job, string error)> Start(TranscodingStartRequest request) + { + var config = GetConfig(); + + if (request == null || string.IsNullOrWhiteSpace(request.src)) + return (null!, "Source URL is required"); + + if (_jobs.Count >= Math.Max(1, config.maxConcurrentJobs)) + return (null!, "Maximum concurrent jobs reached"); + + if (!TryValidateSource(request.src, config, out var source, out var error)) + return (null!, error); + + var id = Guid.NewGuid().ToString("N"); + var streamId = BuildToken(id); + + var outputDir = Path.Combine(config.tempRoot!, id); + Directory.CreateDirectory(outputDir); + + #region ffprobe + var ffprobe = await Http.Get($"http://{CoreInit.conf.listen.localhost}:{CoreInit.conf.listen.port}/ffprobe?media={HttpUtility.UrlEncode(request.src)}", headers: HeadersModel.Init("lcrqpasswd", CoreInit.rootPasswd)); + + if (ffprobe == null || !ffprobe.ContainsKey("format")) + return (null!, "ffprobe"); + #endregion + + var context = new TranscodingStartContext( + source, + SanitizeHeader(GetHeader(request.headers, "userAgent"), Http.UserAgent), + SanitizeHeader(GetHeader(request.headers, "referer")), + MergeHlsOptions(config, request.hls), + MergeAudioOptions(config, request.audio), + request.live, + request.subtitles ?? config.defaultSubtitles, + outputDir, + null, + ffprobe + ); + + var process = CreateProcess(context); + + try + { + if (!process.Start()) + { + process.Dispose(); + return (null!, "Failed to start ffmpeg"); + } + } + catch (System.Exception ex) + { + Serilog.Log.Error(ex, "CatchId={CatchId}", "id_71c20640"); + process.Dispose(); + return (null!, $"Failed to start ffmpeg: {ex.Message}"); + } + + var job = new TranscodingJob(id, streamId, outputDir, process, context); + if (!_jobs.TryAdd(id, job)) + { + try + { + process.Kill(true); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_avj4m3h6"); + } + + process.Dispose(); + try + { + if (Directory.Exists(outputDir)) + Directory.Delete(outputDir, true); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_q5jm90jq"); + } + return (null!, "Failed to register job"); + } + + _ = Task.Run(() => PumpStdErrAsync(job)); + _ = Task.Run(() => IdleWatchdogAsync(job, config)); + + process.EnableRaisingEvents = true; + process.Exited += (_, _) => + { + if (process.EnableRaisingEvents) + OnProcessExit(job); + }; + + return (job, string.Empty); + } + + public bool TryResolveJob(string streamId, out TranscodingJob job) + { + job = null!; + if (string.IsNullOrWhiteSpace(streamId)) + return false; + + if (!TryParseToken(streamId, out var id)) + return false; + + if (!_jobs.TryGetValue(id, out job)) + return false; + + return true; + } + + public async Task<(bool success, string error)> SeekAsync(string streamId, int seconds, int? startSegment = null) + { + if (seconds < 0) + return (false, "ss must be greater or equal 0"); + + if (!TryResolveJob(streamId, out var job)) + return (false, "Job not found"); + + var config = GetConfig(); + + var newContext = job.Context with + { + HlsOptions = new TranscodingHlsOptions + { + seek = seconds, + segDur = job.Context.HlsOptions.segDur, + winSize = job.Context.HlsOptions.winSize, + fmp4 = job.Context.HlsOptions.fmp4 + }, + startNumber = startSegment + }; + + job.Process.EnableRaisingEvents = false; + await StopJobAsync(job, forced: true, cleanup: false); + + Directory.CreateDirectory(newContext.OutputDirectory); + + var process = CreateProcess(newContext); + + try + { + if (!process.Start()) + { + process.Dispose(); + return (false, "Failed to start ffmpeg"); + } + } + catch (System.Exception ex) + { + Serilog.Log.Error(ex, "CatchId={CatchId}", "id_1af34286"); + process.Dispose(); + return (false, $"Failed to start ffmpeg: {ex.Message}"); + } + + var newJob = new TranscodingJob(job.Id, job.StreamId, job.OutputDirectory, process, newContext); + + if (_jobs.AddOrUpdate(job.Id, newJob, (k, v) => newJob) == null) + { + try + { + process.Kill(true); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_rteqssej"); + } + + process.Dispose(); + return (false, "Failed to restart job"); + } + + _ = Task.Run(() => PumpStdErrAsync(newJob)); + _ = Task.Run(() => IdleWatchdogAsync(newJob, config)); + + process.EnableRaisingEvents = true; + process.Exited += (_, _) => + { + if (process.EnableRaisingEvents) + OnProcessExit(newJob); + }; + + return (true, string.Empty); + } + + public async Task StopAsync(string streamId) + { + if (!TryResolveJob(streamId, out var job)) + return false; + + await StopJobAsync(job); + return true; + } + + public void Touch(TranscodingJob job) => job.UpdateLastAccess(); + + public string GetFilePath(TranscodingJob job, string file) + { + if (string.IsNullOrWhiteSpace(file) || !_safeFileNameRegex.IsMatch(file)) + return null; + + var candidate = Path.Combine(job.OutputDirectory, file); + if (!candidate.StartsWith(job.OutputDirectory, StringComparison.Ordinal)) + return null; + + return File.Exists(candidate) ? candidate : null; + } + + public void ReportSegmentAccess(TranscodingJob job, int segmentIndex) + { + job?.UpdateLastSegmentIndex(segmentIndex); + } + + private async Task StopJobAsync(TranscodingJob job, bool forced = false, bool cleanup = true) + { + try + { + if (!job.Process.HasExited) + { + if (forced) + { + job.Process.Kill(true); + } + else + { + try + { + await job.Process.StandardInput.WriteLineAsync("q"); + await job.Process.StandardInput.FlushAsync(); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_a5h7gduz"); + } + + var waitTask = job.Process.WaitForExitAsync(); + var timeout = Task.Delay(TimeSpan.FromMilliseconds(1500)); + var completed = await Task.WhenAny(waitTask, timeout); + if (completed != waitTask) + { + try + { + job.Process.Kill(true); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_9lgmzpo5"); + } + + try + { + await job.Process.WaitForExitAsync(); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_zmkmg1y6"); + } + } + else + { + try + { + await waitTask; + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_a56zgctr"); + } + } + } + } + } + finally + { + if (cleanup) + Cleanup(job); + } + } + + private void Cleanup(TranscodingJob job) + { + var removed = _jobs.TryRemove(job.Id, out _); + + try + { + job.StopBackground(); + job.SignalExit(); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_9c5you07"); + } + + if (!removed) + return; + + try + { + if (Directory.Exists(job.OutputDirectory)) + Directory.Delete(job.OutputDirectory, true); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_z4e0wm3m"); + } + + job.Dispose(); + } + + private ModuleConf GetConfig() + { + return ModInit.conf; + } + + private static string GetHeader(Dictionary headers, string key) + { + if (headers == null || headers.Count == 0) + return null; + + foreach (var (k, v) in headers) + { + if (string.Equals(k, key, StringComparison.OrdinalIgnoreCase)) + return v; + } + + return null; + } + + private static TranscodingHlsOptions MergeHlsOptions(ModuleConf config, TranscodingHlsOptions request) + { + var opt = request ?? config.hlsOptions; + + return new TranscodingHlsOptions + { + seek = opt?.seek > 0 ? opt.seek : 0, + segDur = opt?.segDur > 1 ? opt.segDur : 1, + winSize = opt?.winSize > 5 ? opt.winSize : 5, + fmp4 = opt?.fmp4 ?? true + }; + } + + private static TranscodingAudioOptions MergeAudioOptions(ModuleConf config, TranscodingAudioOptions request) + { + var opt = request ?? config.audioOptions; + + return new TranscodingAudioOptions + { + index = opt?.index >= 0 ? opt.index : 0, + bitrateKbps = opt?.bitrateKbps is > 0 and <= 512 ? opt.bitrateKbps : 160, + stereo = opt?.stereo ?? true, + codec_copy = opt?.codec_copy ?? config.audioOptions.codec_copy + }; + } + + private static bool TryValidateSource(string src, ModuleConf config, out Uri uri, out string error) + { + error = string.Empty; + uri = null!; + + if (!Uri.TryCreate(src, UriKind.Absolute, out uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + error = "Only http/https URLs are allowed"; + return false; + } + + if (config.allowHosts != null && config.allowHosts.Length > 0) + { + if (!config.allowHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase)) + { + error = "Source host is not allowed"; + return false; + } + } + + return true; + } + + public void StopAll() + { + try + { + foreach (var job in _jobs.Values.ToArray()) + { + try + { + _ = StopJobAsync(job, forced: true).ConfigureAwait(false); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_sp9w94ug"); + } + } + } + catch (System.Exception ex) + { + Serilog.Log.Error(ex, "{Class} {CatchId}", "ModInit", "id_ley70nc7"); + } + } + + private Process CreateProcess(TranscodingStartContext context) + { + var config = GetConfig(); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = _ffmpegPath, + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardInput = true, + RedirectStandardOutput = false, + CreateNoWindow = true, + WorkingDirectory = context.OutputDirectory + } + }; + + var args = process.StartInfo.ArgumentList; + + /* +-hide_banner — отключает вывод баннера ffmpeg (версия/конфигурация) в stderr, чтобы логи были чище. +-user_agent {context.UserAgent} — задаёт заголовок User-Agent для HTTP-запросов к входному URL. +-headers "Referer: {context.Referer}\n" — добавляет произвольные HTTP-заголовки (здесь: Referer) при обращении к входному URL. +-re — читает входной поток в реальном времени; полезно при трансляции/стриминге, чтобы не читать вход быстрее реального времени. +-threads 0 — позволяет ffmpeg автоматически выбрать количество потоков (CPU) для кодирования/декодирования. +-fflags +genpts — генерирует PTS для кадров, если их нет (избегает проблем с отсутствующими временными метками). +-i {Source} — указывает входной источник (URL/файл). +-map 0:v:0 — маппит первый видеопоток первого входа в выход. +-map 0:a:0 — маппит первый аудиопоток первого входа в выход. +-sn (опционально, при subtitles == false) — исключить субтитры из вывода. +-dn — исключить data-потоки (metadata/data tracks). +-map_metadata -1 — не копировать глобальные метаданные в выход (очищает метаданные). +-map_chapters -1 — не копировать главы (удаляет chapters). +-c:v copy — копирование видеопотока без перекодирования (для минимальной нагрузки и без потери качества). + +Аудио: +Если transcodeToAac: +-c:a aac — кодировать аудио в AAC. +-ac 2 / -ac 1 — число каналов (стерео/моно) по настройке. +-b:a {N}k — битрейт аудио. +-profile:a aac_low — профиль AAC (обычно LC для совместимости и низкой задержки). +Иначе: -c:a copy — копирование аудио без перекодирования. + +HLS / контейнер: +-avoid_negative_ts disabled — не сдвигать временные метки для предотвращения отрицательных TS; сохраняет исходные PTS при HLS-сегментации. +-max_muxing_queue_size 2048 — увеличивает очередь пакетов при мультиплексировании (помогает против “Too many packets buffered”). +-f hls — формат выхода HLS (HTTP Live Streaming). +-max_delay 5000000 — максимальная задержка буфера (в мкс; зависит от контекста), снижает риск избыточного буферинга. +-hls_segment_type fmp4 или mpegts — тип сегментов HLS (CMAF/fMP4 или MPEG-TS). +для mpegts: -bsf:v h264_mp4toannexb — конвертирует H.264 к Annex-B, как требуется для TS. +-hls_time {segDur} — длительность сегмента в секундах. +-hls_flags append_list+omit_endlist — +append_list — дописывать записи в плейлист; +omit_endlist — не добавлять #EXT-X-ENDLIST, чтобы плейлист считался “живым”. +-hls_list_size {winSize} — размер окна плейлиста (сколько последних сегментов видит клиент). +-master_pl_name index.m3u8 — имя master-плейлиста (если используется). +-hls_fmp4_init_filename {init.mp4} — имя init-сегмента для fMP4. +-hls_segment_filename {seg_%05d.m4s | seg_%05d.ts} — шаблон имени файлов сегментов. +-y {PlaylistPath} — перезаписать выходной файл без подтверждения (путь итогового плейлиста/файла). + */ + + args.Add("-hide_banner"); + + if (context.UserAgent != null) + { + args.Add("-user_agent"); + args.Add(context.UserAgent); + } + + if (!string.IsNullOrWhiteSpace(context.Referer)) + { + args.Add("-headers"); + args.Add($"Referer: {context.Referer}\\r\\n"); + } + + if (context.HlsOptions.seek > 0) + { + args.Add("-ss"); + args.Add(context.HlsOptions.seek.ToString()); + args.Add("-noaccurate_seek"); + } + + args.Add("-nostats"); + args.Add("-progress"); + args.Add("pipe:2"); + args.Add("-stats_period"); + args.Add(context.live ? "1" : "5"); + + #region demuxer + foreach (var c in config.comand["demuxer"]) + { + foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + args.Add(a); + } + #endregion + + #region readrate + if (context.live) + { + args.Add("-re"); + } + else if (config.playlistOptions.readrate > 0) + { + args.Add("-readrate"); + args.Add(config.playlistOptions.readrate.ToString().Replace(",", ".")); + + if (config.playlistOptions.burst > 0) + { + args.Add("-readrate_initial_burst"); + args.Add(config.playlistOptions.burst.ToString()); + } + } + #endregion + + args.Add("-i"); + args.Add(context.Source.AbsoluteUri); + + #region input + foreach (var c in config.comand["input"]) + { + foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + args.Add(a); + } + #endregion + + #region subtitles map + if (context.subtitles && !context.live && 0 >= config.playlistOptions.readrate && context.ffprobe.ContainsKey("streams")) + { + args.Add("-copyts"); + + foreach (var s in context.ffprobe["streams"]) + { + if (s.Value("codec_type") != "subtitle") + continue; + + string codec_name = s.Value("codec_name"); + if (!string.IsNullOrEmpty(codec_name) && config.subtitleOptions.codec.Contains(codec_name)) + { + int subIndex = s.Value("index"); + if (subIndex == 0) + continue; + + foreach (var c in config.subtitleOptions.comand) + { + foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + args.Add(a.Replace("{subIndex}", subIndex.ToString())); + } + } + } + } + #endregion + + #region HLS map + foreach (var c in config.comand["output"]) + { + foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + args.Add(a.Replace("{audio_index}", $"{(0 >= context.Audio.index ? 0 : context.Audio.index)}")); + } + + #region -c:v + if (config.convertOptions.transcodeVideo && config.convertOptions.codec != null && + context.ffprobe != null && context.ffprobe.ContainsKey("streams") && + config.convertOptions.comand != null && config.convertOptions.comand.Count > 0) + { + try + { + bool convert = false; + + string codec_name = context.ffprobe["streams"].First.Value("codec_name") ?? ""; + if (!string.IsNullOrEmpty(codec_name) && config.convertOptions.codec.Contains(codec_name)) + convert = true; + + string pix_fmt = context.ffprobe["streams"].First.Value("pix_fmt") ?? ""; + if (!string.IsNullOrEmpty(pix_fmt) && config.convertOptions.codec.Contains(pix_fmt)) + convert = true; + + if (config.convertOptions.codec.Contains($"{codec_name}_{pix_fmt}")) + convert = true; + + if (convert) + { + string[] comand = config.convertOptions.comand["default"]; + + foreach (string key in new string[] { $"{codec_name}_{pix_fmt}", pix_fmt, codec_name }) + { + if (config.convertOptions.comand.ContainsKey(key)) + { + comand = config.convertOptions.comand[key]; + break; + } + } + + foreach (var c in comand) + { + foreach (var t in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + args.Add(t); + } + } + else + { + args.Add("-c:v"); + args.Add("copy"); + } + } + catch + { + args.Add("-c:v"); + args.Add("copy"); + } + } + else + { + args.Add("-c:v"); + args.Add("copy"); + } + #endregion + + #region -c:a + { + bool convert = true; + + var audioCodec = context.ffprobe["streams"].FirstOrDefault(i => i.Value("index") == ((0 >= context.Audio.index ? 0 : context.Audio.index) + 1)); + + string codec_name = audioCodec?.Value("codec_name") ?? string.Empty; + if (!string.IsNullOrEmpty(codec_name) && context.Audio.codec_copy.Contains(codec_name)) + convert = false; + + string channel = audioCodec?.Value("channels")?.ToString() ?? string.Empty; + if (!string.IsNullOrEmpty(channel) && context.Audio.codec_copy.Contains(channel)) + convert = false; + + if (context.Audio.codec_copy.Contains($"{codec_name}_{channel}")) + convert = false; + + if (convert) + { + string[] comand = config.audioOptions.comand_transcode["default"]; + + foreach (string key in new string[] { $"{codec_name}_{channel}", channel, codec_name }) + { + if (config.audioOptions.comand_transcode.ContainsKey(key)) + { + comand = config.audioOptions.comand_transcode[key]; + break; + } + } + + foreach (var c in comand) + { + foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + args.Add(a.Replace("{stereo}", context.Audio.stereo ? "2" : "1").Replace("{bitrateKbps}", $"{Math.Clamp(context.Audio.bitrateKbps, 32, 512)}k")); + } + } + else + { + args.Add("-c:a"); + args.Add("copy"); + } + } + #endregion + + args.Add("-f"); + args.Add("hls"); + + foreach (var c in config.hlsOptions.comand["output"]) + { + foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + args.Add(a); + } + + #region -hls_segment_type + args.Add("-hls_segment_type"); + + if (context.HlsOptions.fmp4) + { + args.Add("fmp4"); + } + else + { + args.Add("mpegts"); + + foreach (var c in config.hlsOptions.comand["segment_mpegts"]) + { + foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + args.Add(a); + } + } + #endregion + + args.Add("-hls_time"); + args.Add(context.HlsOptions.segDur.ToString(CultureInfo.InvariantCulture)); + + if (context.live) + { + args.Add("-hls_flags"); + args.Add("append_list+omit_endlist+delete_segments"); + } + + args.Add("-hls_list_size"); + args.Add(context.HlsOptions.winSize.ToString(CultureInfo.InvariantCulture)); + + #region -start_number + int? startNumber = context.startNumber; + if (!startNumber.HasValue && context.HlsOptions.seek > 0) + { + int segDur = Math.Max(1, context.HlsOptions.segDur); + startNumber = context.HlsOptions.seek / segDur; + } + + if (startNumber.HasValue) + { + args.Add("-start_number"); + args.Add(startNumber.Value.ToString()); + } + #endregion + + args.Add("-master_pl_name"); + args.Add("index.m3u8"); + + if (context.HlsOptions.fmp4) + { + args.Add("-hls_fmp4_init_filename"); + args.Add("init.mp4"); + } + else + { + args.Add("-hls_playlist_type"); + args.Add("vod"); + } + + args.Add("-hls_segment_filename"); + args.Add(context.HlsOptions.fmp4 ? "seg_%05d.m4s" : "seg_%05d.ts"); + + args.Add("-y"); + args.Add("index.m3u8"); + #endregion + + return process; + } + + private async Task PumpStdErrAsync(TranscodingJob job) + { + try + { + string line; + while ((line = await job.Process.StandardError.ReadLineAsync()) != null) + job.AppendLog(line); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_9kis6e5h"); + } + } + + private async Task IdleWatchdogAsync(TranscodingJob job, ModuleConf config) + { + var idle = TimeSpan.FromSeconds(Math.Max(180, config.idleTimeoutSec)); + var idle_live = TimeSpan.FromSeconds(Math.Max(20, config.idleTimeoutSec_live)); + + try + { + while (!job.CancellationToken.IsCancellationRequested && !job.Process.HasExited) + { + await Task.Delay(TimeSpan.FromSeconds(1), job.CancellationToken); + + if (job.Context.live) + { + if (config.idleTimeoutSec_live == -1) + continue; + + if (DateTime.UtcNow - job.LastAccessUtc > idle_live) + { + await StopJobAsync(job); + break; + } + } + else + { + + if (config.idleTimeoutSec == -1) + continue; + + if (DateTime.UtcNow - job.LastAccessUtc > idle) + { + await StopJobAsync(job); + break; + } + } + } + } + catch (TaskCanceledException) + { + } + } + + private void OnProcessExit(TranscodingJob job) + { + if (job.Context.live) + { + job.SignalExit(); + Cleanup(job); + } + } + + private bool TryParseToken(string streamId, out string id) + { + id = string.Empty; + var parts = streamId.Split('.', 2); + if (parts.Length != 2) + return false; + + id = parts[0]; + if (id.Length != 32) + return false; + + try + { + var padded = parts[1].PadRight(parts[1].Length + (4 - parts[1].Length % 4) % 4, '='); + var hmac = Convert.FromBase64String(padded.Replace('-', '+').Replace('_', '/')); + var expected = ComputeHmac(id); + if (!CryptographicOperations.FixedTimeEquals(hmac, expected)) + return false; + } + catch + { + return false; + } + + return true; + } + + private string BuildToken(string id) + { + var mac = ComputeHmac(id); + var b64 = Convert.ToBase64String(mac).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + return $"{id}.{b64}"; + } + + private byte[] ComputeHmac(string id) + { + using var hmac = new HMACSHA256(_hmacKey); + return hmac.ComputeHash(Encoding.UTF8.GetBytes(id)); + } + + private static string SanitizeHeader(string value, string fallback = "") + { + if (string.IsNullOrWhiteSpace(value)) + return fallback; + + var clean = value.Replace("\r", string.Empty).Replace("\n", string.Empty); + return string.IsNullOrWhiteSpace(clean) ? fallback : clean; + } + + private void CleanupSegments() + { + if (Interlocked.Exchange(ref _segmentCleanupRunning, 1) == 1) + return; + + try + { + if (!(ModInit.conf?.playlistOptions?.delete_segments ?? false)) + return; + + foreach (var job in _jobs.Values) + { + if (job.Context.live) + continue; + + var lastIndex = job.LastSegmentIndex; + if (0 >= lastIndex) + continue; + + try + { + if (!Directory.Exists(job.OutputDirectory)) + continue; + + foreach (var file in Directory.GetFiles(job.OutputDirectory, "seg_*")) + { + try + { + var name = Path.GetFileName(file); + var match = _segmentFileRegex.Match(name); + if (!match.Success) + continue; + + if (!int.TryParse(match.Groups[1].Value, out var index)) + continue; + + if (index >= lastIndex - 1) + continue; + + // первые 10 сегментов оставляем + if (10 >= index) + continue; + + File.Delete(file); + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_nwuuqesw"); + } + } + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_x7fqvn06"); + } + } + } + catch (System.Exception ex) + { + Log.Error(ex, "CatchId={CatchId}", "id_zr6i3b00"); + } + finally + { + Interlocked.Exchange(ref _segmentCleanupRunning, 0); + } + } +} diff --git a/mods/Transcoding/Transcoding.csproj b/mods/Transcoding/Transcoding.csproj new file mode 100644 index 0000000..c5b2704 --- /dev/null +++ b/mods/Transcoding/Transcoding.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + library + true + + + + + + + + + Always + + + + diff --git a/mods/Transcoding/manifest.json b/mods/Transcoding/manifest.json new file mode 100644 index 0000000..bb27dbc --- /dev/null +++ b/mods/Transcoding/manifest.json @@ -0,0 +1,9 @@ +{ + "enable": true, + "tree": [ + "Services", + "Controller.cs", + "ModInit.cs", + "ModuleConf.cs" + ] +} diff --git a/mods/Transcoding/plugin.js b/mods/Transcoding/plugin.js new file mode 100644 index 0000000..d30edf8 --- /dev/null +++ b/mods/Transcoding/plugin.js @@ -0,0 +1,356 @@ +(function () { + var API_BASE = '{localhost}'; + + var activeJob = null; + var heartbeatTimer = null; + var waitHandle = null; + + function account(url) { + url = 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)); + } + if (url.indexOf('token=') == -1) { + var token = '{token}'; + if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token={token}'); + } + return url; + } + + function showWait(message) { + Lampa.Loading.start(function(){}, message); + } + + function hideWait() { + Lampa.Loading.stop(); + } + + + function log() { + try { + var args = Array.prototype.slice.call(arguments); + args.unshift('Transcoder'); + console.log.apply(console, args); + } + catch (err) { + /* noop */ + } + } + + function notify(message) { + if (window.Lampa && Lampa.Noty && Lampa.Noty.show) { + Lampa.Noty.show(message); + } + else { + log(message); + } + } + + function resolveMediaUrl(data) { + if (data && data.url) return data.url; + return ''; + } + + function isMkvSource(data) { + var url = resolveMediaUrl(data); + if (!url) return false; + return /\.(mkv|avi|flv)($|\?|#)/i.test(url) || /\/lite\/pidtor\//i.test(url); + } + + function requestFfprobe(mediaUrl, onSuccess, onError) { + if (!mediaUrl) { + if (onError) onError(new Error('Empty media url')); + return; + } + + var net = new Lampa.Reguest(); + var target = account(API_BASE + '/ffprobe?media=' + encodeURIComponent(mediaUrl)); + + log('request ffprobe', target); + + net.native(target, function (response) { + try { + var json = typeof response === 'string' ? JSON.parse(response) : response; + try { + var streams = json && Array.isArray(json.streams) ? json.streams : []; + var v = streams.find(function(s){ return s.codec_type === 'video'; }); + var rawCodec = (v && (v.codec_name || (v.tags && v.tags.codec_name))) || ''; + } catch(e) { /* noop */ } + if (onSuccess) onSuccess(json || {}); + } catch(e) { + if (onError) onError(e); + } + }, function (error) { + hideWait(); + if (onError) onError(error || new Error('ffprobe request failed')); + }, null, { + dataType: 'text', + timeout: 1000 * 40 + }); + } + + function formatAudioItem(track, index) { + var tags = track && track.tags ? track.tags : {}; + var title = tags.title || tags.handler_name || ('Audio #' + (index + 1)); + var lang = (tags.language || '').toUpperCase(); + var codec = (track.codec_name || '').toUpperCase(); + var channels = (track.channel_layout || '').replace('(side)', '').replace('stereo', '2.0'); + var rate = track.bit_rate ? Math.round(track.bit_rate / 1000) + ' kbps' : ''; + + var subtitleParts = []; + if (lang) subtitleParts.push(lang); + if (codec) subtitleParts.push(codec); + if (channels) subtitleParts.push(channels); + if (rate) subtitleParts.push(rate); + + return { + title: title, + subtitle: subtitleParts.join(' • '), + track: track + }; + } + + function selectAutoAudioTrack(audioTracks) { + for (var i = 0; i < audioTracks.length; i++) { + var disposition = audioTracks[i] && audioTracks[i].disposition; + if (disposition && (disposition.default === 1 || disposition.default === true)) { + return audioTracks[i]; + } + } + + var language = String(Lampa.Storage.get('language', 'ru') || 'ru').toLowerCase().split('-')[0]; + var aliases = { + ru: ['ru', 'rus'], + en: ['en', 'eng'], + uk: ['uk', 'ukr'], + be: ['be', 'bel'], + bg: ['bg', 'bul'], + he: ['he', 'heb'], + cs: ['cs', 'ces', 'cze'], + ro: ['ro', 'ron', 'rum'], + de: ['de', 'deu', 'ger'], + fr: ['fr', 'fra', 'fre'], + es: ['es', 'spa'], + it: ['it', 'ita'], + pt: ['pt', 'por'], + pl: ['pl', 'pol'], + tr: ['tr', 'tur'], + ja: ['ja', 'jpn'], + ko: ['ko', 'kor'], + zh: ['zh', 'zho', 'chi'] + }; + var preferred = aliases[language] || (language ? [language] : []); + + for (var j = 0; j < audioTracks.length; j++) { + var tags = audioTracks[j] && audioTracks[j].tags ? audioTracks[j].tags : {}; + var trackLanguage = String(tags.language || '').toLowerCase(); + if (preferred.indexOf(trackLanguage) !== -1) { + return audioTracks[j]; + } + } + + return audioTracks[0]; + } + + function showAudioSelector(data, audioTracks) { + if (!audioTracks.length) { + notify('Не удалось найти аудиодорожки для MKV файла'); + return; + } + + var manualSelection = Lampa.Storage.get('transcoding_audio_prompt', false); + if (!(manualSelection === true || manualSelection === 'true')) { + startTranscoding(data, selectAutoAudioTrack(audioTracks)); + return; + } + + var items = audioTracks.map(function (track, index) { + return formatAudioItem(track, index); + }); + + var last_controller = Lampa.Controller.enabled().name + + Lampa.Select.show({ + title: 'Выберите аудиодорожку', + items: items, + onSelect: function (item) { + Lampa.Select.close(); + if (!item || !item.track) { + notify('Выбрана пустая дорожка'); + return; + } + startTranscoding(data, item.track); + }, + onBack: function () { + Lampa.Controller.toggle(last_controller) + } + }); + } + + function startTranscoding(data, track) { + stopHeartbeat(); + ensureJobStopped(true); + + var payload = { + src: resolveMediaUrl(data), + audio: { index: track.index -1 }, + live: false + }; + + /*if (data && data.subtitles) { + payload.subtitles = !!data.subtitles; + }*/ + + var net = new Lampa.Reguest(); + net.native(account(API_BASE + '/transcoding/start'), function (response) { + var json = typeof response === 'string' ? JSON.parse(response) : response; + if (!json || !json.streamId || !json.playlistUrl) { + notify('Некорректный ответ от сервера перекодирования'); + log('invalid start response', response); + return; + } + + activeJob = { + streamId: json.streamId, + playlistUrl: json.playlistUrl + }; + + var playback = data || {}; + playback.transcoding = true; + playback.ffprobe = null; + playback.url = json.playlistUrl; + playback.subtitles_call = json.subtitlesUrl; + playback.hls_manifest_timeout = json.hls_timeout_seconds * 1000; + Lampa.Player.play(playback); + + }, function (error) { + hideWait(); + notify('Не удалось запустить перекодирование MKV'); + log('start error', error); + }, JSON.stringify(payload), { + dataType: 'json', + type: 'POST', + timeout: 1000 * 20, + headers: { + 'Content-Type': 'application/json' + } + }); + } + + function sendHeartbeat() { + if (!activeJob) return; + + var net = new Lampa.Reguest(); + net.native(account(API_BASE + '/transcoding/' + activeJob.streamId + '/heartbeat'), function () { + log('heartbeat sent'); + }, function (error) { + hideWait(); + log('heartbeat error', error); + }, null, { + dataType: 'text', + timeout: 1000 * 10 + }); + } + + function stopHeartbeat() { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + } + + function ensureJobStopped(sendRemote) { + if (!activeJob) return; + + var job = activeJob; + activeJob = null; + stopHeartbeat(); + + if (sendRemote) { + var net = new Lampa.Reguest(); + net.native(account(API_BASE + '/transcoding/' + job.streamId + '/stop'), function () { + log('stop sent'); + }, function (error) { + hideWait(); + log('stop error', error); + }, null, { + dataType: 'text', + timeout: 1000 * 10 + }); + } + } + + function registerSettings() { + if (window.lampac_transcoding_audio_setting || !Lampa.SettingsApi || !Lampa.SettingsApi.addParam) return; + window.lampac_transcoding_audio_setting = true; + Lampa.SettingsApi.addParam({ + component: 'player', + param: { + name: 'transcoding_audio_prompt', + type: 'trigger', + values: '', + default: false + }, + field: { + name: 'Спрашивать аудиодорожку при транскодировании', + description: 'Включите для ручного выбора дорожки перед запуском MKV' + } + }); + } + + function handlePlayerStart(e) { + if (!isMkvSource(e.data) || e.data.transcoding || /\/transcoding\//i.test(e.data.url)) return; + + e.abort(); + e.data.url = e.data.url.replace(/&(preload|stat|m3u)/g, '&play'); + + log('intercept mkv playback', e.data.url); + + showWait('Получение списка аудио дорожек...'); + + requestFfprobe(resolveMediaUrl(e.data), function (info) { + hideWait(); + var streams = info && Array.isArray(info.streams) ? info.streams : []; + var audioTracks = streams.filter(function (track) { + return track.codec_type === 'audio'; + }); + showAudioSelector(e.data, audioTracks); + }, function (error) { + hideWait(); + notify('Ошибка анализа MKV файла'); + log('ffprobe error', error); + }); + } + + function handlePlayerDestroy() { + ensureJobStopped(true); + } + + function handleVideoPause() { + if (!activeJob) return; + + stopHeartbeat(); + heartbeatTimer = setInterval(sendHeartbeat, 1000 * 10); + } + + function handleVideoPlay() { + if (!activeJob) return; + + stopHeartbeat(); + } + + if (!window.lampac_transcoding_plugin) { + window.lampac_transcoding_plugin = true; + registerSettings(); + + Lampa.Player.listener.follow('create', handlePlayerStart); + Lampa.Player.listener.follow('destroy', handlePlayerDestroy); + Lampa.PlayerVideo.listener.follow('pause', handleVideoPause); + Lampa.PlayerVideo.listener.follow('play', handleVideoPlay); + } +})(); \ No newline at end of file diff --git a/mods/WatchTogether/ModInit.cs b/mods/WatchTogether/ModInit.cs new file mode 100644 index 0000000..00fc54e --- /dev/null +++ b/mods/WatchTogether/ModInit.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.DependencyInjection; +using Shared; +using Shared.Models.AppConf; +using Shared.Models.Events; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using System.Collections.Generic; +using Shared.Services; + +namespace WatchTogether +{ + public class WatchTogetherConf + { + public bool enable { get; set; } = true; + public bool allow_anonymous { get; set; } = true; + public int relay_max_channels { get; set; } = 500; + public int relay_max_clients_per_channel { get; set; } = 32; + public int gc_empty_timeout_minutes { get; set; } = 30; + public int gc_max_lifetime_hours { get; set; } = 12; + + public List limit_map { get; set; } = new() + { + new("^/watchtogether/", new WafLimitMap { limit = 30, second = 1 }), + new("^/wt/c/", new WafLimitMap { limit = 10, second = 1 }) + }; + } + + /// + /// WatchTogether: a lampac module that acts as both the lparty relay and a + /// shadow host keeping rooms alive when their creator disconnects. + /// + public class ModInit : IModuleLoaded, IModuleConfigure + { + public static string modpath; + public static WatchTogetherConf conf; + + public void Configure(ConfigureModel app) + { + app.services.AddTransient(); + } + + public void Loaded(InitspaceModel baseconf) + { + modpath = baseconf.path; + UpdateConf(); + EventListener.UpdateInitFile += UpdateConf; + ShadowHost.Start(); + } + + public void Dispose() + { + EventListener.UpdateInitFile -= UpdateConf; + ShadowHost.Stop(); + } + + private static bool isWafInjected = false; + + private void UpdateConf() + { + conf = ModuleInvoke.Init("WatchTogether", new WatchTogetherConf()); + + if (!isWafInjected && conf.limit_map != null) + { + foreach (var m in conf.limit_map) + { + CoreInit.conf.WAF.limit_map.Insert(0, m); + } + isWafInjected = true; + } + } + } +} diff --git a/mods/WatchTogether/README.md b/mods/WatchTogether/README.md new file mode 100644 index 0000000..49e74e9 --- /dev/null +++ b/mods/WatchTogether/README.md @@ -0,0 +1,44 @@ +# WatchTogether + +Модуль синхронизированного просмотра для Lampac. Работает одновременно как **хранитель состояния** и как **тихий lparty-клиент**: комната живёт в канале реле, а сервер незаметно подхватывает роль хоста, если создатель отключился. + +Клиентский плагин основан на [lparty.js](https://nrsua.github.io/lampa-nrs/lparty.js) (nrsua, levende) и полностью wire-совместим с его экосистемой — JustPlus Player, lparty-web и стоковый lparty.js подключаются к тем же комнатам без правок. + +## Как это работает + +1. **Создание комнаты.** Человек создаёт комнату в любом lparty-клиенте — его клиент открывает канал `wss:///wt/c/lparty-r-` и является хостом. +2. **Тень.** Сервер видит адрес подключения и начинает пассивно слушать кадры канала: копирует url, метаданные, позицию, скорость и список участников. В списке зрителей он виден как `Lampac`, но ничего не рассылает. +3. **Подмена.** Как только соединение хоста рвётся, сервер мгновенно шлёт кадр `host` и продолжает timeline с последнего хартбита (лаг ≤ 2с, пауза — точно). Попытки других клиентов перехватить хостство отклоняются повторным `host=сервер`. +4. **Возврат.** Создатель переподключился — сервер отдаёт ему роль и снова уходит в тень. +5. **GC.** Пустые комнаты удаляются через `gc_empty_timeout_minutes`, любые — через `gc_max_lifetime_hours`. + +## HTTP (обзор) + +| Маршрут | Описание | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `GET /watchtogether.js` | Плагин для клиента Lampa (адаптированный lparty.js). | +| `GET /watch_together/` | Веб-плеер для анонимных гостей (адаптированный lparty-web). Поддерживает `?room=` и `?relay=`. | +| `WS /wt/c/{channel}` | LParty-совместимый релей: каналы, кадры join/leave/message, эхо с серверным временем. | + +## Скорость воспроизведения + +Наши клиенты передают скорость поле `sp` в кадрах `state`/`sync`/`act`/`hold`/`go` и применяют её к `playbackRate`. Стоковые клиенты поле молча игнорируют — обратная совместимость не нарушается. Если автор JustPlus добавит поддержку `sp`, синхронизация заработает у него автоматически. + +## Конфигурация + +Секция в `init.conf`: **`WatchTogether`**. + +| Поле | Смысл | +| ------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `enable` | Включение модуля (по умолчанию **true**). | +| `allow_anonymous` | Доступ к плагину `/watchtogether.js` и веб-плееру lparty-web без авторизации (по умолчанию **true**). | +| `relay_max_channels` | Лимит одновременных каналов реле (по умолчанию **500**). | +| `relay_max_clients_per_channel` | Лимит участников одного канала (по умолчанию **32**). | +| `gc_empty_timeout_minutes` | Удаление пустой комнаты через N минут (по умолчанию **30**). | +| `gc_max_lifetime_hours` | Удаление любой комнаты через N часов (по умолчанию **12**). | + +## Подключение клиентов + +- Плагин Lampa: `/watchtogether.js` — реле уже настроено на собственный сервер lampac. +- JustPlus Player / lparty.js: в настройках клиента указать адрес реле `ws(s)://<адрес-lampac>/wt/c/`. +- Веб-гость: открыть `https:///watch_together/?room=` — ссылку формирует плагин. diff --git a/mods/WatchTogether/RelayServer.cs b/mods/WatchTogether/RelayServer.cs new file mode 100644 index 0000000..a1c7e10 --- /dev/null +++ b/mods/WatchTogether/RelayServer.cs @@ -0,0 +1,456 @@ +using Core.Middlewares; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net.WebSockets; +using System.Text.Json; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace WatchTogether +{ + /// + /// Maps the lparty-compatible relay endpoint (/wt/c/{channel}) at app startup. + /// Modules cannot map endpoints themselves, so this hooks the pipeline + /// through an IStartupFilter registered in ModInit.Configure. The branch + /// runs before the global pipeline (like /nws) so auth middlewares cannot + /// gate raw websocket clients; RequestInfo is applied in-branch because + /// the WAF reads its output. + /// + public class RelayStartupFilter : IStartupFilter + { + public Action Configure(Action next) + { + return app => + { + app.Map("/wt/c", relayApp => + { + relayApp.UseRequestInfo(); + relayApp.UseWAF(); + relayApp.UseWebSockets(); + relayApp.Run(RelayServer.HandleWebSocketAsync); + }); + + next(app); + }; + } + } + + public class RelayMember + { + public string Uid { get; set; } + public string Alias { get; set; } + public bool Echo { get; set; } + public WebSocket Socket { get; set; } + public SemaphoreSlim SendLock { get; } = new(1, 1); + + /// LParty peer id (the u field of the member's messages). + public string Pid { get; set; } + public string Name { get; set; } + + public long RateSecond; + public int RateCount; + } + + public class RelayChannel + { + public string Name { get; set; } + public readonly object Lock = new(); + public readonly Dictionary Members = new(); + } + + /// + /// Wire-compatible emulation of the itty.ws channel relay used by lparty + /// clients: stateless broadcast groups with join/leave announcements, a + /// member list and a server date on every frame (the date is what lparty + /// uses for clock sync via message echo). + /// + public static class RelayServer + { + static long uidSeq = 0; + static readonly ConcurrentDictionary Channels = new(); + + public static RelayChannel TryGetChannel(string name) => + Channels.TryGetValue(name, out var channel) ? channel : null; + + /// Member uids excluding the server's virtual member. + public static List HumanUids(RelayChannel channel) + { + lock (channel.Lock) + return channel.Members.Keys + .Where(u => !string.Equals(u, ShadowHost.ServerUid, StringComparison.Ordinal)) + .ToList(); + } + + const int MaxMessageBytes = 16 * 1024; + const int MaxMessagesPerSecond = 50; + + public static async Task HandleWebSocketAsync(HttpContext context) + { + if (!ModInit.conf.enable) + { + context.Response.StatusCode = 404; + return; + } + + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + // Inside Map("/wt/c") the request path is already without the prefix: + // "/{channel}", so only the leading slash is stripped here. + string path = context.Request.Path.Value ?? "/"; + string channelName = path.Length > 1 ? Uri.UnescapeDataString(path[1..]) : string.Empty; + if (string.IsNullOrWhiteSpace(channelName) || channelName.Length > 128) + { + context.Response.StatusCode = 404; + return; + } + + bool echo = string.Equals(context.Request.Query["echo"].ToString(), "true", StringComparison.OrdinalIgnoreCase); + string alias = context.Request.Query["as"].ToString() ?? string.Empty; + if (alias.Length > 64) alias = alias[..64]; + + int maxChannels = Math.Max(1, ModInit.conf.relay_max_channels); + if (!Channels.ContainsKey(channelName) && Channels.Count >= maxChannels) + { + context.Response.StatusCode = 429; + return; + } + + var socket = await context.WebSockets.AcceptWebSocketAsync(); + + var member = new RelayMember + { + Uid = "u" + Interlocked.Increment(ref uidSeq), + Alias = alias, + Echo = echo, + Socket = socket + }; + + var channel = Channels.GetOrAdd(channelName, _ => new RelayChannel { Name = channelName }); + + // A server-hosted room keeps its virtual member across channel + // recreation: joiners must see total >= 2 or lparty clients + // declare the room dead on arrival. + if (ShadowHost.ShouldHostChannel(channelName)) + EnsureServerMember(channel); + + bool full = false; + List users; + int total; + lock (channel.Lock) + { + int maxClients = Math.Max(2, ModInit.conf.relay_max_clients_per_channel); + if (channel.Members.Count >= maxClients) + { + full = true; + users = new List(); + total = 0; + } + else + { + channel.Members[member.Uid] = member; + users = new List(channel.Members.Values); + total = users.Count; + } + } + + if (full) + { + _ = SendAsync(member, BuildErrorFrame(ServerNowMs(), "channel_full")); + try { await socket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "channel_full", CancellationToken.None); } catch { } + return; + } + + long now = ServerNowMs(); + _ = SendAsync(member, BuildSelfJoinFrame(member.Uid, now, total, users)); + + byte[] joinFrame = BuildForeignJoinFrame(member.Uid, member.Alias, now, total); + Broadcast(channel, member.Uid, joinFrame); + + ShadowHost.OnMemberJoined(channelName, channel, member); + + try + { + await ReceiveLoop(channelName, channel, member); + } + catch { } + finally + { + RemoveMember(channelName, channel, member); + } + } + + /// The server's virtual lparty member inside a channel: a + /// silent observer while a real host is alive, the acting host after + /// the host is gone. + public static void EnsureServerMember(RelayChannel channel) + { + bool added = false; + int total; + lock (channel.Lock) + { + if (!channel.Members.ContainsKey(ShadowHost.ServerUid)) + { + channel.Members[ShadowHost.ServerUid] = new RelayMember { Uid = ShadowHost.ServerUid, Alias = "Lampac" }; + added = true; + } + total = channel.Members.Count; + } + + if (added) + Broadcast(channel, null, BuildForeignJoinFrame(ShadowHost.ServerUid, "Lampac", ServerNowMs(), total)); + } + + static async Task ReceiveLoop(string channelName, RelayChannel channel, RelayMember member) + { + var socket = member.Socket; + var buffer = new byte[8192]; + + while (socket.State == WebSocketState.Open) + { + var result = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) break; + + using var ms = new MemoryStream(); + ms.Write(buffer, 0, result.Count); + while (!result.EndOfMessage) + { + if (ms.Length > MaxMessageBytes) throw new IOException("relay message too large"); + result = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) return; + ms.Write(buffer, 0, result.Count); + } + + if (ms.Length == 0 || ms.Length > MaxMessageBytes) continue; + + if (!CheckRate(member)) + { + _ = SendAsync(member, BuildErrorFrame(ServerNowMs(), "rate_limited")); + continue; + } + + JsonElement message; + try + { + using var doc = JsonDocument.Parse(ms.ToArray()); + if (doc.RootElement.ValueKind != JsonValueKind.Object) continue; + message = doc.RootElement.Clone(); + } + catch (JsonException) + { + continue; + } + + long now = ServerNowMs(); + + // The echo feeds clock sync: it happens for every message. + // Frames are broadcast verbatim; the shadow host only listens. + if (member.Echo) + { + byte[] echoFrame = BuildMessageFrame(member.Uid, member.Alias, ServerNowMs(), message); + _ = SendAsync(member, echoFrame); + } + + byte[] frame = BuildMessageFrame(member.Uid, member.Alias, now, message); + Broadcast(channel, member.Uid, frame); + + ShadowHost.OnMessage(channelName, channel, member, message, now); + } + } + + static bool CheckRate(RelayMember member) + { + long second = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (Interlocked.CompareExchange(ref member.RateSecond, second, second) != second) + { + member.RateSecond = second; + member.RateCount = 0; + } + member.RateCount++; + return member.RateCount <= MaxMessagesPerSecond; + } + + public static void Broadcast(RelayChannel channel, string excludeUid, byte[] frame) + { + RelayMember[] targets; + lock (channel.Lock) + { + if (channel.Members.Count == 0) return; + targets = new RelayMember[channel.Members.Count]; + channel.Members.Values.CopyTo(targets, 0); + } + + foreach (var target in targets) + { + if (string.Equals(target.Uid, excludeUid, StringComparison.Ordinal)) continue; + _ = SendAsync(target, frame); + } + } + + static void RemoveMember(string channelName, RelayChannel channel, RelayMember member) + { + int total; + bool tornDown = false; + lock (channel.Lock) + { + if (!channel.Members.Remove(member.Uid, out _)) return; + total = channel.Members.Count; + bool onlyServer = total == 1 && channel.Members.ContainsKey(ShadowHost.ServerUid); + + if (total == 0 || onlyServer) + { + tornDown = true; + if (Channels.TryRemove(channel.Name, out var stale) && !ReferenceEquals(stale, channel)) + { + // A newer instance took the name; put it back. + Channels.TryAdd(channel.Name, stale); + } + if (onlyServer) + channel.Members.Remove(ShadowHost.ServerUid, out _); + } + } + + byte[] leaveFrame = BuildLeaveFrame(member.Uid, member.Alias, ServerNowMs(), total); + Broadcast(channel, member.Uid, leaveFrame); + + ShadowHost.OnMemberLeft(channelName, channel, member, tornDown); + + try { member.Socket.Dispose(); } catch { } + } + + public static async Task SendAsync(RelayMember member, byte[] payload) + { + try + { + await member.SendLock.WaitAsync(); + try + { + if (member.Socket.State != WebSocketState.Open) return; + await member.Socket.SendAsync(new ArraySegment(payload), WebSocketMessageType.Text, true, CancellationToken.None); + } + finally + { + member.SendLock.Release(); + } + } + catch { } + } + + static long ServerNowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + static byte[] BuildSelfJoinFrame(string uid, long date, int total, List users) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "join"); + w.WriteBoolean("self", true); + w.WriteString("uid", uid); + w.WriteNumber("date", date); + w.WriteNumber("total", total); + w.WriteStartArray("users"); + foreach (var u in users) + { + w.WriteStartObject(); + w.WriteString("uid", u.Uid); + w.WriteString("alias", u.Alias ?? string.Empty); + w.WriteEndObject(); + } + w.WriteEndArray(); + w.WriteEndObject(); + } + return ms.ToArray(); + } + + static byte[] BuildForeignJoinFrame(string uid, string alias, long date, int total) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "join"); + w.WriteBoolean("self", false); + w.WriteString("uid", uid); + w.WriteString("alias", alias ?? string.Empty); + w.WriteNumber("date", date); + w.WriteNumber("total", total); + w.WriteEndObject(); + } + return ms.ToArray(); + } + + static byte[] BuildLeaveFrame(string uid, string alias, long date, int total) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "leave"); + w.WriteString("uid", uid); + w.WriteString("alias", alias ?? string.Empty); + w.WriteNumber("date", date); + w.WriteNumber("total", total); + w.WriteEndObject(); + } + return ms.ToArray(); + } + + static byte[] BuildErrorFrame(long date, string message) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "error"); + w.WriteNumber("date", date); + w.WriteString("message", message); + w.WriteEndObject(); + } + return ms.ToArray(); + } + + public static byte[] BuildMessageFrame(string uid, string alias, long date, JsonElement message) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "message"); + w.WriteString("uid", uid); + w.WriteString("alias", alias ?? string.Empty); + w.WriteNumber("date", date); + w.WritePropertyName("message"); + message.WriteTo(w); + w.WriteEndObject(); + } + return ms.ToArray(); + } + + public static byte[] BuildMessageFrame(string uid, string alias, long date, object message) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "message"); + w.WriteString("uid", uid); + w.WriteString("alias", alias ?? string.Empty); + w.WriteNumber("date", date); + w.WritePropertyName("message"); + JsonSerializer.Serialize(w, message, message.GetType()); + w.WriteEndObject(); + } + return ms.ToArray(); + } + } +} diff --git a/mods/WatchTogether/ShadowHost.cs b/mods/WatchTogether/ShadowHost.cs new file mode 100644 index 0000000..52234b4 --- /dev/null +++ b/mods/WatchTogether/ShadowHost.cs @@ -0,0 +1,826 @@ +using Shared; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace WatchTogether +{ + /// + /// Server-side state of a room the server eavesdrops on. The server never + /// creates rooms: a room exists because a client opened its channel and a + /// host announced a stream. Until the host vanishes, the server is a + /// silent member; when the host is gone, it seamlessly takes over. + /// + public class ShadowRoom + { + public readonly object Lock = new(); + + public string Channel; + + // host bookkeeping + public string HostUid; + public string HostPid; + public string CreatorPid; + public long HostSeen; + public bool Hosting; + public bool Stalled; + + // timeline + public string State = "paused"; + public double Position; + public long AtServerTime; + public double Speed = 1.0; + + // metadata + public string RoomName; + public string Url; + public string Title; + public string Poster; + public string Source; + public string Type; + public int Tmdb; + + /// Room code as announced by the host in the lobby (ad frames); + /// lets the server list and re-announce rooms it keeps alive. + public string Code; + + // server-side hold barrier (only while Hosting) + public HashSet HoldWaiting; + public double HoldPosition; + public Timer HoldTimer; + + public DateTime CreatedUtc = DateTime.UtcNow; + public DateTime LastActivityUtc = DateTime.UtcNow; + + /// Display name of the member that announced the room. + public string OwnerName; + } + + /// A room advertisement overheard in the lobby channel. + public class LobbyAd + { + public string SenderUid; + public long Seen; + public string Id; + public string Name; + public string Title; + public string Poster; + public string Owner; + public int Members; + public int Tmdb; + public string Type; + public bool Pwd; + } + + /// + /// The shadow host: the WatchTogether module acting as a state-keeping + /// lparty client. While a real host is alive it silently listens and keeps + /// a snapshot of the room (url, metadata, timeline). The moment the host's + /// connection drops, the server steps in as the host on the wire - the + /// members see one "host changed" toast and playback continues without a + /// gap. When the original creator returns, the role is handed back. + /// + public static class ShadowHost + { + public const string ServerUid = "lampac"; + + const string LobbyChannel = "lparty-lobby-v1"; + const string RoomPrefix = "lparty-r-"; + const int MetronomeMs = 2000; + const int GcEveryMs = 60000; + const double MaxAdvancableSeconds = 3600; + const int HoldMaxMs = 15000; + const long ReconnectGraceMs = 60000; + const int AdTtlMs = 120000; + + static Timer metronomeTimer; + static Timer gcTimer; + static int initialized; + + static readonly ConcurrentDictionary rooms = new(); + + /// Room advertisements overheard in the lobby channel: the + /// host announces its code publicly, so the server can keep listing + /// rooms (including ones it keeps alive) without knowing passwords. + static readonly ConcurrentDictionary lobbyAds = new(); + + /// pid -> last hello, per channel; used to skip the join hold + /// for members that are reconnecting rather than new. + static readonly ConcurrentDictionary> knownPids = new(); + + /// The wire channel name for a room code. With an empty + /// password this is computable server-side, which lets the server link + /// a lobby advertisement to the room it belongs to. + static string RoomChannelHash(string roomId, string password) + { + if (string.IsNullOrEmpty(roomId)) return null; + string input = roomId.ToUpperInvariant() + "|" + (password ?? string.Empty); + using var sha = SHA256.Create(); + byte[] hashBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); + return RoomPrefix + Convert.ToHexString(hashBytes).ToLowerInvariant()[..24]; + } + + // ---------------------------------------------------------------- lifecycle + + public static void Start() + { + if (Interlocked.Exchange(ref initialized, 1) == 1) return; + metronomeTimer = new Timer(Metronome, null, MetronomeMs, MetronomeMs); + gcTimer = new Timer(Gc, null, GcEveryMs, GcEveryMs); + } + + public static void Stop() + { + if (Interlocked.Exchange(ref initialized, 0) == 0) return; + metronomeTimer?.Dispose(); + gcTimer?.Dispose(); + metronomeTimer = gcTimer = null; + rooms.Clear(); + knownPids.Clear(); + } + + // ---------------------------------------------------------------- relay hooks + + /// Whether the server's virtual member must exist in this + /// channel right now (it is the acting host). + public static bool ShouldHostChannel(string channelName) + { + if (channelName == LobbyChannel) return false; + return rooms.TryGetValue(channelName, out var room) && room.Hosting; + } + + public static void OnMemberJoined(string channelName, RelayChannel channel, RelayMember member) + { + if (channelName == LobbyChannel) return; + if (!rooms.TryGetValue(channelName, out var room)) return; + + room.LastActivityUtc = DateTime.UtcNow; + + RelayServer.EnsureServerMember(channel); + + // Server-hosted room: answer the joiner like a host would. + if (room.Hosting) + { + var pids = knownPids.GetOrAdd(channelName, _ => new ConcurrentDictionary()); + AnswerHelloAsHost(room, channel, member, pids, 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + } + + public static void OnMemberLeft(string channelName, RelayChannel channel, RelayMember member, bool tornDown) + { + if (channelName == LobbyChannel) + { + // A lobby agent disconnected: its ads die with it. + foreach (var key in lobbyAds.Keys.ToList()) + if (lobbyAds.TryGetValue(key, out var ad) && string.Equals(ad.SenderUid, member.Uid, StringComparison.Ordinal)) + lobbyAds.TryRemove(key, out var dead); + return; + } + + if (!rooms.TryGetValue(channelName, out var room)) return; + + if (!room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal)) + BecomeHost(room, channel, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + + public static void OnMessage(string channelName, RelayChannel channel, RelayMember member, JsonElement msg, long now) + { + if (channelName == LobbyChannel) + { + HandleLobby(member, msg, now); + return; + } + + var room = rooms.GetOrAdd(channelName, _ => new ShadowRoom { Channel = channelName }); + room.LastActivityUtc = DateTime.UtcNow; + + string t = GetString(msg, "t"); + string pid = GetString(msg, "u"); + string name = GetString(msg, "n"); + + if (!string.IsNullOrEmpty(pid)) member.Pid = pid; + if (!string.IsNullOrEmpty(name)) member.Name = name; + + switch (t) + { + case "state": ObserveState(room, channel, member, msg, now); break; + case "sync": ObserveSync(room, member, msg, now); break; + case "act": ObserveAct(room, msg, now); break; + case "url": ObserveUrl(room, member, msg); break; + case "buf": ObserveBuf(room, member, msg); break; + case "host": HandleHostClaim(room, channel, member, msg, now); break; + case "hello": HandleHello(room, channel, member, msg, now); break; + case "ready": HandleReady(room, channel, member, now); break; + case "bye": HandleBye(room, channel, member, now); break; + } + } + + /// The lobby is server-answered: hosts advertise their code + /// in `ad` frames, the server caches them and re-announces rooms it + /// keeps alive - so the list survives a host handover. + static void HandleLobby(RelayMember member, JsonElement msg, long now) + { + string t = GetString(msg, "t"); + + if (t == "ad") + { + if (msg.TryGetProperty("r", out var r) && r.ValueKind == JsonValueKind.Object) + { + string id = GetString(r, "id"); + if (!string.IsNullOrWhiteSpace(id) && id.Length <= 16) + { + lobbyAds[id] = new LobbyAd + { + SenderUid = member.Uid, + Seen = now, + Id = id, + Name = GetString(r, "name") ?? id, + Title = GetString(r, "title") ?? string.Empty, + Poster = GetString(r, "poster") ?? string.Empty, + Owner = GetString(r, "owner") ?? string.Empty, + Members = GetInt(r, "members"), + Tmdb = GetInt(r, "tmdb"), + Type = GetString(r, "type") ?? "movie", + Pwd = GetInt(r, "pwd") != 0, + }; + } + } + return; + } + + if (t != "who") return; + + var listed = new HashSet(StringComparer.Ordinal); + + // Server-kept rooms first, with live member counts. + foreach (var room in rooms.Values) + { + string code, name, title, poster, owner, type; + int tmdb; + bool hasUrl; + lock (room.Lock) + { + code = room.Code; + hasUrl = !string.IsNullOrEmpty(room.Url); + name = room.RoomName; + title = room.Title; + poster = room.Poster; + owner = room.OwnerName; + type = room.Type; + tmdb = room.Tmdb; + } + + if (string.IsNullOrWhiteSpace(code) || listed.Contains(code) || !hasUrl) continue; + + var channel = RelayServer.TryGetChannel(room.Channel); + int members = channel == null ? 0 : RelayServer.HumanUids(channel).Count; + SendLobbyAd(member, now, code, name, title, poster, owner, members, true, tmdb, type); + listed.Add(code); + } + + // Cached ads from hosts. Provably alive (passwordless) rooms + // outlive the TTL; the rest go stale with their announcer. + foreach (var ad in lobbyAds.Values.OrderByDescending(a => a.Seen)) + { + if (listed.Contains(ad.Id)) continue; + + string hash = RoomChannelHash(ad.Id, string.Empty); + bool tracked = hash != null && rooms.ContainsKey(hash); + if (now - ad.Seen > AdTtlMs && !tracked) continue; + + int members = ad.Members; + if (tracked) + { + var channel = RelayServer.TryGetChannel(hash); + if (channel != null) members = RelayServer.HumanUids(channel).Count; + } + + SendLobbyAd(member, now, ad.Id, ad.Name, ad.Title, ad.Poster, ad.Owner, members, true, ad.Tmdb, ad.Type); + listed.Add(ad.Id); + } + } + + static void SendLobbyAd(RelayMember target, long now, string id, string name, string title, string poster, string owner, int members, bool pwd, int tmdb, string type) + { + var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new + { + t = "ad", + r = new { id, name, title, poster, owner, members, pwd = pwd ? 1 : 0, tmdb, type } + }); + _ = RelayServer.SendAsync(target, frame); + } + + // ---------------------------------------------------------------- observation + + static void ObserveState(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) + { + // While hosting, only the creator may take the room back. + if (room.Hosting) + { + if (!string.Equals(member.Pid, room.CreatorPid, StringComparison.Ordinal)) + { + AssertHost(room, channel, now); + return; + } + Handback(room, channel, member, now); + } + + string url = GetString(msg, "url"); + if (string.IsNullOrWhiteSpace(url)) return; + + lock (room.Lock) + { + room.HostUid = member.Uid; + room.HostPid = GetString(msg, "own") ?? member.Pid ?? member.Uid; + room.CreatorPid = room.HostPid; + room.HostSeen = now; + room.Hosting = false; + room.Stalled = false; + + room.RoomName = GetString(msg, "rn") ?? room.RoomName; + room.Url = url.Trim(); + room.Title = GetString(msg, "ti") ?? room.Title; + room.Poster = GetString(msg, "po") ?? room.Poster; + room.Tmdb = GetInt(msg, "tm"); + room.Source = GetString(msg, "src") ?? room.Source; + room.Type = GetString(msg, "ty") ?? room.Type; + room.Code = GetString(msg, "cd") ?? room.Code; + room.OwnerName = member.Alias ?? room.OwnerName; + + room.State = GetString(msg, "s") == "playing" ? "playing" : "paused"; + double p = GetDouble(msg, "p"); + if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; + room.Speed = GetSpeedOr(msg, room.Speed); + room.AtServerTime = now; + } + + RelayServer.EnsureServerMember(channel); + } + + static void ObserveSync(ShadowRoom room, RelayMember member, JsonElement msg, long now) + { + string s = GetString(msg, "s") == "playing" ? "playing" : "paused"; + double p = GetDouble(msg, "p"); + double sp = GetSpeedOr(msg, room.Speed); + + lock (room.Lock) + { + if (room.Hosting) return; + + bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || + (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); + + if (!fromHost && string.IsNullOrEmpty(room.HostUid)) + { + // No known host yet: adopt the sender. + room.HostUid = member.Uid; + room.HostPid = member.Pid; + room.CreatorPid = member.Pid; + fromHost = true; + } + + if (!fromHost) return; + + room.State = s; + if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; + room.Speed = sp; + room.AtServerTime = now; + room.HostSeen = now; + } + } + + static void ObserveAct(ShadowRoom room, JsonElement msg, long now) + { + // Acts from anyone move the room; the host's next sync re-asserts. + if (room.Hosting) return; + + string s = GetString(msg, "s") == "playing" ? "playing" : "paused"; + double p = GetDouble(msg, "p"); + + lock (room.Lock) + { + room.State = s; + if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; + room.Speed = GetSpeedOr(msg, room.Speed); + room.AtServerTime = now; + } + } + + static void ObserveUrl(ShadowRoom room, RelayMember member, JsonElement msg) + { + lock (room.Lock) + { + bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || + (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); + if (!fromHost || room.Hosting) return; + + string url = GetString(msg, "url"); + if (string.IsNullOrWhiteSpace(url)) return; + + room.Url = url.Trim(); + string ti = GetString(msg, "ti"); + if (!string.IsNullOrEmpty(ti)) room.Title = ti.Trim(); + room.State = "paused"; + room.Position = 0; + room.AtServerTime = 0; + room.Stalled = false; + } + } + + static void ObserveBuf(ShadowRoom room, RelayMember member, JsonElement msg) + { + lock (room.Lock) + { + bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || + (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); + if (!fromHost) return; + + room.Stalled = GetBool(msg, "v"); + } + } + + static void HandleHostClaim(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) + { + if (room.Hosting) + { + // Claims are dropped; the server re-asserts itself. + AssertHost(room, channel, now); + return; + } + + string claimer = GetString(msg, "u"); + if (string.IsNullOrEmpty(claimer)) return; + + lock (room.Lock) + { + room.HostUid = member.Uid; + room.HostPid = claimer; + room.CreatorPid = claimer; + room.HostSeen = now; + room.Hosting = false; + } + } + + static void HandleHello(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) + { + string pid = GetString(msg, "u") ?? member.Pid; + string name = GetString(msg, "n"); + string cd = GetString(msg, "cd"); + + var pids = knownPids.GetOrAdd(channel.Name, _ => new ConcurrentDictionary()); + long seenBefore = 0; + if (!string.IsNullOrEmpty(pid)) + { + pids.TryGetValue(pid, out seenBefore); + pids[pid] = now; + } + + // The first participant stating the code links the room to its listing. + if (!string.IsNullOrWhiteSpace(cd)) + { + lock (room.Lock) + { + if (string.IsNullOrEmpty(room.Code)) room.Code = cd.Trim().ToUpperInvariant(); + } + } + + if (!room.Hosting) return; + + if (!string.IsNullOrEmpty(pid) && string.Equals(pid, room.CreatorPid, StringComparison.Ordinal)) + { + Handback(room, channel, member, now); + return; + } + + AnswerHelloAsHost(room, channel, member, pids, seenBefore, now); + } + + /// Server-hosting room, newcomer arrived: introduce the room + /// and, like a real host, freeze it while they buffer. + static void AnswerHelloAsHost(ShadowRoom room, RelayChannel channel, RelayMember member, ConcurrentDictionary pids, long seenBefore, long now) + { + if (!room.Hosting) return; + + RelayServer.EnsureServerMember(channel); + SendStateFrame(room, member); + + bool reconnected = now - seenBefore < ReconnectGraceMs; + + bool playing; + lock (room.Lock) playing = room.State == "playing" && !string.IsNullOrEmpty(room.Url); + + if (!playing) return; + if (reconnected) return; + + StartHold(room, channel, now); + } + + static void HandleReady(ShadowRoom room, RelayChannel channel, RelayMember member, long now) + { + lock (room.Lock) + { + if (!room.Hosting || room.HoldWaiting == null) return; + room.HoldWaiting.Remove(member.Uid); + if (room.HoldWaiting.Count > 0) return; + } + ReleaseHold(room, channel, now); + } + + static void HandleBye(ShadowRoom room, RelayChannel channel, RelayMember member, long now) + { + bool wasHost; + lock (room.Lock) + wasHost = !room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal); + + if (wasHost) BecomeHost(room, channel, now); + } + + // ---------------------------------------------------------------- host transitions + + static void BecomeHost(ShadowRoom room, RelayChannel channel, long now) + { + if (room.Hosting) return; + + lock (room.Lock) + { + room.Hosting = true; + room.HostUid = ServerUid; + room.HostSeen = now; + room.Stalled = false; + + if (room.State == "playing" && room.AtServerTime > 0) + { + // Continue exactly where the dead host was last heard. + double elapsed = (now - room.AtServerTime) / 1000.0; + if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) + room.Position += elapsed * room.Speed; + room.AtServerTime = now; + } + } + + RelayServer.EnsureServerMember(channel); + AssertHost(room, channel, now); + + double pos; + string state; + double speed; + lock (room.Lock) { state = room.State; pos = room.Position; speed = room.Speed; } + var sync = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed }); + RelayServer.Broadcast(channel, null, sync); + } + + static void Handback(ShadowRoom room, RelayChannel channel, RelayMember member, long now) + { + lock (room.Lock) + { + room.Hosting = false; + room.HostUid = member.Uid; + room.HostPid = member.Pid; + room.HostSeen = now; + room.Stalled = false; + } + + var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = member.Pid, n = member.Name ?? member.Alias ?? string.Empty }); + RelayServer.Broadcast(channel, null, frame); + } + + static void AssertHost(ShadowRoom room, RelayChannel channel, long now) + { + var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = ServerUid, n = "Lampac" }); + RelayServer.Broadcast(channel, null, frame); + } + + // ---------------------------------------------------------------- hold barrier (server as host) + + static void StartHold(ShadowRoom room, RelayChannel channel, long now) + { + lock (room.Lock) + { + if (room.HoldWaiting != null) return; // already holding + + AdvanceClock(room, now); + room.HoldPosition = room.Position; + + room.HoldWaiting = new HashSet(RelayServer.HumanUids(channel), StringComparer.Ordinal); + if (room.HoldWaiting.Count == 0) return; + } + + var holdFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "hold", u = ServerUid, p = room.HoldPosition, sp = room.Speed }); + RelayServer.Broadcast(channel, null, holdFrame); + + lock (room.Lock) + { + room.HoldTimer?.Dispose(); + room.HoldTimer = new Timer(_ => + { + var ch = RelayServer.TryGetChannel(room.Channel); + if (ch != null) ReleaseHold(room, ch, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + }, null, HoldMaxMs, Timeout.Infinite); + } + } + + /// Freeze the clock base at the current position. + static void AdvanceClock(ShadowRoom room, long now) + { + if (room.State == "playing" && room.AtServerTime > 0) + { + double elapsed = (now - room.AtServerTime) / 1000.0; + if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) room.Position += elapsed * room.Speed; + } + room.AtServerTime = now; + } + + static void ReleaseHold(ShadowRoom room, RelayChannel channel, long now) + { + double position; + lock (room.Lock) + { + room.HoldWaiting = null; + room.HoldTimer?.Dispose(); + room.HoldTimer = null; + room.State = "playing"; + room.Position = room.HoldPosition; + room.AtServerTime = now; + position = room.HoldPosition; + } + + var goFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "go", u = ServerUid, p = position, sp = room.Speed }); + RelayServer.Broadcast(channel, null, goFrame); + } + + // ---------------------------------------------------------------- metronome + + /// While the server hosts, nobody's player drives the clock: + /// the metronome advances the room by wall time and beats every couple + /// of seconds, exactly like the host heartbeats it replaces. + static void Metronome(object _) + { + try + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + foreach (var room in rooms.Values) + { + if (!room.Hosting) continue; + var channel = RelayServer.TryGetChannel(room.Channel); + if (channel == null) continue; + + bool playing; + lock (room.Lock) + { + playing = room.State == "playing" && !room.Stalled; + if (playing && room.AtServerTime > 0) + { + double elapsed = (now - room.AtServerTime) / 1000.0; + if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) + room.Position += elapsed * room.Speed; + room.AtServerTime = now; + } + } + + if (!playing) continue; + + double pos, speed; + string state; + lock (room.Lock) { pos = room.Position; speed = room.Speed; state = room.State; } + + var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed }); + RelayServer.Broadcast(channel, null, frame); + } + } + catch { } + } + + // ---------------------------------------------------------------- gc + + static void Gc(object _) + { + try + { + var emptyLimit = DateTime.UtcNow.AddMinutes(-Math.Max(5, ModInit.conf.gc_empty_timeout_minutes)); + var lifeLimit = DateTime.UtcNow.AddHours(-Math.Max(1, ModInit.conf.gc_max_lifetime_hours)); + + foreach (var room in rooms.Values.ToArray()) + { + bool hasHumans = false; + var channel = RelayServer.TryGetChannel(room.Channel); + if (channel != null) + { + lock (channel.Lock) + hasHumans = channel.Members.Keys.Any(u => !string.Equals(u, ServerUid, StringComparison.Ordinal)); + } + + bool expired = room.CreatedUtc < lifeLimit || + (!hasHumans && room.LastActivityUtc < emptyLimit); + + if (expired) + { + rooms.TryRemove(room.Channel, out var deadRoom); + knownPids.TryRemove(room.Channel, out var deadPids); + } + } + + long cutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ReconnectGraceMs; + foreach (var pids in knownPids.Values) + { + foreach (var key in pids.Keys.ToList()) + { + if (!pids.TryGetValue(key, out var seen) || seen < cutoff) + pids.TryRemove(key, out var deadPid); + } + } + + long adCutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - AdTtlMs; + foreach (var key in lobbyAds.Keys.ToList()) + { + if (!lobbyAds.TryGetValue(key, out var ad) || ad.Seen < adCutoff) + lobbyAds.TryRemove(key, out var deadAd); + } + } + catch { } + } + + // ---------------------------------------------------------------- helpers + + static void SendStateFrame(ShadowRoom room, RelayMember target) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + double pos; + string state; + double speed; + lock (room.Lock) + { + pos = room.Position; + speed = room.Speed; + state = room.State; + if (state == "playing" && room.AtServerTime > 0) + { + double elapsed = (now - room.AtServerTime) / 1000.0; + if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) pos += elapsed * room.Speed; + } + } + + var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new + { + t = "state", + rn = room.RoomName ?? string.Empty, + own = ServerUid, + url = room.Url ?? string.Empty, + ti = room.Title ?? string.Empty, + po = room.Poster ?? string.Empty, + tm = room.Tmdb, + src = room.Source ?? string.Empty, + ty = room.Type ?? "movie", + s = state, + p = pos, + sp = speed, + }); + + _ = RelayServer.SendAsync(target, frame); + } + + static string GetString(JsonElement obj, string name) + { + if (obj.ValueKind != JsonValueKind.Object) return null; + if (!obj.TryGetProperty(name, out var el)) return null; + if (el.ValueKind == JsonValueKind.String) return el.GetString(); + if (el.ValueKind == JsonValueKind.Null) return null; + return el.ToString(); + } + + static double GetDouble(JsonElement obj, string name) + { + if (obj.ValueKind != JsonValueKind.Object) return 0; + if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetDouble(out var val)) + return val; + return 0; + } + + static double GetSpeedOr(JsonElement obj, double fallback) + { + double v = GetDouble(obj, "sp"); + return v >= 0.25 && v <= 4.0 ? v : fallback; + } + + static int GetInt(JsonElement obj, string name) + { + if (obj.ValueKind != JsonValueKind.Object) return 0; + if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var val)) + return val; + return 0; + } + + static bool GetBool(JsonElement obj, string name) + { + if (obj.ValueKind != JsonValueKind.Object) return false; + return obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.True; + } + } +} diff --git a/mods/WatchTogether/WatchTogether.csproj b/mods/WatchTogether/WatchTogether.csproj new file mode 100644 index 0000000..74cb30d --- /dev/null +++ b/mods/WatchTogether/WatchTogether.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + true + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/mods/WatchTogether/WatchTogetherController.cs b/mods/WatchTogether/WatchTogetherController.cs new file mode 100644 index 0000000..698cda3 --- /dev/null +++ b/mods/WatchTogether/WatchTogetherController.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Shared; +using System; +using System.IO; + +namespace WatchTogether +{ + /// + /// Static file service: the Lampa plugin (adapted lparty.js) and the web + /// guest page (adapted lparty-web), with lampac hosts injected as + /// placeholders. The relay itself lives at /c/ (RelayStartupFilter). + /// + [Route("watchtogether")] + public class WatchTogetherController : BaseController + { + private bool CheckAuth() + { + if (ModInit.conf.allow_anonymous) return true; + if (!CoreInit.conf.accsdb.enable) return true; + return requestInfo.user != null; + } + + [AllowAnonymous] + [HttpGet] + [Route("/watchtogether.js")] + public ActionResult GetPlugin() + { + if (!CheckAuth()) return StatusCode(401); + return ServeFile("plugin.js", "application/javascript; charset=utf-8", js => + js.Replace("{relay}", RelayAddress()) + .Replace("{invite}", InviteAddress())); + } + + [AllowAnonymous] + [HttpGet] + [Route("/watch_together")] + [Route("/watch_together/{file}")] + public ActionResult GetWebPlayer(string file) + { + if (!CheckAuth()) return StatusCode(401); + + switch (file ?? string.Empty) + { + case "": + case "index.html": + return ServeFile("web/index.html", "text/html; charset=utf-8", html => + html.Replace("{relay}", RelayAddress())); + case "lparty-web.js": + return ServeFile("web/lparty-web.js", "application/javascript; charset=utf-8", js => + js.Replace("{relay}", RelayAddress())); + case "i18n.js": + return ServeFile("web/i18n.js", "application/javascript; charset=utf-8", null); + case "names.js": + return ServeFile("web/names.js", "application/javascript; charset=utf-8", null); + default: + return NotFound(); + } + } + + string RelayAddress() + { + return (host.StartsWith("https", StringComparison.Ordinal) ? "wss://" : "ws://") + + host.Replace("https://", "").Replace("http://", "").TrimEnd('/') + "/wt/c/"; + } + + string InviteAddress() + { + return host.TrimEnd('/') + "/watch_together/"; + } + + ActionResult ServeFile(string name, string contentType, Func transform) + { + string path = Path.Combine(ModInit.modpath, name); + if (!System.IO.File.Exists(path)) + return Content("

" + name + " not found

", "text/html; charset=utf-8"); + + long mtime = System.IO.File.GetLastWriteTimeUtc(path).Ticks; + string memKey = "watchtogether:file:" + name + ":" + mtime; + + if (!memoryCache.TryGetValue(memKey, out string raw)) + { + raw = System.IO.File.ReadAllText(path); + memoryCache.Set(memKey, raw, TimeSpan.FromMinutes(10)); + } + + string result = transform != null ? transform(raw) : raw; + return Content(result, contentType); + } + } +} diff --git a/mods/WatchTogether/manifest.json b/mods/WatchTogether/manifest.json new file mode 100644 index 0000000..63567e4 --- /dev/null +++ b/mods/WatchTogether/manifest.json @@ -0,0 +1,10 @@ +{ + "enable": true, + "dynamic": true, + "tree": [ + "ModInit.cs", + "RelayServer.cs", + "ShadowHost.cs", + "WatchTogetherController.cs" + ] +} diff --git a/mods/WatchTogether/plugin.js b/mods/WatchTogether/plugin.js new file mode 100644 index 0000000..cd638bd --- /dev/null +++ b/mods/WatchTogether/plugin.js @@ -0,0 +1,3651 @@ +(function () { + "use strict"; + + if (window.WatchTogether_plugin_started) { + console.log("[WatchTogether]", "Already running."); + return; + } + window.WatchTogether_plugin_started = true; + + var META = { + name: "WatchTogether", + version: "2.0.0", + author: "arabianq, nrsua, levende", + }; + + var DEFAULT_RELAY = "{relay}"; + var DEFAULT_INVITE = "{invite}"; + // Wire-protocol constants shared with all lparty clients (JustPlus etc.) — do not rename. + var LOBBY_CHANNEL = "lparty-lobby-v1"; + var ROOM_PREFIX = "lparty-r-"; + + var _rawLang = (Lampa.Storage.get("language") || "en").toLowerCase(); + var i18n = { + uk: { + menu_title: "WatchTogether", + settings_title: "WatchTogether", + param_show_head: "Відображати WatchTogether на головній", + param_show_head_descr: + "Кнопка WatchTogether у верхній панелі головного екрана.", + param_name: "Ім'я користувача", + param_name_descr: + "Як вас бачитимуть інші в кімнатах. Якщо очистити - буде згенеровано випадкове ім'я.", + param_use_pwd: "Використовувати пароль", + param_use_pwd_descr: + "Запитувати пароль при створенні кімнати та підставляти у власні кімнати.", + param_pwd: "Пароль за замовчуванням", + param_pwd_descr: "Буде використано при створенні кімнат із паролем.", + param_publish: "Показувати кімнату в списку", + param_publish_descr: + "Вимкніть, щоб до вашої кімнати можна було зайти лише за кодом.", + param_relay: "Адреса реле", + param_relay_descr: "WebSocket-реле. За замовчуванням " + DEFAULT_RELAY, + param_invite: "Адреса запрошення", + param_invite_descr: + "Базове посилання для QR-коду. За замовчуванням " + DEFAULT_INVITE, + head_title: "WatchTogether - список кімнат", + create_btn: "Створити кімнату за посиланням", + join_code_btn: "Увійти за кодом кімнати", + full_card_btn: "WatchTogether - Дивитися з друзями", + settings_open_rooms: "Відкрити список кімнат", + settings_open_rooms_descr: + "Показує список доступних кімнат і дозволяє створити свою", + empty_list: "Відкритих кімнат немає", + searching: "Пошук кімнат...", + input_url: "Посилання на потік (m3u8 / mp4)", + input_room_name: "Назва кімнати", + input_password: "Пароль кімнати", + input_join_password: "Введіть пароль кімнати", + input_room_code: "Код кімнати", + connecting: "Підключення...", + create_fail: "Не вдалося створити кімнату", + create_ok: function (n) { + return 'Кімнату "' + n + '" створено'; + }, + room_code: function (c) { + return "Код кімнати: " + c; + }, + join_ok: function (n) { + return 'Ви увійшли до кімнати "' + n + '"'; + }, + no_room: "Кімнату не знайдено або невірний пароль", + no_stream: "У цій кімнаті немає потоку", + kicked: "Ви увійшли до цієї кімнати з іншого пристрою", + host_left: "Хост покинув кімнату - перегляд завершено", + net_err: "Помилка мережі", + need_url: "Не задано посилання на потік", + publish_needs_pwd: "Немає у публічному списку: доступ лише за кодом", + create_from_player: "Поділитися останнім потоком", + pending_share: + "Запустіть відтворення - кімнату буде створено автоматично", + label_owner: "Хост", + label_members: "Глядачів", + badge_room: "Кімната", + badge_viewers: "Глядачів", + notice_joined: function (n) { + return n + " приєднався"; + }, + notice_left: function (n) { + return n + " покинув кімнату"; + }, + notice_paused: function (n) { + return n + " поставив паузу"; + }, + notice_resumed: function (n) { + return n + " продовжив відтворення"; + }, + notice_seeked: function (n) { + return n + " перемотав"; + }, + notice_host_changed: function (n) { + return "Новий хост: " + n; + }, + player_create_descr: "Створити кімнату на цей потік", + player_qr_descr: "Показати QR-код для запрошення друзів", + qr_title: "Приєднатися до кімнати", + qr_hint: "Скануйте камерою телефона", + qr_copy: "Копіювати посилання", + qr_share: "Поділитися", + qr_copied: "Посилання скопійовано", + qr_copy_fail: "Не вдалося скопіювати посилання", + already_in_room: function (n) { + return 'Ви вже в кімнаті "' + n + '"'; + }, + leave_btn: "Покинути кімнату", + left_ok: "Ви покинули кімнату", + notice_hold: "Пауза - чекаємо, поки всі завантажать буфер", + notice_go: "Усі готові - продовжуємо", + }, + en: { + menu_title: "WatchTogether", + settings_title: "WatchTogether", + param_show_head: "Show WatchTogether on the main screen", + param_show_head_descr: + "WatchTogether button in the top bar of the main screen.", + param_name: "Display name", + param_name_descr: + "How others see you in rooms. Clear it to generate a random name.", + param_use_pwd: "Use password", + param_use_pwd_descr: + "Ask for password when creating a room and prefill your default.", + param_pwd: "Default password", + param_pwd_descr: "Used when creating password-protected rooms.", + param_publish: "List room publicly", + param_publish_descr: "Turn off to make your room reachable by code only.", + param_relay: "Relay address", + param_relay_descr: "WebSocket relay. Default is " + DEFAULT_RELAY, + param_invite: "Invite address", + param_invite_descr: + "Base link used in the QR code. Default is " + DEFAULT_INVITE, + head_title: "WatchTogether - available rooms", + create_btn: "Create room from URL", + join_code_btn: "Join by room code", + full_card_btn: "WatchTogether - Watch with friends", + settings_open_rooms: "Open room browser", + settings_open_rooms_descr: + "Shows the list of available rooms and lets you create your own", + empty_list: "No open rooms", + searching: "Looking for rooms...", + input_url: "Stream URL (m3u8 / mp4)", + input_room_name: "Room name", + input_password: "Room password", + input_join_password: "Enter room password", + input_room_code: "Room code", + connecting: "Connecting...", + create_fail: "Failed to create room", + create_ok: function (n) { + return 'Room "' + n + '" created'; + }, + room_code: function (c) { + return "Room code: " + c; + }, + join_ok: function (n) { + return 'Joined room "' + n + '"'; + }, + no_room: "Room not found or wrong password", + no_stream: "This room has no stream", + kicked: "You joined this room from another device", + host_left: "Host left the room - session ended", + net_err: "Network error", + need_url: "Stream URL is empty", + publish_needs_pwd: "Not in the public list: access by code only", + create_from_player: "Share last stream", + pending_share: "Start playback - the room will be created automatically", + label_owner: "Host", + label_members: "Viewers", + badge_room: "Room", + badge_viewers: "Viewers", + notice_joined: function (n) { + return n + " joined"; + }, + notice_left: function (n) { + return n + " left the room"; + }, + notice_paused: function (n) { + return n + " paused"; + }, + notice_resumed: function (n) { + return n + " resumed playback"; + }, + notice_seeked: function (n) { + return n + " seeked"; + }, + notice_host_changed: function (n) { + return "New host: " + n; + }, + player_create_descr: "Create a room for this stream", + player_qr_descr: "Show a QR code to invite friends", + qr_title: "Join the room", + qr_hint: "Scan with your phone camera", + qr_copy: "Copy link", + qr_share: "Share", + qr_copied: "Link copied", + qr_copy_fail: "Could not copy the link", + already_in_room: function (n) { + return 'You are already in room "' + n + '"'; + }, + leave_btn: "Leave room", + left_ok: "You left the room", + notice_hold: "Paused - waiting for everyone to buffer", + notice_go: "Everyone is ready - resuming", + }, + ru: { + menu_title: "WatchTogether", + settings_title: "WatchTogether", + param_show_head: "Показывать WatchTogether на главной", + param_show_head_descr: + "Кнопка WatchTogether в верхней панели главного экрана.", + param_name: "Имя пользователя", + param_name_descr: + "Как вас будут видеть в комнатах. Если очистить - будет сгенерировано случайное имя.", + param_use_pwd: "Использовать пароль", + param_use_pwd_descr: + "Запрашивать пароль при создании комнаты и подставлять в свои комнаты.", + param_pwd: "Пароль по умолчанию", + param_pwd_descr: "Будет использоваться при создании комнат с паролем.", + param_publish: "Показывать комнату в списке", + param_publish_descr: + "Выключите, чтобы в вашу комнату можно было войти только по коду.", + param_relay: "Адрес реле", + param_relay_descr: "WebSocket-реле. По умолчанию " + DEFAULT_RELAY, + param_invite: "Адрес приглашения", + param_invite_descr: + "Базовая ссылка для QR-кода. По умолчанию " + DEFAULT_INVITE, + head_title: "WatchTogether - список комнат", + create_btn: "Создать комнату по ссылке", + join_code_btn: "Войти по коду комнаты", + full_card_btn: "WatchTogether - Смотреть с друзьями", + settings_open_rooms: "Открыть список комнат", + settings_open_rooms_descr: + "Показывает список доступных комнат и позволяет создать свою", + empty_list: "Открытых комнат нет", + searching: "Поиск комнат...", + input_url: "Ссылка на поток (m3u8 / mp4)", + input_room_name: "Название комнаты", + input_password: "Пароль комнаты", + input_join_password: "Введите пароль комнаты", + input_room_code: "Код комнаты", + connecting: "Подключение...", + create_fail: "Не удалось создать комнату", + create_ok: function (n) { + return 'Комната "' + n + '" создана'; + }, + room_code: function (c) { + return "Код комнаты: " + c; + }, + join_ok: function (n) { + return 'Вошли в комнату "' + n + '"'; + }, + no_room: "Комната не найдена или неверный пароль", + no_stream: "В этой комнате нет потока", + kicked: "Вы вошли в комнату с другого устройства", + host_left: "Хост покинул комнату - просмотр завершён", + net_err: "Ошибка сети", + need_url: "Не задана ссылка на поток", + publish_needs_pwd: "Нет в публичном списке: доступ только по коду", + create_from_player: "Поделиться последним потоком", + pending_share: + "Запустите воспроизведение - комната будет создана автоматически", + label_owner: "Хост", + label_members: "Зрителей", + badge_room: "Комната", + badge_viewers: "Зрителей", + notice_joined: function (n) { + return n + " присоединился"; + }, + notice_left: function (n) { + return n + " покинул комнату"; + }, + notice_paused: function (n) { + return n + " поставил паузу"; + }, + notice_resumed: function (n) { + return n + " продолжил воспроизведение"; + }, + notice_seeked: function (n) { + return n + " перемотал"; + }, + notice_host_changed: function (n) { + return "Новый хост: " + n; + }, + player_create_descr: "Создать комнату с этим потоком", + player_qr_descr: "Показать QR-код для приглашения друзей", + qr_title: "Присоединиться к комнате", + qr_hint: "Сканируйте камерой телефона", + qr_copy: "Копировать ссылку", + qr_share: "Поделиться", + qr_copied: "Ссылка скопирована", + qr_copy_fail: "Не удалось скопировать ссылку", + already_in_room: function (n) { + return 'Вы уже в комнате "' + n + '"'; + }, + leave_btn: "Покинуть комнату", + left_ok: "Вы покинули комнату", + notice_hold: "Пауза - ждём, пока все загрузят буфер", + notice_go: "Все готовы - продолжаем", + }, + }; + var T = i18n[_rawLang] || i18n["en"]; + + var LOBBY_COLLECT_MS = 1500; + var JOIN_TIMEOUT_MS = 6000; + var JOIN_EMPTY_TIMEOUT_MS = 2500; + var PING_INTERVAL_MS = 8000; + var ECHO_TIMEOUT_MS = 30000; + var RECONNECT_MS = 4000; + + var ROOM_ORPHAN_MS = 20000; + var EPISODE_SWITCH_MS = 20000; + + var SYNC_HEARTBEAT_MS = 2000; + var SYNC_TOLERANCE_S = 0.3; + var SYNC_CORRECT_ON_S = 0.7; + var SYNC_HARD_SEEK_S = 1.5; + var SYNC_HARD_SEEK_APPLE_S = 3.0; + var SEEK_GUARD_MS = 4000; + var SEEK_BROADCAST_MIN_MS = 2000; + var SEEK_MIN_JUMP_S = 1.0; + var SYNC_RATE_GAIN = 0.1; + var SYNC_MAX_RATE_OFFSET = 0.1; + var SYNC_RATE_RESET_MS = 4000; + var REWIND_GRACE_MS = 2500; + + // Playback speed of the room. Announced in the sp field of + // state/sync/act/hold/go frames; the rate correction works around this base. + var roomSpeed = 1.0; + + function applyRoomSpeed(m) { + if (!m || typeof m.sp !== "number" || !(m.sp >= 0.25 && m.sp <= 4.0)) + return; + if (Math.abs(m.sp - roomSpeed) < 0.005) return; + roomSpeed = m.sp; + var vid = getVideo(); + if ( + vid && + canAdjustRate() && + !vid._lp_correcting && + !isSystemSyncing && + Math.abs((vid.playbackRate || 1) - roomSpeed) > 0.01 + ) { + vid.playbackRate = roomSpeed; + } + } + + var HOLD_MAX_MS = 15000; + var HOLD_BUFFER_S = 6; + var HOLD_MIN_BUFFER_S = 2; + var HOLD_STALL_MS = 3000; + var HOLD_NUDGE_MS = 4000; + var NO_RANGES = -2; + var OUT_OF_RANGE = -1; + var PENDING_SHARE_MS = 300000; + var SHARE_START_DELAY_MS = 600; + var HARD_SEEK_COOLDOWN_MS = 3000; + var PENDING_ACT_MAX_MS = 15000; + var RECONNECT_HELLO_MS = 60000; + var LOG_LIMIT = 400; + + var logRing = []; + + var playerLaunch = Lampa.Storage.get("watchtogether_player", "inner"); + + function lplog() { + var text = Array.prototype.slice.call(arguments).join(" "); + var stamp = new Date().toISOString().substr(11, 12); + + logRing.push(stamp + " " + text); + if (logRing.length > LOG_LIMIT) logRing.shift(); + + return text; + } + + var pid = Lampa.Storage.get("watchtogether_pid", ""); + if (!pid) { + pid = Lampa.Utils.uid(12).toLowerCase(); + Lampa.Storage.set("watchtogether_pid", pid); + } + + var NAME_ADJECTIVES = [ + "Amber", + "Arctic", + "Atomic", + "Brave", + "Bright", + "Calm", + "Chilly", + "Clever", + "Cosmic", + "Crimson", + "Curious", + "Dizzy", + "Dusty", + "Electric", + "Emerald", + "Fancy", + "Fearless", + "Frosty", + "Fuzzy", + "Gentle", + "Golden", + "Happy", + "Hidden", + "Humble", + "Indigo", + "Iron", + "Jolly", + "Lively", + "Lucky", + "Lunar", + "Magic", + "Marble", + "Mighty", + "Mystic", + "Neon", + "Nimble", + "Noble", + "Northern", + "Plasma", + "Polar", + "Quiet", + "Rapid", + "Retro", + "Royal", + "Rusty", + "Scarlet", + "Shiny", + "Silent", + "Silver", + "Sleepy", + "Snowy", + "Solar", + "Sunny", + "Swift", + "Thunder", + "Tiny", + "Turbo", + "Velvet", + "Vivid", + "Wild", + ]; + + var NAME_NOUNS = [ + "Badger", + "Bandit", + "Bear", + "Beaver", + "Bison", + "Camel", + "Captain", + "Cheetah", + "Comet", + "Condor", + "Cookie", + "Coyote", + "Crane", + "Dingo", + "Dolphin", + "Donkey", + "Dragon", + "Eagle", + "Falcon", + "Ferret", + "Fox", + "Gecko", + "Ghost", + "Gibbon", + "Griffin", + "Hamster", + "Hawk", + "Hedgehog", + "Heron", + "Husky", + "Ibex", + "Jaguar", + "Kitten", + "Koala", + "Kraken", + "Lemur", + "Leopard", + "Lion", + "Llama", + "Lynx", + "Mammoth", + "Marmot", + "Meteor", + "Monkey", + "Moose", + "Narwhal", + "Ninja", + "Nomad", + "Ocelot", + "Octopus", + "Osprey", + "Otter", + "Owl", + "Panda", + "Panther", + "Parrot", + "Pelican", + "Penguin", + "Pilot", + "Pirate", + "Puffin", + "Puma", + "Rabbit", + "Raccoon", + "Ranger", + "Raven", + "Rhino", + "Robot", + "Rocket", + "Salmon", + "Seal", + "Shark", + "Sparrow", + "Tiger", + "Toucan", + "Turtle", + "Viking", + "Walrus", + "Wizard", + "Wolf", + "Wombat", + "Yeti", + "Zebra", + ]; + + function pickOne(list) { + return list[Math.floor(Math.random() * list.length)]; + } + + function randomDisplayName() { + return ( + pickOne(NAME_ADJECTIVES) + + pickOne(NAME_NOUNS) + + (10 + Math.floor(Math.random() * 90)) + ); + } + + function getDisplayName() { + var custom = (Lampa.Storage.get("watchtogether_display_name", "") || "") + .toString() + .trim(); + if (!custom) { + custom = randomDisplayName(); + Lampa.Storage.set("watchtogether_display_name", custom); + lplog("display name generated: " + custom); + } + return custom; + } + + function isUsePassword() { + return Lampa.Storage.field("watchtogether_use_password") === true; + } + + function getDefaultPassword() { + return ( + Lampa.Storage.get("watchtogether_default_password", "") || "" + ).toString(); + } + + function isPublish() { + return Lampa.Storage.field("watchtogether_publish") !== false; + } + + function isShowHead() { + return Lampa.Storage.get("watchtogether_show_head", "true") === true; + } + + function getRelay() { + var v = (Lampa.Storage.get("watchtogether_relay", "") || "") + .toString() + .trim(); + if (!v) v = DEFAULT_RELAY; + if (v.charAt(v.length - 1) !== "/") v += "/"; + return v; + } + + function getInvite() { + var v = (Lampa.Storage.get("watchtogether_invite", "") || "") + .toString() + .trim(); + return v || DEFAULT_INVITE; + } + + function safe(s) { + return (s + "").replace(/[<>&"']/g, function (c) { + return "&#" + c.charCodeAt(0) + ";"; + }); + } + + var uiPrevController = "content"; + + function controllerName() { + try { + var e = Lampa.Controller.enabled(); + return (e && e.name) || ""; + } catch (err) { + return ""; + } + } + + function rememberController() { + var name = controllerName(); + if ( + name && + name !== "select" && + name !== "keybord" && + name !== "settings_input" + ) + uiPrevController = name; + return uiPrevController; + } + + function restoreController(name) { + var target = name || uiPrevController || "content"; + + if (target === "content" && playerIsOpen()) target = "player"; + + try { + Lampa.Controller.toggle(target); + if (controllerName() === target) return; + } catch (err) {} + + if (playerIsOpen()) { + try { + Lampa.Controller.toggle("player"); + } catch (err) {} + if (controllerName() === "player") return; + } + + try { + Lampa.Controller.toggle("content"); + } catch (err) {} + } + + function askInput(params, done) { + Lampa.Input.edit( + { + title: params.title, + value: params.value || "", + free: true, + nosave: true, + }, + function (value) { + restoreController(); + done(value); + }, + ); + } + + function closeSettings() { + var guard = 0; + while ($("body").hasClass("settings--open") && guard++ < 4) { + var name = controllerName(); + if (name !== "settings" && name !== "settings_component") break; + try { + Lampa.Controller.back(); + } catch (err) { + break; + } + } + } + + function playerIsOpen() { + try { + if (Lampa.Player && typeof Lampa.Player.opened === "function") + return !!Lampa.Player.opened(); + } catch (err) {} + return !!document.querySelector(".player"); + } + + var SHA_K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]; + + function sha256hex(str) { + var msg = unescape(encodeURIComponent(str)); + var len = msg.length; + var bitLen = len * 8; + var blocks = (((len + 8) >> 6) + 1) * 16; + var w = []; + var i, j; + + for (i = 0; i < blocks; i++) w[i] = 0; + for (i = 0; i < len; i++) + w[i >> 2] |= (msg.charCodeAt(i) & 0xff) << (24 - (i % 4) * 8); + w[len >> 2] |= 0x80 << (24 - (len % 4) * 8); + w[blocks - 1] = bitLen; + + var H = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, + ]; + var m = []; + + function rr(v, n) { + return (v >>> n) | (v << (32 - n)); + } + + for (i = 0; i < blocks; i += 16) { + var a = H[0], + b = H[1], + c = H[2], + d = H[3], + e = H[4], + f = H[5], + g = H[6], + h = H[7]; + + for (j = 0; j < 64; j++) { + if (j < 16) { + m[j] = w[i + j] | 0; + } else { + var s0 = rr(m[j - 15], 7) ^ rr(m[j - 15], 18) ^ (m[j - 15] >>> 3); + var s1 = rr(m[j - 2], 17) ^ rr(m[j - 2], 19) ^ (m[j - 2] >>> 10); + m[j] = (m[j - 16] + s0 + m[j - 7] + s1) | 0; + } + var S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25); + var ch = (e & f) ^ (~e & g); + var t1 = (h + S1 + ch + SHA_K[j] + m[j]) | 0; + var S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22); + var mj = (a & b) ^ (a & c) ^ (b & c); + var t2 = (S0 + mj) | 0; + + h = g; + g = f; + f = e; + e = (d + t1) | 0; + d = c; + c = b; + b = a; + a = (t1 + t2) | 0; + } + + H[0] = (H[0] + a) | 0; + H[1] = (H[1] + b) | 0; + H[2] = (H[2] + c) | 0; + H[3] = (H[3] + d) | 0; + H[4] = (H[4] + e) | 0; + H[5] = (H[5] + f) | 0; + H[6] = (H[6] + g) | 0; + H[7] = (H[7] + h) | 0; + } + + var out = ""; + for (i = 0; i < 8; i++) { + for (j = 3; j >= 0; j--) { + var byte = (H[i] >>> (j * 8)) & 0xff; + out += (byte < 16 ? "0" : "") + byte.toString(16); + } + } + return out; + } + + function roomChannel(roomId, password) { + return ( + ROOM_PREFIX + + sha256hex((roomId || "").toUpperCase() + "|" + (password || "")).substr( + 0, + 24, + ) + ); + } + + function newRoomId() { + var chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + var id = ""; + for (var i = 0; i < 6; i++) + id += chars.charAt(Math.floor(Math.random() * chars.length)); + return id; + } + + function Sock(opts) { + var self = this; + this.channel = opts.channel; + this.alias = opts.alias || ""; + this.echo = !!opts.echo; + this.onEvent = opts.onEvent || function () {}; + this.reconnect = !!opts.reconnect; + this.ws = null; + this.uid = null; + this.members = []; + this.closed = false; + this.retryTimer = null; + + this.url = function () { + var q = "announce=true&list=true"; + if (self.echo) q += "&echo=true"; + if (self.alias) q += "&as=" + encodeURIComponent(self.alias); + return getRelay() + encodeURIComponent(self.channel) + "?" + q; + }; + + this.open = function () { + if (self.closed) return; + if (self.ws && (self.ws.readyState === 0 || self.ws.readyState === 1)) + return; + + try { + self.ws = new WebSocket(self.url()); + } catch (err) { + self.scheduleRetry(); + return; + } + + self.ws.onopen = function () { + console.log("[WatchTogether] ws open", self.channel); + self.onEvent({ kind: "open" }); + }; + + self.ws.onmessage = function (e) { + var d; + try { + d = JSON.parse(e.data); + } catch (err) { + return; + } + if (!d) return; + + if (d.type === "join") { + if (d.self) { + self.uid = d.uid; + self.members = []; + var list = d.users || []; + for (var i = 0; i < list.length; i++) { + if (!self.findMember(list[i].uid)) + self.members.push({ + uid: list[i].uid, + alias: list[i].alias || "", + }); + } + if (!self.findMember(self.uid)) + self.members.push({ uid: self.uid, alias: self.alias }); + self.onEvent({ + kind: "ready", + date: d.date, + total: d.total, + members: self.members, + }); + } else { + if (!self.findMember(d.uid)) + self.members.push({ uid: d.uid, alias: d.alias || "" }); + self.onEvent({ + kind: "join", + date: d.date, + uid: d.uid, + alias: d.alias || "", + total: d.total, + }); + } + return; + } + + if (d.type === "leave") { + for (var k = self.members.length - 1; k >= 0; k--) { + if (self.members[k].uid === d.uid) self.members.splice(k, 1); + } + self.onEvent({ + kind: "leave", + date: d.date, + uid: d.uid, + alias: d.alias || "", + total: d.total, + }); + return; + } + + if (d.type === "error") { + console.log("[WatchTogether] relay error:", d.message); + self.onEvent({ kind: "error", date: d.date, text: d.message }); + return; + } + + if (d.message && typeof d.message === "object") { + self.onEvent({ + kind: "msg", + date: d.date, + uid: d.uid, + alias: d.alias || "", + msg: d.message, + mine: d.uid === self.uid, + }); + } + }; + + self.ws.onclose = function () { + console.log( + "[WatchTogether] ws close", + self.channel, + self.closed ? "(by us)" : "(remote)", + ); + self.uid = null; + self.members = []; + self.onEvent({ kind: "close" }); + self.scheduleRetry(); + }; + + self.ws.onerror = function () {}; + }; + + this.scheduleRetry = function () { + if (!self.reconnect || self.closed) return; + if (self.retryTimer) clearTimeout(self.retryTimer); + self.retryTimer = setTimeout(function () { + self.retryTimer = null; + self.open(); + }, RECONNECT_MS); + }; + + this.findMember = function (uid) { + for (var i = 0; i < self.members.length; i++) + if (self.members[i].uid === uid) return self.members[i]; + return null; + }; + + this.alive = function () { + return !!self.ws && self.ws.readyState === 1; + }; + + this.send = function (obj) { + if (!self.alive()) return false; + try { + self.ws.send(JSON.stringify(obj)); + return true; + } catch (err) { + return false; + } + }; + + this.close = function () { + self.closed = true; + if (self.retryTimer) { + clearTimeout(self.retryTimer); + self.retryTimer = null; + } + if (self.ws) { + try { + self.ws.close(); + } catch (err) {} + self.ws = null; + } + }; + } + + var room = null; + var lobbyHost = null; + var inRoom = false; + var joining = false; + var joinTimer = null; + + var currentRoomId = null; + var currentRoomPassword = ""; + var currentRoomName = ""; + var currentRoomOwner = null; + var currentRoomMeta = { + title: "", + poster: "", + url: "", + tmdb_id: 0, + source: "", + type: "", + }; + + var pidByUid = {}; + + var episodeSwitchAt = 0; + var roomAliveAt = 0; + + function markEpisodeSwitch() { + episodeSwitchAt = Date.now(); + } + function clearEpisodeSwitch() { + episodeSwitchAt = 0; + } + function isEpisodeSwitching() { + return ( + episodeSwitchAt > 0 && Date.now() - episodeSwitchAt < EPISODE_SWITCH_MS + ); + } + + var serverTimeOffset = 0; + var pingSamples = []; + var echoSeq = 0; + var echoPending = {}; + var pingTimer = null; + var watchdogTimer = null; + + var isSystemSyncing = false; + var lastUserActionTime = 0; + var rewindUntil = 0; + var lastHardSeekAt = 0; + var knownPids = {}; + + var holdActive = false; + var holdPosition = 0; + var holdWaiting = {}; + var holdReadySent = false; + var holdTimer = null; + var holdBufferSeen = -1; + var holdBufferAt = 0; + var holdNudgeDone = false; + var initialSyncLock = false; + var targetInitialState = null; + var expectedState = { seek: -1, play: false, pause: false }; + + var lastStreamUrl = null; + var lastStreamTitle = null; + var pendingShareCard = null; + var pendingShareAt = 0; + var lastViewedCard = null; + + function serverNow() { + return Date.now() + serverTimeOffset; + } + + function iAmHost() { + return !!currentRoomOwner && currentRoomOwner === pid; + } + + function memberCount() { + return room ? room.members.length : 0; + } + + function getTmdbId(card) { + if (!card) return 0; + return card.id || card.tmdb_id || 0; + } + + function getCardPoster(card) { + if (!card) return ""; + if (card.poster_path) + return "https://image.tmdb.org/t/p/w300" + card.poster_path; + return card.img || card.background_image || ""; + } + + function expectedPositionNow(state, basePosition, atServerTime) { + if (state !== "playing" || !atServerTime || atServerTime <= 0) + return basePosition; + var elapsedSec = (serverNow() - atServerTime) / 1000; + if (elapsedSec < 0 || elapsedSec > 3600) return basePosition; + return basePosition + elapsedSec; + } + + function roomSend(obj) { + if (!room || !room.alive()) return false; + obj.u = pid; + obj.k = ++echoSeq; + echoPending[obj.k] = Date.now(); + return room.send(obj); + } + + function handleEcho(key, serverDate) { + var t0 = echoPending[key]; + if (!t0) return; + delete echoPending[key]; + + var t1 = Date.now(); + var rtt = t1 - t0; + var offset = serverDate + rtt / 2 - t1; + + pingSamples.push({ offset: offset, rtt: rtt }); + if (pingSamples.length > 8) pingSamples.shift(); + + var best = pingSamples[0]; + for (var i = 1; i < pingSamples.length; i++) + if (pingSamples[i].rtt < best.rtt) best = pingSamples[i]; + serverTimeOffset = best.offset; + } + + function startClockTimers() { + stopClockTimers(); + pingTimer = setInterval(function () { + if (room && room.alive()) roomSend({ t: "ping" }); + }, PING_INTERVAL_MS); + + watchdogTimer = setInterval(function () { + if (!room || !room.alive()) return; + var now = Date.now(); + for (var key in echoPending) { + if (!echoPending.hasOwnProperty(key)) continue; + if (now - echoPending[key] > ECHO_TIMEOUT_MS) { + console.log( + "[WatchTogether]", + lplog("echo watchdog timeout - forcing reconnect"), + ); + echoPending = {}; + try { + room.ws.close(); + } catch (err) {} + return; + } + } + }, 5000); + } + + function stopClockTimers() { + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } + if (watchdogTimer) { + clearInterval(watchdogTimer); + watchdogTimer = null; + } + } + + function buildAd() { + return { + id: currentRoomId, + name: currentRoomName, + title: currentRoomMeta.title || "", + poster: currentRoomMeta.poster || "", + owner: getDisplayName(), + members: memberCount(), + pwd: currentRoomPassword ? 1 : 0, + tmdb: currentRoomMeta.tmdb_id || 0, + type: currentRoomMeta.type || "movie", + }; + } + + function startLobbyAgent() { + stopLobbyAgent(); + if (!isPublish()) return; + // Never advertise a passwordless room, even one we only joined or inherited as host: + // the lobby is a public relay, so anyone reading it could walk straight in. + if (!currentRoomPassword) return; + + lobbyHost = new Sock({ + channel: LOBBY_CHANNEL, + alias: "", + echo: false, + reconnect: true, + onEvent: function (e) { + if (e.kind !== "msg" || e.mine) return; + if (!e.msg || e.msg.t !== "who") return; + if (!inRoom || !currentRoomId) return; + + var ad = buildAd(); + setTimeout( + function () { + if (lobbyHost && inRoom) lobbyHost.send({ t: "ad", r: ad }); + }, + Math.floor(Math.random() * 350) + 50, + ); + }, + }); + lobbyHost.open(); + } + + function stopLobbyAgent() { + if (lobbyHost) { + lobbyHost.close(); + lobbyHost = null; + } + } + + function discoverRooms(done) { + var found = {}; + var finished = false; + + var probe = new Sock({ + channel: LOBBY_CHANNEL, + alias: "", + echo: false, + reconnect: false, + onEvent: function (e) { + if (e.kind === "ready") { + probe.send({ t: "who" }); + return; + } + if ( + e.kind === "msg" && + !e.mine && + e.msg && + e.msg.t === "ad" && + e.msg.r && + e.msg.r.id + ) { + found[e.msg.r.id] = e.msg.r; + } + }, + }); + probe.open(); + + setTimeout(function () { + if (finished) return; + finished = true; + probe.close(); + + var list = []; + for (var id in found) { + if (!found.hasOwnProperty(id)) continue; + list.push(found[id]); + } + list.sort(function (a, b) { + return (b.members || 0) - (a.members || 0); + }); + done(list); + }, LOBBY_COLLECT_MS); + } + + var ICON_FILM = + ''; + + function ensureRoomStyle() { + if (document.getElementById("watchtogether-room-style")) return; + + var style = document.createElement("style"); + + style.id = "watchtogether-room-style"; + style.textContent = + ".selectbox-item__icon > .watchtogether-poster{width:3.4em;height:5.1em;border-radius:0.3em;" + + "background-color:#3e3e3e;object-fit:cover;}" + + ".watchtogether-poster--empty{display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.4);}" + + ".watchtogether-poster--empty > svg{width:1.6em;height:1.6em;}"; + + document.head.appendChild(style); + } + + function roomPosterStub() { + return ( + '
' + + ICON_FILM + + "
" + ); + } + + function roomPoster(r) { + if (!r.poster) return roomPosterStub(); + return ''; + } + + function bindPosterFallback(item) { + item.find("img.watchtogether-poster").on("error", function () { + $(this).replaceWith(roomPosterStub()); + }); + } + + var browserBusy = false; + + function openRoomBrowser() { + if (browserBusy) return; + browserBusy = true; + + rememberController(); + dropStaleRoom(); + + Lampa.Noty.show(T.searching); + + discoverRooms(function (rooms) { + browserBusy = false; + + rememberController(); + + var items = []; + + if (inRoom) { + items.push({ + title: + '✕ ' + + T.leave_btn + + " [" + + safe(currentRoomId || "") + + "]", + action: "leave", + }); + } + + items.push({ + title: '+ ' + T.create_btn + "", + action: "create", + }); + items.push({ + title: '# ' + T.join_code_btn + "", + action: "code", + }); + + if (rooms.length) ensureRoomStyle(); + + for (var i = 0; i < rooms.length; i++) { + var r = rooms[i]; + var lock = r.pwd ? " 🔒" : ""; + var line2 = []; + + if (r.title) line2.push(safe(r.title)); + line2.push(T.label_owner + ": " + safe(r.owner || "")); + line2.push(T.label_members + ": " + (r.members || 0)); + + items.push({ + template: "selectbox_icon", + icon: roomPoster(r), + title: + "" + + safe(r.name || r.id) + + ' [' + + safe(r.id) + + "]" + + lock, + subtitle: line2.join(" · "), + onDraw: bindPosterFallback, + room: r, + }); + } + + if (rooms.length === 0) { + items.push({ + title: '' + T.empty_list + "", + disabled: true, + }); + } + + Lampa.Select.show({ + title: T.head_title, + items: items, + onSelect: function (a) { + restoreController(); + + if (a.disabled) return; + if (a.action === "leave") return manualLeaveRoom(); + if (a.action === "create") return askCreateRoom(); + if (a.action === "code") return askRoomCode(); + if (a.room) return tryJoinRoom(a.room); + }, + onBack: function () { + restoreController(); + }, + }); + }); + } + + function manualLeaveRoom() { + if (!inRoom && !joining) return; + leaveRoom(true); + Lampa.Noty.show(T.left_ok); + } + + function askRoomCode() { + askInput({ title: T.input_room_code, value: "" }, function (val) { + var id = (val || "").toString().trim().toUpperCase(); + if (!id) return; + tryJoinRoom({ id: id, pwd: 1, name: id }); + }); + } + + function tryJoinRoom(r) { + dropStaleRoom(); + + if (inRoom) { + Lampa.Noty.show( + T.already_in_room(currentRoomName || currentRoomId || ""), + ); + return; + } + + if (!r.pwd) return joinRoom(r.id, "", r.name || r.id); + + var prefill = isUsePassword() ? getDefaultPassword() : ""; + askInput({ title: T.input_join_password, value: prefill }, function (val) { + joinRoom(r.id, val || "", r.name || r.id); + }); + } + + function resetRoomState() { + stopClockTimers(); + + $(".watchtogether-room-badge").remove(); + + var vid = getVideo(); + if (vid) clearRateAdjust(vid); + + if (expectedSeekTimer) { + clearTimeout(expectedSeekTimer); + expectedSeekTimer = null; + } + clearPendingSync(); + expectedState = { seek: -1, play: false, pause: false }; + initialSyncLock = false; + targetInitialState = null; + isSystemSyncing = false; + rewindUntil = 0; + holdActive = false; + holdPosition = 0; + holdWaiting = {}; + holdReadySent = false; + resetHoldProgress(); + if (holdTimer) { + clearTimeout(holdTimer); + holdTimer = null; + } + inRoom = false; + joining = false; + currentRoomId = null; + currentRoomPassword = ""; + currentRoomName = ""; + currentRoomOwner = null; + currentRoomMeta = { + title: "", + poster: "", + url: "", + tmdb_id: 0, + source: "", + type: "", + }; + lastStreamUrl = stripRoomParam(lastStreamUrl); + pidByUid = {}; + knownPids = {}; + echoPending = {}; + pingSamples = []; + lastHardSeekAt = 0; + clearEpisodeSwitch(); + roomAliveAt = 0; + + createPending = false; + if (createTimer) { + clearTimeout(createTimer); + createTimer = null; + } + if (joinTimer) { + clearTimeout(joinTimer); + joinTimer = null; + } + } + + function dropStaleRoom() { + if ((inRoom || joining) && !playerIsOpen()) { + console.log( + "[WatchTogether]", + lplog("stale room detected (player closed) - leaving"), + ); + leaveRoom(true); + return true; + } + return false; + } + + function leaveRoom(sendBye) { + if (inRoom || joining) + console.log("[WatchTogether]", lplog("leave room", currentRoomId || "")); + if (room) { + if (sendBye && room.alive()) roomSend({ t: "bye" }); + room.close(); + room = null; + } + stopLobbyAgent(); + resetRoomState(); + } + + function stripRoomParam(url) { + if (!url) return url; + return url.replace(/([?&])room=[^&]*&?/, "$1").replace(/[?&]$/, ""); + } + + function withRoomParam(url) { + if (!url || !currentRoomId || url.search(/[?&]room=/) !== -1) return url; + var data = btoa( + unescape( + encodeURIComponent(currentRoomId + ":" + (currentRoomPassword || "")), + ), + ); + return ( + url + + (url.indexOf("?") === -1 ? "?" : "&") + + "room=" + + encodeURIComponent(data) + ); + } + + function inviteLink() { + return withRoomParam(getInvite()); + } + + function connectRoom(roomId, password, onReady) { + if (room) { + room.close(); + room = null; + } + + currentRoomId = roomId; + currentRoomPassword = password || ""; + roomAliveAt = Date.now(); + + room = new Sock({ + channel: roomChannel(roomId, password), + alias: getDisplayName(), + echo: true, + reconnect: true, + onEvent: onRoomEvent, + }); + room._onReady = onReady; + room.open(); + startClockTimers(); + } + + function androidRoomHandoff(roomId, password, name, meta) { + currentRoomId = roomId; + currentRoomPassword = password || ""; + currentRoomName = name || roomId; + currentRoomMeta = { + title: (meta && meta.title) || "", + poster: (meta && meta.poster) || "", + url: (meta && meta.url) || inviteLink(), + tmdb_id: (meta && meta.tmdb_id) || 0, + source: (meta && meta.source) || "", + type: (meta && meta.type) || "movie", + }; + + console.log( + "[WatchTogether] android player: room", + roomId, + "handed to player, no relay socket from lampa", + ); + + closeSettings(); + playRoomStream( + currentRoomMeta.url, + currentRoomMeta.title || currentRoomName, + currentRoomMeta.poster, + ); + } + + function joinRoom(roomId, password, fallbackName) { + if (playerLaunch === "android") + return androidRoomHandoff(roomId, password, fallbackName, null); + + Lampa.Noty.show(T.connecting); + + joining = true; + currentRoomName = fallbackName || roomId; + + connectRoom(roomId, password, function (ev) { + console.log("[WatchTogether] join: channel ready, total", ev.total); + roomSend({ + t: "hello", + n: getDisplayName(), + cd: iAmHost() && isPublish() ? currentRoomId : undefined, + }); + + if (ev.total <= 1) { + if (joinTimer) clearTimeout(joinTimer); + joinTimer = setTimeout(failJoin, JOIN_EMPTY_TIMEOUT_MS); + } + }); + + if (joinTimer) clearTimeout(joinTimer); + joinTimer = setTimeout(failJoin, JOIN_TIMEOUT_MS); + } + + function failJoin() { + if (!joining) return; + console.log("[WatchTogether] join failed - no answer from room"); + Lampa.Noty.show(T.no_room); + leaveRoom(false); + } + + function extendJoinDeadline() { + if (!joining) return; + if (joinTimer) clearTimeout(joinTimer); + joinTimer = setTimeout(failJoin, JOIN_TIMEOUT_MS); + } + + function acceptRoomState(msg, atServerTime) { + if (joinTimer) { + clearTimeout(joinTimer); + joinTimer = null; + } + joining = false; + + if (!msg.url) { + Lampa.Noty.show(T.no_stream); + leaveRoom(true); + return; + } + + inRoom = true; + roomAliveAt = Date.now(); + currentRoomName = msg.rn || currentRoomName; + currentRoomOwner = msg.own || null; + if (typeof msg.sp === "number" && msg.sp >= 0.25 && msg.sp <= 4.0) + roomSpeed = msg.sp; + currentRoomMeta = { + title: msg.ti || "", + poster: msg.po || "", + url: msg.url, + tmdb_id: msg.tm || 0, + source: msg.src || "", + type: msg.ty || "movie", + }; + + var roomState = msg.s === "playing" ? "playing" : "paused"; + var roomPos = msg.p || 0; + + var needsInitialSync = roomState === "playing" || roomPos > 0.5; + + initialSyncLock = needsInitialSync; + targetInitialState = needsInitialSync + ? { + state: roomState, + position: roomPos, + atServerTime: atServerTime, + } + : null; + + Lampa.Noty.show(T.join_ok(currentRoomName)); + + closeSettings(); + + playRoomStream( + currentRoomMeta.url, + currentRoomMeta.title || currentRoomName, + currentRoomMeta.poster || "", + ); + } + + function askCreateRoom() { + if (lastStreamUrl) { + Lampa.Select.show({ + title: T.create_btn, + items: [ + { + title: + T.create_from_player + " (" + safe(lastStreamTitle || "") + ")", + share: true, + }, + { title: T.input_url, share: false }, + ], + onSelect: function (a) { + restoreController(); + + if (a.share) + askRoomDetails({ + stream_url: lastStreamUrl, + title: lastStreamTitle || "", + }); + else promptStreamUrl(); + }, + onBack: function () { + restoreController(); + }, + }); + } else { + promptStreamUrl(); + } + } + + function promptStreamUrl() { + askInput({ title: T.input_url, value: "" }, function (val) { + if (!val) return Lampa.Noty.show(T.need_url); + askRoomDetails({ stream_url: val, title: "" }); + }); + } + + function askRoomDetails(seed) { + askInput( + { + title: T.input_room_name, + value: seed.title || "Room " + Math.floor(Math.random() * 1000), + }, + function (name) { + if (!name) name = "Room"; + seed.name = name; + + askRoomPassword(seed, function () { + createRoom(seed, false); + }); + }, + ); + } + + // Publishing implies a password, so ask for one whenever either switch wants it. + // An empty answer is allowed - the room is then simply kept out of the lobby list. + function askRoomPassword(seed, done) { + if (!isUsePassword() && !isPublish()) { + seed.password = ""; + return done(); + } + + askInput( + { title: T.input_password, value: getDefaultPassword() }, + function (pwd) { + seed.password = pwd || ""; + done(); + }, + ); + } + + var createPending = false; + var createTimer = null; + function createRoom(seed, hostAlreadyPlaying) { + if (createPending) return; + + if (!hostAlreadyPlaying) dropStaleRoom(); + + if (inRoom) { + Lampa.Noty.show( + T.already_in_room(currentRoomName || currentRoomId || ""), + ); + return; + } + if (!seed.stream_url) { + Lampa.Noty.show(T.need_url); + return; + } + + createPending = true; + + var id = newRoomId(); + + currentRoomName = seed.name || "Room-" + id; + currentRoomOwner = pid; + currentRoomMeta = { + title: seed.title || "", + poster: seed.poster || "", + url: seed.stream_url, + tmdb_id: seed.tmdb_id || 0, + source: seed.source || "", + type: seed.type || "movie", + }; + + connectRoom(id, seed.password || "", function () { + createPending = false; + if (createTimer) { + clearTimeout(createTimer); + createTimer = null; + } + + inRoom = true; + joining = false; + roomAliveAt = Date.now(); + + initialSyncLock = false; + targetInitialState = null; + + startLobbyAgent(); + + // One notification, not a queue: each Noty replaces the previous one, so the + // name, the code and the unlisted warning have to travel together or get eaten. + var lines = [T.create_ok(currentRoomName), T.room_code(id)]; + if (!currentRoomPassword && isPublish()) lines.push(T.publish_needs_pwd); + // Noty renders with .html() and its time is in ms - \n would collapse, 8 would blink. + Lampa.Noty.show(lines.join("
"), { time: 8000 }); + + var vid = getVideo(); + + if (hostAlreadyPlaying && playerIsOpen() && vid) { + roomSend({ + t: "sync", + s: vid.paused ? "paused" : "playing", + p: vid.currentTime || 0, + }); + } else { + if (hostAlreadyPlaying) + console.log( + "[WatchTogether]", + lplog("player was expected open but is not - starting it"), + ); + + closeSettings(); + + playRoomStream( + currentRoomMeta.url, + currentRoomMeta.title || currentRoomName, + currentRoomMeta.poster || "", + ); + } + }); + + if (createTimer) clearTimeout(createTimer); + createTimer = setTimeout(function () { + createTimer = null; + if (createPending) { + createPending = false; + Lampa.Noty.show(T.create_fail); + leaveRoom(false); + } + }, JOIN_TIMEOUT_MS); + } + + function autoCreateRoomFromPending(card, streamUrl) { + var title = + card.title || + card.name || + card.original_title || + card.original_name || + ""; + var type = + card.name || card.number_of_seasons || card.first_air_date + ? "tv" + : "movie"; + + var seed = { + stream_url: streamUrl, + title: title, + poster: getCardPoster(card), + tmdb_id: getTmdbId(card), + source: card.source || "tmdb", + type: type, + name: title || "Room " + Math.floor(Math.random() * 1000), + password: "", + }; + + askRoomPassword(seed, function () { + createRoom(seed, true); + }); + } + + function onRoomEvent(e) { + if (e.kind === "ready") { + echoPending = {}; + + if (room && room._onReady) { + var cb = room._onReady; + room._onReady = null; + cb(e); + return; + } + + if (inRoom) { + roomSend({ + t: "hello", + n: getDisplayName(), + cd: iAmHost() && isPublish() ? currentRoomId : undefined, + }); + if (iAmHost()) sendHostState(); + } + updateRoomBadge(); + return; + } + + if (e.kind === "close") { + updateRoomBadge(); + return; + } + + if (e.kind === "join") { + updateRoomBadge(); + return; + } + + if (e.kind === "leave") { + onMemberLeave(e); + return; + } + + if (e.kind !== "msg" || !e.msg) return; + + if (e.mine) { + if (e.msg.k) handleEcho(e.msg.k, e.date); + return; + } + + var m = e.msg; + if (m.u) pidByUid[e.uid] = m.u; + + if (m.u === pid && m.t === "hello") { + Lampa.Noty.show(T.kicked); + leaveRoom(false); + return; + } + + if (m.t === "hello") { + if (!inRoom) return; + Lampa.Noty.show(T.notice_joined(m.n || e.alias)); + + var seenAt = m.u ? knownPids[m.u] : 0; + var reconnected = seenAt && Date.now() - seenAt < RECONNECT_HELLO_MS; + if (m.u) knownPids[m.u] = Date.now(); + + console.log( + "[WatchTogether] hello from", + m.u, + reconnected ? "(reconnect)" : "(new)", + ); + + if (iAmHost() && !reconnected) startJoinHold(m.u); + + setTimeout( + function () { + if (!inRoom) return; + roomSend({ t: "me", n: getDisplayName() }); + if (iAmHost()) sendHostState(); + }, + Math.floor(Math.random() * 250) + 50, + ); + updateRoomBadge(); + return; + } + + if (m.t === "hold") { + if (!inRoom && !joining) return; + if (currentRoomOwner && m.u !== currentRoomOwner) return; + extendJoinDeadline(); + applyRoomSpeed(m); + applyHold(m.p || 0); + return; + } + + if (m.t === "ready") { + markHoldReady(m.u); + return; + } + + if (m.t === "go") { + if (!inRoom && !joining) return; + if (currentRoomOwner && m.u !== currentRoomOwner) return; + applyRoomSpeed(m); + releaseHold(typeof m.p === "number" ? m.p : holdPosition); + return; + } + + if (m.t === "me") { + updateRoomBadge(); + return; + } + + if (m.t === "state") { + if (joining) acceptRoomState(m, e.date); + return; + } + if (m.t === "host") { + currentRoomOwner = m.u; + clearPendingSync(); + Lampa.Noty.show(T.notice_host_changed(m.n || e.alias)); + return; + } + + if (m.t === "url") { + if (!inRoom || !m.url) return; + if (currentRoomOwner && m.u !== currentRoomOwner) return; + + currentRoomMeta.url = m.url; + currentRoomMeta.title = m.ti || currentRoomMeta.title; + markEpisodeSwitch(); + clearPendingSync(); + try { + playRoomStream(m.url, m.ti || currentRoomName, ""); + } catch (err) {} + return; + } + + if (m.t === "sync" || m.t === "act") { + if (!inRoom) return; + + if ( + m.t === "sync" && + (iAmHost() || (currentRoomOwner && m.u !== currentRoomOwner)) + ) + return; + + applyRoomSpeed(m); + + if (m.t === "act" && m.v) { + var text = formatNotice(m.v, m.n || e.alias); + if (text) Lampa.Noty.show(text); + } + + var state = m.s === "playing" ? "playing" : "paused"; + var position = m.p || 0; + + // вхідна пауза на кінці серії — це природне завершення відео у партнера, а не дія користувача + if (m.t === "act" && state === "paused") { + var d = getVideo() && getVideo().duration; + if (d && isFinite(d) && d > 0 && position >= d - 1.0) return; + } + + if (initialSyncLock) { + targetInitialState = { + state: state, + position: position, + atServerTime: e.date, + }; + return; + } + + var vid = getVideo(); + if (!vid) return; + + if (isRewinding() || holdActive) return; + if (Date.now() - lastUserActionTime < 2000) return; + // a seek of ours is waiting out the debounce - the heartbeat cannot know about it yet, + // so its position is stale by definition. explicit acts still get through + if (m.t === "sync" && pendingSeekBroadcast) return; + + // anything newer supersedes a deferred seek - never let a stale snapshot outlive it + pendingAct = null; + + isSystemSyncing = true; + applySync(vid, state, position, e.date); + setTimeout(function () { + isSystemSyncing = false; + }, 500); + + // an explicit seek that could not land (buffering / seek cooldown) is kept and retried + // instead of being dropped - dropping it lets our heartbeat drag the peer back. + // threshold matches applySync's hard seek, so a drift it chose to fix by playbackRate + // is not mistaken for a missed seek + if (m.t === "act" && m.v === "seeked") { + var target = expectedPositionNow(state, position, e.date); + if (Math.abs((vid.currentTime || 0) - target) > hardSeekThreshold()) { + pendingAct = { + state: state, + position: position, + atServerTime: e.date, + at: Date.now(), + until: Date.now() + PENDING_ACT_MAX_MS, + }; + console.log( + "[WatchTogether]", + "act seek deferred, target", + target.toFixed(2), + ); + } + } + return; + } + + if (m.t === "bye") { + updateRoomBadge(); + return; + } + } + + function sendHostState() { + roomSend({ + t: "state", + rn: currentRoomName, + own: currentRoomOwner || pid, + // the code in the clear lets the server re-announce this room in the + // lobby; withheld when publishing is off, so the room stays unlisted + cd: isPublish() ? currentRoomId : undefined, + url: currentRoomMeta.url, + ti: currentRoomMeta.title, + po: currentRoomMeta.poster, + tm: currentRoomMeta.tmdb_id, + src: currentRoomMeta.source, + ty: currentRoomMeta.type, + s: hostPlaybackState(), + p: hostPlaybackPosition(), + sp: roomSpeed, + }); + } + + function hostPlaybackState() { + var vid = getVideo(); + if (!vid) return "paused"; + return vid.paused ? "paused" : "playing"; + } + + function hostPlaybackPosition() { + var vid = getVideo(); + return vid ? vid.currentTime || 0 : 0; + } + + function onMemberLeave(e) { + if (!inRoom) { + updateRoomBadge(); + return; + } + + var leaverPid = pidByUid[e.uid]; + delete pidByUid[e.uid]; + + Lampa.Noty.show(T.notice_left(e.alias || "")); + updateRoomBadge(); + + if (leaverPid && holdWaiting[leaverPid]) markHoldReady(leaverPid); + + var hostGone = currentRoomOwner && leaverPid === currentRoomOwner; + if (!hostGone) return; + + setTimeout(function () { + if (!inRoom || !room || !room.uid) return; + if (hostStillPresent()) return; + + var uids = [room.uid]; + for (var i = 0; i < room.members.length; i++) { + if (room.members[i].uid !== room.uid) uids.push(room.members[i].uid); + } + uids.sort(); + + if (uids[0] === room.uid) { + currentRoomOwner = pid; + clearPendingSync(); + roomSend({ t: "host", n: getDisplayName() }); + Lampa.Noty.show(T.notice_host_changed(getDisplayName())); + startLobbyAgent(); + } + }, 500); + } + + function hostStillPresent() { + if (!currentRoomOwner) return false; + if (currentRoomOwner === pid) return true; + if (!room) return false; + for (var i = 0; i < room.members.length; i++) { + if (pidByUid[room.members[i].uid] === currentRoomOwner) return true; + } + return false; + } + + function formatNotice(verb, who) { + if (!who) return ""; + if (verb === "joined") return T.notice_joined(who); + if (verb === "left") return T.notice_left(who); + if (verb === "paused") return T.notice_paused(who); + if (verb === "resumed" || verb === "playing") return T.notice_resumed(who); + if (verb === "seeked") return T.notice_seeked(who); + if (verb === "host_changed") return T.notice_host_changed(who); + return who + " · " + verb; + } + + function getVideo() { + var vid = null; + if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.video) + vid = Lampa.PlayerVideo.video(); + if (!vid) + vid = + document.querySelector(".player-video__display video") || + document.querySelector(".player video") || + document.querySelector("video"); + return vid; + } + + function roomMarker() { + if (!currentRoomId) return ""; + try { + return btoa( + unescape( + encodeURIComponent(currentRoomId + ":" + (currentRoomPassword || "")), + ), + ); + } catch (err) { + return ""; + } + } + + function playRoomStream(url, title, poster) { + console.log( + "[WatchTogether]", + "start room stream via", + playerLaunch, + url ? url.substr(0, 60) : "", + ); + + if (Lampa.Player.runas) Lampa.Player.runas(playerLaunch); + + Lampa.Player.play({ + url: url, + title: title || "", + poster: poster || "", + launch_player: playerLaunch, + }); + } + + function pauseVideo(vid) { + if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.pause) + Lampa.PlayerVideo.pause(); + else if (vid) vid.pause(); + } + + function playVideo(vid, onFail) { + if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.play) + return Lampa.PlayerVideo.play(); + if (!vid) return; + var p = vid.play(); + if (p && p["catch"]) + p["catch"](function () { + if (onFail) onFail(); + }); + } + + function bufferedAhead(vid, position) { + var ranges; + + try { + ranges = vid.buffered; + } catch (err) { + return NO_RANGES; + } + if (!ranges || !ranges.length) return NO_RANGES; + + for (var i = 0; i < ranges.length; i++) { + if (position >= ranges.start(i) - 1 && position <= ranges.end(i)) { + return ranges.end(i) - position; + } + } + + return OUT_OF_RANGE; + } + + function holdReadyNow(vid) { + if (!vid) return false; + + var now = Date.now(); + var ahead = bufferedAhead(vid, holdPosition); + + if (ahead > holdBufferSeen + 0.25) { + holdBufferSeen = ahead; + holdBufferAt = now; + } + + if (ahead === NO_RANGES) return vid.readyState >= 3; + + if (ahead >= HOLD_BUFFER_S) return true; + if (vid.readyState >= 4 && ahead > 0) return true; + + var idle = now - (holdBufferAt || now); + + if (ahead >= HOLD_MIN_BUFFER_S && idle > HOLD_STALL_MS) { + console.log( + "[WatchTogether]", + "hold: buffer stopped growing at", + ahead.toFixed(2) + "s - accepting", + ); + return true; + } + + if (!holdNudgeDone && idle > HOLD_NUDGE_MS && vid.readyState >= 1) { + holdNudgeDone = true; + console.log( + "[WatchTogether]", + "hold: nudging loader, ahead=" + + ahead.toFixed(2) + + " pos=" + + (vid.currentTime || 0).toFixed(2), + ); + setExpectedSeek(holdPosition); + vid.currentTime = holdPosition + 0.05; + } + + return false; + } + + function resetHoldProgress() { + holdBufferSeen = -1; + holdBufferAt = Date.now(); + holdNudgeDone = false; + } + + function startJoinHold(newcomerPid) { + if (!inRoom || !iAmHost()) return; + + var vid = getVideo(); + + if (!holdActive) { + holdActive = true; + holdPosition = vid ? vid.currentTime || 0 : 0; + holdWaiting = {}; + holdReadySent = false; + resetHoldProgress(); + + if (vid && !vid.paused) { + expectPause(); + pauseVideo(vid); + } + + Lampa.Noty.show(T.notice_hold); + console.log("[WatchTogether]", "hold start at", holdPosition.toFixed(2)); + } + + if (newcomerPid && newcomerPid !== pid) holdWaiting[newcomerPid] = true; + + roomSend({ t: "hold", p: holdPosition, sp: roomSpeed }); + + if (holdTimer) clearTimeout(holdTimer); + holdTimer = setTimeout(finishHold, HOLD_MAX_MS); + } + + function markHoldReady(fromPid) { + if (!holdActive || !iAmHost()) return; + if (fromPid) delete holdWaiting[fromPid]; + checkHoldDone(); + } + + function checkHoldDone() { + if (!holdActive || !iAmHost()) return; + + for (var key in holdWaiting) { + if (holdWaiting.hasOwnProperty(key)) return; + } + + if (!holdReadyNow(getVideo())) return; + + finishHold(); + } + + function finishHold() { + if (!holdActive) return; + + var position = holdPosition; + + console.log("[WatchTogether]", "hold finish at", position.toFixed(2)); + roomSend({ t: "go", p: position, sp: roomSpeed }); + releaseHold(position); + } + + function releaseHold(position) { + var wasHolding = holdActive; + + holdActive = false; + holdWaiting = {}; + holdReadySent = false; + resetHoldProgress(); + if (holdTimer) { + clearTimeout(holdTimer); + holdTimer = null; + } + + initialSyncLock = false; + targetInitialState = null; + + if (wasHolding) Lampa.Noty.show(T.notice_go); + + var vid = getVideo(); + if (!vid) return; + + if ( + typeof position === "number" && + Math.abs((vid.currentTime || 0) - position) > 0.5 + ) { + setExpectedSeek(position); + vid.currentTime = position; + } + + if (vid.paused) { + expectPlay(); + playVideo(vid, function () { + expectedState.play = false; + }); + } + } + + function applyHold(position) { + holdActive = true; + holdPosition = position || 0; + holdReadySent = false; + resetHoldProgress(); + + Lampa.Noty.show(T.notice_hold); + console.log("[WatchTogether]", "hold received at", holdPosition.toFixed(2)); + + var vid = getVideo(); + if (!vid) return; + + if (!vid.paused) { + expectPause(); + pauseVideo(vid); + } + + if (Math.abs((vid.currentTime || 0) - holdPosition) > 1) { + setExpectedSeek(holdPosition); + vid.currentTime = holdPosition; + } + } + + function holdTick() { + if (!holdActive) return; + + if (iAmHost()) return checkHoldDone(); + + if (holdReadySent) return; + + var vid = getVideo(); + if (!holdReadyNow(vid)) return; + if (!roomSend({ t: "ready" })) return; + + holdReadySent = true; + console.log( + "[WatchTogether]", + "hold: ready sent, ahead=" + bufferedAhead(vid, holdPosition).toFixed(2), + ); + } + + function sendSync(state, verb) { + if (!inRoom || initialSyncLock || holdActive) return; + var vid = getVideo(); + if (!vid) return; + + if (verb) { + roomSend({ + t: "act", + s: state, + p: vid.currentTime || 0, + v: verb, + n: getDisplayName(), + sp: roomSpeed, + }); + } else { + roomSend({ t: "sync", s: state, p: vid.currentTime || 0, sp: roomSpeed }); + } + } + + var expectedSeekTimer = null; + var seekGuardUntil = 0; + var lastSeekBroadcastAt = 0; + var lastKnownPosition = 0; + var pendingSeekBroadcast = null; + var pendingAct = null; + + // both carry a position that only makes sense for the video that was playing when they were + // recorded - they must die with it, or they land on the next episode + function clearPendingSync() { + if (pendingSeekBroadcast) { + clearTimeout(pendingSeekBroadcast); + pendingSeekBroadcast = null; + } + pendingAct = null; + } + + function applyPendingAct() { + if (!pendingAct) return; + + if (Date.now() > pendingAct.until) { + console.log( + "[WatchTogether]", + "act seek given up after " + PENDING_ACT_MAX_MS + "ms", + ); + pendingAct = null; + return; + } + + // same gates the live path applies - a retry must never outrank a newer local action + if (lastUserActionTime > pendingAct.at) { + console.log( + "[WatchTogether]", + "act seek dropped, superseded by local action", + ); + pendingAct = null; + return; + } + if (!inRoom || initialSyncLock || isRewinding() || holdActive) return; + if (Date.now() - lastUserActionTime < 2000) return; + + var vid = getVideo(); + if (!vid) return; + if (videoBusy(vid)) return; + if (Date.now() - lastHardSeekAt <= HARD_SEEK_COOLDOWN_MS) return; + + var act = pendingAct; + pendingAct = null; + + console.log("[WatchTogether]", "act seek retry"); + isSystemSyncing = true; + applySync(vid, act.state, act.position, act.atServerTime); + setTimeout(function () { + isSystemSyncing = false; + }, 500); + } + + function setExpectedSeek(pos) { + expectedState.seek = pos; + seekGuardUntil = Date.now() + SEEK_GUARD_MS; + + if (expectedSeekTimer) clearTimeout(expectedSeekTimer); + expectedSeekTimer = setTimeout(function () { + expectedSeekTimer = null; + expectedState.seek = -1; + }, SEEK_GUARD_MS); + } + + function clearExpectedSeek() { + if (expectedSeekTimer) { + clearTimeout(expectedSeekTimer); + expectedSeekTimer = null; + } + expectedState.seek = -1; + seekGuardUntil = 0; + } + + function seekIsOurs() { + return Date.now() < seekGuardUntil; + } + + function broadcastUserSeek(vid) { + if ( + Math.abs((vid.currentTime || 0) - lastKnownPosition) < SEEK_MIN_JUMP_S + ) { + console.log( + "[WatchTogether]", + "seek ignored, position barely moved", + (vid.currentTime || 0).toFixed(2), + ); + return; + } + + if (isRewinding()) rewindUntil = Date.now() + 400; + lastKnownPosition = vid.currentTime || 0; + lastUserActionTime = Date.now(); + + // throttle defers the burst, never drops it - a dropped seek means the host + // heartbeat drags us back to its old position two seconds later + if (Date.now() - lastSeekBroadcastAt < SEEK_BROADCAST_MIN_MS) { + console.log( + "[WatchTogether]", + "seek broadcast deferred at", + (vid.currentTime || 0).toFixed(2), + ); + // debounce, not a periodic flush: a long scrub must end in ONE extra broadcast, + // not one every SEEK_BROADCAST_MIN_MS - each of those costs everyone a hard seek + if (pendingSeekBroadcast) clearTimeout(pendingSeekBroadcast); + pendingSeekBroadcast = setTimeout(function () { + pendingSeekBroadcast = null; + if (!inRoom || initialSyncLock || holdActive) return; + var v = getVideo(); + if (!v) return; + lastSeekBroadcastAt = Date.now(); + lastKnownPosition = v.currentTime || 0; + lastUserActionTime = Date.now(); + sendSync(v.paused ? "paused" : "playing", "seeked"); + }, SEEK_BROADCAST_MIN_MS); + return; + } + + if (pendingSeekBroadcast) { + clearTimeout(pendingSeekBroadcast); + pendingSeekBroadcast = null; + } + lastSeekBroadcastAt = Date.now(); + sendSync(vid.paused ? "paused" : "playing", "seeked"); + } + + function isRewinding() { + return Date.now() < rewindUntil; + } + + function onUserRewind() { + if (!inRoom || initialSyncLock) return; + rewindUntil = Date.now() + REWIND_GRACE_MS; + lastUserActionTime = Date.now(); + clearExpectedSeek(); + } + + function expectPlay() { + expectedState.play = true; + setTimeout(function () { + expectedState.play = false; + }, 500); + } + + function expectPause() { + expectedState.pause = true; + setTimeout(function () { + expectedState.pause = false; + }, 500); + } + + function clearRateAdjust(vid) { + if (vid._lp_rate_timeout) { + clearTimeout(vid._lp_rate_timeout); + vid._lp_rate_timeout = null; + } + vid._lp_correcting = false; + if (!canAdjustRate()) return; + if (vid.playbackRate !== roomSpeed) vid.playbackRate = roomSpeed; + } + + function videoBusy(vid) { + // a seek in flight is the loader working - same rule as buffering, do not fight it. + // a long jump can stay seeking for many seconds; correcting position mid-flight + // is what dragged the seeker back to the old spot + return !!vid._lp_buffering || vid.readyState < 3 || !!vid.seeking; + } + + var appleNative = (function () { + try { + return !!( + Lampa.Platform && + (Lampa.Platform.is("apple") || Lampa.Platform.is("apple_tv")) + ); + } catch (err) { + return false; + } + })(); + + function canAdjustRate() { + return !appleNative; + } + + function hardSeekThreshold() { + return appleNative ? SYNC_HARD_SEEK_APPLE_S : SYNC_HARD_SEEK_S; + } + + function applySync(vid, state, basePosition, atServerTime) { + if (vid.currentTime === undefined) return; + + var busy = videoBusy(vid); + + if (busy) { + clearRateAdjust(vid); + } else { + var expected = expectedPositionNow(state, basePosition, atServerTime); + var diff = vid.currentTime - expected; + var absDiff = Math.abs(diff); + + if (absDiff > hardSeekThreshold()) { + clearRateAdjust(vid); + vid._lp_correcting = false; + + if (Date.now() - lastHardSeekAt > HARD_SEEK_COOLDOWN_MS) { + lastHardSeekAt = Date.now(); + console.log( + "[WatchTogether]", + "hard seek", + vid.currentTime.toFixed(2), + "->", + expected.toFixed(2), + ); + setExpectedSeek(expected); + vid.currentTime = expected; + } else { + console.log( + "[WatchTogether]", + "hard seek skipped (cooldown), drift", + absDiff.toFixed(2), + ); + } + } else if ( + canAdjustRate() && + (vid._lp_correcting + ? absDiff > SYNC_TOLERANCE_S + : absDiff > SYNC_CORRECT_ON_S) + ) { + vid._lp_correcting = true; + + var raw = diff * SYNC_RATE_GAIN; + var offset = Math.max( + -SYNC_MAX_RATE_OFFSET, + Math.min(SYNC_MAX_RATE_OFFSET, raw), + ); + var newRate = roomSpeed - offset; + if (Math.abs(vid.playbackRate - newRate) > 0.005) + vid.playbackRate = newRate; + if (vid._lp_rate_timeout) clearTimeout(vid._lp_rate_timeout); + vid._lp_rate_timeout = setTimeout(function () { + vid._lp_rate_timeout = null; + vid._lp_correcting = false; + if (vid.playbackRate !== roomSpeed) vid.playbackRate = roomSpeed; + }, SYNC_RATE_RESET_MS); + } else { + vid._lp_correcting = false; + clearRateAdjust(vid); + } + } + + if (state === "paused" && !vid.paused) { + expectPause(); + clearRateAdjust(vid); + pauseVideo(vid); + return; + } + + if (state === "playing" && vid.paused && !busy) { + expectPlay(); + playVideo(vid, function () { + expectedState.play = false; + }); + } + } + + setInterval(function () { + if (inRoom || joining) { + if (playerIsOpen()) roomAliveAt = Date.now(); + else if (roomAliveAt && Date.now() - roomAliveAt > ROOM_ORPHAN_MS) { + console.log( + "[WatchTogether]", + lplog("room without player - auto leave"), + ); + leaveRoom(true); + } + } + + if (!inRoom) { + $(".watchtogether-room-badge").remove(); + return; + } + + // before the video check - a deferred act must be able to expire while there is no player, + // otherwise it silences the host heartbeat indefinitely + applyPendingAct(); + + var vid = getVideo(); + if (!vid) return; + updateRoomBadge(); + holdTick(); + + // our own seek that never produced a 'seeked' event (target equals current position) would + // otherwise keep the guard armed for the full SEEK_GUARD_MS and swallow the next user seek + if ( + expectedState.seek >= 0 && + !vid.seeking && + Math.abs((vid.currentTime || 0) - expectedState.seek) <= 0.5 + ) + clearExpectedSeek(); + + if (!seekIsOurs()) lastKnownPosition = vid.currentTime || 0; + + if (vid._lp_hooked) return; + vid._lp_hooked = true; + + // A joining client starts at the room's speed before the first sync lands. + if (canAdjustRate() && Math.abs((vid.playbackRate || 1) - roomSpeed) > 0.01) + vid.playbackRate = roomSpeed; + + var enforceInitial = function () { + if (!initialSyncLock || !targetInitialState) return; + var expected = expectedPositionNow( + targetInitialState.state, + targetInitialState.position, + targetInitialState.atServerTime, + ); + if (Math.abs(vid.currentTime - expected) > 1) { + setExpectedSeek(expected); + vid.currentTime = expected; + } + if (targetInitialState.state === "paused") { + expectPause(); + pauseVideo(vid); + } else { + expectPlay(); + playVideo(vid); + } + if (!vid._lp_enforce_timeout) { + vid._lp_enforce_timeout = setTimeout(function () { + vid._lp_enforce_timeout = null; + initialSyncLock = false; + targetInitialState = null; + expectedState.play = false; + expectedState.pause = false; + }, 3000); + } + }; + + if (vid.readyState >= 1) enforceInitial(); + else vid.addEventListener("loadedmetadata", enforceInitial); + vid.addEventListener("canplay", enforceInitial); + + vid.addEventListener("waiting", function () { + vid._lp_buffering = true; + console.log( + "[WatchTogether]", + "buffering start at", + (vid.currentTime || 0).toFixed(2), + ); + }); + vid.addEventListener("canplay", function () { + if (vid._lp_buffering) + console.log( + "[WatchTogether]", + "buffering end at", + (vid.currentTime || 0).toFixed(2), + ); + vid._lp_buffering = false; + }); + vid.addEventListener("playing", function () { + if (vid._lp_buffering) + console.log( + "[WatchTogether]", + "buffering end at", + (vid.currentTime || 0).toFixed(2), + ); + vid._lp_buffering = false; + }); + vid.addEventListener("error", function () { + console.log( + "[WatchTogether]", + "video error", + vid.error ? "code " + vid.error.code : "", + ); + }); + vid.addEventListener("stalled", function () { + console.log("[WatchTogether]", "video stalled"); + }); + + vid.addEventListener("ratechange", function () { + if ( + !inRoom || + initialSyncLock || + holdActive || + isSystemSyncing || + vid._lp_correcting + ) + return; + var newRate = vid.playbackRate || 1.0; + if (!(newRate >= 0.25 && newRate <= 4.0)) return; + if (Math.abs(newRate - roomSpeed) < 0.01) return; + roomSpeed = newRate; + lastUserActionTime = Date.now(); + // Only the host drives the room speed; guests keep it for themselves. + if (iAmHost()) { + roomSend({ + t: "act", + s: vid.paused ? "paused" : "playing", + p: vid.currentTime || 0, + n: getDisplayName(), + sp: roomSpeed, + }); + } + }); + + vid.addEventListener("play", function () { + if (initialSyncLock) { + if (targetInitialState && targetInitialState.state === "paused") { + pauseVideo(vid); + } + return; + } + var wasExpected = expectedState.play; + expectedState.play = false; + if (wasExpected) return; + + if (holdActive) { + console.log( + "[WatchTogether]", + "user started playback during hold - releasing it", + ); + lastUserActionTime = Date.now(); + + if (iAmHost()) { + finishHold(); + } else { + holdReadySent = true; + roomSend({ t: "ready" }); + releaseHold(holdPosition); + } + return; + } + + if (isRewinding()) { + lastUserActionTime = Date.now(); + return; + } + if (vid._lp_buffer_paused) { + vid._lp_buffer_paused = false; + return; + } + lastUserActionTime = Date.now(); + sendSync("playing", "resumed"); + }); + + vid.addEventListener("pause", function () { + clearRateAdjust(vid); + if (initialSyncLock) return; + var wasExpected = expectedState.pause; + expectedState.pause = false; + if (wasExpected) return; + + if (isRewinding()) { + lastUserActionTime = Date.now(); + return; + } + if (vid._lp_buffering || vid.readyState < 3) { + vid._lp_buffer_paused = true; + return; + } + if (vid.ended) return; + lastUserActionTime = Date.now(); + sendSync("paused", "paused"); + }); + + vid.addEventListener("seeked", function () { + if (!isSystemSyncing) clearRateAdjust(vid); + if (initialSyncLock) { + if (targetInitialState) { + var expected = expectedPositionNow( + targetInitialState.state, + targetInitialState.position, + targetInitialState.atServerTime, + ); + if (Math.abs(vid.currentTime - expected) > 1) { + setExpectedSeek(expected); + vid.currentTime = expected; + } + } + return; + } + if (isSystemSyncing) return; + + if (seekIsOurs()) { + console.log( + "[WatchTogether]", + "own seek settled at", + (vid.currentTime || 0).toFixed(2), + ); + clearExpectedSeek(); + lastKnownPosition = vid.currentTime || 0; + return; + } + + if (expectedState.seek !== -1) clearExpectedSeek(); + + broadcastUserSeek(vid); + }); + + // announce the jump when it STARTS, not when it settles: currentTime already holds the + // target, while a long jump can stay unsettled for many seconds - and an unannounced + // seeker gets dragged back by the host heartbeat the moment its buffer is ready + vid.addEventListener("seeking", function () { + // deliberately NOT gated on isSystemSyncing: that flag stays up 500ms after every + // applied sync, i.e. a quarter of the time at a 2s heartbeat, and it swallowed any + // user seek started in that window. seekIsOurs() is armed only when we actually + // moved the position ourselves, which is the real question here + if (initialSyncLock || seekIsOurs()) return; + broadcastUserSeek(vid); + }); + }, 1000); + + setInterval(function () { + if (!inRoom || initialSyncLock || isSystemSyncing || holdActive) return; + if (!iAmHost()) return; + if (expectedState.seek !== -1) return; + if (pendingAct) return; + var vid = getVideo(); + if (!vid) return; + if (vid.ended) return; + sendSync(vid.paused ? "paused" : "playing", null); + }, SYNC_HEARTBEAT_MS); + + var debugRefreshTimer = null; + + function buildDebugHtml() { + var wsState = + room && room.ws + ? ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][room.ws.readyState] || + "UNKNOWN" + : "NO_WS"; + var lastRtt = pingSamples.length + ? pingSamples[pingSamples.length - 1].rtt + : "-"; + var bestRtt = "-"; + for (var i = 0; i < pingSamples.length; i++) { + if (bestRtt === "-" || pingSamples[i].rtt < bestRtt) + bestRtt = pingSamples[i].rtt; + } + var pendingCount = 0; + for (var k in echoPending) { + if (echoPending.hasOwnProperty(k)) pendingCount++; + } + + var pos = "-", + bufRdy = "-"; + var vid = getVideo(); + if (vid) { + pos = (vid.currentTime || 0).toFixed(2); + bufRdy = vid.readyState + (vid._lp_buffering ? "*" : ""); + } + + return [ + "
Relay: " + safe(wsState) + "
", + "
Room: " + + safe(currentRoomId || "-") + + (currentRoomPassword ? " 🔒" : "") + + "
", + "
Role: " + (iAmHost() ? "host" : "guest") + "
", + "
Members: " + memberCount() + "
", + "
Lobby: " + + (lobbyHost && lobbyHost.alive() ? "published" : "off") + + "
", + "
Clock offset: " + Math.round(serverTimeOffset) + " ms
", + "
RTT last/best: " + lastRtt + " / " + bestRtt + " ms
", + "
Echo pending: " + pendingCount + "
", + "
initialSyncLock: " + initialSyncLock + "
", + "
Video: " + pos + " s (ready " + bufRdy + ")
", + ].join(""); + } + + function updateRoomBadge() { + if (!inRoom || !currentRoomId) return; + + var nameContainer = $(".player-info__name, .player-panel__name"); + if (nameContainer.length && !$(".watchtogether-room-badge").length) { + var badge = $( + '
' + + '' + + '' + + "
", + ); + badge.on("mouseenter", function () { + var $p = $(this).find(".watchtogether-debug-panel"); + $p.html(buildDebugHtml()).css("display", "block"); + if (debugRefreshTimer) clearInterval(debugRefreshTimer); + debugRefreshTimer = setInterval(function () { + if ($p.is(":visible")) $p.html(buildDebugHtml()); + else { + clearInterval(debugRefreshTimer); + debugRefreshTimer = null; + } + }, 500); + }); + badge.on("mouseleave", function () { + $(this).find(".watchtogether-debug-panel").css("display", "none"); + if (debugRefreshTimer) { + clearInterval(debugRefreshTimer); + debugRefreshTimer = null; + } + }); + nameContainer.after(badge); + } + + var $badge = $(".watchtogether-room-badge"); + if (!$badge.length) return; + + var ok = room && room.alive(); + var dotColor = ok ? "#00e676" : "#ff5252"; + var dot = + ''; + + $badge + .find(".watchtogether-room-badge-text") + .html( + dot + + T.badge_room + + ': ' + + safe(currentRoomName || currentRoomId) + + " " + + '[' + + safe(currentRoomId) + + "] | " + + T.badge_viewers + + ": " + + memberCount() + + "", + ); + } + + var passwordParamItem = null; + function updatePasswordVisibility() { + if (passwordParamItem) + passwordParamItem.toggleClass("hide", !isUsePassword()); + } + + var displayNameParamItem = null; + function syncDisplayNameField() { + var name = getDisplayName(); + if (displayNameParamItem) + displayNameParamItem.find(".settings-param__value").text(name); + } + + function registerSettings() { + if (!Lampa.SettingsApi) return; + + Lampa.SettingsApi.addComponent({ + component: "lparty", + name: T.settings_title, + icon: '', + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { name: "watchtogether_meta", type: "static" }, + field: { + name: META.name + " v" + META.version, + description: "Authors: " + META.author, + }, + onRender: function (item) { + item.on("hover:enter", function () {}); + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { name: "watchtogether_open_rooms", type: "button" }, + field: { + name: T.settings_open_rooms, + description: T.settings_open_rooms_descr, + }, + onChange: function () { + openRoomBrowser(); + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_show_head", + type: "trigger", + default: true, + }, + field: { name: T.param_show_head, description: T.param_show_head_descr }, + onChange: function () { + syncHeadIcon(); + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_display_name", + type: "input", + values: "", + default: "", + placeholder: getDisplayName(), + }, + field: { name: T.param_name, description: T.param_name_descr }, + onRender: function (item) { + displayNameParamItem = item; + syncDisplayNameField(); + }, + onChange: function () { + syncDisplayNameField(); + }, + }); + + if (Lampa.Platform.is("android")) { + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_player", + type: "select", + values: { + inner: Lampa.Lang.translate("settings_param_player_inner"), + android: "Android", + }, + default: "inner", + }, + field: { + name: Lampa.Lang.translate("settings_player_type"), + description: Lampa.Lang.translate("settings_player_type_descr"), + }, + onChange: function (value) { + Lampa.Storage.set("watchtogether_player", value); + playerLaunch = value; + }, + }); + } + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { name: "watchtogether_publish", type: "trigger", default: true }, + field: { name: T.param_publish, description: T.param_publish_descr }, + onChange: function () { + if (inRoom && iAmHost()) { + if (isPublish()) startLobbyAgent(); + else stopLobbyAgent(); + } + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_use_password", + type: "trigger", + default: false, + }, + field: { name: T.param_use_pwd, description: T.param_use_pwd_descr }, + onChange: function () { + updatePasswordVisibility(); + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_default_password", + type: "input", + values: "", + default: "", + placeholder: "", + }, + field: { name: T.param_pwd, description: T.param_pwd_descr }, + onRender: function (item) { + passwordParamItem = item; + updatePasswordVisibility(); + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_relay", + type: "input", + values: "", + default: DEFAULT_RELAY, + placeholder: DEFAULT_RELAY, + }, + field: { name: T.param_relay, description: T.param_relay_descr }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_invite", + type: "input", + values: "", + default: DEFAULT_INVITE, + placeholder: DEFAULT_INVITE, + }, + field: { name: T.param_invite, description: T.param_invite_descr }, + }); + } + + var headButton = null; + + function addHeadIcon() { + if (window.WatchTogether_head_added) return; + if (!Lampa.Head || typeof Lampa.Head.addIcon !== "function") return; + window.WatchTogether_head_added = true; + + var svg = + ''; + headButton = Lampa.Head.addIcon(svg, openRoomBrowser); + if (headButton && headButton.attr) headButton.attr("title", T.menu_title); + } + + function syncHeadIcon() { + var show = isShowHead(); + if (show) addHeadIcon(); + if (headButton && headButton.toggleClass) + headButton.toggleClass("hide", !show); + } + + if (window.appready) syncHeadIcon(); + else + Lampa.Listener.follow("app", function (e) { + if (e.type === "ready") syncHeadIcon(); + }); + + Lampa.Listener.follow("full", function (e) { + if (e.type !== "complite") return; + + var cardData = + (e.data && e.data.movie) || + (e.object && e.object.activity && e.object.activity.object + ? e.object.activity.object.item + : null) || + (e.object && e.object.item); + if (!cardData) return; + + lastViewedCard = cardData; + }); + + function onPlayerStart(e) { + if (!e || !e.url) return; + lastStreamUrl = e.url; + lastStreamTitle = + (e.movie && (e.movie.title || e.movie.name)) || e.title || ""; + + roomAliveAt = Date.now(); + + var switching = isEpisodeSwitching(); + clearEpisodeSwitch(); + + if (switching && inRoom) { + if (iAmHost()) { + currentRoomMeta.url = e.url; + currentRoomMeta.title = lastStreamTitle; + roomSend({ t: "url", url: e.url, ti: lastStreamTitle }); + } + return; + } + + if (pendingShareCard) { + var card = pendingShareCard; + var url = e.url; + var fresh = Date.now() - pendingShareAt < PENDING_SHARE_MS; + + pendingShareCard = null; + pendingShareAt = 0; + + if (fresh) { + uiPrevController = "player"; + + setTimeout(function () { + console.log("[WatchTogether]", "auto create room from card menu"); + autoCreateRoomFromPending(card, url); + }, SHARE_START_DELAY_MS); + } + } + } + + if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.listener) { + Lampa.PlayerVideo.listener.follow("start", onPlayerStart); + Lampa.PlayerVideo.listener.follow("rewind", onUserRewind); + } + + if (typeof Lampa.Player !== "undefined" && Lampa.Player.listener) { + Lampa.Player.listener.follow("start", onPlayerStart); + Lampa.Player.listener.follow("destroy", function () { + clearPendingSync(); + + var vid = getVideo(); + if (vid) { + vid._lp_hooked = false; + clearRateAdjust(vid); + } + + if (!inRoom && !joining) return; + + if (isEpisodeSwitching()) return; + + leaveRoom(true); + }); + } + + if ( + typeof Lampa.PlayerPlaylist !== "undefined" && + Lampa.PlayerPlaylist.listener && + Lampa.PlayerPlaylist.listener.follow + ) { + Lampa.PlayerPlaylist.listener.follow("select", function () { + if (inRoom && iAmHost()) markEpisodeSwitch(); + }); + } + + window.addEventListener("beforeunload", function () { + if (inRoom) leaveRoom(true); + }); + + var ICON_COPY = + ''; + var ICON_SHARE = + ''; + + function canShare() { + try { + return ( + typeof navigator !== "undefined" && + typeof navigator.share === "function" + ); + } catch (err) { + return false; + } + } + + function legacyCopy(text) { + try { + var area = document.createElement("textarea"); + + area.value = text; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.top = "-1000px"; + area.style.opacity = "0"; + + document.body.appendChild(area); + area.select(); + if (area.setSelectionRange) area.setSelectionRange(0, text.length); + + var ok = document.execCommand("copy"); + + document.body.removeChild(area); + + return !!ok; + } catch (err) { + return false; + } + } + + function copyToClipboard(text, done) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then( + function () { + done(true); + }, + function () { + done(legacyCopy(text)); + }, + ); + return; + } + } catch (err) {} + + done(legacyCopy(text)); + } + + function ensureQrStyle() { + if (document.getElementById("watchtogether-qr-style")) return; + + var style = document.createElement("style"); + + style.id = "watchtogether-qr-style"; + style.textContent = + ".watchtogether-qr-actions{display:flex;justify-content:center;gap:0.8em;margin-top:0.9em;}" + + ".watchtogether-qr-btn{display:inline-flex;align-items:center;justify-content:center;width:2.8em;height:2.8em;" + + "border-radius:50%;background:rgba(255,255,255,0.14);color:#fff;cursor:pointer;" + + "transition:background 0.2s,color 0.2s,transform 0.2s;padding:0.7em;}" + + ".watchtogether-qr-btn.focus,.watchtogether-qr-btn:hover{background:#fff;color:#000;transform:scale(1.08);}"; + + document.head.appendChild(style); + } + + function showRoomQr() { + var link = inviteLink(); + var qrUrl = + "https://api.qrserver.com/v1/create-qr-code/?size=600x600&data=" + + encodeURIComponent(link); + + var isMobile = + Lampa.Platform && + Lampa.Platform.screen && + Lampa.Platform.screen("mobile"); + uiPrevController = isMobile ? "player" : "player_panel"; + + ensureQrStyle(); + + var actions = + '
' + + '
' + + ICON_COPY + + "
" + + (canShare() + ? '
' + + ICON_SHARE + + "
" + : "") + + "
"; + + var body = $( + '
' + + '
' + + T.qr_title + + " - " + + safe(currentRoomName || currentRoomId || "") + + "
" + + '' + + '
' + + T.qr_hint + + "
" + + '
' + + safe(link) + + "
" + + actions + + "
", + ); + + Lampa.Modal.open({ + title: "", + size: "medium", + html: body, + select: body.find('[data-watchtogether-action="copy"]')[0], + onSelect: function (item) { + var target = $(item); + var action = + target.attr("data-watchtogether-action") || + target + .closest("[data-watchtogether-action]") + .attr("data-watchtogether-action"); + + if (action === "copy") { + copyToClipboard(link, function (ok) { + lplog("invite link copy", ok ? "ok" : "failed"); + Lampa.Noty.show(ok ? T.qr_copied : T.qr_copy_fail); + }); + return; + } + + if (action === "share") { + try { + var shared = navigator.share({ + title: T.qr_title, + text: currentRoomName || currentRoomId || T.qr_title, + url: link, + }); + + if (shared && shared["catch"]) + shared["catch"](function (err) { + if (err && err.name === "AbortError") return; + lplog("share failed", err && err.name); + }); + } catch (err) { + lplog("share threw", err && err.message); + } + } + }, + onBack: function () { + Lampa.Modal.close(); + restoreController(); + }, + }); + } + + function createRoomFromPlayer() { + dropStaleRoom(); + + if (inRoom) { + Lampa.Noty.show( + T.already_in_room(currentRoomName || currentRoomId || ""), + ); + return; + } + if (!lastStreamUrl) { + Lampa.Noty.show(T.need_url); + return; + } + + var isMobile = + Lampa.Platform && + Lampa.Platform.screen && + Lampa.Platform.screen("mobile"); + uiPrevController = isMobile ? "player" : "player_panel"; + restoreController(); + + autoCreateRoomFromPending(lastViewedCard || {}, lastStreamUrl); + } + + if ( + typeof Lampa.Select !== "undefined" && + Lampa.Select.listener && + Lampa.Select.listener.follow + ) { + Lampa.Select.listener.follow("preshow", function (e) { + if (!e || !e.active || !Array.isArray(e.active.items)) return; + + var items = e.active.items; + var hasPlayer = false, + hasFileMenu = false, + isPlayerSettings = false, + alreadyInjected = false; + + for (var i = 0; i < items.length; i++) { + var it = items[i]; + if (!it) continue; + if (it.player) hasPlayer = true; + if (it.mark || it.timeclear || it.clearmark) hasFileMenu = true; + if ( + it.method === "size" || + it.method === "speed" || + it.method === "subs" || + it.method === "share" || + it.method === "segments" + ) + isPlayerSettings = true; + if (it.watchtogether_inject || it.watchtogether_inject_player) + alreadyInjected = true; + } + if (alreadyInjected) return; + + if (hasPlayer && hasFileMenu) { + items.push({ + title: T.full_card_btn, + player: "inner", + watchtogether_inject: true, + }); + + var originalOnSelect = e.active.onSelect; + e.active.onSelect = function (a) { + if (a && a.watchtogether_inject) { + pendingShareCard = lastViewedCard || {}; + pendingShareAt = Date.now(); + Lampa.Noty.show(T.pending_share); + } + if (originalOnSelect) originalOnSelect(a); + }; + return; + } + + if (isPlayerSettings) { + items.push({ + title: T.full_card_btn, + subtitle: inRoom ? T.player_qr_descr : T.player_create_descr, + method: "watchtogether_create", + watchtogether_inject_player: true, + }); + + var originalOnSelectP = e.active.onSelect; + e.active.onSelect = function (a) { + if (a && a.watchtogether_inject_player) { + if (inRoom) showRoomQr(); + else createRoomFromPlayer(); + return; + } + if (originalOnSelectP) originalOnSelectP(a); + }; + } + }); + } + + window.WatchTogether = { + version: META.version, + log: function () { + return logRing.join("\n"); + }, + dump: function () { + console.log(logRing.join("\n")); + return logRing.length; + }, + state: function () { + var vid = getVideo(); + return { + room: currentRoomId, + name: currentRoomName, + host: iAmHost(), + owner: currentRoomOwner, + inRoom: inRoom, + joining: joining, + members: memberCount(), + relay: room && room.ws ? room.ws.readyState : null, + lobby: !!(lobbyHost && lobbyHost.alive()), + hold: holdActive, + holdPosition: holdPosition, + holdWaiting: Object.keys(holdWaiting), + initialSyncLock: initialSyncLock, + clockOffset: Math.round(serverTimeOffset), + echoPending: Object.keys(echoPending).length, + video: vid + ? { + position: vid.currentTime, + paused: vid.paused, + rate: vid.playbackRate, + readyState: vid.readyState, + buffering: !!vid._lp_buffering, + } + : null, + }; + }, + leave: function () { + leaveRoom(true); + return "left"; + }, + }; + + registerSettings(); + console.log( + "[WatchTogether]", + lplog( + "started, relay: " + + getRelay() + + ", lang: " + + _rawLang + + ", name: " + + getDisplayName(), + ), + ); +})(); diff --git a/mods/WatchTogether/web/i18n.js b/mods/WatchTogether/web/i18n.js new file mode 100644 index 0000000..7d69595 --- /dev/null +++ b/mods/WatchTogether/web/i18n.js @@ -0,0 +1,324 @@ +/** + * Three languages, no libraries. Auto-detection picks between Ukrainian and English + * only: Russian is in the list but is never selected without an explicit choice. + * + * A dictionary value is either a string or an object of plural forms + * (one/few/many/other) resolved through Intl.PluralRules. + */ + +export const LANGS = [ + { code: "uk", name: "Українська" }, + { code: "en", name: "English" }, + { code: "ru", name: "Русский" }, +]; + +const DICT = { + uk: { + tagline: "Спільний перегляд із Lampa", + lang_label: "Мова", + + join_heading: "Увійти за кодом", + code_ph: "Код кімнати", + pwd_ph: "Пароль", + join_btn: "Увійти", + + rooms_heading: "Відкриті кімнати", + refresh_btn: "Оновити", + searching: "Шукаємо…", + empty_title: "Поки жодної кімнати", + empty_hint: + "Кімната з’явиться тут, щойно хтось у Lampa створить її з увімкненою публікацією. Якщо код уже є — введіть його вище.", + + create_heading: "Створити кімнату", + url_ph: "Посилання на потік (.m3u8 або .mp4)", + title_ph: "Назва", + create_btn: "Створити", + url_hint: "Потрібне https-посилання, і сервер має дозволяти CORS.", + cancel_btn: "Скасувати", + done_btn: "Готово", + + settings_heading: "Налаштування", + name_ph: "Ваше ім’я", + regenerate_name: "Згенерувати інше ім’я", + relay_label: "Реле", + publish_label: "Показувати мою кімнату у списку", + + leave_btn: "Вийти", + host_badge: "ви хост", + switch_ph: "Нове посилання на потік", + switch_btn: "Перемкнути всім", + status_connecting: "З’єднання…", + status_online: "На зв’язку", + status_offline: "Зв’язок втрачено", + viewers: { + one: "{n} глядач", + few: "{n} глядачі", + many: "{n} глядачів", + other: "{n} глядача", + }, + + room_created: "Кімнату створено · код {id}", + join_ok: "Ви в кімнаті {name}", + joined: "{name} приєднався", + left_room: "{name} вийшов", + host_changed: "Новий хост: {name}", + you_are_host: "Тепер ви хост", + notice_hold: "Пауза — чекаємо, поки всі завантажать буфер", + notice_go: "Усі готові — продовжуємо", + act_resumed: "{name} продовжив", + act_paused: "{name} поставив на паузу", + act_seeked: "{name} перемотав", + someone: "учасник", + + connecting: "Підключення…", + password_prompt: "Пароль кімнати {name}", + need_code: "Введіть код кімнати", + need_url: "Потрібне посилання на потік", + need_pwd_public: + "Кімната у списку потребує пароля — задайте його або вимкніть публікацію", + already_in_room: "Ви вже в кімнаті {name}", + create_failed: "Не вдалося створити кімнату", + join_failed: "Кімнату не знайдено або пароль невірний", + kicked: "Ви підключилися з іншого місця", + no_stream: "У кімнаті немає потоку", + relay_error: "Реле: {text}", + autoplay_blocked: "Браузер заблокував автозапуск — натисніть play", + mixed_content: + "Потік віддається по http, а сторінка по https — браузер це блокує", + cors_error: + "Потік не завантажується: схоже, сервер не віддає Access-Control-Allow-Origin", + playback_error: "Помилка відтворення: {details}", + media_error: + "Браузер не зміг відкрити потік (формат, CORS або mixed content)", + }, + + en: { + tagline: "Watch together with Lampa", + lang_label: "Language", + + join_heading: "Join with a code", + code_ph: "Room code", + pwd_ph: "Password", + join_btn: "Join", + + rooms_heading: "Open rooms", + refresh_btn: "Refresh", + searching: "Searching…", + empty_title: "No rooms right now", + empty_hint: + "A room appears here as soon as someone in Lampa creates one with publishing enabled. If you already have a code, enter it above.", + + create_heading: "Create a room", + url_ph: "Stream link (.m3u8 or .mp4)", + title_ph: "Title", + create_btn: "Create", + url_hint: "Needs an https link from a server that allows CORS.", + cancel_btn: "Cancel", + done_btn: "Done", + + settings_heading: "Settings", + name_ph: "Your name", + regenerate_name: "Generate another name", + relay_label: "Relay", + publish_label: "List my room publicly", + + leave_btn: "Leave", + host_badge: "you host", + switch_ph: "New stream link", + switch_btn: "Switch for everyone", + status_connecting: "Connecting…", + status_online: "Connected", + status_offline: "Connection lost", + viewers: { one: "{n} viewer", other: "{n} viewers" }, + + room_created: "Room created · code {id}", + join_ok: "You are in {name}", + joined: "{name} joined", + left_room: "{name} left", + host_changed: "New host: {name}", + you_are_host: "You are the host now", + notice_hold: "Paused — waiting for everyone to buffer", + notice_go: "Everyone is ready — resuming", + act_resumed: "{name} resumed", + act_paused: "{name} paused", + act_seeked: "{name} skipped", + someone: "someone", + + connecting: "Connecting…", + password_prompt: "Password for {name}", + need_code: "Enter a room code", + need_url: "A stream link is required", + need_pwd_public: + "A publicly listed room needs a password — set one or turn off publishing", + already_in_room: "You are already in {name}", + create_failed: "Could not create the room", + join_failed: "Room not found, or the password is wrong", + kicked: "You connected from somewhere else", + no_stream: "The room has no stream", + relay_error: "Relay: {text}", + autoplay_blocked: "The browser blocked autoplay — press play", + mixed_content: + "The stream is served over http while this page is https — the browser blocks that", + cors_error: + "The stream will not load: the server likely omits Access-Control-Allow-Origin", + playback_error: "Playback error: {details}", + media_error: + "The browser could not open the stream (format, CORS or mixed content)", + }, + + ru: { + tagline: "Совместный просмотр с Lampa", + lang_label: "Язык", + + join_heading: "Войти по коду", + code_ph: "Код комнаты", + pwd_ph: "Пароль", + join_btn: "Войти", + + rooms_heading: "Открытые комнаты", + refresh_btn: "Обновить", + searching: "Ищем…", + empty_title: "Пока ни одной комнаты", + empty_hint: + "Комната появится здесь, как только кто-то в Lampa создаст её с включённой публикацией. Если код уже есть — введите его выше.", + + create_heading: "Создать комнату", + url_ph: "Ссылка на поток (.m3u8 или .mp4)", + title_ph: "Название", + create_btn: "Создать", + url_hint: "Нужна https-ссылка, и сервер должен разрешать CORS.", + cancel_btn: "Отмена", + done_btn: "Готово", + + settings_heading: "Настройки", + name_ph: "Ваше имя", + regenerate_name: "Сгенерировать другое имя", + relay_label: "Реле", + publish_label: "Показывать мою комнату в списке", + + leave_btn: "Выйти", + host_badge: "вы хост", + switch_ph: "Новая ссылка на поток", + switch_btn: "Переключить всем", + status_connecting: "Соединение…", + status_online: "На связи", + status_offline: "Связь потеряна", + viewers: { + one: "{n} зритель", + few: "{n} зрителя", + many: "{n} зрителей", + other: "{n} зрителя", + }, + + room_created: "Комната создана · код {id}", + join_ok: "Вы в комнате {name}", + joined: "{name} присоединился", + left_room: "{name} вышел", + host_changed: "Новый хост: {name}", + you_are_host: "Теперь вы хост", + notice_hold: "Пауза — ждём, пока все загрузят буфер", + notice_go: "Все готовы — продолжаем", + act_resumed: "{name} продолжил", + act_paused: "{name} поставил на паузу", + act_seeked: "{name} перемотал", + someone: "участник", + + connecting: "Подключение…", + password_prompt: "Пароль комнаты {name}", + need_code: "Введите код комнаты", + need_url: "Нужна ссылка на поток", + need_pwd_public: + "Комната в списке требует пароль — задайте его или отключите публикацию", + already_in_room: "Вы уже в комнате {name}", + create_failed: "Не удалось создать комнату", + join_failed: "Комната не найдена или пароль неверный", + kicked: "Вы подключились из другого места", + no_stream: "В комнате нет потока", + relay_error: "Реле: {text}", + autoplay_blocked: "Браузер заблокировал автозапуск — нажмите play", + mixed_content: + "Поток отдаётся по http, а страница по https — браузер это блокирует", + cors_error: + "Поток не загружается: похоже, сервер не отдаёт Access-Control-Allow-Origin", + playback_error: "Ошибка воспроизведения: {details}", + media_error: + "Браузер не смог открыть поток (формат, CORS или mixed content)", + }, +}; + +export const DICTS = DICT; // exposed so tests can check the dictionaries are complete + +const STORE_KEY = "lparty_lang"; +const listeners = new Set(); +let current = "uk"; + +/** Russian is deliberately not reachable here: it is a manual choice only. */ +export function detectLang() { + for (const tag of navigator.languages || [navigator.language || ""]) { + const code = String(tag).toLowerCase().split("-")[0]; + if (code === "uk") return "uk"; + if (code === "en") return "en"; + } + return "uk"; +} + +export function getLang() { + return current; +} + +export function setLang(code) { + if (!DICT[code]) return; + current = code; + try { + localStorage.setItem(STORE_KEY, code); + } catch (err) {} + document.documentElement.lang = code; + listeners.forEach((fn) => fn(code)); +} + +export function initLang() { + let saved = null; + try { + saved = localStorage.getItem(STORE_KEY); + } catch (err) {} + current = DICT[saved] ? saved : detectLang(); + document.documentElement.lang = current; + return current; +} + +export function onLangChange(fn) { + listeners.add(fn); +} + +export function t(key, params) { + let value = DICT[current][key]; + if (value === undefined) return key; // visible immediately; silence would be worse + + if (typeof value === "object") { + const n = Number(params && params.n); + const form = new Intl.PluralRules(current).select(n); + value = value[form] ?? value.other; + } + + return params + ? value.replace(/\{(\w+)\}/g, (m, name) => + params[name] === undefined ? m : params[name], + ) + : value; +} + +/** + * Repaints static nodes. data-i18n sets textContent, data-i18n-ph sets the + * placeholder, data-i18n-label sets aria-label. + */ +export function applyTranslations(root = document) { + root.querySelectorAll("[data-i18n]").forEach((el) => { + el.textContent = t(el.dataset.i18n); + }); + root.querySelectorAll("[data-i18n-ph]").forEach((el) => { + el.placeholder = t(el.dataset.i18nPh); + }); + root.querySelectorAll("[data-i18n-label]").forEach((el) => { + el.setAttribute("aria-label", t(el.dataset.i18nLabel)); + }); +} diff --git a/mods/WatchTogether/web/index.html b/mods/WatchTogether/web/index.html new file mode 100644 index 0000000..7e481ea --- /dev/null +++ b/mods/WatchTogether/web/index.html @@ -0,0 +1,1456 @@ + + + + + + + WatchTogether + + + + + + + +
+ + +
+
+
+ WatchTogether +
+
+ +
+ +
+
+

+
+ + + +
+ +
+

+ + +
+
+ +
+ +
+

+
+ +

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

+ + + +
+
+
+ +
+
+ + + + + + + + +
+ +
+ +
+
+ +
+ + + + + + diff --git a/mods/WatchTogether/web/lparty-web.js b/mods/WatchTogether/web/lparty-web.js new file mode 100644 index 0000000..32e0dc1 --- /dev/null +++ b/mods/WatchTogether/web/lparty-web.js @@ -0,0 +1,1898 @@ +/** + * Port of the lparty.js protocol (Lampa watch-party) to a plain web page. + * Wire-compatible: same channels, same messages, same timings. + * Original: https://nrsua.github.io/lampa-nrs/lparty.js + * + * The transport is the itty.ws relay, which is stateless: it broadcasts inside a + * channel and adds a server timestamp plus a member list. All room logic lives + * here, so there is no server side to write. + */ + +export const DEFAULT_RELAY = "{relay}"; +export const LOBBY_CHANNEL = "lparty-lobby-v1"; +export const ROOM_PREFIX = "lparty-r-"; + +/** + * Copied verbatim from the original. These are calibrated against their relay and + * their players: change them on one side only and two clients pull each other in + * different directions, which is worse than no correction at all. + */ +export const TUNING = { + toleranceS: 0.3, + // Correction starts above correctOnS and only stops below toleranceS. Without that + // hysteresis the rate flapped around a single threshold on every heartbeat. + correctOnS: 0.7, + hardSeekS: 1.5, + // Native Apple HLS seeks coarsely and ignores playbackRate, so it gets a looser bound. + hardSeekNativeS: 3.0, + rateGain: 0.1, + maxRateOffset: 0.1, + rateResetMs: 4000, + heartbeatMs: 2000, + pingIntervalMs: 8000, + echoTimeoutMs: 30000, + reconnectMs: 4000, + joinTimeoutMs: 6000, + // The original 1500 was too tight: replies land ~1.2 s after the query and missed + // the window. Purely local, so it does not affect wire compatibility. + lobbyCollectMs: 2500, + userActionMs: 2000, + systemSyncMs: 500, + initialLockMs: 3000, + expectPlayMs: 500, + + // Buffering barrier, added in plugin 1.3.0. The host pauses everyone at one + // position and waits for a `ready` from each participant it is holding for. + holdMaxMs: 15000, + holdBufferS: 6, + holdMinBufferS: 2, + holdStallMs: 3000, + holdNudgeMs: 4000, + + rewindGraceMs: 2500, + hardSeekCooldownMs: 3000, + reconnectHelloMs: 60000, + + // User seeks + seekGuardMs: 4000, // how long a position we set ourselves stays "ours" + seekBroadcastMinMs: 2000, // a scrub is throttled into one trailing broadcast, never dropped + seekMinJumpS: 1.0, // below this the position barely moved; nothing to announce + pendingActMaxMs: 15000, // how long a peer's seek is retried before giving up + + // End of media. Purely local, like the two above: they change what we say and what we obey, + // never the wire, so a Lampa peer needs no matching calibration. + endSlackS: 1.0, // a reported position this near our own duration is a file running out + reportGapMs: 1500, // how far apart two reports must be to say whether their sender moves +}; + +const NO_RANGES = -2; +const OUT_OF_RANGE = -1; + +// ---------------------------------------------------------------- pure functions + +/** + * The original hashes UTF-8 bytes (unescape(encodeURIComponent(s))), so the built-in + * crypto.subtle yields a byte-identical digest — verified against their own code. + * Needs a secure context; GitHub Pages is always https, so that costs nothing. + */ +export async function sha256hex(str) { + const buf = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(str), + ); + return [...new Uint8Array(buf)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export async function roomChannel(roomId, password) { + const hex = await sha256hex( + (roomId || "").toUpperCase() + "|" + (password || ""), + ); + return ROOM_PREFIX + hex.substr(0, 24); +} + +export function newRoomId() { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no lookalike I/O/0/1 + let id = ""; + for (let i = 0; i < 6; i++) + id += chars.charAt(Math.floor(Math.random() * chars.length)); + return id; +} + +/** Where the room should be right now, given it plays from basePosition since atServerTime. */ +export function expectedPositionNow(state, basePosition, atServerTime, nowMs) { + if (state !== "playing" || !atServerTime || atServerTime <= 0) + return basePosition; + const elapsedSec = (nowMs - atServerTime) / 1000; + if (elapsedSec < 0 || elapsedSec > 3600) return basePosition; // garbage timestamp + return basePosition + elapsedSec; +} + +/** + * diff = our position minus the expected one. Positive diff means we ran ahead. + * + * @param opts.correcting already nudging the rate — keep going until inside toleranceS + * @param opts.hardSeekS threshold for giving up on rate and jumping + * @param opts.canAdjustRate false for players that ignore playbackRate (native Apple HLS) + */ +export function syncDecision(diff, opts = {}, t = TUNING) { + const { + correcting = false, + hardSeekS = t.hardSeekS, + canAdjustRate = true, + } = opts; + const abs = Math.abs(diff); + + if (abs > hardSeekS) return { action: "seek", rate: 1 }; + + // Enter correction only on a real gap, leave it only when properly closed. + const enterAt = correcting ? t.toleranceS : t.correctOnS; + if (canAdjustRate && abs > enterAt) { + const offset = Math.max( + -t.maxRateOffset, + Math.min(t.maxRateOffset, diff * t.rateGain), + ); + return { action: "rate", rate: 1 - offset }; + } + + return { action: "reset", rate: 1 }; +} + +/** + * Whether a reported position is that sender's file running out rather than a place in it. + * Lampa announces the end of an episode as a pause carrying the duration to the millisecond, + * and obeyed literally that skips whatever is left here and parks on the last frame. + * + * Unknown or infinite duration — a player still loading, a live stream — answers no: there is + * nothing to compare against, and a live stream has no end to be at. + */ +export function atMediaEnd(position, duration, t = TUNING) { + if (!Number.isFinite(duration) || duration <= 0) return false; + return position >= duration - t.endSlackS; +} + +/** + * Whether the sender of two reports is standing still while calling itself playing — a peer + * refilling its buffer. Lampa's heartbeat repeats position and state without checking that + * playback advanced, and each repeat read as the room having jumped backwards. + * + * @returns true / false, or null when these two reports cannot say (a different sender, a + * sender that is paused, less than one heartbeat apart, or a jump backwards, which is + * a seek and is heard as one) + */ +export function reportStalled(prev, report, t = TUNING) { + if (!prev || prev.pid !== report.pid || !report.playing) return null; + const gap = report.at - prev.at; + if (gap < t.reportGapMs) return null; + const moved = report.position - prev.position; + if (moved < 0) return null; + // Against the sender's own rate: at half speed a player covers half the gap, and flat + // arithmetic would call that a stall — one peer picking 0.5x would stop everybody else. + const due = (gap / 1000) * Math.max(0.05, report.speed || 1); + return moved * 2 < due; +} + +/** Take the sample with the lowest RTT — it is the least distorted one. */ +export function pickOffset(samples) { + if (!samples.length) return 0; + let best = samples[0]; + for (const s of samples) if (s.rtt < best.rtt) best = s; + return best.offset; +} + +// ---------------------------------------------------------------- relay socket + +class Sock { + constructor({ + relay, + channel, + alias = "", + echo = false, + reconnect = false, + onEvent = () => {}, + }) { + this.relay = relay; + this.channel = channel; + this.alias = alias; + this.echo = echo; + this.reconnect = reconnect; + this.onEvent = onEvent; + this.ws = null; + this.uid = null; + this.members = []; + this.closed = false; + this.retryTimer = null; + this.onReady = null; + } + + url() { + let q = "announce=true&list=true"; + if (this.echo) q += "&echo=true"; + if (this.alias) q += "&as=" + encodeURIComponent(this.alias); + return this.relay + encodeURIComponent(this.channel) + "?" + q; + } + + open() { + if (this.closed) return; + if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) + return; + + try { + this.ws = new WebSocket(this.url()); + } catch (err) { + this.scheduleRetry(); + return; + } + + this.ws.onopen = () => this.onEvent({ kind: "open" }); + + this.ws.onmessage = (e) => { + let d; + try { + d = JSON.parse(e.data); + } catch (err) { + return; + } + if (!d) return; + + if (d.type === "join") { + if (d.self) { + this.uid = d.uid; + this.members = []; + for (const u of d.users || []) { + if (!this.findMember(u.uid)) + this.members.push({ uid: u.uid, alias: u.alias || "" }); + } + // The relay does not always list us among the users; host election counts + // members, so we have to be in our own list. + if (!this.findMember(this.uid)) + this.members.push({ uid: this.uid, alias: this.alias }); + this.onEvent({ + kind: "ready", + date: d.date, + total: d.total, + members: this.members, + }); + } else { + if (!this.findMember(d.uid)) + this.members.push({ uid: d.uid, alias: d.alias || "" }); + this.onEvent({ + kind: "join", + date: d.date, + uid: d.uid, + alias: d.alias || "", + total: d.total, + }); + } + return; + } + + if (d.type === "leave") { + this.members = this.members.filter((m) => m.uid !== d.uid); + this.onEvent({ + kind: "leave", + date: d.date, + uid: d.uid, + alias: d.alias || "", + total: d.total, + }); + return; + } + + if (d.type === "error") { + this.onEvent({ kind: "error", date: d.date, text: d.message }); + return; + } + + if (d.message && typeof d.message === "object") { + this.onEvent({ + kind: "msg", + date: d.date, + uid: d.uid, + alias: d.alias || "", + msg: d.message, + mine: d.uid === this.uid, + }); + } + }; + + this.ws.onclose = () => { + this.uid = null; + this.members = []; + this.onEvent({ kind: "close" }); + this.scheduleRetry(); + }; + + this.ws.onerror = () => {}; + } + + scheduleRetry() { + if (!this.reconnect || this.closed) return; + clearTimeout(this.retryTimer); + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.open(); + }, TUNING.reconnectMs); + } + + findMember(uid) { + return this.members.find((m) => m.uid === uid) || null; + } + + alive() { + return !!this.ws && this.ws.readyState === 1; + } + + send(obj) { + if (!this.alive()) return false; + try { + this.ws.send(JSON.stringify(obj)); + return true; + } catch (err) { + return false; + } + } + + close() { + this.closed = true; + clearTimeout(this.retryTimer); + this.retryTimer = null; + if (this.ws) { + try { + this.ws.close(); + } catch (err) {} + this.ws = null; + } + } +} + +// ---------------------------------------------------------------- room + +const emptyMeta = () => ({ + title: "", + poster: "", + url: "", + tmdb_id: 0, + source: "", + type: "", +}); + +export class Party { + /** + * @param video the