Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
+13 -11
View File
@@ -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/<Name>/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/<Name>/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.<name>` в `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/<bucket>/` — после правки удалить соответствующий файл (`grep -rl secuses cache/static/`) или весь каталог static, затем перезапросить.
5. Новые пользовательские модули класть в `mods/<Name>/` (монтировать в `/lampac/mods/<Name>`), переопределения плагинов — `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/<Name>/`, либо каталог в `mods/<Name>/`. Одиночные тома `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/<Name> ./mods/<Name>` и выставить `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.<name>` в `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/<bucket>/` — после правки удалить соответствующий файл (`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/<name>.js` → 200).
5. Новые пользовательские модули и копии включаемых — класть в `mods/<Name>/` (один том `./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`).
+20 -19
View File
@@ -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: <Name>`, роуты вроде `/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/<Name>/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`.
## Ссылки
+15 -9
View File
@@ -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,
+1 -5
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
<RootNamespace>AdminPanel</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>
+366
View File
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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
{
}
}
}
}
+107
View File
@@ -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<GroupDto> Build(JObject currentRoot)
{
if (currentRoot == null)
currentRoot = new JObject();
var inFile = new HashSet<string>(
currentRoot.Properties().Select(p => p.Name),
StringComparer.Ordinal);
var assigned = new HashSet<string>(StringComparer.Ordinal);
var result = new List<GroupDto>();
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<GroupDto> BuildCatalog()
{
var list = new List<GroupDto>(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<string> CatalogRootKeys { get; } = new(
Catalog.SelectMany(g => g.Keys),
StringComparer.Ordinal);
}
public sealed record GroupDto(string Id, string Title, string Hint, string[] Keys);
+18
View File
@@ -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()
{
}
}
+98
View File
@@ -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`** | Интерфейс входа и панели. |
+401
View File
@@ -0,0 +1,401 @@
<!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>Вход — Lampac Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<script>
(function () {
try {
var t = localStorage.getItem('lampac-theme');
var dark = t === 'light' ? false : t === 'dark' ? true : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
} catch (e) { /* ignore */ }
})();
</script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Consolas, monospace;
--text-xs: 0.6875rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-lg: 1rem;
--sp-1: 0.25rem;
--sp-2: 0.5rem;
--sp-3: 0.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(0.16, 1, 0.3, 1);
--dur: 180ms;
}
[data-theme="dark"] {
--bg: #0c0e12;
--surface: #13161d;
--surface-2: #1a1e28;
--surface-3: #222733;
--border: #2a3040;
--border-subtle: rgba(255, 255, 255, 0.06);
--text: #e2e6ef;
--text-muted: #7c849a;
--text-faint: #4d5568;
--accent: #3ec9d1;
--accent-soft: #2ea8b0;
--accent-dim: rgba(62, 201, 209, 0.12);
--accent-hover: rgba(62, 201, 209, 0.22);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45);
}
[data-theme="light"] {
--bg: #f4f5f8;
--surface: #ffffff;
--surface-2: #f0f1f5;
--surface-3: #e8eaf0;
--border: #d4d8e3;
--border-subtle: rgba(0, 0, 0, 0.06);
--text: #1a1e28;
--text-muted: #6b7280;
--text-faint: #9ca3af;
--accent: #0d9499;
--accent-soft: #0b7d82;
--accent-dim: rgba(13, 148, 153, 0.1);
--accent-hover: rgba(13, 148, 153, 0.18);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12);
}
html,
body {
min-height: 100%;
}
body {
font-family: var(--font-body);
font-size: var(--text-base);
color: var(--text);
background: var(--bg);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.auth-app {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
.topbar {
display: flex;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-6);
min-height: 52px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.topbar-logo {
display: flex;
align-items: center;
gap: var(--sp-2);
font-weight: 700;
font-size: var(--text-lg);
letter-spacing: -0.02em;
color: var(--text);
flex-shrink: 0;
white-space: nowrap;
}
.topbar-logo svg {
flex-shrink: 0;
}
.topbar-logo span {
font-weight: 400;
color: var(--text-muted);
margin-left: 2px;
}
.topbar-spacer {
flex: 1;
min-width: var(--sp-2);
}
.btn-icon {
width: 34px;
height: 34px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--surface-2);
color: var(--text-muted);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all var(--dur) var(--ease);
flex-shrink: 0;
}
.btn-icon:hover {
color: var(--text);
background: var(--surface-3);
}
.btn-icon svg {
width: 16px;
height: 16px;
}
.auth-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-6) var(--sp-4);
}
.auth-card {
width: 100%;
max-width: 380px;
padding: var(--sp-6);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-xl);
box-shadow: var(--shadow-lg);
animation: authIn 280ms var(--ease);
}
@keyframes authIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.auth-card h1 {
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: -0.01em;
margin-bottom: var(--sp-1);
}
.auth-card .lead {
font-size: var(--text-xs);
color: var(--text-muted);
margin-bottom: var(--sp-5);
line-height: 1.6;
}
.auth-form {
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.auth-field label {
display: block;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-muted);
margin-bottom: var(--sp-2);
}
.auth-field input[type="password"] {
font: inherit;
font-size: var(--text-sm);
width: 100%;
padding: 8px 12px;
border-radius: var(--r-md);
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
[data-theme="light"] .auth-field input[type="password"] {
background: var(--surface);
}
.auth-field input[type="password"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.auth-field input::placeholder {
color: var(--text-faint);
}
.btn {
font: inherit;
font-size: var(--text-xs);
font-weight: 600;
padding: 10px 16px;
border-radius: var(--r-md);
border: 1px solid var(--accent);
cursor: pointer;
transition: all var(--dur) var(--ease);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
width: 100%;
}
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn-primary:hover {
background: var(--accent-soft);
border-color: var(--accent-soft);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.btn-primary:active {
transform: translateY(0);
}
@media (max-width: 480px) {
.topbar-logo span {
display: none;
}
}
</style>
<script>
(function () {
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
var m = document.getElementById('metaTheme');
if (m) m.setAttribute('content', dark ? '#0c0e12' : '#f4f5f8');
})();
</script>
</head>
<body>
<div class="auth-app">
<header class="topbar">
<div class="topbar-logo">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
</svg>
Lampac <span>Admin</span>
</div>
<div class="topbar-spacer"></div>
<button type="button" class="btn-icon" id="themeBtn" title="Сменить тему (Alt+T)" aria-label="Сменить тему">
<svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
</header>
<main class="auth-main">
<div class="auth-card">
<h1>Вход</h1>
<p class="lead">Введите пароль доступа к панели. Сессия сохраняется в cookie на 180 дней.</p>
<form class="auth-form" id="loginForm">
<div class="auth-field">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password" />
</div>
<button type="submit" class="btn btn-primary">Войти</button>
</form>
</div>
</main>
</div>
<script>
(function () {
var LAMPAC_THEME_STORAGE_KEY = 'lampac-theme';
function $(sel) { return document.querySelector(sel); }
function setTheme(dark) {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
var meta = $('#metaTheme');
if (meta) meta.setAttribute('content', dark ? '#0c0e12' : '#f4f5f8');
try { localStorage.setItem(LAMPAC_THEME_STORAGE_KEY, dark ? 'dark' : 'light'); } catch (e) { }
syncThemeIcon();
}
function syncThemeIcon() {
var d = document.documentElement.getAttribute('data-theme') === 'dark';
var icon = $('#themeIcon');
if (!icon) return;
if (d) {
icon.innerHTML = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
} else {
icon.innerHTML = '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>';
}
}
syncThemeIcon();
$('#themeBtn').addEventListener('click', function () {
var d = document.documentElement.getAttribute('data-theme') === 'dark';
setTheme(!d);
});
document.addEventListener('keydown', function (e) {
if (e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
var d = document.documentElement.getAttribute('data-theme') === 'dark';
setTheme(!d);
}
});
$('#loginForm').addEventListener('submit', function (e) {
e.preventDefault();
var password = $('#password').value;
var maxAge = 60 * 60 * 24 * 180;
document.cookie = 'accspasswd=' + encodeURIComponent(password) +
'; max-age=' + maxAge +
'; path=/; SameSite=Lax' +
(window.isSecureContext ? '; Secure' : '');
window.location.href = '/adminpanel';
});
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{
"enable": true,
"dynamic": true,
"tree": [
"AdminPanelController.cs",
"ConfigSectionGroups.cs",
"ModInit.cs"
]
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
<RootNamespace>DatabaseEditor</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>
@@ -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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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" });
}
}
File diff suppressed because it is too large Load Diff
+64
View File
@@ -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<bool, EventMiddleware, Task<bool>> MiddlewareHandler = OnMiddleware;
public void Loaded(InitspaceModel initspace)
{
modpath = initspace.path;
EventListener.MiddlewareAsync += MiddlewareHandler;
}
public void Dispose()
{
EventListener.MiddlewareAsync -= MiddlewareHandler;
}
static async Task<bool> 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 = "<a href=\"/weblog\" aria-current=\"page\">Weblog</a>";
const string databaseLink = activeLink + "\n <a href=\"/database-editor\">Базы</a>";
if (html.Contains(activeLink, StringComparison.Ordinal))
html = html.Replace(activeLink, databaseLink, StringComparison.Ordinal);
else
{
const string navEnd = "</nav>";
int navIndex = html.IndexOf(navEnd, StringComparison.OrdinalIgnoreCase);
if (navIndex >= 0)
html = html.Insert(navIndex, "<a href=\"/database-editor\">Базы</a>\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;
}
}
+42
View File
@@ -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.
+649
View File
@@ -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();
})();
+151
View File
@@ -0,0 +1,151 @@
<!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>Базы — Lampac</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/database-editor/style.css">
</head>
<body>
<div class="app">
<header class="topbar">
<a class="topbar-logo" href="/database-editor">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5"/>
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)"/>
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity=".7"/>
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity=".45"/>
</svg>
Lampac <span>Базы</span>
</a>
<div class="topbar-divider"></div>
<nav class="topbar-app-nav" aria-label="Разделы Lampac"><a href="/adminpanel">Admin</a><a href="/stats">Stats</a><a href="/weblog">Weblog</a><a href="/telemetry">Telemetry</a><a href="/database-editor" aria-current="page">Базы</a></nav>
<div class="topbar-spacer"></div>
<button class="icon-btn theme-btn" id="themeBtn" title="Переключить тему" aria-label="Переключить тему">
<svg class="theme-icon theme-icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg class="theme-icon theme-icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</button>
</header>
<main class="main">
<section id="listView">
<div class="hero">
<div><h1>Редактор TimeCode и Sync</h1><p>Пользователи, карточки, позиции просмотра и безопасные копии SQLite.</p></div>
<div class="hero-actions"><button class="btn" id="backupAllBtn">⇩ <span>Backup TimeCode + Sync</span></button><button class="btn" id="restoreBtn">↥ <span>Восстановить</span></button><button class="btn" id="renameUserBtn">Переименовать пользователя</button><button class="btn danger" id="deleteUserBtn">Удалить пользователя</button></div>
</div>
<section class="summary" id="summary"></section>
<section class="workspace">
<div class="toolbar">
<div class="db-tabs" role="tablist"><button class="db-tab active" data-db="timecode" role="tab">TimeCode</button><button class="db-tab" data-db="sync" role="tab">Sync</button></div>
<select class="select user-filter" id="timecodeUserFilter" title="Пользователь TimeCode"><option value="">Все пользователи TimeCode</option></select>
<div class="search"><span>⌕</span><input id="searchInput" type="search" placeholder="ID, пользователь, карточка или JSON…" autocomplete="off"></div>
<div class="toolbar-spacer"></div>
<select class="select" id="pageSize" title="Записей на странице"><option>25</option><option>50</option><option>100</option></select>
<button class="icon-btn" id="refreshBtn" title="Обновить" aria-label="Обновить">↻</button>
</div>
<div class="loading" id="loading"></div>
<div class="table-wrap">
<table class="data-table"><thead><tr id="tableHead"></tr></thead><tbody id="tableBody"></tbody></table>
<div class="empty" id="empty"><div><div class="empty-icon">⌕</div><strong>Записей не найдено</strong><div class="muted">Измените поиск или выберите другую базу.</div></div></div>
</div>
<div class="pager"><div class="pager-info" id="pagerInfo">—</div><div class="pager-buttons"><button class="icon-btn" id="prevBtn">←</button><span class="page-label" id="pageLabel">1 / 1</span><button class="icon-btn" id="nextBtn">→</button></div></div>
</section>
</section>
<section class="sync-detail" id="syncDetail" hidden>
<div class="detail-head">
<button class="btn" id="backToUsers">← Пользователи</button>
<div class="detail-identity"><div class="eyebrow">Sync-пользователь</div><h1 id="syncUserName">—</h1><div class="muted" id="syncUserMeta">—</div></div>
<div class="detail-actions"><button class="btn" id="rawSyncBtn">JSON целиком</button><button class="btn" id="refreshSyncBtn">↻ Обновить</button></div>
</div>
<div class="detail-toolbar">
<div class="search"><span>⌕</span><input id="syncSearch" type="search" placeholder="Название или ID карточки…" autocomplete="off"></div>
<select class="select" id="syncCategory"><option value="">Все категории</option></select>
<span class="result-count" id="syncResultCount"></span>
</div>
<div class="media-grid" id="syncGrid"></div>
<div class="empty sync-empty" id="syncEmpty"><div><div class="empty-icon">□</div><strong>Карточек не найдено</strong></div></div>
<div class="detail-pager"><button class="btn" id="syncPrev">← Назад</button><span id="syncPageLabel">1 / 1</span><button class="btn" id="syncNext">Вперёд →</button></div>
</section>
</main>
</div>
<div class="modal-backdrop" id="recordModal" role="dialog" aria-modal="true">
<div class="modal modal-wide">
<div class="modal-head"><span class="modal-title" id="recordModalTitle">Запись</span><span class="modal-id" id="recordModalId"></span><button class="icon-btn modal-close" data-close="recordModal">×</button></div>
<div class="modal-body"><div class="form-grid">
<div class="field full"><label for="userField">Пользователь</label><input id="userField" maxlength="512" autocomplete="off"></div>
<div class="field timecode-field"><label for="cardField">Карточка</label><input id="cardField" maxlength="512" autocomplete="off"></div>
<div class="field timecode-field"><label for="itemField">Элемент / hash</label><input id="itemField" maxlength="512" autocomplete="off"></div>
<div class="field full"><div class="json-tools"><label for="dataField">JSON-данные</label><button class="btn small" type="button" id="formatBtn">Форматировать</button><span class="json-status" id="jsonStatus"></span></div><textarea id="dataField" spellcheck="false"></textarea></div>
</div></div>
<div class="modal-foot"><button class="btn danger" id="deleteRecordBtn">Удалить</button><button class="btn" data-close="recordModal">Отмена</button><button class="btn primary" id="saveRecordBtn">Сохранить</button></div>
</div>
</div>
<div class="modal-backdrop" id="categoryModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Категории карточки</span><button class="icon-btn modal-close" data-close="categoryModal">×</button></div>
<div class="modal-body">
<div class="category-card-head" id="categoryCardHead"></div>
<div class="category-options" id="categoryOptions"></div>
<p class="modal-hint">Изменяется только выбранная карточка. Остальные позиции пользователя останутся без изменений.</p>
</div>
<div class="modal-foot"><button class="btn danger" id="deleteSyncItemBtn">Удалить карточку</button><button class="btn" data-close="categoryModal">Отмена</button><button class="btn primary" id="saveSyncItemBtn">Сохранить категории</button></div>
</div>
</div>
<div class="modal-backdrop" id="renameUserModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Переименовать пользователя</span><button class="icon-btn modal-close" data-close="renameUserModal">×</button></div>
<div class="modal-body"><div class="form-grid">
<div class="field full"><label for="oldUserField">Текущий пользователь</label><select class="select" id="oldUserField"><option value="">Загрузка пользователей…</option></select></div>
<div class="field full"><label for="newUserField">Новое имя</label><input id="newUserField" maxlength="512" autocomplete="off"></div>
<p class="modal-hint field full">Важно: перед переименованием выйдите из старой учётной записи на всех устройствах или заранее измените её логин/account_email в настройках Lampa и, если пользователь задан там, в init/AccsDB. Активный клиент со старым именем после обновления страницы создаст его в Sync и TimeCode повторно. Новое имя не должно быть занято.</p>
</div></div>
<div class="modal-foot"><button class="btn" data-close="renameUserModal">Отмена</button><button class="btn primary" id="confirmRenameUserBtn">Переименовать</button></div>
</div>
</div>
<div class="modal-backdrop" id="deleteUserModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Удалить пользователя</span><button class="icon-btn modal-close" data-close="deleteUserModal">×</button></div>
<div class="modal-body"><div class="form-grid">
<div class="field full"><label for="deleteUserField">Пользователь</label><select class="select" id="deleteUserField"><option value="">Загрузка пользователей…</option></select></div>
<p class="modal-hint field full">Будут безвозвратно удалены все закладки Sync и все позиции TimeCode выбранного пользователя.</p>
</div></div>
<div class="modal-foot"><button class="btn" data-close="deleteUserModal">Отмена</button><button class="btn danger" id="confirmDeleteUserBtn">Удалить пользователя</button></div>
</div>
</div>
<div class="modal-backdrop" id="backupModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Резервные копии созданы</span><button class="icon-btn modal-close" data-close="backupModal">×</button></div>
<div class="modal-body"><div class="backup-results" id="backupResults"></div></div>
<div class="modal-foot"><button class="btn primary" data-close="backupModal">Готово</button></div>
</div>
</div>
<div class="modal-backdrop" id="restoreModal" role="dialog" aria-modal="true">
<div class="modal modal-wide">
<div class="modal-head"><span class="modal-title">Восстановление базы</span><button class="icon-btn modal-close" data-close="restoreModal">×</button></div>
<div class="modal-body">
<p class="modal-hint restore-warning">Выберите резервную копию. Перед восстановлением текущая база будет автоматически сохранена в отдельный файл <code>before-restore</code>.</p>
<div class="restore-tabs"><button class="db-tab active" data-restore-db="sync">Sync</button><button class="db-tab" data-restore-db="timecode">TimeCode</button></div>
<div class="backup-results restore-results" id="restoreResults"></div>
<div class="empty restore-empty" id="restoreEmpty"><div><strong>Резервных копий нет</strong></div></div>
</div>
<div class="modal-foot"><button class="btn" data-close="restoreModal">Закрыть</button></div>
</div>
</div>
<div class="toast-wrap" id="toasts"></div>
<script src="/database-editor/app.js"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{
"enable": true,
"references": [
"Microsoft.Data.Sqlite"
]
}
+439
View File
@@ -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; }
}
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>
+47
View File
@@ -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<string> 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<bool, EventMiddleware, bool> 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<RequestModel>();
var path = e.httpContext.Request.Path.Value;
if (requestInfo != null && path != null && ExternalBindPaths.Contains(path))
requestInfo.IsLocalIp = true;
return true;
}
}
}
+45
View File
@@ -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`; контроллеров и статики в модуле нет.
+7
View File
@@ -0,0 +1,7 @@
{
"enable": true,
"dynamic": true,
"tree": [
"ModInit.cs"
]
}
+88
View File
@@ -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<AppDbContext> options) : base(options) { }
public AppDbContext() : base() { }
public DbSet<LogModelSql> jurnal { get; set; }
public DbSet<UserInfoModelSql> unfo { get; set; }
public DbSet<HeaderModelSql> 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<LogModelSql>().HasIndex(x => x.uid);
modelBuilder.Entity<LogModelSql>().HasIndex(x => x.time);
modelBuilder.Entity<LogModelSql>().HasIndex(x => x.balancer);
modelBuilder.Entity<LogModelSql>().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<string, string> Headers
{
get
{
if (string.IsNullOrEmpty(HeadersJson)) return new();
try { return JsonSerializer.Deserialize<Dictionary<string, string>>(HeadersJson) ?? new(); }
catch { return new(); }
}
set
{
var safe = (value ?? new Dictionary<string, string>())
.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);
}
}
}
@@ -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<AppDbContext> _contextFactory;
public ApiController(IDbContextFactory<AppDbContext> 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<IActionResult> Login([FromForm] string password)
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var cacheKey = GetAttemptsKey(ip);
var attempts = _cache.Get<int?>(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 = @"
<!DOCTYPE html>
<html><head><meta charset='utf-8'></head><body>
<script>
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', '/lite/logrequest');
}
window.location.replace('/lite/logrequest');
</script>
</body></html>";
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<IActionResult> 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<List<JournalItemDto>>.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<List<JournalItemDto>>.Ok(result));
}
[HttpGet("premium-chart")]
public async Task<IActionResult> 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<object>.Ok(result));
}
[HttpGet("stats")]
public async Task<IActionResult> 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<StatsDto>.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<StatsDto>.Ok(new StatsDto
{
Today = today,
Month = month,
UniqueIp = uniqueIp,
UniqueUserAgent = uniqueUserAgent,
TopUsers = topUsers,
TopBalancers = topBalancers
}));
}
private IActionResult UnauthorizedResponse() => StatusCode(401, ApiResponse<object>.Fail("Unauthorized"));
private IActionResult Ok<T>(ApiResponse<T> response) => new JsonResult(response);
}
+24
View File
@@ -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;
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<IsPackable>true</IsPackable>
<RootNamespace>LogUserRequest</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>
@@ -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<bool> InvokeAsync(bool first, EventMiddleware e)
{
if (first)
return Task.FromResult(true);
var httpContext = e.httpContext;
var requestInfo = httpContext.Features.Get<RequestModel>();
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<string, string>() };
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);
}
}
+375
View File
@@ -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<AppDbContext>(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<JObject>(File.ReadAllText(manifestPath)) ?? new();
conf.logDay = manifest["logDay"]?.Value<int>() ?? 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<object>(),
topBalancers = Array.Empty<object>()
};
}
}
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); }
}
}
@@ -0,0 +1,11 @@
namespace LogUserRequest.Models.DTO;
public class ApiResponse<T>
{
public bool Success { get; set; } = true;
public string? Error { get; set; }
public T? Data { get; set; }
public static ApiResponse<T> Ok(T data) => new() { Success = true, Data = data };
public static ApiResponse<T> Fail(string error) => new() { Success = false, Error = error };
}
@@ -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; }
}
@@ -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<TopItemDto>();
public TopItemDto[] TopBalancers { get; set; } = Array.Empty<TopItemDto>();
}
public class TopItemDto
{
public string Name { get; set; } = "";
public int Count { get; set; }
}
+336
View File
@@ -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.
+110
View File
@@ -0,0 +1,110 @@
<!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>LogUserRequest — Вход</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --font-body: 'Inter', system-ui, -apple-system, sans-serif; }
[data-theme="dark"] { --bg: #0c0e12; --surface: #13161d; --surface-2: #1a1e28; --border: #2a3040; --text: #e2e6ef; --text-muted: #7c849a; --accent: #3ec9d1; --accent-soft: #2ea8b0; --err: #f87171; --err-dim: rgba(248, 113, 113, 0.12); --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45); }
[data-theme="light"] { --bg: #f4f5f8; --surface: #ffffff; --surface-2: #f0f1f5; --border: #d4d8e3; --text: #1a1e28; --text-muted: #6b7280; --accent: #0d9499; --accent-soft: #0b7d82; --err: #dc2626; --err-dim: rgba(220, 38, 38, 0.1); --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12); }
html, body { height: 100%; }
body { font-family: var(--font-body); background: var(--bg); display: flex; justify-content: center; align-items: center; color: var(--text); -webkit-font-smoothing: antialiased; }
.auth-container { background: var(--surface); border: 1px solid var(--border); border-radius: 24px; padding: 48px 40px; width: 100%; max-width: 420px; box-shadow: var(--shadow-lg); }
.logo { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 32px; }
.logo svg { width: 36px; height: 36px; }
.logo span { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text); }
.logo small { font-weight: 400; color: var(--text-muted); margin-left: 4px; }
h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 24px; text-align: center; color: var(--accent); }
.error { background: var(--err-dim); border: 1px solid var(--err); border-radius: 10px; padding: 12px 16px; margin-bottom: 20px; color: var(--err); font-size: 0.9rem; display: none; }
.error.show { display: block; }
input { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 12px; padding: 14px 18px; color: var(--text); font-size: 1rem; margin-bottom: 24px; transition: border 0.15s; }
input:focus { outline: none; border-color: var(--accent); }
button { width: 100%; background: var(--accent); border: none; border-radius: 12px; padding: 14px; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; transition: background 0.15s; }
button:hover { background: var(--accent-soft); }
.theme-toggle { position: absolute; top: 20px; right: 20px; width: 40px; height: 40px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; }
.theme-toggle:hover { color: var(--text); }
.theme-toggle svg { width: 20px; height: 20px; }
</style>
</head>
<body>
<button class="theme-toggle" id="themeBtn" title="Сменить тему (Alt+T)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<div class="auth-container">
<div class="logo">
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
</svg>
<span>LogUserRequest <small>Lite</small></span>
</div>
<h2>🔐 Вход</h2>
<div class="error" id="errorMsg"></div>
<form method="post" action="/lite/logrequest/login">
<input type="password" name="password" placeholder="Пароль" autocomplete="off" autofocus>
<button type="submit">Войти</button>
</form>
</div>
<script>
const THEME_KEY = 'loguser-theme';
function setTheme(dark) {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
try { localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); } catch(e) {}
}
const saved = localStorage.getItem(THEME_KEY);
setTheme(saved === 'dark' || (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches));
document.getElementById('themeBtn').addEventListener('click', () => {
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
});
document.addEventListener('keydown', e => {
if (e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
}
});
const urlParams = new URLSearchParams(window.location.search);
const errorEl = document.getElementById('errorMsg');
if (urlParams.has('error')) {
const remaining = urlParams.get('remaining');
errorEl.innerText = remaining ? `Неверный пароль. Осталось попыток: ${remaining}` : 'Неверный пароль';
errorEl.classList.add('show');
}
if (urlParams.has('blocked')) {
errorEl.innerText = 'Доступ заблокирован на сутки';
errorEl.classList.add('show');
}
// Предотвращаем возврат на auth через кнопку "назад"
(function() {
if (window.history && window.history.replaceState) {
const url = new URL(window.location);
if (!url.searchParams.has('error') && !url.searchParams.has('blocked')) {
window.history.replaceState(null, '', '/lite/logrequest');
}
}
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
fetch('/lite/logrequest/api?skip=0&take=1', {
credentials: 'include',
cache: 'no-cache'
}).then(r => {
if (r.ok) window.location.replace('/lite/logrequest');
}).catch(() => {});
}
});
})();
</script>
</body>
</html>
+487
View File
@@ -0,0 +1,487 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LogUserRequest Lite</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --font-body: 'Inter', system-ui, sans-serif; --font-mono: 'JetBrains Mono', monospace; --text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --r-md: 8px; --r-lg: 12px; --r-xl: 16px; }
[data-theme="dark"] { --bg: #0c0e12; --surface: #13161d; --surface-2: #1a1e28; --surface-3: #222733; --border: #2a3040; --text: #e2e6ef; --text-muted: #7c849a; --text-faint: #4d5568; --accent: #3ec9d1; --accent-dim: rgba(62, 201, 209, 0.12); --ok: #4ade80; --err: #f87171; --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); --modal-overlay: rgba(0,0,0,0.7); --chart-grid: #2a3040; }
[data-theme="light"] { --bg: #f4f5f8; --surface: #ffffff; --surface-2: #f0f1f5; --surface-3: #e8eaf0; --border: #d4d8e3; --text: #1a1e28; --text-muted: #6b7280; --text-faint: #9ca3af; --accent: #0d9499; --accent-dim: rgba(13, 148, 153, 0.1); --ok: #16a34a; --err: #dc2626; --shadow-sm: 0 1px 2px rgba(0,0,0,0.06); --modal-overlay: rgba(0,0,0,0.5); --chart-grid: #d4d8e3; }
html, body { height: 100%; }
body { font-family: var(--font-body); font-size: var(--text-base); background: var(--bg); color: var(--text); line-height: 1.5; -webkit-font-smoothing: antialiased; padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.header-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.logo { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1.2rem; color: var(--text); }
.logo svg { width: 28px; height: 28px; }
.logo small { font-weight: 400; color: var(--text-muted); margin-left: 4px; }
.actions { display: flex; gap: 8px; }
.btn { font: inherit; font-size: var(--text-xs); font-weight: 600; padding: 8px 16px; border-radius: var(--r-md); border: 1px solid var(--accent); background: var(--accent-dim); color: var(--accent); cursor: pointer; transition: all 0.15s; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: rgba(62, 201, 209, 0.22); transform: translateY(-1px); }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent); opacity: 0.85; }
.btn-premium { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); margin-left: 8px; }
.btn-premium:hover { background: rgba(62, 201, 209, 0.22); }
.btn-premium svg { width: 16px; height: 16px; }
.btn-ghost { border-color: var(--border); background: var(--surface-2); color: var(--text-muted); }
.btn-ghost:hover { color: var(--text); background: var(--surface-3); }
.btn-icon { width: 34px; height: 34px; padding: 0; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); color: var(--text-muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.btn-icon:hover { color: var(--text); background: var(--surface-3); }
.btn-icon svg { width: 16px; height: 16px; }
.filter-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; }
.filter-bar input { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 10px 16px; color: var(--text); font-size: 0.9rem; flex: 1; min-width: 200px; }
.filter-bar input:focus { outline: none; border-color: var(--accent); }
.stats-block { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); padding: 20px; margin-bottom: 24px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { background: var(--surface-2); border-radius: var(--r-lg); padding: 16px; text-align: center; border: 1px solid rgba(255,255,255,0.06); }
.stat-value { font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1.2; }
.stat-label { font-size: var(--text-xs); color: var(--text-muted); margin-top: 6px; }
.top-section { margin-bottom: 20px; }
.top-section h3 { font-size: 0.9rem; font-weight: 600; margin-bottom: 12px; color: var(--text-muted); }
.scroll-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.grid-item { background: var(--surface-2); border: 1px solid rgba(255,255,255,0.06); border-radius: var(--r-lg); padding: 8px 14px; min-width: 100px; display: inline-flex; align-items: center; gap: 8px; }
.grid-rank { font-size: var(--text-xs); font-weight: 700; color: var(--text-faint); background: var(--surface-3); width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; }
.grid-name { font-size: var(--text-sm); font-weight: 500; }
.grid-count { font-size: var(--text-xs); color: var(--text-muted); background: var(--surface-3); padding: 2px 8px; border-radius: 6px; }
.requests-list { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); overflow: hidden; }
.request-card { background: var(--surface-2); border-bottom: 1px solid var(--border); padding: 16px 20px; }
.request-card:last-child { border-bottom: none; }
.request-header { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 8px; font-size: 0.8rem; }
.request-time { color: var(--text-faint); }
.request-uid { color: var(--accent); font-weight: 600; }
.request-ip { color: var(--text-muted); }
.request-uri { font-family: var(--font-mono); font-size: 0.75rem; word-break: break-all; color: var(--text-muted); margin-top: 6px; }
.loading, .empty { text-align: center; padding: 40px; color: var(--text-faint); }
/* Модальное окно */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--modal-overlay); display: flex; align-items: flex-start; justify-content: center; z-index: 1000; padding: 20px; padding-top: 60px; backdrop-filter: blur(4px); overflow-y: auto; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); max-width: 950px; width: 100%; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); animation: modalSlide 0.2s ease; max-height: calc(100vh - 80px); overflow-y: auto; }
@keyframes modalSlide { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); z-index: 5; }
.modal-header h2 { font-size: 1.25rem; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.modal-header h2 svg { width: 24px; height: 24px; color: var(--accent); }
.modal-close { width: 32px; height: 32px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; }
.modal-close:hover { color: var(--text); background: var(--surface-3); }
.modal-body { padding: 24px; }
/* График в модалке */
.lite-chart-block { background: var(--surface-2); border-radius: var(--r-lg); padding: 20px; margin-bottom: 32px; }
.lite-chart-header { margin-bottom: 16px; }
.lite-chart-header h3 { font-size: 1rem; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.lite-chart-header h3 svg { width: 20px; height: 20px; }
.lite-chart-container { position: relative; height: 200px; }
.lite-scroll-hint { display: flex; justify-content: center; margin-top: 12px; }
.lite-scroll-arrow { color: var(--text-faint); font-size: var(--text-xs); display: flex; flex-direction: column; align-items: center; gap: 4px; opacity: 0.7; }
.lite-scroll-arrow svg { width: 20px; height: 20px; animation: bounce 2s infinite; }
@keyframes bounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(6px); } 60% { transform: translateY(3px); } }
.premium-section { margin-top: 8px; }
.premium-header { display: flex; align-items: center; gap: 8px; margin-bottom: 24px; }
.premium-header h3 { font-size: 1.1rem; font-weight: 700; color: var(--accent); display: flex; align-items: center; gap: 8px; }
.premium-header svg { width: 24px; height: 24px; }
.comparison-table { width: 100%; border-collapse: collapse; margin: 0 0 24px; font-size: var(--text-sm); }
.comparison-table th { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 500; font-size: var(--text-xs); }
.comparison-table td { padding: 8px; border-bottom: 1px solid var(--border); text-align: left; }
.comparison-table tr:last-child td { border-bottom: none; }
.comparison-table .feature-col { font-weight: 500; color: var(--text); }
.comparison-table .lite-col { color: var(--text-muted); }
.comparison-table .premium-col { color: var(--ok); }
.comparison-table .both-col { color: var(--ok); }
.video-container { position: relative; padding-bottom: 56.25%; height: 0; margin: 24px 0; border-radius: var(--r-lg); overflow: hidden; border: 1px solid var(--border); }
.video-container iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 0; }
.telegram-section { background: var(--accent-dim); border-radius: var(--r-lg); padding: 24px; text-align: center; border: 1px solid var(--accent); }
.telegram-section h3 { color: var(--accent); margin-bottom: 8px; }
.telegram-section p { color: var(--text-muted); margin-bottom: 20px; }
.telegram-link { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: #fff; padding: 12px 24px; border-radius: var(--r-lg); text-decoration: none; font-weight: 600; transition: all 0.15s; }
.telegram-link:hover { opacity: 0.85; transform: translateY(-2px); }
.telegram-link svg { width: 20px; height: 20px; fill: #fff; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<div class="logo">
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
</svg>
<span>LogUserRequest Lite</span>
</div>
<button class="btn btn-premium" id="premiumBtn">
Premium
</button>
</div>
<div class="actions">
<button class="btn-icon" id="themeBtn" title="Сменить тему (Alt+T)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<button class="btn btn-ghost" id="logoutBtn">Выйти</button>
</div>
</div>
<div class="filter-bar">
<input type="text" id="uidInput" placeholder="UID пользователя (оставьте пустым для всех)">
<button class="btn btn-primary" onclick="applyFilter()">Показать</button>
<button class="btn btn-ghost" onclick="resetFilter()">Сбросить</button>
</div>
<div class="stats-block" id="statsBlock" style="display:none;">
<div class="stats-grid">
<div class="stat-card"><div class="stat-value" id="statToday">0</div><div class="stat-label">Запросов сегодня</div></div>
<div class="stat-card"><div class="stat-value" id="statMonth">0</div><div class="stat-label">За месяц</div></div>
<div class="stat-card"><div class="stat-value" id="statDevices">0</div><div class="stat-label">Устройств</div></div>
<div class="stat-card"><div class="stat-value" id="statIps">0</div><div class="stat-label">IP адресов</div></div>
</div>
<div class="top-section">
<h3>👥 Топ пользователей</h3>
<div class="scroll-grid" id="topUsers"></div>
</div>
<div class="top-section">
<h3>⚡ Топ балансиров</h3>
<div class="scroll-grid" id="topBalancers"></div>
</div>
</div>
<div class="requests-list" id="requestsList">
<div class="loading">Загрузка...</div>
</div>
</div>
<!-- Модальное окно Premium -->
<div class="modal-overlay hidden" id="premiumModal">
<div class="modal">
<div class="modal-header">
<h2>
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="currentColor" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="currentColor" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="currentColor" opacity="0.45" />
</svg>
LogUserRequest Lite
</h2>
<button class="modal-close" id="closeModalBtn">✕</button>
</div>
<div class="modal-body">
<!-- Блок Lite-версии с графиком -->
<div class="lite-chart-block">
<div class="lite-chart-header">
<h3>
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true" width="20" height="20">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="currentColor" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="currentColor" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="currentColor" opacity="0.45" />
</svg>
Активность за 3 дня
</h3>
</div>
<div class="lite-chart-container">
<canvas id="premiumChart"></canvas>
</div>
<div class="lite-scroll-hint">
<span class="lite-scroll-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M19 12l-7 7-7-7"/>
</svg>
Прокрутите чтобы увидеть Premium-возможности
</span>
</div>
</div>
<!-- Разделитель / пустое пространство -->
<div style="height: 400px;"></div>
<!-- Premium секция -->
<div class="premium-section">
<div class="premium-header">
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="var(--accent)" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
</svg>
<h3>LogUserRequest Premium</h3>
</div>
<table class="comparison-table">
<thead>
<tr><th>Функция</th><th>Lite-версия</th><th>Premium-версия</th></tr>
</thead>
<tbody>
<tr><td class="feature-col">Логирование запросов</td><td class="both-col">✅ Все запросы</td><td class="both-col">✅ Все запросы</td></tr>
<tr><td class="feature-col">Фильтрация служебных путей</td><td class="both-col">✅ Хардкод-список</td><td class="premium-col">✅ Гибкий UI + regex + IP</td></tr>
<tr><td class="feature-col">Авторизация</td><td class="both-col">✅ Сессии + rate-limit</td><td class="both-col">✅ Сессии + rate-limit</td></tr>
<tr><td class="feature-col">База данных</td><td class="both-col">✅ SQLite</td><td class="premium-col">✅ SQLite с индексами</td></tr>
<tr><td class="feature-col">Очистка старых записей</td><td class="both-col">✅ По дням (logDay)</td><td class="premium-col">✅ По дням + по размеру БД</td></tr>
<tr><td class="feature-col">Статистика</td><td class="both-col">✅ Топы пользователей и балансиров</td><td class="premium-col">✅ Топы + графики + модальные окна</td></tr>
<tr><td class="feature-col">Тёмная/светлая тема</td><td class="both-col">✅ Есть</td><td class="both-col">✅ Есть</td></tr>
<tr><td class="feature-col">Мобильная адаптация</td><td class="both-col">✅ Есть</td><td class="both-col">✅ Есть</td></tr>
<tr><td class="feature-col">Поиск по UID</td><td class="both-col">✅ Поле ввода</td><td class="premium-col">✅ Поле ввода + модальное окно</td></tr>
<tr><td class="feature-col">Дашборд</td><td class="lite-col">❌ Простой список</td><td class="premium-col">✅ Полноценный SPA с графиками</td></tr>
<tr><td class="feature-col">Дедупликация перебора балансиров</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ First + Last (окно 30 сек)</td></tr>
<tr><td class="feature-col">Фильтр по дате</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ На дашборде</td></tr>
<tr><td class="feature-col">Активность в реальном времени</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Live-обновления</td></tr>
<tr><td class="feature-col">Экспорт данных</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ CSV/JSON (ZIP)</td></tr>
<tr><td class="feature-col">Массовое удаление</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ С гибкими фильтрами</td></tr>
<tr><td class="feature-col">Архивация</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ По дате + по размеру БД</td></tr>
<tr><td class="feature-col">Telegram-уведомления</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Мониторинг + архив + ошибки</td></tr>
<tr><td class="feature-col">Health-check</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ API для мониторинга</td></tr>
<tr><td class="feature-col">Настройка фильтров через UI</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Модальное окно + авто-сохранение</td></tr>
<tr><td class="feature-col">Защита от ReDoS</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Кэширование regex + таймаут</td></tr>
<tr><td class="feature-col">Очередь с защитой от переполнения</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ До 50000 записей</td></tr>
<tr><td class="feature-col">Потребление памяти</td><td class="both-col">~20-30 МБ</td><td class="both-col">~50-70 МБ</td></tr>
</tbody>
</table>
<!-- Видео-обзор -->
<div class="video-container">
<iframe src="https://drive.google.com/file/d/1VCgOGJIVy4CVjYV1wL47t_5Vb5dbs-Gg/preview" allow="autoplay; fullscreen" allowfullscreen></iframe>
</div>
<!-- Telegram -->
<div class="telegram-section">
<h3>💬 Заинтересовал Premium?</h3>
<p>Напишите автору в Telegram — отвечу на вопросы и дам доступ</p>
<a href="https://t.me/Viacheslav_Sh1" target="_blank" class="telegram-link">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.562 8.161c-.18 1.897-.962 6.502-1.359 8.627-.168.9-.5 1.201-.82 1.23-.697.064-1.226-.46-1.901-.903-1.056-.692-1.653-1.123-2.678-1.799-1.185-.781-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.015-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.329-.913.489-1.302.481-.428-.009-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.015 3.333-1.386 4.025-1.627 4.476-1.635.099-.002.321.023.465.141.145.118.185.276.204.388.018.112.013.23.01.356z"/>
</svg>
@Viacheslav_Sh1
</a>
</div>
</div>
</div>
</div>
</div>
<script>
const THEME_KEY = 'loguser-theme';
function setTheme(dark) {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
try { localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); } catch(e) {}
}
const saved = localStorage.getItem(THEME_KEY);
setTheme(saved === 'dark' || (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches));
document.getElementById('themeBtn').addEventListener('click', () => {
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
});
document.addEventListener('keydown', e => {
if (e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
}
});
let currentUid = '';
let skip = 0;
const take = 50;
let loading = false;
let allLoaded = false;
let premiumChart = null;
// Модальное окно
const modal = document.getElementById('premiumModal');
const premiumBtn = document.getElementById('premiumBtn');
const closeBtn = document.getElementById('closeModalBtn');
function openModal() {
modal.classList.remove('hidden');
loadPremiumChart();
}
premiumBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', () => modal.classList.add('hidden'));
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.add('hidden'); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') modal.classList.add('hidden'); });
async function apiCall(path) {
const res = await fetch('/lite/logrequest' + path, { credentials: 'include' });
if (res.status === 401 || res.status === 403) {
window.location.href = '/lite/logrequest/auth';
throw new Error('Unauthorized');
}
const json = await res.json();
return json.success ? json.data : json;
}
async function loadPremiumChart() {
if (premiumChart) return;
try {
const result = await apiCall('/premium-chart?days=3');
const ctx = document.getElementById('premiumChart').getContext('2d');
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#3ec9d1';
const gridColor = getComputedStyle(document.documentElement).getPropertyValue('--chart-grid').trim() || '#2a3040';
let labels, values;
if (result && result.data && result.data.length > 0) {
labels = result.data.map(d => new Date(d.date).toLocaleDateString('ru', { day: 'numeric', month: 'short' }));
values = result.data.map(d => d.count);
} else {
labels = ['Нет данных'];
values = [0];
}
premiumChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Запросы',
data: values,
borderColor: accentColor,
backgroundColor: accentColor + '20',
tension: 0.3,
fill: true,
pointBackgroundColor: accentColor
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: gridColor } },
x: { grid: { display: false } }
}
}
});
} catch(e) {
console.error('Chart error:', e);
}
}
async function loadStats() {
try {
const url = currentUid ? `/stats?uid=${encodeURIComponent(currentUid)}` : '/stats';
const stats = await apiCall(url);
if (!stats) return;
document.getElementById('statsBlock').style.display = 'block';
document.getElementById('statToday').innerText = stats.today || 0;
document.getElementById('statMonth').innerText = stats.month || 0;
document.getElementById('statDevices').innerText = stats.uniqueUserAgent || 0;
document.getElementById('statIps').innerText = stats.uniqueIp || 0;
const topUsers = document.getElementById('topUsers');
const users = stats.topUsers || [];
topUsers.innerHTML = users.map((u, idx) => `
<div class="grid-item">
<span class="grid-rank">${idx + 1}</span>
<span class="grid-name">${escapeHtml(u.name)}</span>
<span class="grid-count">${u.count}</span>
</div>
`).join('') || '<div class="empty">Нет данных</div>';
const topBalancers = document.getElementById('topBalancers');
const balancers = stats.topBalancers || [];
topBalancers.innerHTML = balancers.map((b, idx) => `
<div class="grid-item">
<span class="grid-rank">${idx + 1}</span>
<span class="grid-name">${escapeHtml(b.name)}</span>
<span class="grid-count">${b.count}</span>
</div>
`).join('') || '<div class="empty">Нет данных</div>';
} catch(e) {
console.error('Stats error:', e);
}
}
async function loadRequests(reset = true) {
if (reset) { skip = 0; allLoaded = false; document.getElementById('requestsList').innerHTML = ''; }
if (loading || allLoaded) return;
loading = true;
try {
let url = `/api?skip=${skip}&take=${take}`;
if (currentUid) url += `&uid=${encodeURIComponent(currentUid)}`;
const data = await apiCall(url);
if (!data || (Array.isArray(data) && data.length === 0)) {
allLoaded = true;
if (skip === 0) document.getElementById('requestsList').innerHTML = '<div class="empty">Нет записей</div>';
loading = false;
return;
}
if (reset) document.getElementById('requestsList').innerHTML = '';
data.forEach(r => {
const card = document.createElement('div');
card.className = 'request-card';
card.innerHTML = `
<div class="request-header">
<span class="request-time">${new Date(r.time).toLocaleString()}</span>
<span class="request-uid">${escapeHtml(r.userUid || r.uid || 'unknown')}</span>
<span class="request-ip">${escapeHtml(r.ip || 'unknown')}</span>
</div>
<div class="request-uri">${escapeHtml(r.uri || '')}</div>
`;
document.getElementById('requestsList').appendChild(card);
});
skip += data.length;
if (data.length < take) allLoaded = true;
loading = false;
} catch(e) {
console.error('Load error:', e);
loading = false;
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[m] || m));
}
function applyFilter() {
currentUid = document.getElementById('uidInput').value.trim();
loadStats();
loadRequests(true);
}
function resetFilter() {
document.getElementById('uidInput').value = '';
currentUid = '';
loadStats();
loadRequests(true);
}
document.getElementById('logoutBtn').addEventListener('click', () => {
window.location.href = '/lite/logrequest/logout';
});
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
if (!loading && !allLoaded) loadRequests(false);
}
});
loadStats();
loadRequests(true);
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{
"enable": true,
"version": 1,
"namespace": "LogUserRequest",
"logDay": 90
}
+295
View File
@@ -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<ActionResult> Home(int? daily_salt)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicCatalogService.GetHomeAsync(profileId, daily_salt ?? 0);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/playlists")]
async public Task<ActionResult> Playlists()
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicUserPlaylistService.ListAsync(profileId, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(new { available = true, playlists = result }));
}
[HttpGet]
[Route("music/playlists/tracks")]
async public Task<ActionResult> PlaylistTracks(string id)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var tracks = await MusicUserPlaylistService.GetTracksAsync(profileId, id, HttpContext.RequestAborted);
tracks = MusicImageProxyService.Apply(this, tracks);
return ContentTo(MusicJson.Serialize(new { available = true, playlist_id = id, tracks }));
}
[HttpPost]
[Route("music/playlists/create")]
async public Task<ActionResult> PlaylistCreate(string title)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
string playlistId = await MusicUserPlaylistService.CreateAsync(profileId, title, HttpContext.RequestAborted);
return ContentTo(MusicJson.Serialize(new { created = playlistId != null, playlist_id = playlistId, title }));
}
[HttpPost]
[Route("music/playlists/delete")]
async public Task<ActionResult> PlaylistDelete(string id)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
bool removed = await MusicUserPlaylistService.DeleteAsync(profileId, id, HttpContext.RequestAborted);
return ContentTo(MusicJson.Serialize(new { removed, playlist_id = id }));
}
[HttpPost]
[Route("music/playlists/import")]
[Route("music/playlists/import/soundcloud")] // legacy-роут для старого клиентского JS
async public Task<ActionResult> PlaylistImport(string url)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicUserPlaylistService.ImportAsync(profileId, url, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpPost]
[Route("music/playlists/sync")]
async public Task<ActionResult> PlaylistSync(string id)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicUserPlaylistService.SyncAsync(profileId, id, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpPost]
[Route("music/playlists/track/add")]
async public Task<ActionResult> PlaylistTrackAdd(string id, string track)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
MusicTrack parsed = null;
try
{
parsed = string.IsNullOrWhiteSpace(track) ? null : MusicJson.Deserialize<MusicTrack>(track);
}
catch
{
}
bool saved = await MusicUserPlaylistService.AddTrackAsync(profileId, id, parsed, HttpContext.RequestAborted);
var playlist = saved ? await MusicUserPlaylistService.GetSummaryAsync(profileId, id, HttpContext.RequestAborted) : null;
if (playlist != null)
playlist = MusicImageProxyService.Apply(this, playlist);
return ContentTo(MusicJson.Serialize(new { saved, playlist_id = id, playlist }));
}
[HttpPost]
[Route("music/playlists/track/remove")]
async public Task<ActionResult> PlaylistTrackRemove(string id, string track_id)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
bool removed = await MusicUserPlaylistService.RemoveTrackAsync(profileId, id, track_id, HttpContext.RequestAborted);
var playlist = removed ? await MusicUserPlaylistService.GetSummaryAsync(profileId, id, HttpContext.RequestAborted) : null;
if (playlist != null)
playlist = MusicImageProxyService.Apply(this, playlist);
return ContentTo(MusicJson.Serialize(new { removed, playlist_id = id, track_id, playlist }));
}
[HttpPost]
[Route("music/playlists/track/move")]
async public Task<ActionResult> PlaylistTrackMove(string id, string track_id, int position)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
bool moved = await MusicUserPlaylistService.MoveTrackAsync(profileId, id, track_id, position, HttpContext.RequestAborted);
var playlist = moved ? await MusicUserPlaylistService.GetSummaryAsync(profileId, id, HttpContext.RequestAborted) : null;
if (playlist != null)
playlist = MusicImageProxyService.Apply(this, playlist);
return ContentTo(MusicJson.Serialize(new { moved, playlist_id = id, track_id, position, playlist }));
}
[HttpGet]
[Route("music/daily")]
async public Task<ActionResult> DailyMix(int day, int? salt)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicDailyMixService.GetMixAsync(profileId, day, salt ?? 0, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/stats/top")]
async public Task<ActionResult> StatsTop(int? limit)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicStatsService.GetTopAsync(profileId, limit ?? 30, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpPost]
[Route("music/stats/clear")]
async public Task<ActionResult> StatsClear()
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
int removed = await MusicStatsService.ClearAsync(profileId, HttpContext.RequestAborted);
return ContentTo(MusicJson.Serialize(new { cleared = true, removed }));
}
[HttpPost]
[Route("music/radio")]
async public Task<ActionResult> Radio(string seeds, string exclude, int? limit)
{
// защитные капы: сырой JSON режем ДО десериализации (клиент шлёт
// exclude всей очереди ≤500 компакт-треков ≈ 150KB — 400KB с запасом),
// распарсенные списки — до разумных размеров
const int maxRawJsonLength = 400_000;
const int maxSeeds = 10;
const int maxExclude = 1000;
static List<MusicTrack> parseTracks(string value, int maxItems)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > maxRawJsonLength)
return new List<MusicTrack>();
try
{
var parsed = MusicJson.Deserialize<List<MusicTrack>>(value) ?? new List<MusicTrack>();
return parsed.Count > maxItems ? parsed.Take(maxItems).ToList() : parsed;
}
catch
{
return new List<MusicTrack>();
}
}
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicRadioService.GetAsync(profileId, new MusicRadioRequest
{
seeds = parseTracks(seeds, maxSeeds),
exclude = parseTracks(exclude, maxExclude)
}, limit ?? 20, HttpContext.RequestAborted);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/lyrics")]
async public Task<ActionResult> Lyrics(string title, string artist_name, string album_title, int? duration_ms, string youtube_id)
{
var result = await MusicLyricsService.GetAsync(title, artist_name, album_title, duration_ms, youtube_id, HttpContext.RequestAborted);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/providers")]
public ActionResult Providers()
{
var result = new
{
metadata = MusicProviderRegistry.DescribeMetadata(),
audio = MusicProviderRegistry.DescribeAudio(),
auth = MusicProviderRegistry.DescribeAuth()
};
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/section")]
async public Task<ActionResult> Section(string id)
{
var result = await MusicCatalogService.GetBrowseSectionAsync(id);
if (result == null)
return ContentTo(MusicJson.Serialize(new { available = false, message = "Section not found." }));
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
}
+41
View File
@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Mvc;
namespace Music;
public class AuthController : BaseController
{
[HttpGet]
[Route("music/auth/state")]
async public Task<ActionResult> State()
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var result = await MusicAuthService.GetStatesAsync(profileId);
return ContentTo(MusicJson.Serialize(result));
}
[HttpPost]
[Route("music/auth/save")]
async public Task<ActionResult> Save(string provider, string payload)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
bool result = await MusicAuthService.SaveAsync(provider, payload, profileId);
return ContentTo(MusicJson.Serialize(new
{
provider,
saved = result
}));
}
[HttpPost]
[Route("music/auth/logout")]
async public Task<ActionResult> Logout(string provider)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
await MusicAuthService.LogoutAsync(provider, profileId);
return ContentTo(MusicJson.Serialize(new
{
provider,
logged_out = true
}));
}
}
@@ -0,0 +1,169 @@
using Microsoft.AspNetCore.Mvc;
namespace Music;
public class PlaybackController : BaseController
{
[HttpGet]
[Route("music/matches")]
async public Task<ActionResult> Matches(string id, string provider, string audio_provider, string playback_mode, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc, string query)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
var result = string.IsNullOrWhiteSpace(query)
? await MusicResolver.GetMatchesAsync(track, audio_provider, playback_mode, profileId)
: await MusicResolver.GetMatchesByQueryAsync(track, audio_provider, query, playback_mode);
return ContentTo(MusicJson.Serialize(result));
}
[HttpPost]
[Route("music/match/select")]
async public Task<ActionResult> SelectMatch(string id, string provider, string audio_provider, string playback_mode, string match_id, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc, string query)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
bool saved = await MusicResolver.SelectMatchAsync(track, audio_provider, match_id, playback_mode, profileId, query);
return ContentTo(MusicJson.Serialize(new
{
saved,
track_id = id,
audio_provider,
match_id
}));
}
[HttpPost]
[Route("music/match/reset")]
async public Task<ActionResult> ResetMatch(string id, string provider, string playback_mode, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc)
{
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
bool reset = track != null && await MusicSourceMatchService.DeleteAsync(track.id, playback_mode);
return ContentTo(MusicJson.Serialize(new
{
reset,
track_id = id
}));
}
[HttpGet]
[Route("music/play")]
async public Task<ActionResult> Play(string id, string provider, string audio_provider, string stream_mode, string playback_mode, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
var result = await MusicPlaybackService.ResolveTrackAsync(track, audio_provider, stream_mode, playback_mode, profileId, HttpContext.RequestAborted);
MusicPlaybackService.PrepareStreamSources(host, track, provider, audio_provider, stream_mode, playback_mode, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/stream")]
async public Task<ActionResult> Stream(string ticket, string id, string provider, string audio_provider, string quality, string stream_mode, string playback_mode, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc)
{
if (MusicStreamTicketService.TryGet(ticket, out var ticketSource))
{
// тикет жив, но upstream-ссылка внутри могла умереть (403/404/410/416 —
// googlevideo TTL/IP-привязка): не транслируем смерть клиенту, а
// падаем в пере-резолв по fallback-параметрам ниже
var relayed = await MusicStreamRelayService.RelayAsync(HttpContext, ticketSource, failOnUpstreamError: !string.IsNullOrWhiteSpace(id));
if (relayed != null)
return relayed;
}
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
// upstream-ссылка умерла (403/410/416 в relay) — кэш sources для этого
// трека может держать те же мёртвые URL: пере-резолв идёт с refreshSources
var result = await MusicPlaybackService.ResolveTrackAsync(track, audio_provider, stream_mode, playback_mode, profileId, HttpContext.RequestAborted, refreshSources: true);
if (track == null || result?.available != true || result.sources.Count == 0)
return StatusCode(404);
var source = MusicPlaybackService.SelectSource(result.sources, quality, stream_mode);
if (source == null || string.IsNullOrWhiteSpace(source.url))
return StatusCode(404);
return await MusicStreamRelayService.RelayAsync(HttpContext, source);
}
[HttpPost]
[Route("music/history/mark")]
async public Task<ActionResult> MarkHistory(string id, string provider, string title, string artist_name, string album_id, string album_title, int? duration_ms, string date, string isrc, bool count_play = false, long? played_ms = null, string image = null)
{
var track = await MusicPlaybackService.ResolveRequestTrackAsync(id, provider, title, artist_name, album_id, album_title, duration_ms, date, isrc);
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
if (track != null)
{
image = image?.Trim();
if (image?.StartsWith("//", StringComparison.Ordinal) == true)
image = "https:" + image;
if (track.images?.Any(i => !string.IsNullOrWhiteSpace(i?.url)) != true
&& !string.IsNullOrWhiteSpace(image) && image.Length <= 8192
&& Uri.TryCreate(image, UriKind.Absolute, out var imageUri)
&& (imageUri.Scheme == Uri.UriSchemeHttp || imageUri.Scheme == Uri.UriSchemeHttps))
{
// Client artwork belongs to this history entry, not the shared metadata cache.
track = MusicJson.Deserialize<MusicTrack>(MusicJson.Serialize(track));
track.images = new List<MusicImage> { new() { url = imageUri.AbsoluteUri } };
}
await MusicPlaybackHistoryService.SaveAsync(profileId, track);
// статистику инкрементим только по честному прослушиванию:
// mark дёргается и для обновления payload (смена источника),
// без флага это накручивало бы счётчики
if (count_play || played_ms > 0)
await MusicStatsService.RecordPlayAsync(profileId, track, count_play, played_ms, HttpContext.RequestAborted);
}
return ContentTo(MusicJson.Serialize(new
{
saved = track != null,
track_id = id
}));
}
[HttpPost]
[Route("music/history/remove")]
async public Task<ActionResult> RemoveHistory(string id)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
bool removed = await MusicPlaybackHistoryService.RemoveAsync(profileId, id);
return ContentTo(MusicJson.Serialize(new
{
removed,
track_id = id
}));
}
[HttpGet]
[Route("music/playlist.m3u")]
async public Task<ActionResult> Playlist(string ids, string provider, string audio_provider, string stream_mode, string playback_mode, string playlist_strategy)
{
string profileId = MusicProfileIdentity.Resolve(requestInfo, Request);
string playlist = await MusicPlaylistService.BuildAsync(new MusicPlaylistRequest
{
ids = ids,
provider = provider,
audio_provider = audio_provider,
stream_mode = stream_mode,
playback_mode = playback_mode,
playlist_strategy = playlist_strategy,
profile_id = profileId,
host = host,
uid = Request.Query["uid"].FirstOrDefault(),
account_email = Request.Query["account_email"].FirstOrDefault(),
cancellation_token = HttpContext.RequestAborted
});
return Content(playlist, "audio/x-mpegurl; charset=utf-8");
}
}
@@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Mvc;
namespace Music;
public class SearchController : BaseController
{
[HttpGet]
[Route("music/search")]
async public Task<ActionResult> Search(string q, string provider, bool full = false)
{
var result = await MusicCatalogService.SearchAsync(q, provider, full);
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/artist")]
async public Task<ActionResult> Artist(string id, string provider)
{
var result = await MusicCatalogService.GetArtistAsync(id, provider);
if (result == null)
return ContentTo(MusicJson.Serialize(new { available = false, message = "Artist not found." }));
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/artistsection")]
async public Task<ActionResult> ArtistSection(string id, string provider, string page, string artist_name, int limit = 20)
{
var result = await MusicCatalogService.GetArtistSectionAsync(id, provider, page, limit, artist_name);
if (result == null)
return ContentTo(MusicJson.Serialize(new { available = false, message = "Artist section not found." }));
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/artistimg")]
async public Task<ActionResult> ArtistImage(string id, string name, string country, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(name))
return ContentTo(MusicJson.Serialize(new { available = false, id, images = new List<MusicImage>() }));
var artist = new MusicArtist
{
id = id,
name = name,
country = country
};
artist.images = await DiscogsArtistImageService.ResolveImagesAsync(artist, cancellationToken) ?? new List<MusicImage>();
artist = MusicImageProxyService.Apply(this, artist);
return ContentTo(MusicJson.Serialize(new
{
available = artist.images.Count > 0,
id = id,
images = artist.images
}));
}
[HttpGet]
[Route("music/album")]
async public Task<ActionResult> Album(string id, string provider)
{
var result = await MusicCatalogService.GetAlbumAsync(id, provider);
if (result == null)
return ContentTo(MusicJson.Serialize(new { available = false, message = "Album not found." }));
result = MusicImageProxyService.Apply(this, result);
return ContentTo(MusicJson.Serialize(result));
}
[HttpGet]
[Route("music/track")]
async public Task<ActionResult> Track(string id, string provider)
{
var result = await MusicCatalogService.GetTrackAsync(id, provider);
if (result == null)
return ContentTo(MusicJson.Serialize(new { available = false, message = "Track not found." }));
return ContentTo(MusicJson.Serialize(result));
}
}
+11
View File
@@ -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;
+136
View File
@@ -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<WafLimitRootMap> appliedWafRules = new();
public static string modpath;
public static ModuleConf conf;
public void Configure(ConfigureModel app)
{
app.services.AddDbContextFactory<MusicContext>(MusicContext.ConfiguringDbBuilder);
}
public void Loaded(InitspaceModel initspace)
{
modpath = initspace.path;
updateConf();
EventListener.UpdateInitFile += updateConf;
Directory.CreateDirectory("database/music");
MusicContext.Initialization(initspace.app.ApplicationServices);
}
public void Dispose()
{
EventListener.UpdateInitFile -= updateConf;
RemoveWafRules();
}
void updateConf()
{
conf = ModuleInvoke.Init("Music", new ModuleConf()
{
useproxy = false,
useproxystream = false,
globalnameproxy = null,
proxy = null,
default_metadata_provider = "musicbrainz",
default_audio_provider = "youtubeaudio",
default_auth_provider = "",
client_debug_enabled = false,
stats_clear_enabled = false,
daily_reset_enabled = false,
youtube_audio_enabled = true,
spotify_search_fallback_enabled = false,
spotify_discovery_enabled = true,
spotify_country = "us",
sefon_audio_enabled = true,
soundcloud_enabled = true,
soundcloud_discovery_enabled = true,
soundcloud_audio_enabled = true,
soundcloud_auth_enabled = false,
applemusic_country = "us",
applemusic_album_resolver = "auto",
soundcloud_client_id = "",
soundcloud_client_secret = "",
soundcloud_redirect_uri = "",
soundcloud_country = "US",
z3fm_enabled = false,
z3fm_audio_enabled = false,
z3fm_proxy_enabled = false,
z3fm_proxy_url = "",
z3fm_proxy_username = "",
z3fm_proxy_password = "",
limit_map = new List<WafLimitRootMap>()
{
new("^/music", new WafLimitMap { limit = 15, second = 1 })
}
});
ApplyWafRules(conf.limit_map);
MusicProxyService.ConfigurationChanged();
}
static void ApplyWafRules(IReadOnlyCollection<WafLimitRootMap> currentRules)
{
var waf = CoreInit.conf?.WAF;
if (waf == null)
return;
lock (wafRulesLock)
{
var limitMap = waf.limit_map?.ToList() ?? new List<WafLimitRootMap>();
RemoveAppliedWafRules(limitMap);
var nextAppliedRules = new List<WafLimitRootMap>();
foreach (var rule in currentRules ?? Array.Empty<WafLimitRootMap>())
{
if (rule == null || (string.IsNullOrWhiteSpace(rule.path) && string.IsNullOrWhiteSpace(rule.pattern)))
continue;
limitMap.Insert(0, rule);
nextAppliedRules.Add(rule);
}
// WAF может одновременно обслуживать запросы: публикуем новый снимок
// списка одной записью, не меняя коллекцию, которую он перечисляет.
waf.limit_map = limitMap;
appliedWafRules = nextAppliedRules;
}
}
static void RemoveWafRules()
{
lock (wafRulesLock)
{
var waf = CoreInit.conf?.WAF;
if (waf != null)
{
var limitMap = waf.limit_map?.ToList() ?? new List<WafLimitRootMap>();
RemoveAppliedWafRules(limitMap);
waf.limit_map = limitMap;
}
appliedWafRules = new List<WafLimitRootMap>();
}
}
static void RemoveAppliedWafRules(List<WafLimitRootMap> limitMap)
{
if (limitMap == null || appliedWafRules.Count == 0)
return;
limitMap.RemoveAll(existing => appliedWafRules.Any(applied => ReferenceEquals(existing, applied)));
}
}
+309
View File
@@ -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<MusicImage> images { get; set; } = new();
public List<MusicProviderRef> provider_refs { get; set; } = new();
public List<MusicAlbum> albums { get; set; } = new();
public List<MusicBrowseSection> sections { get; set; } = new();
}
public class MusicAlbum
{
public string id { get; set; }
public string title { get; set; }
public string artist_id { get; set; }
public string artist_name { get; set; }
public string lookup_query { get; set; }
public string lookup_provider { get; set; }
public int? year { get; set; }
public string date { get; set; }
public string type { get; set; }
public string release_id { get; set; }
public string description { get; set; }
public int? search_score { get; set; }
public List<string> secondary_types { get; set; } = new();
public List<MusicImage> images { get; set; } = new();
public List<MusicProviderRef> provider_refs { get; set; } = new();
public List<MusicTrack> tracks { get; set; } = new();
}
public class MusicTrack
{
public string id { get; set; }
public string title { get; set; }
public string artist_id { get; set; }
public string artist_name { get; set; }
public List<string> artists { get; set; } = new();
public string album_id { get; set; }
public string album_title { get; set; }
public string isrc { get; set; }
public int? duration_ms { get; set; }
public int? track_number { get; set; }
public int? disc_number { get; set; }
public string date { get; set; }
public int? search_score { get; set; }
public List<MusicImage> images { get; set; } = new();
public List<MusicProviderRef> provider_refs { get; set; } = new();
// добавлено автопродолжением очереди; не влияет на резолв источника
public bool auto_radio { get; set; }
}
public class MusicAudioMatch
{
public string provider_id { get; set; }
public string id { get; set; }
public string title { get; set; }
public List<string> artists { get; set; } = new();
public string album_title { get; set; }
public string isrc { get; set; }
public int? duration_ms { get; set; }
public string payload { get; set; }
// выбран пользователем вручную: обходит эвристики релевантности
// и не перезаписывается авто-подбором
public bool pinned { get; set; }
}
public class MusicLyricsResponse
{
public bool available { get; set; }
public bool retry { get; set; }
public string message { get; set; }
public string source { get; set; }
public string source_mode { get; set; }
public bool synced { get; set; }
public List<MusicLyricsLine> lines { get; set; } = new();
public string plain { get; set; }
}
public class MusicLyricsLine
{
public int time_ms { get; set; }
public string text { get; set; }
}
public class MusicPlaybackSource
{
public string provider_id { get; set; }
public string url { get; set; }
public string external_url { get; set; }
public string mime_type { get; set; }
public int? bitrate { get; set; }
public string quality { get; set; }
public Dictionary<string, string> headers { get; set; } = new();
[JsonIgnore]
public string proxy_url { get; set; }
[JsonIgnore]
public string proxy_username { get; set; }
[JsonIgnore]
public string proxy_password { get; set; }
[JsonIgnore]
public string proxy_scope { get; set; }
}
public class MusicAuthState
{
public string provider_id { get; set; }
public string provider_name { get; set; }
public bool authenticated { get; set; }
public bool requires_auth { get; set; }
public bool token_ready { get; set; }
public bool web_ready { get; set; }
public string mode { get; set; }
public string message { get; set; }
}
public class YandexMusicCredentials
{
public string ym_token { get; set; }
public string ym_session_id { get; set; }
public string ym_sessionid2 { get; set; }
public string ym_yandexuid { get; set; }
public string ym_yandex_login { get; set; }
public string ym_l_token { get; set; }
}
public class YandexMusicMatchPayload
{
public string track_id { get; set; }
public string album_id { get; set; }
public string album_title { get; set; }
public int? bitrate_kbps { get; set; }
public string codec { get; set; }
}
public class MusicProviderDescriptor
{
public string id { get; set; }
public string name { get; set; }
public string type { get; set; }
public bool enabled { get; set; }
public bool requires_auth { get; set; }
public List<string> capabilities { get; set; } = new();
}
public class MusicSection
{
public string id { get; set; }
public string title { get; set; }
public string endpoint { get; set; }
public string type { get; set; }
}
public class MusicBrowseSection
{
public string id { get; set; }
public string title { get; set; }
public string type { get; set; }
public string source_provider { get; set; }
public bool has_more { get; set; }
public string next_page { get; set; }
public List<MusicAlbum> albums { get; set; } = new();
public List<MusicArtist> artists { get; set; } = new();
public List<MusicTrack> tracks { get; set; } = new();
}
public class MusicSearchResult
{
public List<MusicArtist> artists { get; set; } = new();
public List<MusicAlbum> albums { get; set; } = new();
public List<MusicTrack> tracks { get; set; } = new();
}
public class MusicSearchResponse
{
public string query { get; set; }
public string status { get; set; }
public string metadata_provider { get; set; }
public bool metadata_pending { get; set; }
public List<string> audio_providers { get; set; } = new();
public List<MusicArtist> artists { get; set; } = new();
public List<MusicAlbum> albums { get; set; } = new();
public List<MusicTrack> tracks { get; set; } = new();
public List<MusicBrowseSection> search_sections { get; set; } = new();
}
public class MusicRecentlyPlayedItem
{
public MusicTrack track { get; set; }
public DateTime played_at { get; set; }
}
public class MusicUserPlaylistSummary
{
public string id { get; set; }
public string title { get; set; }
public int track_count { get; set; }
public List<MusicImage> images { get; set; } = new();
public MusicUserPlaylistSource source { get; set; }
}
public class MusicUserPlaylistSource
{
public string type { get; set; }
public string url { get; set; }
public string user_id { get; set; }
public string playlist_id { get; set; }
public string title { get; set; }
}
public class MusicUserPlaylistImportResult
{
public bool available { get; set; }
public string message { get; set; }
public string playlist_id { get; set; }
public string title { get; set; }
public int track_count { get; set; }
public bool truncated { get; set; }
public List<MusicTrack> tracks { get; set; } = new();
public MusicUserPlaylistSource source { get; set; }
}
public class MusicHomeResponse
{
public string title { get; set; }
public string status { get; set; }
public string version { get; set; }
public List<MusicSection> sections { get; set; } = new();
public List<MusicProviderDescriptor> metadata_providers { get; set; } = new();
public List<MusicProviderDescriptor> audio_providers { get; set; } = new();
public List<MusicProviderDescriptor> auth_providers { get; set; } = new();
public List<MusicRecentlyPlayedItem> recently_played { get; set; } = new();
public List<MusicUserPlaylistSummary> user_playlists { get; set; } = new();
public List<MusicDailyMixSummary> daily_mixes { get; set; } = new();
public List<MusicBrowseSection> browse_sections { get; set; } = new();
public bool browse_sections_warming { get; set; }
}
public class MusicPlayResponse
{
public bool available { get; set; }
public string reason { get; set; }
public string message { get; set; }
public string track_id { get; set; }
public MusicAudioMatch selected_match { get; set; }
public List<MusicPlaybackSource> sources { get; set; } = new();
}
public class MusicMatchesResponse
{
public bool available { get; set; }
public string message { get; set; }
public string track_id { get; set; }
public MusicAudioMatch selected_match { get; set; }
public List<MusicAudioMatch> matches { get; set; } = new();
}
public class MusicDailyMixSummary
{
public int day { get; set; }
public string title { get; set; }
public bool today { get; set; }
public List<string> artists { get; set; } = new();
}
public class MusicDailyMixResponse
{
public bool available { get; set; }
public string message { get; set; }
public int day { get; set; }
public string title { get; set; }
public List<string> artists { get; set; } = new();
public List<MusicTrack> tracks { get; set; } = new();
}
public class MusicRadioRequest
{
public List<MusicTrack> seeds { get; set; } = new();
public List<MusicTrack> exclude { get; set; } = new();
}
public class MusicRadioResponse
{
public bool available { get; set; }
public string message { get; set; }
public List<MusicTrack> tracks { get; set; } = new();
}
+39
View File
@@ -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);
}
}
+78
View File
@@ -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<WafLimitRootMap> limit_map { get; set; } = new();
}
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="plugin.js">
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project>
@@ -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<MusicSearchResult> SearchAsync(string query, bool expanded = false, CancellationToken cancellationToken = default);
Task<MusicArtist> GetArtistAsync(string id, CancellationToken cancellationToken = default);
Task<MusicAlbum> GetAlbumAsync(string id, CancellationToken cancellationToken = default);
Task<MusicTrack> GetTrackAsync(string id, CancellationToken cancellationToken = default);
}
public interface IMusicDiscoveryProvider
{
string Id { get; }
string Name { get; }
bool Enabled { get; }
Task<List<MusicBrowseSection>> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default);
Task<MusicBrowseSection> GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default);
}
public interface IMusicAudioProvider
{
string Id { get; }
string Name { get; }
bool Enabled { get; }
bool RequiresAuth { get; }
bool CacheMissingMatches { get; }
Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default);
Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default);
bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match);
bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match);
IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track);
}
public interface IMusicAuthProvider
{
string Id { get; }
string Name { get; }
bool Enabled { get; }
Task<MusicAuthState> GetStateAsync(string profileId = null, CancellationToken cancellationToken = default);
Task<bool> SaveAsync(string payload, string profileId = null, CancellationToken cancellationToken = default);
Task LogoutAsync(string profileId = null, CancellationToken cancellationToken = default);
}
@@ -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<MusicAlbum> GetCatalogAlbumAsync(string id, CancellationToken cancellationToken = default)
{
var match = catalogAlbumIdRegex.Match(id ?? string.Empty);
return match.Success
? GetCatalogAlbumAsync(match.Groups[1].Value, match.Groups[2].Value, cancellationToken)
: Task.FromResult<MusicAlbum>(null);
}
public static async Task<MusicAlbum> GetCatalogAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken = default)
{
try
{
return (await LoadCatalogAlbumAsync(storefront, albumId, cancellationToken)).album;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
public static async Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(string inputUrl, CancellationToken cancellationToken = default)
{
var entity = ParseEntity(inputUrl);
if (entity == null)
{
if (IsProfileUrl(inputUrl))
return ImportUnavailable("Apple Music профиль не импортируется. Вставь ссылку на конкретный публичный плейлист или альбом.");
return ImportUnavailable("Вставь ссылку на Apple Music плейлист или альбом.");
}
// прогрев кэша токена + дружелюбная ошибка, если Apple недоступен
string token = await GetDeveloperTokenAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(token))
return ImportUnavailable("Не удалось получить Apple Music токен.");
try
{
return entity.Value.type == "album"
? await ImportAlbumAsync(entity.Value.storefront, entity.Value.id, cancellationToken)
: await ImportPlaylistByIdAsync(entity.Value.storefront, entity.Value.id, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
Console.WriteLine($"[Music] apple music import failed: {ex.Message}");
return ImportUnavailable("Apple Music не ответил, попробуй ещё раз.");
}
}
public static Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
return Task.FromResult(ImportUnavailable("У плейлиста нет Apple Music источника."));
return ImportPlaylistAsync(source.url, cancellationToken);
}
static (string storefront, string type, string id)? ParseEntity(string value)
{
value = value?.Trim();
if (string.IsNullOrWhiteSpace(value))
return null;
var match = entityUrlRegex.Match(value);
if (!match.Success)
return null;
string type = match.Groups[2].Value.ToLowerInvariant();
string id = match.Groups[3].Value;
// альбомная ссылка с плейлистным id (и наоборот) — мусор, не берём
bool playlistId = id.StartsWith("pl.", StringComparison.OrdinalIgnoreCase);
if (type == "playlist" != playlistId)
return null;
return (match.Groups[1].Value.ToLowerInvariant(), type, id);
}
static bool IsAppleMusicUrl(string value)
{
if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri))
return false;
return string.Equals(uri.Host, "music.apple.com", StringComparison.OrdinalIgnoreCase);
}
static bool IsProfileUrl(string value)
{
if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri))
return false;
return string.Equals(uri.Host, "music.apple.com", StringComparison.OrdinalIgnoreCase)
&& uri.AbsolutePath.StartsWith("/profile/", StringComparison.OrdinalIgnoreCase);
}
// Анонимный developer-JWT лежит в бандле /assets/index~*.js главной страницы;
// Apple выпускает его на ~месяц — кэшируем сутки и сбрасываем на 401.
static async Task<string> GetDeveloperTokenAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(developerToken) && developerTokenExpiresAt > DateTime.UtcNow)
return developerToken;
await developerTokenLock.WaitAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(developerToken) && developerTokenExpiresAt > DateTime.UtcNow)
return developerToken;
string html = await FetchTextAsync("https://music.apple.com/us/browse", "text/html,application/xhtml+xml", cancellationToken);
var bundleMatch = assetBundleRegex.Match(html ?? string.Empty);
if (!bundleMatch.Success)
return null;
string script = await FetchTextAsync("https://music.apple.com" + bundleMatch.Groups[1].Value, "application/javascript,*/*", cancellationToken);
var tokenMatch = developerTokenRegex.Match(script ?? string.Empty);
if (!tokenMatch.Success)
return null;
developerToken = tokenMatch.Groups[1].Value;
developerTokenExpiresAt = DateTime.UtcNow.AddHours(24);
return developerToken;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
finally
{
developerTokenLock.Release();
}
}
static void InvalidateDeveloperToken()
{
developerToken = null;
developerTokenExpiresAt = DateTime.MinValue;
}
static async Task<string> FetchTextAsync(string url, string accept, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", accept);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
return await response.Content.ReadAsStringAsync(cancellationToken);
}
// сам берёт токен из кэша; на 401 сбрасывает его и повторяет запрос
// один раз со свежим — протухший developer-токен не роняет импорт/sync
static async Task<JsonElement?> ApiAsync(string path, CancellationToken cancellationToken)
{
for (int attempt = 0; attempt < 2; attempt++)
{
string token = await GetDeveloperTokenAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(token))
return null;
using var request = new HttpRequestMessage(HttpMethod.Get, ApiBaseUrl + path);
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
request.Headers.TryAddWithoutValidation("Origin", "https://music.apple.com");
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "application/json");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
InvalidateDeveloperToken();
Console.WriteLine("[Music] apple music developer token expired");
continue;
}
if (!response.IsSuccessStatusCode)
return null;
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
return null;
}
static async Task<MusicUserPlaylistImportResult> ImportPlaylistByIdAsync(string storefront, string playlistId, CancellationToken cancellationToken)
{
// мета плейлиста ради имени; треки из relationships не берём —
// единообразно листаем /tracks с нуля (одна лишняя страница, зато один путь)
var meta = await ApiAsync($"/v1/catalog/{storefront}/playlists/{Uri.EscapeDataString(playlistId)}", cancellationToken);
var playlist = FirstDataElement(meta);
if (playlist == null)
return ImportUnavailable("Apple Music плейлист не найден или приватный.");
string title = GetString(GetProperty(playlist.Value, "attributes") ?? default, "name");
var tracks = new List<MusicTrack>();
for (int page = 0; page < MaxPages && tracks.Count < MaxImportTracks; page++)
{
var root = await ApiAsync($"/v1/catalog/{storefront}/playlists/{Uri.EscapeDataString(playlistId)}/tracks?limit={PageLimit}&offset={page * PageLimit}", cancellationToken);
if (root == null)
{
// страница не отдалась — импорт атомарный, усечёнку не сохраняем
return page == 0
? ImportUnavailable("Apple Music плейлист не удалось прочитать.")
: ImportUnavailable("Apple Music не отдал плейлист целиком, попробуй ещё раз.");
}
int pageItems = MapTrackPage(root.Value, tracks, albumImagesFallback: null, albumTitleFallback: null, dateFallback: null);
bool hasNext = GetProperty(root.Value, "next") != null;
if (pageItems == 0 || !hasNext)
break;
}
tracks = DeduplicateTracks(tracks);
if (tracks.Count == 0)
return ImportUnavailable("В Apple Music плейлисте не найдено треков.");
title = string.IsNullOrWhiteSpace(title) ? "Apple Music Playlist" : title.Trim();
return new MusicUserPlaylistImportResult
{
available = true,
title = title,
track_count = tracks.Count,
truncated = tracks.Count >= MaxImportTracks,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = PlaylistSourceType,
url = $"https://music.apple.com/{storefront}/playlist/_/{playlistId}",
playlist_id = playlistId,
title = title
}
};
}
static async Task<MusicUserPlaylistImportResult> ImportAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken)
{
var loaded = await LoadCatalogAlbumAsync(storefront, albumId, cancellationToken);
var album = loaded.album;
if (album == null)
return ImportUnavailable(loaded.error ?? "Apple Music альбом не найден.");
string playlistTitle = string.IsNullOrWhiteSpace(album.artist_name) ? album.title : $"{album.artist_name} — {album.title}";
return new MusicUserPlaylistImportResult
{
available = true,
title = playlistTitle,
track_count = album.tracks.Count,
tracks = album.tracks,
source = new MusicUserPlaylistSource
{
type = AlbumSourceType,
url = $"https://music.apple.com/{storefront}/album/_/{albumId}",
playlist_id = albumId,
title = playlistTitle
}
};
}
static async Task<(MusicAlbum album, string error)> LoadCatalogAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken)
{
storefront = storefront?.Trim().ToLowerInvariant();
albumId = albumId?.Trim();
if (!Regex.IsMatch(storefront ?? string.Empty, "^[a-z]{2}$") || !Regex.IsMatch(albumId ?? string.Empty, "^[0-9]+$"))
return (null, "Некорректный Apple Music album ID.");
var meta = await ApiAsync($"/v1/catalog/{storefront}/albums/{Uri.EscapeDataString(albumId)}", cancellationToken);
var albumElement = FirstDataElement(meta);
if (albumElement == null)
return (null, "Apple Music альбом не найден.");
var attributes = GetProperty(albumElement.Value, "attributes");
string title = attributes != null ? GetString(attributes.Value, "name") : null;
string artistName = attributes != null ? GetString(attributes.Value, "artistName") : null;
string date = attributes != null ? GetString(attributes.Value, "releaseDate") : null;
var albumImages = attributes != null ? MapArtwork(GetProperty(attributes.Value, "artwork")) : null;
var tracks = new List<MusicTrack>();
int fetched = 0;
// первая порция треков приезжает в relationships, остальное — пагинацией;
// offset считаем по сырому числу элементов (не по замапленным — часть фильтруется)
var relationshipTracks = GetProperty(albumElement.Value, "relationships", "tracks");
bool hasNext = false;
if (relationshipTracks != null)
{
fetched += MapTrackPage(relationshipTracks.Value, tracks, albumImages, title, date);
hasNext = GetProperty(relationshipTracks.Value, "next") != null;
}
for (int page = 1; hasNext && page < MaxPages && tracks.Count < MaxImportTracks; page++)
{
var root = await ApiAsync($"/v1/catalog/{storefront}/albums/{Uri.EscapeDataString(albumId)}/tracks?limit={PageLimit}&offset={fetched}", cancellationToken);
if (root == null)
return (null, "Apple Music не отдал альбом целиком, попробуй ещё раз.");
int pageItems = MapTrackPage(root.Value, tracks, albumImages, title, date);
fetched += pageItems;
hasNext = GetProperty(root.Value, "next") != null && pageItems > 0;
}
tracks = DeduplicateTracks(tracks);
if (tracks.Count == 0)
return (null, "В Apple Music альбоме не найдено треков.");
title = string.IsNullOrWhiteSpace(title) ? "Apple Music Album" : title.Trim();
string canonicalId = $"applemusic:album:{storefront}:{albumId}";
int? year = date?.Length >= 4 && int.TryParse(date[..4], out int parsedYear) ? parsedYear : null;
foreach (var track in tracks)
track.album_id = canonicalId;
return (new MusicAlbum
{
title = title,
id = canonicalId,
artist_name = string.IsNullOrWhiteSpace(artistName) ? "Apple Music" : artistName.Trim(),
date = date,
year = year,
type = "Album",
images = albumImages ?? new List<MusicImage>(),
tracks = tracks,
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = albumId }
}
}, null);
}
// маппит data[] страницы (плейлист-треки или альбом-треки); возвращает СЫРОЕ
// число элементов страницы — по нему считается offset (часть элементов фильтруется)
static int MapTrackPage(JsonElement root, List<MusicTrack> tracks, List<MusicImage> albumImagesFallback, string albumTitleFallback, string dateFallback)
{
if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
return 0;
int rawCount = 0;
foreach (var item in data.EnumerateArray())
{
rawCount++;
// в плейлистах бывают music-videos — берём только песни
string kind = GetString(item, "type");
if (!string.IsNullOrWhiteSpace(kind) && !string.Equals(kind, "songs", StringComparison.OrdinalIgnoreCase))
continue;
var mapped = MapTrackElement(item, albumImagesFallback, albumTitleFallback, dateFallback);
if (mapped != null)
tracks.Add(mapped);
}
return rawCount;
}
static MusicTrack MapTrackElement(JsonElement item, List<MusicImage> albumImagesFallback, string albumTitleFallback, string dateFallback)
{
var attributes = GetProperty(item, "attributes");
if (attributes == null)
return null;
string title = GetString(attributes.Value, "name")?.Trim();
string trackId = GetString(item, "id");
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(trackId))
return null;
string artistName = GetString(attributes.Value, "artistName")?.Trim();
var images = MapArtwork(GetProperty(attributes.Value, "artwork"));
if (images.Count == 0 && albumImagesFallback != null)
images = albumImagesFallback.ToList();
return new MusicTrack
{
id = $"applemusic:track:{trackId}",
title = title,
artist_name = string.IsNullOrWhiteSpace(artistName) ? "Apple Music" : artistName,
artists = string.IsNullOrWhiteSpace(artistName) ? new List<string>() : new List<string> { artistName },
album_title = GetString(attributes.Value, "albumName")?.Trim() ?? albumTitleFallback,
isrc = MusicIsrc.Normalize(GetString(attributes.Value, "isrc")),
duration_ms = GetInt(attributes.Value, "durationInMillis"),
track_number = GetInt(attributes.Value, "trackNumber"),
disc_number = GetInt(attributes.Value, "discNumber"),
date = GetString(attributes.Value, "releaseDate") ?? dateFallback,
images = images,
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = trackId }
}
};
}
// artwork.url — шаблон с {w}x{h}; просим два размера, крупный первым
static List<MusicImage> MapArtwork(JsonElement? artwork)
{
var result = new List<MusicImage>();
string template = artwork != null ? GetString(artwork.Value, "url") : null;
if (string.IsNullOrWhiteSpace(template) || !template.Contains("{w}", StringComparison.OrdinalIgnoreCase))
return result;
foreach (int size in new[] { 600, 300 })
{
result.Add(new MusicImage
{
url = template.Replace("{w}", size.ToString()).Replace("{h}", size.ToString()),
width = size,
height = size
});
}
return result;
}
static JsonElement? FirstDataElement(JsonElement? root)
{
if (root == null || !root.Value.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array || data.GetArrayLength() == 0)
return null;
return data[0];
}
}
@@ -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<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (track == null || MusicPlaybackModeService.IsVideo(playbackMode))
return Array.Empty<MusicAudioMatch>();
try
{
return await SefonSupport.SearchAsync(track, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new MusicAudioTransientException(Id, "match", ex);
}
}
public Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (match == null || MusicPlaybackModeService.IsVideo(playbackMode))
return Task.FromResult<IReadOnlyList<MusicPlaybackSource>>(Array.Empty<MusicPlaybackSource>());
return Task.FromResult(SefonSupport.BuildSources(match));
}
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
return Task.FromResult<MusicPlaybackSource>(null);
}
public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
}
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
{
return SefonSupport.IsRelevantMatch(track, match);
}
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
{
return false;
}
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
{
return Array.Empty<string>();
}
}
@@ -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<List<MusicAudioMatch>> SearchAsync(MusicTrack track, CancellationToken cancellationToken = default)
{
var queries = BuildTrackQueries(track).Take(MaxSearchQueries).ToList();
if (queries.Count == 0)
return new List<MusicAudioMatch>();
var merged = new Dictionary<string, MusicAudioMatch>(StringComparer.OrdinalIgnoreCase);
var tasks = queries
.Select(query => SearchQueryResultAsync(query, cancellationToken))
.ToList();
var batches = await Task.WhenAll(tasks);
foreach (var batch in batches)
{
foreach (var match in batch.matches)
{
if (match == null || string.IsNullOrWhiteSpace(match.id))
continue;
merged[match.id] = match;
}
}
var ranked = RankMatches(track, merged.Values)
.Where(match => IsRelevantMatch(track, match))
.Take(5)
.ToList();
if (ranked.Count == 0)
{
var failure = batches.FirstOrDefault(i => i.error != null).error;
if (failure != null)
throw failure;
}
return ranked;
}
public static async Task<List<MusicTrack>> SearchTracksByQueryAsync(string query, int limit = 10, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
return new List<MusicTrack>();
var matches = await SearchQuerySafeAsync(query.Trim(), cancellationToken);
if (matches.Count == 0)
return new List<MusicTrack>();
return matches
.Select(match => new { match, score = ScoreLooseQueryMatch(query, match) })
.Where(item => item.score > 0)
.OrderByDescending(item => item.score)
.ThenBy(item => item.match.title)
.Take(Math.Max(1, limit))
.Select(item => MapTrack(item.match))
.Where(track => track != null)
.ToList();
}
public static bool IsRelevantMatch(MusicTrack query, MusicAudioMatch match)
{
if (query == null || match == null)
return false;
if (ScoreMatch(query, match) < 10)
return false;
if (!HasArtistOverlap(query, match))
return false;
if (query.duration_ms.HasValue && match.duration_ms.HasValue)
{
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
if (diff > 90000)
return false;
}
return true;
}
static async Task<List<MusicAudioMatch>> SearchQueryAsync(string query, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(query))
return new List<MusicAudioMatch>();
string url = $"{BaseUrl}/song/{Uri.EscapeDataString(query)}";
using var response = await httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (MusicHttp.IsTransientFailureStatus(response.StatusCode))
throw new HttpRequestException($"Sefon returned {(int)response.StatusCode}.", null, response.StatusCode);
return new List<MusicAudioMatch>();
}
string html = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(html))
return new List<MusicAudioMatch>();
var nodes = HtmlParse.Nodes(html, "//div[contains(concat(' ', normalize-space(@class), ' '), ' muzmo-track ') and contains(concat(' ', normalize-space(@class), ' '), ' track-item ')]");
if (nodes.Count == 0)
return new List<MusicAudioMatch>();
var matches = new List<MusicAudioMatch>();
foreach (var node in nodes)
{
string streamPath = node.SelectText(string.Empty, "data-file");
string title = WebUtility.HtmlDecode(node.SelectText(string.Empty, "data-track-title") ?? string.Empty).Trim();
string artist = WebUtility.HtmlDecode(node.SelectText(string.Empty, "data-artist") ?? string.Empty).Trim();
string trackId = node.SelectText(string.Empty, "data-tid");
string trackUrl = node.SelectText(".//a[contains(concat(' ', normalize-space(@class), ' '), ' muzmo-track__title ')]", "href");
string albumTitle = CleanAlbumTitle(node.SelectText(".//span[contains(concat(' ', normalize-space(@class), ' '), ' short-track__info-album ')]"));
int? durationMs = ParseDurationMs(node.SelectText(".//span[contains(concat(' ', normalize-space(@class), ' '), ' short-track__time ')]"));
if (string.IsNullOrWhiteSpace(streamPath) || string.IsNullOrWhiteSpace(title))
continue;
string streamUrl = BuildAbsoluteUrl(streamPath);
if (string.IsNullOrWhiteSpace(streamUrl))
continue;
string directStreamUrl = TryDecodeDirectStreamUrl(streamUrl);
matches.Add(new MusicAudioMatch
{
provider_id = ProviderId,
id = !string.IsNullOrWhiteSpace(trackId) ? trackId : streamUrl,
title = NormalizeMatchTitle(title, artist),
artists = string.IsNullOrWhiteSpace(artist) ? new List<string>() : new List<string> { artist },
album_title = albumTitle,
duration_ms = durationMs,
payload = MusicJson.Serialize(new SefonMatchPayload
{
stream_url = streamUrl,
stream_direct_url = directStreamUrl,
track_url = BuildAbsoluteUrl(trackUrl),
referer = $"{BaseUrl}/"
})
});
}
return matches;
}
static async Task<List<MusicAudioMatch>> SearchQuerySafeAsync(string query, CancellationToken cancellationToken)
{
try
{
return await SearchQueryAsync(query, cancellationToken);
}
catch
{
return new List<MusicAudioMatch>();
}
}
static async Task<(List<MusicAudioMatch> matches, Exception error)> SearchQueryResultAsync(string query, CancellationToken cancellationToken)
{
try
{
return (await SearchQueryAsync(query, cancellationToken), null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return (new List<MusicAudioMatch>(), ex);
}
}
public static IReadOnlyList<MusicPlaybackSource> BuildSources(MusicAudioMatch match)
{
var payload = string.IsNullOrWhiteSpace(match?.payload)
? null
: MusicJson.Deserialize<SefonMatchPayload>(match.payload);
string sourceUrl = !string.IsNullOrWhiteSpace(payload?.stream_direct_url)
? payload.stream_direct_url
: payload?.stream_url;
if (string.IsNullOrWhiteSpace(sourceUrl))
return Array.Empty<MusicPlaybackSource>();
var headers = new Dictionary<string, string>();
if (string.IsNullOrWhiteSpace(payload?.stream_direct_url))
{
headers["Referer"] = string.IsNullOrWhiteSpace(payload.referer) ? $"{BaseUrl}/" : payload.referer;
headers["Origin"] = BaseUrl;
}
return new List<MusicPlaybackSource>
{
new()
{
provider_id = ProviderId,
url = sourceUrl,
mime_type = "audio/mpeg",
quality = "MP3",
headers = headers
}
};
}
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches)
{
return matches
.Select(match => new { match, score = ScoreMatch(query, match) })
.Where(i => i.score > -18)
.OrderByDescending(i => i.score)
.ThenBy(i => i.match.title)
.Select(i => i.match)
.ToList();
}
static int ScoreMatch(MusicTrack query, MusicAudioMatch match)
{
string title = NormalizeText(match?.title);
string album = NormalizeText(match?.album_title);
string artist = NormalizeText(match?.artists.FirstOrDefault());
string lowered = $"{title} {artist} {album}";
string queryAlbum = NormalizeText(query?.album_title);
var queryTitles = BuildNormalizedTitleVariants(query?.title);
var queryArtists = NormalizeArtistFragments(query?.artist_name);
string queryPrimaryArtist = queryArtists.FirstOrDefault() ?? string.Empty;
var matchArtists = NormalizeArtistFragments(match?.artists.FirstOrDefault());
int score = 0;
if (queryTitles.Count > 0)
{
if (queryTitles.Any(i => title == i))
score += 16;
else if (queryTitles.Any(i => !string.IsNullOrWhiteSpace(i) && title.Contains(i)))
score += 10;
else if (queryTitles.Any(i => AllTokensPresent(i, title)))
score += 5;
else
score -= 18;
}
if (!string.IsNullOrWhiteSpace(queryPrimaryArtist))
{
if (matchArtists.Contains(queryPrimaryArtist) || artist == queryPrimaryArtist)
score += 12;
else if (artist.Contains(queryPrimaryArtist))
score += 7;
else
score -= 12;
if (matchArtists.FirstOrDefault() == queryPrimaryArtist)
score += 4;
}
foreach (var queryArtist in queryArtists.Skip(1).Take(3))
{
if (matchArtists.Contains(queryArtist) || artist.Contains(queryArtist))
score += 3;
}
if (!string.IsNullOrWhiteSpace(queryAlbum) && !string.IsNullOrWhiteSpace(album))
{
if (album == queryAlbum)
score += 3;
else if (album.Contains(queryAlbum) || queryAlbum.Contains(album))
score += 1;
}
if (query?.duration_ms.HasValue == true && query.duration_ms.Value > 0 && match?.duration_ms.HasValue == true)
{
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
if (diff <= 3000)
score += 8;
else if (diff <= 10000)
score += 5;
else if (diff <= 20000)
score += 2;
else if (diff > 120000)
score -= 20;
else if (diff > 60000)
score -= 12;
else if (diff > 30000)
score -= 6;
}
else if (match?.duration_ms.HasValue == true && match.duration_ms.Value > 0 && !IsShortTrackQuery(queryTitles))
{
if (match.duration_ms.Value < 90000)
score -= 14;
else if (match.duration_ms.Value < 120000)
score -= 8;
}
if (HardPenaltyWords.Any(i => lowered.Contains(i)))
score -= 18;
if (SoftPenaltyWords.Any(i => lowered.Contains(i)))
score -= 6;
if (AlbumNoiseWords.Any(i => album.Contains(i)))
score -= 4;
if (artist.Contains("www") || artist.Contains(".ru") || artist.Contains(".com"))
score -= 4;
if (title.Length > 140)
score -= 3;
return score;
}
static int ScoreLooseQueryMatch(string query, MusicAudioMatch match)
{
string expected = NormalizeText(query);
if (string.IsNullOrWhiteSpace(expected) || match == null)
return 0;
string title = NormalizeText(match.title);
string artist = NormalizeText(match.artists.FirstOrDefault());
string album = NormalizeText(match.album_title);
string joined = $"{artist} {title} {album}".Trim();
int score = 0;
if (joined == expected)
score += 80;
else if (!string.IsNullOrWhiteSpace(title) && title == expected)
score += 70;
else if (!string.IsNullOrWhiteSpace(joined) && joined.Contains(expected))
score += 50;
else if (AllTokensPresent(expected, joined))
score += 35;
if (!string.IsNullOrWhiteSpace(title) && title.Contains(expected))
score += 10;
if (HardPenaltyWords.Any(i => title.Contains(i)) || HardPenaltyWords.Any(i => album.Contains(i)))
score -= 40;
if (SoftPenaltyWords.Any(i => title.Contains(i)) || SoftPenaltyWords.Any(i => album.Contains(i)))
score -= 18;
return score;
}
static MusicTrack MapTrack(MusicAudioMatch match)
{
if (match == null || string.IsNullOrWhiteSpace(match.title))
return null;
string artistName = match.artists?.FirstOrDefault() ?? string.Empty;
return new MusicTrack
{
id = $"sefon:search:{match.id}",
title = match.title,
artist_name = artistName,
artists = string.IsNullOrWhiteSpace(artistName) ? new List<string>() : new List<string> { artistName },
album_title = match.album_title,
duration_ms = match.duration_ms
};
}
static bool HasArtistOverlap(MusicTrack query, MusicAudioMatch match)
{
var queryArtists = NormalizeArtistFragments(query?.artist_name);
if (queryArtists.Count == 0)
return true;
var matchArtists = NormalizeArtistFragments(match?.artists.FirstOrDefault());
string normalizedArtist = NormalizeText(match?.artists.FirstOrDefault());
foreach (var queryArtist in queryArtists)
{
if (string.IsNullOrWhiteSpace(queryArtist))
continue;
if (matchArtists.Contains(queryArtist) || normalizedArtist.Contains(queryArtist))
return true;
}
return false;
}
static string BuildAbsoluteUrl(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
value = WebUtility.HtmlDecode(value.Trim());
if (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return value;
if (!value.StartsWith("/"))
value = "/" + value;
return BaseUrl + value;
}
static string TryDecodeDirectStreamUrl(string streamUrl)
{
if (string.IsNullOrWhiteSpace(streamUrl))
return null;
const string marker = "/stream/mym/";
int markerIndex = streamUrl.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0)
return null;
string encoded = streamUrl[(markerIndex + marker.Length)..].Trim();
if (string.IsNullOrWhiteSpace(encoded))
return null;
try
{
string padded = encoded.Replace('-', '+').Replace('_', '/');
int mod = padded.Length % 4;
if (mod > 0)
padded = padded.PadRight(padded.Length + (4 - mod), '=');
string decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(padded));
if (Uri.TryCreate(decoded, UriKind.Absolute, out var uri) &&
(string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
{
return uri.ToString();
}
}
catch
{
}
return null;
}
static string CleanAlbumTitle(string value)
{
value = WebUtility.HtmlDecode(value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(value))
return null;
if (string.Equals(value, "Not entered tracks", StringComparison.OrdinalIgnoreCase))
return null;
return value.Trim().Trim('/');
}
static string NormalizeMatchTitle(string title, string artist)
{
title = WebUtility.HtmlDecode(title ?? string.Empty).Trim();
artist = WebUtility.HtmlDecode(artist ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(title))
return title;
if (string.IsNullOrWhiteSpace(artist))
return title;
string normalizedTitle = NormalizeText(title);
string normalizedArtist = NormalizeText(artist);
if (string.IsNullOrWhiteSpace(normalizedTitle) || string.IsNullOrWhiteSpace(normalizedArtist))
return title;
if (normalizedTitle == normalizedArtist)
return title;
var prefixPattern = $"^{Regex.Escape(artist)}\\s*[-–—:]\\s*";
return Regex.Replace(title, prefixPattern, string.Empty, RegexOptions.IgnoreCase).Trim();
}
static int? ParseDurationMs(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var parts = value.Trim().Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 2 &&
int.TryParse(parts[0], out int minutes) &&
int.TryParse(parts[1], out int seconds))
{
return ((minutes * 60) + seconds) * 1000;
}
if (parts.Length == 3 &&
int.TryParse(parts[0], out int hours) &&
int.TryParse(parts[1], out minutes) &&
int.TryParse(parts[2], out seconds))
{
return ((hours * 3600) + (minutes * 60) + seconds) * 1000;
}
return null;
}
static bool AllTokensPresent(string query, string value)
{
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(value))
return false;
var tokens = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return tokens.Length > 0 && tokens.All(value.Contains);
}
static List<string> BuildTrackQueries(MusicTrack track)
{
var queries = new List<string>();
string title = CleanQueryValue(track?.title);
string titlePrimary = CleanQueryValue(StripTitleDecorations(track?.title));
string artistPrimary = CleanQueryValue(ExtractPrimaryArtist(track?.artist_name));
string artistFull = CleanQueryValue(track?.artist_name);
void AddQuery(string artist, string trackTitle)
{
var parts = new[] { artist, trackTitle }
.Where(i => !string.IsNullOrWhiteSpace(i))
.ToArray();
if (parts.Length == 0)
return;
string value = string.Join(" ", parts).Trim();
if (!string.IsNullOrWhiteSpace(value) && queries.All(i => !string.Equals(i, value, StringComparison.OrdinalIgnoreCase)))
queries.Add(value);
}
AddQuery(artistPrimary, titlePrimary);
AddQuery(artistFull, titlePrimary);
if (!string.Equals(titlePrimary, title, StringComparison.OrdinalIgnoreCase))
{
AddQuery(artistPrimary, title);
AddQuery(artistFull, title);
}
AddQuery(string.Empty, titlePrimary);
return queries;
}
static List<string> BuildNormalizedTitleVariants(string value)
{
var variants = new List<string>();
void Add(string next)
{
next = NormalizeText(next);
if (!string.IsNullOrWhiteSpace(next) && variants.All(i => i != next))
variants.Add(next);
}
Add(value);
Add(StripTitleDecorations(value));
return variants;
}
static bool IsShortTrackQuery(List<string> normalizedTitles)
{
return normalizedTitles.Any(i =>
i.Contains("intro")
|| i.Contains("outro")
|| i.Contains("interlude")
|| i.Contains("skit")
|| i.Contains("snippet"));
}
static List<string> NormalizeArtistFragments(string value)
{
if (string.IsNullOrWhiteSpace(value))
return new List<string>();
string normalized = NormalizeText(value);
normalized = Regex.Replace(normalized, @"\b(feat|ft|featuring|prod|with|and|x|vs|и)\b", "|");
normalized = Regex.Replace(normalized, @"\s+-\s+", "|");
normalized = normalized.Replace("&", "|").Replace(",", "|");
return normalized
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(CleanQueryValue)
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
static string ExtractPrimaryArtist(string value)
{
return NormalizeArtistFragments(value).FirstOrDefault() ?? value;
}
static string StripTitleDecorations(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = WebUtility.HtmlDecode(value).Trim();
value = Regex.Replace(value, @"\(([^)]*(feat|ft|live|remix|version|edit|explicit|clean|radio)[^)]*)\)", string.Empty, RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"\[([^\]]*(feat|ft|live|remix|version|edit|explicit|clean|radio)[^\]]*)\]", string.Empty, RegexOptions.IgnoreCase);
return Regex.Replace(value, @"\s+", " ").Trim();
}
static string CleanQueryValue(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = WebUtility.HtmlDecode(value).Trim();
value = Regex.Replace(value, @"\s+", " ");
return value.Trim(' ', '-', '–', '—', ',', ';', ':');
}
static string NormalizeText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = WebUtility.HtmlDecode(value).ToLowerInvariant();
value = Regex.Replace(value, @"[\(\)\[\]\{\}\-_]+", " ");
value = Regex.Replace(value, @"[^0-9a-zа-яёіїєґ\s]+", " ");
return Regex.Replace(value, @"\s+", " ").Trim();
}
}
@@ -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<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (track == null || MusicPlaybackModeService.IsVideo(playbackMode))
return Array.Empty<MusicAudioMatch>();
try
{
return await SoundCloudSupport.SearchAudioAsync(track, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new MusicAudioTransientException(Id, "match", ex);
}
}
public async Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (match == null || MusicPlaybackModeService.IsVideo(playbackMode))
return Array.Empty<MusicPlaybackSource>();
try
{
return await SoundCloudSupport.BuildAudioSourcesAsync(match, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new MusicAudioTransientException(Id, "stream", ex);
}
}
public async Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (match == null || MusicPlaybackModeService.IsVideo(playbackMode))
return null;
try
{
return await SoundCloudSupport.BuildPreferredAudioSourceAsync(match, cancellationToken);
}
catch
{
return null;
}
}
public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
}
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
{
return SoundCloudSupport.IsRelevantAudioMatch(track, match);
}
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
{
return SoundCloudSupport.HasExactTrackId(track);
}
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
{
return new[]
{
SefonSupport.ProviderId,
YouTubeMusicSearchSupport.ProviderId
};
}
}
@@ -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<string, (DateTime at, Task<ResolvedManifest> task)> speculativeManifests = new();
static readonly TimeSpan speculativeManifestTtl = TimeSpan.FromMinutes(2);
sealed record ResolvedManifest(StreamManifest Manifest, MusicProxyLease ProxyLease);
public string Id => "youtubeaudio";
public string Name => "YouTube Audio";
public bool Enabled => ModInit.conf?.youtube_audio_enabled == true;
public bool RequiresAuth => false;
public bool CacheMissingMatches => false;
public async Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (track == null)
return Array.Empty<MusicAudioMatch>();
var queries = YouTubeAudioSupport.BuildTrackQueries(track, playbackMode);
var directMatch = YouTubeAudioSupport.BuildDirectMatch(track);
if (queries.Count == 0)
return directMatch != null ? new[] { directMatch } : Array.Empty<MusicAudioMatch>();
try
{
var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api);
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
var results = new List<VideoSearchResult>();
var seen = new HashSet<string>(StringComparer.Ordinal);
// batch-parallel: пачки по 3 запроса параллельно (холодный резолв был
// суммой 3-6 последовательных поисков по 1-2s = главная часть 5-16s
// ожидания /music/play). Merge строго в порядке исходных queries —
// вход ранжирования тот же, что у последовательного цикла; dedupe и
// кап 36 на слиянии; набрали кап — следующая пачка не запускается
bool speculated = false;
for (int batchStart = 0; batchStart < queries.Count && results.Count < 36; batchStart += 3)
{
var batchTasks = queries
.Skip(batchStart)
.Take(3)
.Select(query => FetchQueryResultsAsync(youtube, query, cancellationToken))
.ToList();
await Task.WhenAll(batchTasks);
foreach (var batchTask in batchTasks)
{
foreach (var video in batchTask.Result)
{
var videoId = video.Id.Value;
if (!string.IsNullOrWhiteSpace(videoId) && seen.Add(videoId))
results.Add(video);
if (results.Count >= 36)
break;
}
if (results.Count >= 36)
break;
}
// впереди ещё пачка — после первой пачки уже часто есть
// очевидный победитель. Если он проходит строгий гейт
// уверенности, не жжём вторую пачку YouTube-поисков вообще.
// Манифест проверяем до раннего выхода: если топ-кандидат
// оказался без стримов, сохраняем старую полную fallback-схему.
if (directMatch == null && batchStart == 0 && batchStart + 3 < queries.Count && results.Count < 36)
{
var earlyRanked = YouTubeAudioSupport.RankMatches(track, YouTubeAudioSupport.ConvertSearchResults(results), playbackMode);
var earlyTop = earlyRanked.FirstOrDefault();
if (YouTubeAudioSupport.IsConfidentEarlyMatch(track, earlyTop))
{
try
{
var manifestTask = StartSpeculativeManifest(earlyTop.id);
if (manifestTask != null)
{
await manifestTask.WaitAsync(cancellationToken);
proxyLease.Success();
return earlyRanked;
}
}
catch (OperationCanceledException)
{
throw;
}
catch
{
// stream-less первый кандидат — не меняем поведение,
// продолжаем полный поиск и даём ранжированию шанс.
}
}
}
// впереди ещё пачка — спекулятивно тянем манифест текущего
// фаворита (directMatch, если есть — резолвер пробует его первым)
if (!speculated && batchStart + 3 < queries.Count && results.Count < 36)
{
speculated = true;
var prelim = YouTubeAudioSupport.RankMatches(track, YouTubeAudioSupport.ConvertSearchResults(results), playbackMode);
StartSpeculativeManifest(directMatch?.id ?? prelim.FirstOrDefault()?.id);
}
}
var matches = YouTubeAudioSupport.ConvertSearchResults(results);
var ranked = YouTubeAudioSupport.RankMatches(track, matches, playbackMode);
proxyLease.Success();
if (directMatch == null)
return ranked;
var ordered = new List<MusicAudioMatch> { directMatch };
ordered.AddRange(ranked.Where(i => !string.Equals(i.id, directMatch.id, StringComparison.Ordinal)));
return ordered;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
if (MusicHttp.IsProxyFailure(ex))
MusicProxyService.ReportFailure($"Music:{Id}:api");
if (directMatch != null)
return new[] { directMatch };
throw new MusicAudioTransientException(Id, "match", ex);
}
}
// «Найти вручную» в Источниках: сырой пользовательский запрос — без
// переписывания, ранжирования и гейта релевантности, решает человек
public async Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
{
query = query?.Trim();
if (string.IsNullOrWhiteSpace(query))
return Array.Empty<MusicAudioMatch>();
try
{
var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api);
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
var results = new List<VideoSearchResult>();
await foreach (var video in youtube.Search.GetVideosAsync(query, cancellationToken))
{
results.Add(video);
if (results.Count >= 20)
break;
}
proxyLease.Success();
return YouTubeAudioSupport.ConvertSearchResults(results);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
if (MusicHttp.IsProxyFailure(ex))
MusicProxyService.ReportFailure($"Music:{Id}:api");
throw new MusicAudioTransientException(Id, "search", ex);
}
}
public async Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (match == null || string.IsNullOrWhiteSpace(match.id))
return Array.Empty<MusicPlaybackSource>();
for (int attempt = 0; attempt < 2; attempt++)
{
try
{
var resolved = attempt == 0 ? await TryTakeSpeculativeManifestAsync(match.id) : null;
resolved ??= await ResolveManifestAsync(match.id, cancellationToken);
if (MusicPlaybackModeService.IsVideo(playbackMode))
{
var videoStreams = resolved.Manifest.GetMuxedStreams();
var sources = YouTubeAudioSupport.ConvertVideoStreams(videoStreams);
foreach (var source in sources)
resolved.ProxyLease.ApplyTo(source, overwrite: true);
return sources;
}
var audioStreams = resolved.Manifest.GetAudioOnlyStreams();
var audioSources = YouTubeAudioSupport.ConvertAudioStreams(audioStreams);
foreach (var source in audioSources)
resolved.ProxyLease.ApplyTo(source, overwrite: true);
return audioSources;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
if (attempt == 0 && ShouldRetryManifestFailure(ex))
{
await Task.Delay(250, cancellationToken);
continue;
}
Console.WriteLine($"[Music] youtube stream manifest failed for {match.id}: {ex.GetType().Name}: {ex.Message}");
if (IsPermanentManifestFailure(ex))
return Array.Empty<MusicPlaybackSource>();
throw new MusicAudioTransientException(Id, "stream", ex);
}
}
return Array.Empty<MusicPlaybackSource>();
}
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
return Task.FromResult<MusicPlaybackSource>(null);
}
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
{
return YouTubeAudioSupport.IsRelevantMatch(track, match);
}
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
{
return false;
}
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
{
return Array.Empty<string>();
}
static Task<ResolvedManifest> StartSpeculativeManifest(string videoId)
{
if (string.IsNullOrWhiteSpace(videoId))
return null;
var proxyLease = MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
string cacheKey = BuildManifestCacheKey(videoId, proxyLease);
if (speculativeManifests.ContainsKey(cacheKey))
return speculativeManifests.TryGetValue(cacheKey, out var existing) ? existing.task : null;
// уборка протухших записей (непотреблённые спекуляции — промахи ранжирования)
foreach (var entry in speculativeManifests)
{
if (DateTime.UtcNow - entry.Value.at > speculativeManifestTtl)
speculativeManifests.TryRemove(entry.Key, out _);
}
// CancellationToken.None: спекуляция переживает отмену исходного запроса —
// результат заберёт следующий GetStreams по тому же видео в пределах TTL
var task = ResolveManifestAsync(videoId, CancellationToken.None, proxyLease);
speculativeManifests[cacheKey] = (DateTime.UtcNow, task);
_ = task.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
return task;
}
static async Task<ResolvedManifest> TryTakeSpeculativeManifestAsync(string videoId)
{
if (string.IsNullOrWhiteSpace(videoId))
return null;
var proxyLease = MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
string cacheKey = BuildManifestCacheKey(videoId, proxyLease);
if (!speculativeManifests.TryRemove(cacheKey, out var entry))
return null;
if (DateTime.UtcNow - entry.at > speculativeManifestTtl)
return null;
try
{
return await entry.task;
}
catch
{
// упавшая спекуляция не считается попыткой — обычный путь
// с его retry-семантикой отработает как раньше
return null;
}
}
static async Task<List<VideoSearchResult>> FetchQueryResultsAsync(YoutubeClient youtube, string query, CancellationToken cancellationToken)
{
var list = new List<VideoSearchResult>();
await foreach (var video in youtube.Search.GetVideosAsync(query, cancellationToken))
{
list.Add(video);
if (list.Count >= 16)
break;
}
return list;
}
static async Task<ResolvedManifest> ResolveManifestAsync(string videoId, CancellationToken cancellationToken, MusicProxyLease proxyLease = null)
{
proxyLease ??= MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
try
{
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
var manifest = await youtube.Videos.Streams.GetManifestAsync(videoId, cancellationToken);
proxyLease.Success();
return new ResolvedManifest(manifest, proxyLease);
}
catch (Exception ex)
{
if (proxyLease.Enabled && MusicHttp.IsProxyFailure(ex))
proxyLease.Failure();
throw;
}
}
static string BuildManifestCacheKey(string videoId, MusicProxyLease proxyLease)
{
string route = proxyLease?.Enabled == true
? $"{proxyLease.Scope}|{proxyLease.Data.ip}|{proxyLease.Data.username}"
: "direct";
return $"{videoId}|{route}|cfg:{MusicProxyService.ConfigurationVersion}";
}
static bool ShouldRetryManifestFailure(Exception ex)
{
return ex != null
&& ex.GetType().Name == "YoutubeExplodeException"
&& (ex.Message?.IndexOf("cipher manifest", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0;
}
static bool IsPermanentManifestFailure(Exception ex)
{
for (var current = ex; current != null; current = current.InnerException)
{
if (current.GetType().Name is "VideoUnavailableException" or "VideoUnplayableException")
return true;
}
return false;
}
}
@@ -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<string> BuildTrackQueries(MusicTrack track, string playbackMode = null)
{
string title = StripFeatureSegment(track?.title)?.Trim();
string album = track?.album_title?.Trim();
string artist = track?.artist_name?.Trim();
string normalizedTitle = NormalizeText(title);
string normalizedAlbum = NormalizeText(album);
bool preferVideo = MusicPlaybackModeService.IsVideo(playbackMode);
string primaryHint = preferVideo ? "official music video" : "official audio";
string secondaryHint = preferVideo ? "music video" : "audio";
var queryArtists = BuildQueryArtists(artist, track?.artists);
var queries = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
void add(string value)
{
value = value?.Trim();
if (string.IsNullOrWhiteSpace(value))
return;
var key = NormalizeText(value);
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
return;
queries.Add(value);
}
add(MusicIsrc.Normalize(track?.isrc));
if (queryArtists.Count == 0 || string.IsNullOrWhiteSpace(title))
return queries;
foreach (var queryArtist in queryArtists)
add($"{queryArtist} \"{title}\" {primaryHint}");
foreach (var queryArtist in queryArtists)
add($"{queryArtist} {title} {primaryHint}");
foreach (var queryArtist in queryArtists)
add($"{queryArtist} \"{title}\"");
foreach (var queryArtist in queryArtists)
add($"{queryArtist} {title}");
if (ShouldAppendAlbumHint(title) && !string.IsNullOrWhiteSpace(album) && normalizedAlbum != normalizedTitle)
add($"{artist} \"{title}\" {album}");
foreach (var queryArtist in queryArtists)
add($"{queryArtist} {title} {secondaryHint}");
return queries;
}
public static MusicAudioMatch BuildDirectMatch(MusicTrack track)
{
string videoId = ResolveVideoId(track);
if (string.IsNullOrWhiteSpace(videoId))
return null;
return new MusicAudioMatch
{
provider_id = "youtubeaudio",
id = videoId,
title = track?.title,
artists = !string.IsNullOrWhiteSpace(track?.artist_name) ? new List<string> { track.artist_name } : track?.artists ?? new List<string>(),
album_title = track?.album_title,
isrc = MusicIsrc.Normalize(track?.isrc),
duration_ms = track?.duration_ms,
payload = MusicJson.Serialize(new Dictionary<string, string>
{
["video_id"] = videoId
})
};
}
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches, string playbackMode = null)
{
string queryTitle = NormalizeTitleForMatch(query?.title);
string queryArtist = NormalizeText(query?.artist_name);
string queryAlbum = NormalizeText(query?.album_title);
string queryPenaltyContext = NormalizeText($"{query?.title} {query?.album_title}");
string queryArtistCompact = CompactText(queryArtist);
bool preferVideo = MusicPlaybackModeService.IsVideo(playbackMode);
return matches
.Select(match =>
{
int score = 0;
string rawTitle = NormalizeText(match.title);
string title = NormalizeTitleForMatch(match.title);
string primaryTitle = NormalizePrimaryTitleForMatch(match.title);
string album = NormalizeText(match.album_title);
string fullTitle = NormalizeText($"{StripFeatureSegment(match.title)} {match.album_title}");
string rawFullTitle = NormalizeText($"{match.title} {match.album_title}");
var artists = match.artists.Select(NormalizeText).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var titleMatch = EvaluateTitleMatch(queryTitle, title, rawFullTitle);
bool strongVariant = HasUnexpectedStrongVariant(queryPenaltyContext, rawFullTitle);
bool primaryTitleContainsQuery = !string.IsNullOrWhiteSpace(queryTitle)
&& !string.IsNullOrWhiteSpace(primaryTitle)
&& primaryTitle.Contains(queryTitle, StringComparison.Ordinal);
bool noisyTitleContainsQuery = !primaryTitleContainsQuery
&& !string.IsNullOrWhiteSpace(queryTitle)
&& !string.IsNullOrWhiteSpace(title)
&& title.Contains(queryTitle, StringComparison.Ordinal);
if (titleMatch.exact)
score += 14;
else if (primaryTitleContainsQuery)
score += 10;
else if (titleMatch.allTokens)
score += 8;
else if (titleMatch.coverage >= 0.5d)
score += 3;
else if (!string.IsNullOrWhiteSpace(queryTitle))
score -= 20;
if (!string.IsNullOrWhiteSpace(queryArtist) && !string.IsNullOrWhiteSpace(queryTitle) && rawFullTitle.Contains(queryArtist) && rawFullTitle.Contains(queryTitle))
score += 4;
// Если совпадение нашлось только в скобках/хвосте, это часто
// название альбома или подборки, а не самого трека.
if (noisyTitleContainsQuery)
score -= 10;
if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a == queryArtist))
score += 5;
else if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a.Contains(queryArtist) || queryArtist.Contains(a)))
score += 3;
if (!string.IsNullOrWhiteSpace(queryArtistCompact) && artists.Any(a =>
CompactText(a) == $"{queryArtistCompact}music"
|| CompactText(a) == $"{queryArtistCompact}vevo"))
{
score += 3;
}
if (!string.IsNullOrWhiteSpace(queryAlbum) && album.Contains(queryAlbum))
score += 1;
if (query.duration_ms.HasValue && match.duration_ms.HasValue)
{
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
if (diff <= 3000)
score += 4;
else if (diff <= 10000)
score += 2;
else if (diff > 90000)
score -= 12;
else if (diff > 45000)
score -= 6;
if (query.duration_ms.Value > 0 && match.duration_ms.Value > query.duration_ms.Value * 2)
score -= 16;
}
else if (match.duration_ms.HasValue && match.duration_ms.Value > 0 && match.duration_ms.Value < 90000 && !LooksLikeShortTrackQuery(queryTitle))
{
score -= 8;
}
string lowered = $"{rawTitle} {string.Join(" ", artists)} {album}";
if (BonusWords.Any(i => lowered.Contains(i)))
score += 2;
if (artists.Any(a => a.Contains("topic")))
score += 4;
if (preferVideo)
{
if (title.Contains("official video") || title.Contains("music video"))
score += 8;
if (lowered.Contains("hd") || lowered.Contains("4k"))
score += 2;
if (title.Contains("official audio") || title.Contains("provided to youtube"))
score -= 10;
if (artists.Any(a => a.Contains("topic")))
score -= 10;
if (lowered.Contains("lyrics") || lowered.Contains("visualizer"))
score -= 4;
}
else
{
if (title.Contains("official audio") || title.Contains("provided to youtube"))
score += 4;
if (title.Contains("official video") || title.Contains("music video"))
score += 1;
}
if (!string.IsNullOrWhiteSpace(queryArtist) && !string.IsNullOrWhiteSpace(queryTitle) && title.StartsWith($"{queryArtist} {queryTitle}"))
score += 3;
foreach (var word in PenaltyWords)
{
string normalizedWord = NormalizeText(word);
if (!string.IsNullOrWhiteSpace(normalizedWord) && lowered.Contains(normalizedWord) && !queryPenaltyContext.Contains(normalizedWord))
score -= 3;
}
// Instrumental/karaoke/drumless версии почти всегда являются
// другим треком для пользователя. Малый общий штраф не хватал:
// точное title+artist+duration могло перебить нормальную версию.
if (strongVariant)
score -= 40;
if (!string.IsNullOrWhiteSpace(queryAlbum) && !string.IsNullOrWhiteSpace(album) && album == queryAlbum)
score += 2;
return new { match, score, titleMatch, strongVariant };
})
.ToList() is var ranked
? (ranked.Any(i => i.titleMatch.relevant)
? ranked
.Where(i => i.titleMatch.relevant)
.Where(i => !i.strongVariant)
.Where(i => i.score > -12)
.OrderByDescending(i => i.score)
.ThenBy(i => i.match.title)
.Take(5)
.Select(i => i.match)
.ToList()
: ranked
.Where(i => !i.strongVariant)
.Where(i => i.score > -8)
.OrderByDescending(i => i.score)
.ThenBy(i => i.match.title)
.Take(5)
.Select(i => i.match)
.ToList())
: new List<MusicAudioMatch>();
}
public static bool IsRelevantMatch(MusicTrack query, MusicAudioMatch match)
{
if (query == null || match == null)
return false;
string queryContext = NormalizeText($"{query.title} {query.album_title}");
string candidateContext = NormalizeText($"{match.title} {match.album_title}");
if (HasUnexpectedStrongVariant(queryContext, candidateContext))
return false;
string queryTitle = NormalizeTitleForMatch(query.title);
if (string.IsNullOrWhiteSpace(queryTitle))
return true;
string primaryTitle = NormalizePrimaryTitleForMatch(match.title);
return EvaluateTitleMatch(queryTitle, primaryTitle, primaryTitle).relevant;
}
public static bool IsConfidentEarlyMatch(MusicTrack query, MusicAudioMatch match)
{
if (query == null || match == null)
return false;
string queryTitle = NormalizeTitleForMatch(query.title);
string candidatePrimaryTitle = NormalizePrimaryTitleForMatch(match.title);
if (string.IsNullOrWhiteSpace(queryTitle) || string.IsNullOrWhiteSpace(candidatePrimaryTitle))
return false;
if (LooksLikeShortTrackQuery(queryTitle))
return false;
if (!ContainsTokenPhrase(candidatePrimaryTitle, queryTitle))
return false;
if (!HasArtistMatch(query, match))
return false;
if (!HasTightDurationMatch(query, match, 5000))
return false;
if (HasUnexpectedEarlyExitNoise(query, match))
return false;
return true;
}
public static List<MusicAudioMatch> ConvertSearchResults(IEnumerable<VideoSearchResult> videos)
{
var matches = new List<MusicAudioMatch>();
foreach (var video in videos)
{
string id = video.Id.Value;
string title = video.Title;
string author = video.Author.ChannelTitle;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(title))
continue;
matches.Add(new MusicAudioMatch
{
provider_id = "youtubeaudio",
id = id,
title = title,
artists = string.IsNullOrWhiteSpace(author) ? new List<string>() : new List<string> { author },
album_title = null,
duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null,
payload = MusicJson.Serialize(new Dictionary<string, string>
{
["video_id"] = id,
["author"] = author ?? string.Empty
})
});
}
return matches;
}
public static IReadOnlyList<MusicPlaybackSource> ConvertAudioStreams(IEnumerable<IAudioStreamInfo> streams)
{
return streams
.OrderByDescending(i => IsPreferredContainer(i))
.ThenByDescending(i => i.Bitrate.BitsPerSecond)
.Take(3)
.Select(i => new MusicPlaybackSource
{
provider_id = "youtubeaudio",
url = i.Url,
mime_type = GetMimeType(i),
bitrate = (int)Math.Max(1L, i.Bitrate.BitsPerSecond / 1000),
quality = $"{GetContainerName(i).ToUpperInvariant()} {Math.Max(1L, i.Bitrate.BitsPerSecond / 1000)}kbps",
headers = new Dictionary<string, string>
{
["Referer"] = "https://www.youtube.com/",
["Origin"] = "https://www.youtube.com"
}
})
.ToList();
}
public static IReadOnlyList<MusicPlaybackSource> ConvertVideoStreams(IEnumerable<MuxedStreamInfo> streams)
{
return streams
.OrderByDescending(i => IsPreferredVideoContainer(i))
.ThenByDescending(i => i.VideoQuality.MaxHeight)
.ThenByDescending(i => i.Bitrate.BitsPerSecond)
.Take(3)
.Select(i => new MusicPlaybackSource
{
provider_id = "youtubeaudio",
url = i.Url,
mime_type = GetVideoMimeType(i),
bitrate = (int)Math.Max(1L, i.Bitrate.BitsPerSecond / 1000),
quality = BuildVideoQualityLabel(i),
headers = new Dictionary<string, string>
{
["Referer"] = "https://www.youtube.com/",
["Origin"] = "https://www.youtube.com"
}
})
.ToList();
}
static bool IsPreferredContainer(IAudioStreamInfo stream)
{
string name = GetContainerName(stream);
return name is "m4a" or "mp4";
}
static bool IsPreferredVideoContainer(MuxedStreamInfo stream)
{
string name = GetContainerName(stream);
return name is "mp4" or "m4v";
}
static string GetContainerName(IStreamInfo stream)
=> stream.Container.Name?.ToLowerInvariant() ?? stream.Container.ToString().ToLowerInvariant();
static string GetMimeType(IAudioStreamInfo stream)
{
string container = GetContainerName(stream);
return container is "m4a" or "mp4" ? "audio/mp4" : "audio/webm";
}
static string GetVideoMimeType(MuxedStreamInfo stream)
{
string container = GetContainerName(stream);
return container is "mp4" or "m4v" ? "video/mp4" : "video/webm";
}
static string BuildVideoQualityLabel(MuxedStreamInfo stream)
{
string container = GetContainerName(stream).ToUpperInvariant();
string height = !string.IsNullOrWhiteSpace(stream.VideoQuality.Label) ? stream.VideoQuality.Label : $"{stream.VideoQuality.MaxHeight}p";
long bitrate = Math.Max(1L, stream.Bitrate.BitsPerSecond / 1000);
return $"{container} {height} {bitrate}kbps";
}
static string NormalizeText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = Regex.Replace(value, @"[\(\)\[\]\{\}\-_]+", " ");
value = value.Trim().ToLowerInvariant().Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(value.Length);
foreach (char ch in value)
{
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark)
continue;
if (char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch))
builder.Append(ch);
}
return Regex.Replace(builder.ToString(), @"\s+", " ").Trim();
}
static string NormalizeTitleForMatch(string value) => NormalizeText(StripFeatureSegment(value));
static List<string> BuildQueryArtists(string primaryArtist, IEnumerable<string> artists)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
void add(string value)
{
value = value?.Trim();
if (string.IsNullOrWhiteSpace(value))
return;
var key = NormalizeText(value);
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
return;
result.Add(value);
}
add(primaryArtist);
foreach (var artist in artists ?? Array.Empty<string>())
{
add(artist);
if (result.Count >= 3)
break;
}
return result;
}
static string NormalizePrimaryTitleForMatch(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = StripFeatureSegment(value);
value = Regex.Replace(value, @"[\(\[\{][^)\]\}]+[\)\]\}]", " ");
value = Regex.Replace(value, @"\s+[|/]\s+.*$", " ");
return NormalizeText(value);
}
static string CompactText(string value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Replace(" ", string.Empty);
static bool LooksLikeShortTrackQuery(string normalizedTitle)
{
if (string.IsNullOrWhiteSpace(normalizedTitle))
return false;
return GenericTrackTitles.Contains(normalizedTitle)
|| normalizedTitle.Contains("intro", StringComparison.Ordinal)
|| normalizedTitle.Contains("outro", StringComparison.Ordinal)
|| normalizedTitle.Contains("skit", StringComparison.Ordinal)
|| normalizedTitle.Contains("interlude", StringComparison.Ordinal);
}
static bool ContainsTokenPhrase(string value, string phrase)
{
if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(phrase))
return false;
if (value == phrase)
return true;
return value.StartsWith($"{phrase} ", StringComparison.Ordinal)
|| value.EndsWith($" {phrase}", StringComparison.Ordinal)
|| value.Contains($" {phrase} ", StringComparison.Ordinal);
}
static bool HasArtistMatch(MusicTrack query, MusicAudioMatch match)
{
var expectedArtists = new List<string>();
if (!string.IsNullOrWhiteSpace(query?.artist_name))
expectedArtists.Add(query.artist_name);
foreach (var artist in query?.artists ?? new List<string>())
{
if (!string.IsNullOrWhiteSpace(artist))
expectedArtists.Add(artist);
}
var normalizedExpected = expectedArtists
.Select(NormalizeText)
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.Ordinal)
.ToList();
if (normalizedExpected.Count == 0)
return false;
var normalizedCandidateArtists = (match?.artists ?? new List<string>())
.Select(NormalizeText)
.Where(i => !string.IsNullOrWhiteSpace(i))
.ToList();
string rawCandidateText = NormalizeText($"{match?.title} {string.Join(" ", match?.artists ?? new List<string>())}");
foreach (var expected in normalizedExpected)
{
string compactExpected = CompactText(expected);
if (string.IsNullOrWhiteSpace(compactExpected))
continue;
foreach (var candidate in normalizedCandidateArtists)
{
string compactCandidate = CompactText(candidate);
if (candidate == expected
|| candidate.Contains(expected, StringComparison.Ordinal)
|| expected.Contains(candidate, StringComparison.Ordinal)
|| compactCandidate == compactExpected
|| compactCandidate == $"{compactExpected}vevo"
|| compactCandidate == $"{compactExpected}music"
|| compactCandidate == $"{compactExpected}topic")
return true;
}
if (!string.IsNullOrWhiteSpace(rawCandidateText) && ContainsTokenPhrase(rawCandidateText, expected))
return true;
}
return false;
}
static bool HasTightDurationMatch(MusicTrack query, MusicAudioMatch match, int toleranceMs)
{
if (query?.duration_ms.HasValue != true || match?.duration_ms.HasValue != true)
return false;
return Math.Abs(query.duration_ms.Value - match.duration_ms.Value) <= toleranceMs;
}
static bool HasUnexpectedEarlyExitNoise(MusicTrack query, MusicAudioMatch match)
{
string queryContext = NormalizeText($"{query?.title} {query?.album_title}");
string candidateContext = NormalizeText($"{match?.title} {match?.album_title} {string.Join(" ", match?.artists ?? new List<string>())}");
if (string.IsNullOrWhiteSpace(candidateContext))
return false;
foreach (var word in EarlyExitPenaltyWords)
{
string normalizedWord = NormalizeText(word);
if (!string.IsNullOrWhiteSpace(normalizedWord)
&& ContainsTokenPhrase(candidateContext, normalizedWord)
&& !ContainsTokenPhrase(queryContext, normalizedWord))
return true;
}
return false;
}
static bool HasUnexpectedStrongVariant(string queryContext, string candidateContext)
{
if (string.IsNullOrWhiteSpace(candidateContext))
return false;
foreach (var word in StrongVariantWords.Concat(LiveVariantWords))
{
string normalizedWord = NormalizeText(word);
if (!string.IsNullOrWhiteSpace(normalizedWord)
&& ContainsTokenPhrase(candidateContext, normalizedWord)
&& !ContainsTokenPhrase(queryContext, normalizedWord))
return true;
}
return false;
}
static string StripFeatureSegment(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
string original = value.Trim();
string stripped = Regex.Replace(
original,
@"\s*[\(\[\{]\s*(?:feat\.?|ft\.?|featuring)\s*[:\-]?\s+[^)\]\}]+[\)\]\}]\s*",
" ",
RegexOptions.IgnoreCase);
stripped = Regex.Replace(
stripped,
@"\s+(?:feat\.?|ft\.?|featuring)\s*[:\-]?\s+.+$",
" ",
RegexOptions.IgnoreCase);
stripped = Regex.Replace(stripped, @"\s+", " ").Trim();
return string.IsNullOrWhiteSpace(stripped) ? original : stripped;
}
static bool ShouldAppendAlbumHint(string title)
{
var normalized = NormalizeText(title);
if (string.IsNullOrWhiteSpace(normalized))
return false;
if (GenericTrackTitles.Contains(normalized))
return true;
return normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 2;
}
static string ResolveVideoId(MusicTrack track)
{
if (!string.IsNullOrWhiteSpace(track?.id) && track.id.StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
return track.id["youtube:".Length..].Trim();
if (track?.provider_refs == null)
return null;
foreach (var providerRef in track.provider_refs)
{
if (!string.Equals(providerRef?.provider, "youtubeaudio", StringComparison.OrdinalIgnoreCase))
continue;
if (!string.IsNullOrWhiteSpace(providerRef.external_id))
return providerRef.external_id.Trim();
}
return null;
}
static (bool relevant, bool exact, bool allTokens, double coverage) EvaluateTitleMatch(string queryTitle, string title, string fullTitle)
{
if (string.IsNullOrWhiteSpace(queryTitle))
return (true, false, false, 0d);
bool exact = !string.IsNullOrWhiteSpace(title) && title == queryTitle;
var queryTokens = queryTitle
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (queryTokens.Length == 0)
return (true, exact, false, 0d);
int matchedTokens = queryTokens.Count(token =>
(!string.IsNullOrWhiteSpace(title) && title.Contains(token, StringComparison.Ordinal))
|| (!string.IsNullOrWhiteSpace(fullTitle) && fullTitle.Contains(token, StringComparison.Ordinal)));
double coverage = matchedTokens / (double)queryTokens.Length;
bool allTokens = matchedTokens == queryTokens.Length;
bool relevant = exact
|| allTokens
|| (queryTokens.Length >= 3 && coverage >= 0.75d);
return (relevant, exact, allTokens, coverage);
}
}
@@ -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<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (!Enabled || track == null)
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
return Z3FmSupport.SearchAsync(track, cancellationToken);
}
public Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (!Enabled || match == null)
return Task.FromResult<IReadOnlyList<MusicPlaybackSource>>(Array.Empty<MusicPlaybackSource>());
return Z3FmSupport.BuildSourcesAsync(match, cancellationToken);
}
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
return Task.FromResult<MusicPlaybackSource>(null);
}
public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
}
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
{
return Z3FmSupport.IsRelevantMatch(track, match);
}
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
{
return false;
}
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
{
return Array.Empty<string>();
}
}
@@ -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(
"<div class=\"song song-wrap[^\"]*\">(?<block>.*?)<span[^>]*data-time=\"(?<seconds>\\d+)\"[^>]*data-sid=\"(?<sid>\\d+)\"[^>]*data-url=\"(?<download>/download/\\d+)\"[^>]*data-title=\"(?<fulltitle>[^\"]+)\"[^>]*class=\"song-play[^\"]*\"",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
);
static readonly Regex artistRegex = new(
"<div class=\"song-artist\">.*?<a[^>]*href=\"(?<href>/artist/\\d+)\"[^>]*>(?<inner>.*?)</a>",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
);
static readonly Regex titleRegex = new(
"<div class=\"song-name\">.*?<a[^>]*href=\"(?<href>/song/\\d+)\"[^>]*>(?<inner>.*?)</a>",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
);
static readonly Regex songPageDownloadRegex = new(
"data-sid=\"(?<sid>\\d+)\"\\s+data-url=\"(?<download>/download/\\d+)\"",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
);
static readonly string[] noisyTerms =
{
"remix", "mix", "instrumental", "karaoke", "spedup", "slowed", "nightcore", "8d", "cover", "live", "edit", "extended"
};
public static bool IsModuleEnabled => ModInit.conf?.z3fm_enabled == true;
public static bool IsAudioEnabled => IsModuleEnabled && ModInit.conf?.z3fm_audio_enabled == true;
public static bool IsProxyEnabled => IsModuleEnabled && ModInit.conf?.z3fm_proxy_enabled == true;
public static string BuildSearchUrl(string query)
{
query = string.IsNullOrWhiteSpace(query) ? string.Empty : query.Trim();
return $"{BaseUrl.TrimEnd('/')}{SearchPath}?keywords={Uri.EscapeDataString(query)}";
}
public static Dictionary<string, string> CreateBrowserHeaders()
{
return new Dictionary<string, string>
{
["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
["Accept-Language"] = "ru,en-US;q=0.9,en;q=0.8",
["Cache-Control"] = "no-cache",
["Pragma"] = "no-cache",
["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7; rv:139.0) Gecko/20100101 Firefox/139.0"
};
}
public static async Task<IReadOnlyList<MusicAudioMatch>> SearchAsync(MusicTrack track, CancellationToken cancellationToken = default)
{
if (track == null || string.IsNullOrWhiteSpace(track.title))
return Array.Empty<MusicAudioMatch>();
var matches = new Dictionary<string, MusicAudioMatch>(StringComparer.Ordinal);
foreach (var query in BuildQueryVariants(track))
{
cancellationToken.ThrowIfCancellationRequested();
var page = await FetchPageAsync(BuildSearchUrl(query), cancellationToken);
if (page == null || string.IsNullOrWhiteSpace(page.Html))
continue;
foreach (var item in ParseSearchResults(page.Html))
{
cancellationToken.ThrowIfCancellationRequested();
if (!string.IsNullOrWhiteSpace(page.CookieHeader))
{
cookieCache.Set(BuildCookieCacheKey(item.sid), new Z3FmCookieContext
{
CookieHeader = page.CookieHeader,
Referer = string.IsNullOrWhiteSpace(item.trackUrl) ? page.Url : item.trackUrl
}, cookieLifetime);
}
if (matches.ContainsKey(item.sid))
continue;
matches[item.sid] = new MusicAudioMatch
{
provider_id = AudioProviderId,
id = item.sid,
title = item.title,
artists = string.IsNullOrWhiteSpace(item.artist) ? new List<string>() : new List<string> { item.artist },
duration_ms = item.durationMs,
payload = MusicJson.Serialize(new Z3FmMatchPayload
{
sid = item.sid,
download_url = item.downloadUrl,
track_url = item.trackUrl
})
};
}
if (matches.Count > 0)
break;
}
return RankMatches(track, matches.Values).Take(20).ToList();
}
public static async Task<IReadOnlyList<MusicPlaybackSource>> BuildSourcesAsync(MusicAudioMatch match, CancellationToken cancellationToken = default)
{
var payload = string.IsNullOrWhiteSpace(match?.payload)
? null
: MusicJson.Deserialize<Z3FmMatchPayload>(match.payload);
if (payload == null || string.IsNullOrWhiteSpace(payload.sid))
return Array.Empty<MusicPlaybackSource>();
var context = await EnsureCookieContextAsync(payload, cancellationToken);
string url = BuildAbsoluteUrl(payload.download_url);
if (string.IsNullOrWhiteSpace(url))
return Array.Empty<MusicPlaybackSource>();
var headers = new Dictionary<string, string>
{
["Referer"] = context?.Referer ?? BuildAbsoluteUrl(payload.track_url) ?? $"{BaseUrl}/",
["Origin"] = BaseUrl,
["User-Agent"] = CreateBrowserHeaders()["User-Agent"]
};
if (!string.IsNullOrWhiteSpace(context?.CookieHeader))
headers["Cookie"] = context.CookieHeader;
var source = new MusicPlaybackSource
{
provider_id = AudioProviderId,
url = url,
mime_type = "audio/mpeg",
quality = "MP3",
headers = headers
};
ApplyConfiguredProxy(source);
return new List<MusicPlaybackSource> { source };
}
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches)
{
return matches
.Select(match => new { match, score = ScoreMatch(query, match) })
.Where(i => i.score >= 4)
.OrderByDescending(i => i.score)
.ThenBy(i => i.match.title)
.Select(i => i.match)
.ToList();
}
public static bool IsRelevantMatch(MusicTrack query, MusicAudioMatch match)
{
return ScoreMatch(query, match) >= 6;
}
public static void ApplyConfiguredProxy(MusicPlaybackSource source)
{
if (source == null)
return;
var configured = ParseConfiguredProxy();
if (configured == null)
return;
source.proxy_url = configured.Server;
source.proxy_username = configured.Username;
source.proxy_password = configured.Password;
}
public static WebProxy GetHttpProxy()
{
var configured = ParseConfiguredProxy();
if (configured == null)
return null;
var credentials = string.IsNullOrWhiteSpace(configured.Username)
? null
: new NetworkCredential(configured.Username, configured.Password ?? string.Empty);
return new WebProxy(configured.Server, false, null, credentials);
}
public static (string ip, string username, string password) GetBrowserProxyData()
{
var configured = ParseConfiguredProxy();
if (configured == null)
return default;
return (configured.Server, configured.Username, configured.Password);
}
public static bool HasConfiguredProxy() => ParseConfiguredProxy() != null;
static async Task<Z3FmCookieContext> EnsureCookieContextAsync(Z3FmMatchPayload payload, CancellationToken cancellationToken)
{
if (payload == null || string.IsNullOrWhiteSpace(payload.sid))
return null;
if (cookieCache.TryGetValue(BuildCookieCacheKey(payload.sid), out Z3FmCookieContext cached) && cached != null)
return cached;
string trackUrl = BuildAbsoluteUrl(payload.track_url);
if (string.IsNullOrWhiteSpace(trackUrl))
return null;
var page = await FetchPageAsync(trackUrl, cancellationToken);
if (page == null)
return null;
if (string.IsNullOrWhiteSpace(payload.download_url))
{
var songPage = ParseSongPage(page.Html);
if (songPage != null && songPage.sid == payload.sid && !string.IsNullOrWhiteSpace(songPage.downloadUrl))
payload.download_url = songPage.downloadUrl;
}
if (string.IsNullOrWhiteSpace(page.CookieHeader))
return null;
var context = new Z3FmCookieContext
{
CookieHeader = page.CookieHeader,
Referer = trackUrl
};
cookieCache.Set(BuildCookieCacheKey(payload.sid), context, cookieLifetime);
return context;
}
static IEnumerable<string> BuildQueryVariants(MusicTrack track)
{
var variants = new List<string>();
void add(string value)
{
value = value?.Trim();
if (string.IsNullOrWhiteSpace(value))
return;
if (variants.All(existing => !string.Equals(existing, value, StringComparison.OrdinalIgnoreCase)))
variants.Add(value);
}
add($"{track.artist_name} {track.title}");
if (track.artists != null && track.artists.Count > 0)
{
string credit = string.Join(' ', track.artists.Where(i => !string.IsNullOrWhiteSpace(i)));
add($"{credit} {track.title}");
}
add(track.title);
return variants;
}
static async Task<Z3FmBrowserPage> FetchPageAsync(string url, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var headers = CreateBrowserHeaders();
var proxyData = GetBrowserProxyData();
MusicProxyLease proxyLease = null;
if (string.IsNullOrWhiteSpace(proxyData.ip))
{
proxyLease = MusicProxyService.Acquire(AudioProviderId, MusicProxyPurpose.Api);
proxyData = proxyLease.Data;
}
var init = new BaseSettings
{
plugin = "music.z3fm",
priorityBrowser = "firefox"
};
try
{
using var browser = new PlaywrightBrowser(init.priorityBrowser);
var page = await browser.NewPageAsync(init.plugin, headers, proxyData, keepopen: true, imitationHuman: false, deferredDispose: false).ConfigureAwait(false);
if (page == null)
return null;
var response = await page.GotoAsync(url, new PageGotoOptions
{
Timeout = 15_000,
WaitUntil = WaitUntilState.DOMContentLoaded
}).ConfigureAwait(false);
string html = null;
if (response != null)
html = await response.TextAsync().ConfigureAwait(false);
html ??= await page.ContentAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(html) || IsBlockedHtml(html))
{
proxyLease?.Failure();
return null;
}
var cookies = await page.Context.CookiesAsync().ConfigureAwait(false);
proxyLease?.Success();
return new Z3FmBrowserPage
{
Url = url,
Html = html,
CookieHeader = BuildCookieHeader(cookies)
};
}
catch
{
proxyLease?.Failure();
return null;
}
}
static IEnumerable<Z3FmParsedEntry> ParseSearchResults(string html)
{
if (string.IsNullOrWhiteSpace(html))
yield break;
foreach (Match match in resultBlockRegex.Matches(html))
{
string block = match.Groups["block"].Value;
string sid = match.Groups["sid"].Value;
string downloadUrl = match.Groups["download"].Value;
string fullTitle = HtmlDecode(match.Groups["fulltitle"].Value);
if (string.IsNullOrWhiteSpace(sid) || string.IsNullOrWhiteSpace(downloadUrl))
continue;
string title = ExtractTitle(block);
string artist = ExtractArtist(block);
string trackUrl = ExtractTrackUrl(block);
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
(artist, title) = SplitFullTitle(fullTitle);
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
continue;
int? durationMs = null;
if (int.TryParse(match.Groups["seconds"].Value, out int seconds) && seconds > 0)
durationMs = seconds * 1000;
yield return new Z3FmParsedEntry
{
sid = sid,
artist = artist,
title = title,
downloadUrl = BuildAbsoluteUrl(downloadUrl),
trackUrl = BuildAbsoluteUrl(trackUrl),
durationMs = durationMs
};
}
}
static Z3FmParsedEntry ParseSongPage(string html)
{
if (string.IsNullOrWhiteSpace(html))
return null;
var match = songPageDownloadRegex.Match(html);
if (!match.Success)
return null;
return new Z3FmParsedEntry
{
sid = match.Groups["sid"].Value,
downloadUrl = BuildAbsoluteUrl(match.Groups["download"].Value)
};
}
static string ExtractArtist(string block)
{
var match = artistRegex.Match(block ?? string.Empty);
if (!match.Success)
return null;
return CleanInlineText(match.Groups["inner"].Value);
}
static string ExtractTitle(string block)
{
var match = titleRegex.Match(block ?? string.Empty);
if (!match.Success)
return null;
return CleanInlineText(match.Groups["inner"].Value);
}
static string ExtractTrackUrl(string block)
{
var match = titleRegex.Match(block ?? string.Empty);
return match.Success ? match.Groups["href"].Value : null;
}
static (string artist, string title) SplitFullTitle(string fullTitle)
{
fullTitle = CleanInlineText(fullTitle);
if (string.IsNullOrWhiteSpace(fullTitle))
return (null, null);
int delimiter = fullTitle.IndexOf(" - ", StringComparison.Ordinal);
if (delimiter <= 0 || delimiter >= fullTitle.Length - 3)
return (null, fullTitle);
return (fullTitle[..delimiter].Trim(), fullTitle[(delimiter + 3)..].Trim());
}
static int ScoreMatch(MusicTrack query, MusicAudioMatch match)
{
string queryTitle = SearchNameTo.Convert(query?.title, string.Empty);
string matchTitle = SearchNameTo.Convert(match?.title, string.Empty);
string fullMatchTitle = SearchNameTo.Convert($"{match?.artists?.FirstOrDefault()} {match?.title}", string.Empty);
string queryPrimaryArtist = BuildArtistFragments(query?.artist_name).FirstOrDefault() ?? string.Empty;
var matchArtists = BuildArtistFragments(match?.artists?.FirstOrDefault());
int score = 0;
if (!string.IsNullOrWhiteSpace(queryTitle))
{
if (string.Equals(matchTitle, queryTitle, StringComparison.Ordinal))
score += 18;
else if (!string.IsNullOrWhiteSpace(matchTitle) && matchTitle.Contains(queryTitle, StringComparison.Ordinal))
score += 10;
else if (AllTokensPresent(queryTitle, matchTitle))
score += 5;
else
score -= 14;
}
if (!string.IsNullOrWhiteSpace(queryPrimaryArtist))
{
if (matchArtists.Contains(queryPrimaryArtist) || string.Equals(SearchNameTo.Convert(match?.artists?.FirstOrDefault(), string.Empty), queryPrimaryArtist, StringComparison.Ordinal))
score += 12;
else if (matchArtists.Any(a => a.Contains(queryPrimaryArtist, StringComparison.Ordinal)))
score += 6;
else
score -= 8;
}
if (query?.duration_ms.HasValue == true && match?.duration_ms.HasValue == true)
{
int delta = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
if (delta <= 2_000) score += 8;
else if (delta <= 5_000) score += 6;
else if (delta <= 10_000) score += 3;
else if (delta <= 20_000) score += 1;
else score -= 2;
}
string queryNoiseBase = $"{SearchNameTo.Convert(query?.title, string.Empty)} {SearchNameTo.Convert(query?.artist_name, string.Empty)}";
foreach (var term in noisyTerms)
{
if (!queryNoiseBase.Contains(term, StringComparison.Ordinal) && fullMatchTitle.Contains(term, StringComparison.Ordinal))
score -= 6;
}
return score;
}
static List<string> BuildArtistFragments(string artist)
{
artist = artist?.Trim();
if (string.IsNullOrWhiteSpace(artist))
return new List<string>();
return artist
.Split(new[] { ",", "&", " x ", " feat. ", " feat ", " ft. ", " ft ", " and " }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(i => SearchNameTo.Convert(i, string.Empty))
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.Ordinal)
.ToList();
}
static bool AllTokensPresent(string query, string target)
{
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(target))
return false;
var tokens = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return tokens.Length > 0 && tokens.All(token => target.Contains(token, StringComparison.Ordinal));
}
static string CleanInlineText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
value = Regex.Replace(value, "<.*?>", string.Empty, RegexOptions.Singleline);
value = HtmlDecode(value);
value = value.Replace("\r", " ").Replace("\n", " ").Trim();
return Regex.Replace(value, "\\s{2,}", " ").Trim();
}
static string HtmlDecode(string value) => WebUtility.HtmlDecode(value ?? string.Empty);
static string BuildAbsoluteUrl(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
value = value.Trim();
if (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return value;
if (!value.StartsWith('/'))
value = "/" + value;
return BaseUrl + value;
}
static string BuildCookieHeader(IReadOnlyList<BrowserContextCookiesResult> cookies)
{
if (cookies == null || cookies.Count == 0)
return null;
var parts = cookies
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Value != null)
.Select(c => $"{c.Name}={c.Value}")
.ToArray();
return parts.Length == 0 ? null : string.Join("; ", parts);
}
static bool IsBlockedHtml(string html)
{
if (string.IsNullOrWhiteSpace(html))
return true;
return html.Contains("Just a moment", StringComparison.OrdinalIgnoreCase)
|| html.Contains("cf-challenge", StringComparison.OrdinalIgnoreCase)
|| html.Contains("challenge-platform", StringComparison.OrdinalIgnoreCase)
|| html.Contains("Attention Required", StringComparison.OrdinalIgnoreCase)
|| html.Contains("Access denied", StringComparison.OrdinalIgnoreCase);
}
static string BuildCookieCacheKey(string sid) => $"z3fm:cookie:{sid}";
sealed class ProxyConfiguration
{
public string Server { get; init; }
public string Username { get; init; }
public string Password { get; init; }
}
sealed class Z3FmBrowserPage
{
public string Url { get; init; }
public string Html { get; init; }
public string CookieHeader { get; init; }
}
sealed class Z3FmCookieContext
{
public string CookieHeader { get; init; }
public string Referer { get; init; }
}
sealed class Z3FmParsedEntry
{
public string sid { get; init; }
public string artist { get; init; }
public string title { get; init; }
public string trackUrl { get; set; }
public string downloadUrl { get; set; }
public int? durationMs { get; init; }
}
sealed class Z3FmMatchPayload
{
public string sid { get; set; }
public string track_url { get; set; }
public string download_url { get; set; }
}
static ProxyConfiguration ParseConfiguredProxy()
{
if (!IsProxyEnabled)
return null;
string raw = ModInit.conf?.z3fm_proxy_url?.Trim();
if (string.IsNullOrWhiteSpace(raw))
return null;
if (!raw.Contains("://", StringComparison.Ordinal))
raw = "http://" + raw;
if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri) || string.IsNullOrWhiteSpace(uri.Host))
return null;
string username = ModInit.conf?.z3fm_proxy_username?.Trim();
string password = ModInit.conf?.z3fm_proxy_password ?? string.Empty;
if (string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(uri.UserInfo))
{
var parts = uri.UserInfo.Split(':', 2, StringSplitOptions.None);
username = Uri.UnescapeDataString(parts[0]);
password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
}
var builder = new UriBuilder(uri)
{
UserName = string.Empty,
Password = string.Empty
};
return new ProxyConfiguration
{
Server = builder.Uri.GetLeftPart(UriPartial.Authority),
Username = string.IsNullOrWhiteSpace(username) ? null : username,
Password = string.IsNullOrWhiteSpace(username) ? null : password
};
}
}
@@ -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<MusicAuthState> GetStateAsync(string profileId = null, CancellationToken cancellationToken = default)
{
var credentials = await MusicAuthStorageService.GetAsync<SoundCloudCredentials>(profileId, Id, cancellationToken);
credentials = SoundCloudSupport.NormalizeCredentials(credentials);
bool hasToken = SoundCloudSupport.HasAccessToken(credentials);
bool expired = SoundCloudSupport.IsTokenExpired(credentials);
return new MusicAuthState
{
provider_id = Id,
provider_name = Name,
authenticated = hasToken && !expired,
requires_auth = true,
token_ready = hasToken,
web_ready = hasToken,
mode = "oauth",
message = SoundCloudSupport.BuildStateMessage(credentials)
};
}
public async Task<bool> SaveAsync(string payload, string profileId = null, CancellationToken cancellationToken = default)
{
var parsed = SoundCloudSupport.ParseSavePayload(payload);
if (!parsed.TryGetValue("access_token", out var accessToken) || string.IsNullOrWhiteSpace(accessToken))
return false;
var credentials = await MusicAuthStorageService.GetAsync<SoundCloudCredentials>(profileId, Id, cancellationToken) ?? new SoundCloudCredentials();
credentials.access_token = accessToken;
credentials.refresh_token = parsed.TryGetValue("refresh_token", out var refreshToken) ? refreshToken : credentials.refresh_token;
credentials.token_type = parsed.TryGetValue("token_type", out var tokenType) ? tokenType : credentials.token_type;
credentials.scope = parsed.TryGetValue("scope", out var scope) ? scope : credentials.scope;
credentials.username = parsed.TryGetValue("username", out var username) ? username : credentials.username;
credentials.user_urn = parsed.TryGetValue("user_urn", out var userUrn) ? userUrn : credentials.user_urn;
if (parsed.TryGetValue("expires_in", out var expiresInRaw) && long.TryParse(expiresInRaw, out var expiresInSeconds) && expiresInSeconds > 0)
credentials.expires_at_unix = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + expiresInSeconds;
await MusicAuthStorageService.SaveAsync(profileId, Id, SoundCloudSupport.NormalizeCredentials(credentials), cancellationToken);
return true;
}
public Task LogoutAsync(string profileId = null, CancellationToken cancellationToken = default)
{
return MusicAuthStorageService.DeleteAsync(profileId, Id, cancellationToken);
}
}
@@ -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<List<MusicBrowseSection>> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default)
{
var feed = await GetTopAlbumsAsync(cancellationToken);
if (feed.Count == 0)
return new List<MusicBrowseSection>();
var sections = new List<MusicBrowseSection>
{
new MusicBrowseSection
{
id = "browse:top_albums",
title = "Популярные альбомы",
type = "album",
source_provider = Id,
has_more = feed.Count > limit,
albums = feed.Take(limit).ToList()
}
};
var fresh = feed
.Where(i => TryParseDate(i.date, out var releaseDate) && releaseDate >= DateTime.UtcNow.Date.AddDays(-180))
.OrderByDescending(i => i.date)
.Take(limit)
.ToList();
if (fresh.Count > 0)
{
sections.Add(new MusicBrowseSection
{
id = "browse:fresh_releases",
title = "Свежие релизы",
type = "album",
source_provider = Id,
has_more = fresh.Count >= limit,
albums = fresh
});
}
sections.AddRange(BuildGenreSections(feed, limit));
return sections;
}
public async Task<MusicBrowseSection> GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default)
{
var feed = await GetTopAlbumsAsync(cancellationToken);
if (feed.Count == 0)
return null;
return sectionId switch
{
"browse:top_albums" => new MusicBrowseSection
{
id = sectionId,
title = "Популярные альбомы",
type = "album",
source_provider = Id,
has_more = false,
albums = feed.Take(Math.Max(limit, 1)).ToList()
},
"browse:fresh_releases" => new MusicBrowseSection
{
id = sectionId,
title = "Свежие релизы",
type = "album",
source_provider = Id,
has_more = false,
albums = feed
.Where(i => TryParseDate(i.date, out var releaseDate) && releaseDate >= DateTime.UtcNow.Date.AddDays(-180))
.OrderByDescending(i => i.date)
.Take(Math.Max(limit, 1))
.ToList()
},
_ when TryParseGenreSection(feed, sectionId, out var genre) => BuildGenreSection(feed, genre, Math.Max(limit, 1), hasMore: false),
_ => null
};
}
public static bool IsChartAlbum(string provider, string id)
=> string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase)
|| (id ?? string.Empty).StartsWith("applecharts:", StringComparison.OrdinalIgnoreCase);
public Task<MusicAlbum> GetAlbumAsync(string id, CancellationToken cancellationToken = default)
{
string albumId = (id ?? string.Empty).StartsWith("applecharts:", StringComparison.OrdinalIgnoreCase)
? id["applecharts:".Length..]
: id;
return Regex.IsMatch(albumId ?? string.Empty, "^[0-9]+$")
? AppleMusicSupport.GetCatalogAlbumAsync(CurrentCountry, albumId, cancellationToken)
: Task.FromResult<MusicAlbum>(null);
}
async Task<List<MusicAlbum>> GetTopAlbumsAsync(CancellationToken cancellationToken)
{
string country = GetCountry();
var albums = await MusicMetadataCacheService.GetOrCreateAsync(
ProviderId,
"browse",
$"{country}:top-albums",
cacheTtl,
() => LoadTopAlbumsAsync(country, cancellationToken),
cancellationToken
) ?? new List<MusicAlbum>();
string resolver = GetAlbumResolver();
return albums.Select(album => CopyAlbumForResponse(album, resolver)).ToList();
}
static MusicAlbum CopyAlbumForResponse(MusicAlbum album, string resolver)
{
return new MusicAlbum
{
id = album.id,
title = album.title,
artist_id = album.artist_id,
artist_name = album.artist_name,
lookup_query = album.lookup_query,
lookup_provider = resolver,
year = album.year,
date = album.date,
type = album.type,
release_id = album.release_id,
description = album.description,
search_score = album.search_score,
secondary_types = album.secondary_types?.ToList() ?? new List<string>(),
images = album.images?.ToList() ?? new List<MusicImage>(),
provider_refs = album.provider_refs?.ToList() ?? new List<MusicProviderRef>(),
tracks = album.tracks?.ToList() ?? new List<MusicTrack>()
};
}
async Task<List<MusicAlbum>> LoadTopAlbumsAsync(string country, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://rss.marketingtools.apple.com/api/v2/{country}/music/most-played/100/albums.json");
request.Headers.TryAddWithoutValidation("User-Agent", userAgent);
request.Headers.TryAddWithoutValidation("Accept", "application/json");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return new List<MusicAlbum>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var root = JsonNode.Parse(json) as JsonObject;
var results = root?["feed"]?["results"] as JsonArray;
if (results == null)
return new List<MusicAlbum>();
return results
.Select(node => ParseAlbum(node as JsonObject))
.Where(i => i != null)
.ToList();
}
static string GetCountry()
{
string country = ModInit.conf?.applemusic_country;
if (string.IsNullOrWhiteSpace(country))
return defaultCountry;
country = country.Trim().ToLowerInvariant();
return Regex.IsMatch(country, "^[a-z]{2}$") ? country : defaultCountry;
}
public static string CurrentCountry => GetCountry();
static string GetAlbumResolver()
{
string resolver = ModInit.conf?.applemusic_album_resolver?.Trim().ToLowerInvariant();
return resolver is "applemusic" or "spotify" or "soundcloud" or "musicbrainz" ? resolver : "auto";
}
static MusicAlbum ParseAlbum(JsonObject item)
{
if (item == null)
return null;
string title = item["name"]?.GetValue<string>()?.Trim();
string artistName = item["artistName"]?.GetValue<string>()?.Trim();
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artistName))
return null;
string lookupQuery = $"{artistName} {title}";
string artwork = UpgradeArtwork(item["artworkUrl100"]?.GetValue<string>());
string date = item["releaseDate"]?.GetValue<string>();
string appleId = item["id"]?.GetValue<string>();
string externalId = string.IsNullOrWhiteSpace(appleId)
? StableId(lookupQuery)
: appleId;
return new MusicAlbum
{
id = $"applecharts:{externalId}",
title = title,
artist_name = artistName,
lookup_query = lookupQuery,
lookup_provider = GetAlbumResolver(),
date = date,
year = ParseYear(date),
type = "Album",
description = JoinGenres(item["genres"] as JsonArray),
images = string.IsNullOrWhiteSpace(artwork)
? new List<MusicImage>()
: new List<MusicImage> { new() { url = artwork, width = 600, height = 600 } },
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = externalId }
}
};
}
static string JoinGenres(JsonArray items)
{
if (items == null)
return null;
var genres = items
.Select(node => node?["name"]?.GetValue<string>())
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct()
.ToList();
return genres.Count == 0 ? null : string.Join(" · ", genres);
}
static List<MusicBrowseSection> BuildGenreSections(List<MusicAlbum> feed, int limit)
{
if (feed == null || feed.Count == 0)
return new List<MusicBrowseSection>();
return feed
.Select(item => PrimaryGenre(item))
.Where(genre => !string.IsNullOrWhiteSpace(genre))
.GroupBy(genre => genre)
.Where(group => group.Count() >= Math.Max(6, limit / 2))
.OrderByDescending(group => group.Count())
.ThenBy(group => group.Key)
.Take(3)
.Select(group => BuildGenreSection(feed, group.Key, limit, hasMore: group.Count() > limit))
.Where(section => section != null && section.albums.Count > 0)
.ToList();
}
static MusicBrowseSection BuildGenreSection(List<MusicAlbum> feed, string genre, int limit, bool hasMore)
{
if (feed == null || feed.Count == 0 || string.IsNullOrWhiteSpace(genre))
return null;
var albums = feed
.Where(album => string.Equals(PrimaryGenre(album), genre, StringComparison.OrdinalIgnoreCase))
.Take(Math.Max(limit, 1))
.ToList();
if (albums.Count == 0)
return null;
return new MusicBrowseSection
{
id = $"browse:genre:{GenreSlug(genre)}",
title = $"Популярное: {LocalizeGenre(genre)}",
type = "album",
source_provider = ProviderId,
has_more = hasMore,
albums = albums
};
}
static string PrimaryGenre(MusicAlbum album)
{
if (album == null || string.IsNullOrWhiteSpace(album.description))
return null;
return album.description
.Split(" · ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault(genre => !string.Equals(genre, "Music", StringComparison.OrdinalIgnoreCase));
}
static string GenreSlug(string genre)
{
if (string.IsNullOrWhiteSpace(genre))
return string.Empty;
var slug = System.Text.RegularExpressions.Regex.Replace(genre.ToLowerInvariant(), @"[^a-z0-9]+", "-").Trim('-');
return string.IsNullOrWhiteSpace(slug) ? StableId(genre) : slug;
}
static bool TryParseGenreSection(List<MusicAlbum> feed, string sectionId, out string genre)
{
genre = null;
if (string.IsNullOrWhiteSpace(sectionId) || !sectionId.StartsWith("browse:genre:", StringComparison.OrdinalIgnoreCase))
return false;
var slug = sectionId.Substring("browse:genre:".Length);
if (string.IsNullOrWhiteSpace(slug))
return false;
var feedGenres = feed?
.Select(PrimaryGenre)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList() ?? new List<string>();
genre = feedGenres.FirstOrDefault(item => string.Equals(GenreSlug(item), slug, StringComparison.OrdinalIgnoreCase));
return !string.IsNullOrWhiteSpace(genre);
}
static string LocalizeGenre(string genre)
{
return genre switch
{
"Hip-Hop/Rap" => "Хип-хоп и рэп",
"Pop" => "Поп",
"Rock" => "Рок",
"Country" => "Кантри",
"Alternative" => "Альтернатива",
"R&B/Soul" => "R&B и соул",
"Dance" => "Танцевальная",
"Singer/Songwriter" => "Авторская песня",
"Latin" => "Латино",
"Christian" => "Христианская музыка",
"Jazz" => "Джаз",
"Electronic" => "Электроника",
"K-Pop" => "K-Pop",
"Indie Rock" => "Инди-рок",
"Hard Rock" => "Хард-рок",
"Soundtrack" => "Саундтреки",
_ => genre
};
}
static string UpgradeArtwork(string url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
return System.Text.RegularExpressions.Regex.Replace(
url,
"/\\d+x\\d+bb\\.",
"/600x600bb.",
System.Text.RegularExpressions.RegexOptions.IgnoreCase
);
}
static int? ParseYear(string value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length < 4)
return null;
return int.TryParse(value.Substring(0, 4), out var year) ? year : null;
}
static bool TryParseDate(string value, out DateTime date)
{
return DateTime.TryParse(value, out date);
}
static string StableId(string value)
{
var bytes = MD5.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
@@ -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<List<MusicBrowseSection>> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default)
{
return GetHomeSectionsInternalAsync(limit, cancellationToken);
}
public Task<MusicBrowseSection> GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default)
{
return GetSectionInternalAsync(sectionId, limit, cancellationToken);
}
public Task<MusicAlbum> GetAlbumAsync(string id, CancellationToken cancellationToken = default)
{
return SoundCloudSupport.GetChartAlbumAsync(id, cancellationToken);
}
async Task<List<MusicBrowseSection>> GetHomeSectionsInternalAsync(int limit, CancellationToken cancellationToken)
{
var albums = await SoundCloudSupport.GetChartAlbumsAsync(cancellationToken);
if (albums.Count == 0)
return new List<MusicBrowseSection>();
return new List<MusicBrowseSection>
{
new()
{
id = SoundCloudSupport.ChartsSectionId,
title = "SoundCloud",
type = "album",
source_provider = Id,
has_more = albums.Count > limit,
albums = albums.Take(Math.Max(limit, 1)).ToList()
}
};
}
async Task<MusicBrowseSection> GetSectionInternalAsync(string sectionId, int limit, CancellationToken cancellationToken)
{
if (!string.Equals(sectionId, SoundCloudSupport.ChartsSectionId, StringComparison.OrdinalIgnoreCase))
return null;
var albums = await SoundCloudSupport.GetChartAlbumsAsync(cancellationToken);
if (albums.Count == 0)
return null;
return new MusicBrowseSection
{
id = SoundCloudSupport.ChartsSectionId,
title = "SoundCloud",
type = "album",
source_provider = Id,
has_more = false,
albums = albums.ToList()
};
}
}
@@ -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<Playlist> globalPlaylists = new List<Playlist>
{
new() { title = "Топ-50 Spotify: мир", playlistId = "37i9dQZEVXbMDoHDwVN2tF" },
new() { title = "Today's Top Hits", playlistId = "37i9dQZF1DXcBWIGoYBM5M" },
new() { title = "New Music Friday", playlistId = "37i9dQZF1DX4JAvHpjipBk" },
new() { title = "RapCaviar", playlistId = "37i9dQZF1DX0XUsuxWHRQd" },
new() { title = "Fresh Finds", playlistId = "37i9dQZF1DWWjGdmeTyeJ6" }
};
static readonly IReadOnlyList<Playlist> themedPlaylists = new List<Playlist>
{
new() { title = "Клубные хиты", playlistId = "37i9dQZF1DX0BcQWzuB7ZO" },
new() { title = "Ностальгическая вечеринка", playlistId = "37i9dQZF1DX7F6T2n2fegs" },
new() { title = "Хиты 2000-х", playlistId = "37i9dQZF1DX4o1oenSJRJd" },
new() { title = "Хиты 2010-х", playlistId = "37i9dQZF1DWWylYLMvjuRG" },
new() { title = "Весёлая пятница", playlistId = "37i9dQZF1DX1g0iEXLFycr" }
};
static readonly IReadOnlyDictionary<string, IReadOnlyList<Playlist>> countryThemedPlaylists =
new Dictionary<string, IReadOnlyList<Playlist>>(StringComparer.OrdinalIgnoreCase)
{
["GLOBAL"] = new List<Playlist>
{
new() { title = "Хиты 80-х", playlistId = "37i9dQZF1DX4UtSsGT1Sbe" },
new() { title = "Хиты 90-х", playlistId = "37i9dQZF1DXbTxeAdrVG2l" },
new() { title = "Классика рока", playlistId = "37i9dQZF1DWXRqgorJj26U" },
new() { title = "Песни для поездки", playlistId = "37i9dQZF1DWWMOmoXKqHTD" },
new() { title = "Спокойные хиты", playlistId = "37i9dQZF1DX4WYpdgoIcn6" }
},
["UA"] = new List<Playlist>
{
new() { title = "Літні хіти України", playlistId = "37i9dQZF1DX86ZB9BGPT0a" },
new() { title = "Українська ностальгія", playlistId = "3FUapgKzH0x7qSygyWS7rk" },
new() { title = "Українські хіти 2000-х", playlistId = "3Id8TtStr3uzNv79RhOgmb" },
new() { title = "Українські легенди", playlistId = "158jVnGiYCk96G6oJweWVz" },
new() { title = "Українська вечірка", playlistId = "3wKPQ5RdRZymbAcEEkVRwT" }
},
["CA"] = new List<Playlist>
{
new() { title = "Классика канадского рока", playlistId = "37i9dQZF1DX4bKgmgKIPtO" },
new() { title = "Канада: хиты 90-х", playlistId = "37i9dQZF1DX9NmM48Aqz3e" },
new() { title = "Канадский хип-хоп", playlistId = "37i9dQZF1DX59ogDi1Z2XL" },
new() { title = "Классика канадской инди-музыки", playlistId = "37i9dQZF1DX5dx2bmWL9SZ" },
new() { title = "Канадское кантри", playlistId = "37i9dQZF1DWYV2Gh2QglGo" }
},
["US"] = new List<Playlist>
{
new() { title = "Американа: классика", playlistId = "37i9dQZF1DX7tfbjVrTPnV" },
new() { title = "R&B-вечеринка", playlistId = "37i9dQZF1DX2hNQN2Fv6Cy" },
new() { title = "Хип-хоп 90-х", playlistId = "37i9dQZF1DX186v583rmzp" },
new() { title = "Хип-хоп 2000-х", playlistId = "37i9dQZF1DX1lHW2vbQwNN" },
new() { title = "Хип-хоп 2010-х", playlistId = "37i9dQZF1DX97h7ftpNSYT" }
},
["RU"] = new List<Playlist>
{
new() { title = "Русские клубные хиты", playlistId = "0PwJbcDouwFD1oKhynlUR9" },
new() { title = "Русская ностальгия", playlistId = "5oeCCfcOxkBLRL5AXf0iVs" },
new() { title = "Русские хиты 2000-х", playlistId = "1JNYqFwOv1bIrXUjHTIDAC" },
new() { title = "Русские хиты 2010-х", playlistId = "6OZJ2pHVbVto0SgTMMAK0G" },
new() { title = "Русская вечеринка", playlistId = "4IYyB7pb7JIoT3ciWBgJJa" }
},
["PL"] = new List<Playlist>
{
new() { title = "Rap klub", playlistId = "37i9dQZF1DX3HUaZJRcDLd" },
new() { title = "Polskie przeboje wszech czasów", playlistId = "37i9dQZF1DX8J2l55TrZk6" },
new() { title = "Polski rap 2000.", playlistId = "37i9dQZF1DX4tIiLXPvlZC" },
new() { title = "Polskie lata 2010.", playlistId = "37i9dQZF1DX7bSIS915wSM" },
new() { title = "Polska impreza", playlistId = "37i9dQZF1DX7P3ukP665LS" }
},
["FR"] = new List<Playlist>
{
new() { title = "La French Touch", playlistId = "37i9dQZF1DX9cbNxuNYT3d" },
new() { title = "Pop francophone", playlistId = "37i9dQZF1DX9ZKIkjFureA" },
new() { title = "Années 2000", playlistId = "37i9dQZF1DWWTe89CFDZnE" },
new() { title = "Années 2010", playlistId = "37i9dQZF1DWVrO0SOklesK" },
new() { title = "Soirée française", playlistId = "37i9dQZF1EIe3TBxFniBXv" }
},
["DE"] = new List<Playlist>
{
new() { title = "Deutschpop Klassiker", playlistId = "37i9dQZF1DX2cNqJ4LgCMf" },
new() { title = "Schlager Klassiker", playlistId = "37i9dQZF1DX6uHioFvkN7A" },
new() { title = "Deutschrap 2000er", playlistId = "37i9dQZF1DX5sbPvjd2Huv" },
new() { title = "Deutschrap 2010er", playlistId = "37i9dQZF1DX1q42kBiHxxd" },
new() { title = "POPLAND", playlistId = "37i9dQZF1DXbKGrOUA30KN" }
}
};
static readonly IReadOnlyDictionary<string, IReadOnlyList<Playlist>> countryPlaylists =
new Dictionary<string, IReadOnlyList<Playlist>>(StringComparer.OrdinalIgnoreCase)
{
["UA"] = new List<Playlist>
{
new() { title = "Топ-50 Spotify: Украина", playlistId = "37i9dQZEVXbKkidEfWYRuD" },
new() { title = "Hot Hits Україна", playlistId = "37i9dQZF1DX1V3tM4cuX0v" },
new() { title = "TOP POP 2026", playlistId = "37i9dQZF1DX2vTOtsQ5Isl" },
new() { title = "EQUAL Україна", playlistId = "37i9dQZF1DWTtjLYc6QFF2" },
new() { title = "Music for Ukraine", playlistId = "37i9dQZF1DWY0TJvcbosib" }
},
["CA"] = new List<Playlist>
{
new() { title = "Топ-50 Spotify: Канада", playlistId = "37i9dQZEVXbKj23U1GF4IR" },
new() { title = "Hot Hits Canada", playlistId = "37i9dQZF1DWXT8uSSn6PRy" },
new() { title = "New Music Friday Canada", playlistId = "37i9dQZF1DX5DfG8gQdC3F" },
new() { title = "Fresh Finds Canada", playlistId = "37i9dQZF1DWVxStm5ni6tl" },
new() { title = "Today's Top Hits", playlistId = "37i9dQZF1DXcBWIGoYBM5M" }
},
["US"] = new List<Playlist>
{
new() { title = "Топ-50 Spotify: США", playlistId = "37i9dQZEVXbLRQDuF5jeBp" },
new() { title = "Hot Hits USA", playlistId = "37i9dQZF1DX0kbJZpiYdZl" },
new() { title = "Today's Top Hits", playlistId = "37i9dQZF1DXcBWIGoYBM5M" },
new() { title = "New Music Friday", playlistId = "37i9dQZF1DX4JAvHpjipBk" },
new() { title = "RapCaviar", playlistId = "37i9dQZF1DX0XUsuxWHRQd" }
},
["RU"] = new List<Playlist>
{
new() { title = "Русский Топ-50", playlistId = "1qkR4WrgRWFg1w3Qik1lTu" },
new() { title = "Русские хиты", playlistId = "7xm8bTP6NFGMDudMHsC2qp" },
new() { title = "Русский поп", playlistId = "4dL0DgSaYON6tODhwM72gw" },
new() { title = "Русский рэп", playlistId = "0Y8djwSTijxFoC3Z0vdinF" },
new() { title = "Русский рок", playlistId = "0Fb2Hc0MdcME6knQ0vFiby" }
},
["PL"] = new List<Playlist>
{
new() { title = "Топ-50 Spotify: Польша", playlistId = "37i9dQZEVXbN6itCcaL3Tt" },
new() { title = "Hot Hits Polska", playlistId = "37i9dQZF1DX49bSMRljsho" },
new() { title = "New Music Friday Polska", playlistId = "37i9dQZF1DXaMTfp0LE4AZ" },
new() { title = "Fresh Finds Polska", playlistId = "37i9dQZF1DWTI0B69TStH2" },
new() { title = "RADAR Polska", playlistId = "37i9dQZF1DX1aXwAOtpwvU" }
},
["FR"] = new List<Playlist>
{
new() { title = "Топ-50 Spotify: Франция", playlistId = "37i9dQZEVXbIPWwFssbupI" },
new() { title = "Hits du Moment", playlistId = "37i9dQZF1DWVuV87wUBNwc" },
new() { title = "La Hit Liste", playlistId = "37i9dQZF1DX7LjobXS2hzX" },
new() { title = "New Music Friday France", playlistId = "37i9dQZF1DX742okrrpwah" },
new() { title = "PVNCHLNRS", playlistId = "37i9dQZF1DX1X23oiQRTB5" }
},
["DE"] = new List<Playlist>
{
new() { title = "Топ-50 Spotify: Германия", playlistId = "37i9dQZEVXbJiZcmkrIHGU" },
new() { title = "Hot Hits Deutschland", playlistId = "37i9dQZF1DX4jP4eebSWR9" },
new() { title = "New Music Friday Deutschland", playlistId = "37i9dQZF1DWUW2bvSkjcJ6" },
new() { title = "Deutschrap Brandneu", playlistId = "37i9dQZF1DWSTqUqJcxFk6" },
new() { title = "Fresh Finds GSA", playlistId = "37i9dQZF1DX2ddCYH6QIK5" }
}
};
const string defaultCountry = "US";
public string Id => SpotifySupport.DiscoveryProviderId;
public string Name => "Spotify";
public bool Enabled => ModInit.conf?.spotify_discovery_enabled == true;
public async Task<List<MusicBrowseSection>> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default)
{
var section = await BuildSectionAsync(limit, cancellationToken);
return section == null ? new List<MusicBrowseSection>() : new List<MusicBrowseSection> { section };
}
public async Task<MusicBrowseSection> GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default)
{
var profile = GetProfile();
if (!string.Equals(sectionId, BuildSectionId(profile.country), StringComparison.OrdinalIgnoreCase))
return null;
return await BuildSectionAsync(limit, cancellationToken);
}
public static bool IsPlaylistAlbum(string provider, string id)
=> string.Equals(provider, SpotifySupport.DiscoveryProviderId, StringComparison.OrdinalIgnoreCase)
&& TryGetPlaylistId(id, out _);
public async Task<MusicAlbum> GetPlaylistAlbumAsync(string id, CancellationToken cancellationToken = default)
{
if (!TryGetPlaylistId(id, out string playlistId))
return null;
var profile = GetProfile();
var playlist = profile.playlists.FirstOrDefault(item =>
string.Equals(item.playlistId, playlistId, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
return null;
var album = await LoadPlaylistAsync(playlist.playlistId, SpotifySupport.PlaylistTrackLimit, cancellationToken);
return album == null ? null : CopyAlbum(album, playlist.title, includeTracks: true);
}
async Task<MusicBrowseSection> BuildSectionAsync(int limit, CancellationToken cancellationToken)
{
var profile = GetProfile();
int take = Math.Clamp(limit, 1, profile.playlists.Count);
var playlists = profile.playlists.Take(take).ToList();
var tasks = playlists.Select(item => LoadPlaylistAsync(item.playlistId, 1, cancellationToken)).ToList();
var results = await Task.WhenAll(tasks);
var albums = new List<MusicAlbum>();
for (int i = 0; i < playlists.Count; i++)
{
if (results[i] != null)
albums.Add(CopyAlbum(results[i], playlists[i].title, includeTracks: false));
}
if (albums.Count == 0)
return null;
return new MusicBrowseSection
{
id = BuildSectionId(profile.country),
title = profile.title,
type = "album",
source_provider = SpotifySupport.DiscoveryProviderId,
has_more = profile.playlists.Count > take,
albums = albums
};
}
static (string country, string title, IReadOnlyList<Playlist> playlists) GetProfile()
{
string country = NormalizeCountry(ModInit.conf?.spotify_country);
if (!countryPlaylists.TryGetValue(country, out var playlists))
return ("GLOBAL", "Spotify: мир", AppendThemed("GLOBAL", globalPlaylists));
string title = country switch
{
"UA" => "Spotify: Украина",
"CA" => "Spotify: Канада",
"US" => "Spotify: США",
"RU" => "Spotify: Россия",
"PL" => "Spotify: Польша",
"FR" => "Spotify: Франция",
"DE" => "Spotify: Германия",
_ => "Spotify"
};
return (country, title, AppendThemed(country, playlists));
}
static IReadOnlyList<Playlist> AppendThemed(string country, IReadOnlyList<Playlist> playlists)
{
var result = playlists.ToList();
if (countryThemedPlaylists.TryGetValue(country, out var countryPlaylists))
result.AddRange(countryPlaylists);
result.AddRange(themedPlaylists);
return result;
}
static string NormalizeCountry(string country)
{
country = country?.Trim().ToUpperInvariant();
return !string.IsNullOrWhiteSpace(country) && Regex.IsMatch(country, "^[A-Z]{2}$")
? country
: defaultCountry;
}
static Task<MusicAlbum> LoadPlaylistAsync(string playlistId, int limit, CancellationToken cancellationToken)
{
int take = Math.Clamp(limit, 1, SpotifySupport.PlaylistTrackLimit);
return MusicMetadataCacheService.GetOrCreateAsync(
SpotifySupport.DiscoveryProviderId,
"playlist",
$"{playlistId}:paged-v2:{take}",
cacheTtl,
() => SpotifySupport.GetPlaylistAlbumAsync(playlistId, take, cancellationToken),
cancellationToken
);
}
static MusicAlbum CopyAlbum(MusicAlbum album, string title, bool includeTracks)
{
return new MusicAlbum
{
id = album.id,
title = string.IsNullOrWhiteSpace(title) ? album.title : title,
artist_name = album.artist_name,
type = album.type,
description = album.description,
images = album.images?.ToList() ?? new List<MusicImage>(),
provider_refs = album.provider_refs?.ToList() ?? new List<MusicProviderRef>(),
tracks = includeTracks ? album.tracks?.ToList() ?? new List<MusicTrack>() : new List<MusicTrack>()
};
}
static bool TryGetPlaylistId(string id, out string playlistId)
{
playlistId = null;
const string prefix = "spotify:playlist:";
if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return false;
string value = id[prefix.Length..];
if (!Regex.IsMatch(value, "^[A-Za-z0-9]{22}$"))
return false;
playlistId = value;
return true;
}
static string BuildSectionId(string country)
=> $"browse:spotify:{country.ToLowerInvariant()}";
}
@@ -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<List<MusicBrowseSection>> GetHomeSectionsAsync(int limit, CancellationToken cancellationToken = default)
{
var feed = await GetTracksAsync(cancellationToken);
if (feed.Count == 0)
return new List<MusicBrowseSection>();
return new List<MusicBrowseSection>
{
new()
{
id = "browse:vk_top_tracks",
title = "Топ треки VK",
type = "track",
source_provider = Id,
has_more = feed.Count > limit,
tracks = feed.Take(limit).ToList()
}
};
}
public async Task<MusicBrowseSection> GetSectionAsync(string sectionId, int limit, CancellationToken cancellationToken = default)
{
if (!string.Equals(sectionId, "browse:vk_top_tracks", StringComparison.OrdinalIgnoreCase))
return null;
var feed = await GetTracksAsync(cancellationToken);
if (feed.Count == 0)
return null;
return new MusicBrowseSection
{
id = sectionId,
title = "Топ треки VK",
type = "track",
source_provider = Id,
has_more = false,
tracks = feed.Take(Math.Max(limit, 1)).ToList()
};
}
async Task<List<MusicTrack>> GetTracksAsync(CancellationToken cancellationToken)
{
return await MusicMetadataCacheService.GetOrCreateAsync(
providerId,
"browse",
"top200:tracks:v2",
cacheTtl,
() => LoadTracksAsync(cancellationToken),
cancellationToken
) ?? new List<MusicTrack>();
}
async Task<List<MusicTrack>> LoadTracksAsync(CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, baseUrl);
request.Headers.TryAddWithoutValidation("User-Agent", userAgent);
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return new List<MusicTrack>();
var html = await response.Content.ReadAsStringAsync(cancellationToken);
var json = ExtractTracksJsonArray(html);
if (json == null)
return new List<MusicTrack>();
return json
.OfType<JsonObject>()
.Select(ParseTrack)
.Where(i => i != null)
.ToList();
}
static JsonArray ExtractTracksJsonArray(string html)
{
if (string.IsNullOrWhiteSpace(html))
return null;
const string marker = "\\\"tracks\\\":[";
int markerIndex = html.IndexOf(marker, StringComparison.Ordinal);
if (markerIndex < 0)
return null;
int start = html.IndexOf('[', markerIndex);
if (start < 0)
return null;
int depth = 0;
for (int i = start; i < html.Length; i++)
{
char c = html[i];
if (c == '[')
{
depth++;
continue;
}
if (c == ']')
{
depth--;
if (depth == 0)
{
string arrayJson = html.Substring(start, i - start + 1)
.Replace("\\\"", "\"")
.Replace("\\\\", "\\");
return JsonNode.Parse(arrayJson) as JsonArray;
}
}
}
return null;
}
static MusicTrack ParseTrack(JsonObject item)
{
if (item == null)
return null;
string title = item["title"]?.GetValue<string>()?.Trim();
string artist = item["artist"]?.GetValue<string>()?.Trim();
string sourceId = item["id"]?.GetValue<int?>()?.ToString();
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
return null;
string trackId = string.IsNullOrWhiteSpace(sourceId)
? $"vkchart:{StableId($"{artist}::{title}")}"
: $"vkchart:{sourceId}";
string cover = item["cover_url"]?.GetValue<string>();
int? position = item["position"]?.GetValue<int?>();
return new MusicTrack
{
id = trackId,
title = title,
artist_name = artist,
album_title = "VK Top 200",
track_number = position,
images = string.IsNullOrWhiteSpace(cover)
? new List<MusicImage>()
: new List<MusicImage> { new() { url = cover, width = 640, height = 640 } },
provider_refs = new List<MusicProviderRef>
{
new() { provider = providerId, external_id = sourceId ?? trackId }
}
};
}
static string StableId(string value)
{
var bytes = MD5.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
@@ -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<List<MusicTrack>> SearchTracksByQueryAsync(
string query,
int limit = 10,
CancellationToken cancellationToken = default,
string expectedArtist = null,
bool requireAuthorMatch = false)
{
if (string.IsNullOrWhiteSpace(query))
return new List<MusicTrack>();
try
{
using var youtube = new YoutubeClient(youtubeHttp);
var videos = new List<VideoSearchResult>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var searchQuery in BuildQueries(query).Take(maxSearchQueries))
{
int queryCount = 0;
await foreach (var video in youtube.Search.GetVideosAsync(searchQuery, cancellationToken))
{
string videoId = video.Id.Value;
if (!string.IsNullOrWhiteSpace(videoId) && seen.Add(videoId))
videos.Add(video);
queryCount++;
if (queryCount >= Math.Max(limit * 2, 12) || videos.Count >= Math.Max(limit * 3, 18))
break;
}
if (videos.Count >= Math.Max(limit * 3, 18))
break;
}
return videos
.Where(video => !requireAuthorMatch
|| SameArtist(CleanArtist(video.Author.ChannelTitle), expectedArtist))
.Select(video => MapTrack(video, expectedArtist))
.Where(i => i != null)
.Take(Math.Max(1, limit))
.ToList();
}
catch
{
return new List<MusicTrack>();
}
}
public static async Task<List<MusicAlbum>> SearchPlaylistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
return new List<MusicAlbum>();
try
{
using var youtube = new YoutubeClient(youtubeHttp);
var playlists = new List<MusicAlbum>();
var seen = new HashSet<string>(StringComparer.Ordinal);
await foreach (var playlist in youtube.Search.GetPlaylistsAsync(query, cancellationToken))
{
var mapped = MapPlaylist(playlist);
if (mapped != null && seen.Add(mapped.id))
playlists.Add(mapped);
if (playlists.Count >= Math.Max(1, limit))
break;
}
return playlists;
}
catch
{
return new List<MusicAlbum>();
}
}
public static async Task<List<MusicArtist>> SearchArtistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
return new List<MusicArtist>();
try
{
using var youtube = new YoutubeClient(youtubeHttp);
var artists = new List<MusicArtist>();
var seen = new HashSet<string>(StringComparer.Ordinal);
await foreach (var channel in youtube.Search.GetChannelsAsync($"{query} music", cancellationToken))
{
var mapped = MapChannel(channel);
if (mapped != null && seen.Add(mapped.id))
artists.Add(mapped);
if (artists.Count >= Math.Max(1, limit))
break;
}
return artists;
}
catch
{
return new List<MusicArtist>();
}
}
public static async Task<MusicAlbum> GetPlaylistAlbumAsync(string id, CancellationToken cancellationToken = default)
{
string playlistId = ExtractPlaylistId(id);
if (!string.IsNullOrWhiteSpace(playlistId))
return await GetPlaylistAlbumInternalAsync(playlistId, cancellationToken);
string channelId = ExtractChannelUploadsId(id);
if (!string.IsNullOrWhiteSpace(channelId))
return await GetChannelUploadsAlbumAsync(channelId, cancellationToken);
return null;
}
static async Task<MusicAlbum> GetPlaylistAlbumInternalAsync(string playlistId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(playlistId))
return null;
try
{
using var youtube = new YoutubeClient(youtubeHttp);
Playlist playlist = null;
try
{
playlist = await youtube.Playlists.GetAsync(playlistId, cancellationToken);
}
catch
{
playlist = null;
}
var album = new MusicAlbum
{
id = $"{playlistPrefix}{playlistId}",
title = playlist?.Title ?? "YouTube Music Playlist",
artist_name = CleanArtist(playlist?.Author.ChannelTitle),
type = "Playlist",
description = "YouTube Music",
images = ExtractImages(playlist),
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = playlistId }
}
};
int number = 1;
await foreach (var video in youtube.Playlists.GetVideosAsync(playlistId, cancellationToken))
{
var track = MapPlaylistTrack(video, album, number);
if (track != null)
{
if (album.images.Count == 0 && track.images.Count > 0)
album.images = track.images;
album.tracks.Add(track);
number++;
}
if (album.tracks.Count >= 50)
break;
}
return album;
}
catch
{
return null;
}
}
public static async Task<MusicArtist> GetChannelArtistAsync(string id, CancellationToken cancellationToken = default)
{
string channelId = ExtractChannelId(id);
if (string.IsNullOrWhiteSpace(channelId))
return null;
try
{
using var youtube = new YoutubeClient(youtubeHttp);
Channel channel = null;
try
{
channel = await youtube.Channels.GetAsync(channelId, cancellationToken);
}
catch
{
channel = null;
}
string name = channel?.Title ?? channelId;
var images = ExtractImages(channel);
return new MusicArtist
{
id = $"{channelPrefix}{channelId}",
name = name,
description = "YouTube Music",
images = images,
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = channelId }
},
albums = new List<MusicAlbum>
{
BuildChannelUploadsAlbum(channelId, name, images)
}
};
}
catch
{
return null;
}
}
public static bool IsPlaylistAlbum(string provider, string id)
{
return string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrWhiteSpace(ExtractPlaylistId(id)) || !string.IsNullOrWhiteSpace(ExtractChannelUploadsId(id)));
}
public static bool IsChannelArtist(string provider, string id)
{
return string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(ExtractChannelId(id));
}
static IEnumerable<string> BuildQueries(string query)
{
query = Regex.Replace(query.Trim(), @"\s+", " ");
yield return $"{query} official audio";
yield return $"{query} topic";
yield return query;
}
static MusicTrack MapTrack(VideoSearchResult video, string expectedArtist = null)
{
string videoId = video?.Id.Value;
string rawTitle = video?.Title?.Trim();
if (string.IsNullOrWhiteSpace(videoId) || string.IsNullOrWhiteSpace(rawTitle))
return null;
string artist = CleanArtist(video.Author.ChannelTitle);
string title = CleanTitle(rawTitle, artist, out var titleArtist);
if (string.IsNullOrWhiteSpace(artist))
artist = titleArtist;
// Artist-pool search knows which artist it requested. This resolves
// both common upload formats (Artist - Title and Title - Artist)
// without guessing in the generic recommendation normalizer.
if (!string.IsNullOrWhiteSpace(expectedArtist)
&& TrySplitTitleSides(rawTitle, out var left, out var right))
{
if (SameArtist(left, expectedArtist))
{
artist = expectedArtist.Trim();
title = StripTitleNoise(right);
}
else if (SameArtist(right, expectedArtist))
{
artist = expectedArtist.Trim();
title = StripTitleNoise(left);
}
}
return new MusicTrack
{
id = $"youtube:{videoId}",
title = string.IsNullOrWhiteSpace(title) ? rawTitle : title,
artist_name = artist,
artists = string.IsNullOrWhiteSpace(artist) ? new List<string>() : new List<string> { artist },
duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null,
images = ExtractImages(video),
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = videoId }
}
};
}
static bool TrySplitTitleSides(string rawTitle, out string left, out string right)
{
left = null;
right = null;
if (string.IsNullOrWhiteSpace(rawTitle))
return false;
foreach (var separator in titleSeparators)
{
var parts = rawTitle.Split(separator, 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
continue;
left = parts[0];
right = parts[1];
return true;
}
return false;
}
static MusicAlbum MapPlaylist(PlaylistSearchResult playlist)
{
string playlistId = playlist?.Id.Value;
string title = playlist?.Title?.Trim();
if (string.IsNullOrWhiteSpace(playlistId) || string.IsNullOrWhiteSpace(title))
return null;
return new MusicAlbum
{
id = $"{playlistPrefix}{playlistId}",
title = title,
artist_name = CleanArtist(playlist.Author.ChannelTitle),
type = "Playlist",
description = "YouTube Music",
images = ExtractImages(playlist),
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = playlistId }
}
};
}
static MusicArtist MapChannel(ChannelSearchResult channel)
{
string channelId = channel?.Id.Value;
string title = channel?.Title?.Trim();
if (string.IsNullOrWhiteSpace(channelId) || string.IsNullOrWhiteSpace(title))
return null;
return new MusicArtist
{
id = $"{channelPrefix}{channelId}",
name = title,
description = "YouTube Music",
images = ExtractImages(channel),
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = channelId }
}
};
}
static MusicAlbum BuildChannelUploadsAlbum(string channelId, string channelTitle, List<MusicImage> images = null)
{
return new MusicAlbum
{
id = $"{channelUploadsPrefix}{channelId}",
title = "Видео канала",
artist_id = $"{channelPrefix}{channelId}",
artist_name = channelTitle,
type = "Playlist",
description = "YouTube Music",
images = images ?? new List<MusicImage>(),
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = channelId }
}
};
}
static async Task<MusicAlbum> GetChannelUploadsAlbumAsync(string channelId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(channelId))
return null;
try
{
using var youtube = new YoutubeClient(youtubeHttp);
Channel channel = null;
try
{
channel = await youtube.Channels.GetAsync(channelId, cancellationToken);
}
catch
{
channel = null;
}
var album = BuildChannelUploadsAlbum(channelId, channel?.Title ?? channelId, ExtractImages(channel));
int number = 1;
await foreach (var video in youtube.Channels.GetUploadsAsync(channelId, cancellationToken))
{
var track = MapUploadTrack(video, album, number);
if (track != null)
{
if (album.images.Count == 0 && track.images.Count > 0)
album.images = track.images;
album.tracks.Add(track);
number++;
}
if (album.tracks.Count >= 50)
break;
}
return album;
}
catch
{
return null;
}
}
static MusicTrack MapPlaylistTrack(PlaylistVideo video, MusicAlbum album, int number)
{
string videoId = video?.Id.Value;
string rawTitle = video?.Title?.Trim();
if (string.IsNullOrWhiteSpace(videoId) || string.IsNullOrWhiteSpace(rawTitle))
return null;
string artist = CleanArtist(video.Author.ChannelTitle);
string title = CleanTitle(rawTitle, artist, out var titleArtist);
if (string.IsNullOrWhiteSpace(artist))
artist = titleArtist;
return new MusicTrack
{
id = $"youtube:{videoId}",
title = string.IsNullOrWhiteSpace(title) ? rawTitle : title,
artist_name = artist,
artists = string.IsNullOrWhiteSpace(artist) ? new List<string>() : new List<string> { artist },
album_id = album?.id,
album_title = album?.title,
duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null,
track_number = number,
images = ExtractImages(video),
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = videoId }
}
};
}
static MusicTrack MapUploadTrack(object video, MusicAlbum album, int number)
{
string videoId = GetNestedString(video, "Id", "Value");
string rawTitle = GetPropertyString(video, "Title")?.Trim();
if (string.IsNullOrWhiteSpace(videoId) || string.IsNullOrWhiteSpace(rawTitle))
return null;
string artist = CleanArtist(GetNestedString(video, "Author", "ChannelTitle"));
string title = CleanTitle(rawTitle, artist, out var titleArtist);
if (string.IsNullOrWhiteSpace(artist))
artist = titleArtist;
return new MusicTrack
{
id = $"youtube:{videoId}",
title = string.IsNullOrWhiteSpace(title) ? rawTitle : title,
artist_name = artist,
artists = string.IsNullOrWhiteSpace(artist) ? new List<string>() : new List<string> { artist },
album_id = album?.id,
album_title = album?.title,
duration_ms = GetDurationMs(video),
track_number = number,
images = ExtractImages(video),
provider_refs = new List<MusicProviderRef>
{
new() { provider = ProviderId, external_id = videoId }
}
};
}
static List<MusicImage> ExtractImages(params object[] sources)
{
var images = new List<MusicImage>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(string url, int? width = null, int? height = null)
{
if (string.IsNullOrWhiteSpace(url) || !seen.Add(url))
return;
images.Add(new MusicImage
{
url = url,
width = width,
height = height
});
}
foreach (var source in sources)
{
if (source == null)
continue;
Add(GetPropertyString(source, "LogoUrl"));
if (GetPropertyValue(source, "Thumbnails") is not IEnumerable thumbnails)
continue;
foreach (var thumbnail in thumbnails)
{
string url = GetPropertyString(thumbnail, "Url");
int? width = GetInt(GetNestedValue(thumbnail, "Resolution", "Width")) ?? GetInt(GetPropertyValue(thumbnail, "Width"));
int? height = GetInt(GetNestedValue(thumbnail, "Resolution", "Height")) ?? GetInt(GetPropertyValue(thumbnail, "Height"));
Add(url, width, height);
}
}
return images
.OrderBy(i => i.width ?? 0)
.ThenBy(i => i.height ?? 0)
.ToList();
}
static string GetPropertyString(object target, string name)
{
return GetPropertyValue(target, name)?.ToString();
}
static string GetNestedString(object target, params string[] names)
{
return GetNestedValue(target, names)?.ToString();
}
static object GetNestedValue(object target, params string[] names)
{
object value = target;
foreach (var name in names)
{
value = GetPropertyValue(value, name);
if (value == null)
return null;
}
return value;
}
static object GetPropertyValue(object target, string name)
{
return target?.GetType().GetProperty(name)?.GetValue(target);
}
static int? GetDurationMs(object target)
{
var value = GetPropertyValue(target, "Duration");
return value is TimeSpan duration ? (int)duration.TotalMilliseconds : null;
}
static int? GetInt(object value)
{
if (value == null)
return null;
if (value is int intValue)
return intValue;
if (value is long longValue)
return longValue > int.MaxValue ? int.MaxValue : (int)longValue;
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
}
static string ExtractPlaylistId(string id)
{
if (string.IsNullOrWhiteSpace(id))
return null;
if (id.StartsWith(playlistPrefix, StringComparison.OrdinalIgnoreCase))
return id.Substring(playlistPrefix.Length);
return null;
}
static string ExtractChannelUploadsId(string id)
{
if (string.IsNullOrWhiteSpace(id))
return null;
if (id.StartsWith(channelUploadsPrefix, StringComparison.OrdinalIgnoreCase))
return id.Substring(channelUploadsPrefix.Length);
return null;
}
static string ExtractChannelId(string id)
{
if (string.IsNullOrWhiteSpace(id))
return null;
if (id.StartsWith(channelPrefix, StringComparison.OrdinalIgnoreCase))
return id.Substring(channelPrefix.Length);
return null;
}
static string CleanArtist(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
value = Regex.Replace(value.Trim(), @"\s*-\s*Topic$", string.Empty, RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"\s*VEVO$", string.Empty, RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"\s+", " ").Trim();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
static string CleanTitle(string rawTitle, string knownArtist, out string titleArtist)
{
titleArtist = null;
if (string.IsNullOrWhiteSpace(rawTitle))
return rawTitle;
string title = Regex.Replace(rawTitle.Trim(), @"\s+", " ");
foreach (var separator in titleSeparators)
{
var parts = title.Split(separator, 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2)
continue;
string left = parts[0];
string right = parts[1];
if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
continue;
titleArtist = left;
if (string.IsNullOrWhiteSpace(knownArtist) || SameArtist(left, knownArtist))
return StripTitleNoise(right);
}
return StripTitleNoise(title);
}
static string StripTitleNoise(string title)
{
if (string.IsNullOrWhiteSpace(title))
return title;
title = Regex.Replace(title, @"\s*\((official\s+)?(audio|video|music\s+video|visualizer|lyrics?)\)\s*$", string.Empty, RegexOptions.IgnoreCase);
title = Regex.Replace(title, @"\s*\[(official\s+)?(audio|video|music\s+video|visualizer|lyrics?)\]\s*$", string.Empty, RegexOptions.IgnoreCase);
title = Regex.Replace(title, @"\s+", " ").Trim();
return string.IsNullOrWhiteSpace(title) ? null : title;
}
static bool SameArtist(string left, string right)
{
string a = Normalize(left);
string b = Normalize(right);
return !string.IsNullOrWhiteSpace(a)
&& !string.IsNullOrWhiteSpace(b)
&& (a == b || a.Contains(b, StringComparison.Ordinal) || b.Contains(a, StringComparison.Ordinal));
}
static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = Regex.Replace(value.ToLowerInvariant(), @"\s*-\s*topic$", string.Empty);
value = Regex.Replace(value, @"\s*vevo$", string.Empty);
value = new string(value.Where(ch => char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch)).ToArray());
return Regex.Replace(value, @"\s+", " ").Trim();
}
}
File diff suppressed because it is too large Load Diff
@@ -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(@"<div id=gnodMap>(.*?)</div>", RegexOptions.Singleline | RegexOptions.Compiled);
static readonly Regex anchorRegex = new("<a href=\"[^\"]*\" class=S id=s(\\d+)>([^<]*)</a>", RegexOptions.Compiled);
static MusicMapSupport()
{
httpClient.Timeout = TimeSpan.FromSeconds(10);
}
public static async Task<List<string>> GetSimilarArtistsAsync(string artist, CancellationToken cancellationToken = default)
{
string normalized = NormalizeName(artist);
if (string.IsNullOrWhiteSpace(normalized))
return new List<string>();
try
{
// пустой ответ кэшируется коротким emptyTtl (20 мин) — разовый
// сбой сайта не залипает на 14 дней
return await MusicMetadataCacheService.GetOrCreateAsync(
"musicmap",
"similar_artists",
$"{cacheVersion}|{normalized}",
similarCacheTtl,
() => FetchSimilarArtistsAsync(artist, cancellationToken),
cancellationToken
) ?? new List<string>();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new List<string>();
}
}
static async Task<List<string>> FetchSimilarArtistsAsync(string artist, CancellationToken cancellationToken)
{
var result = new List<string>();
try
{
// формат пути music-map: имя в lower, пробелы -> '+', остальное
// percent-encoded (кириллица и '&' включительно)
string path = Uri.EscapeDataString(artist.Trim().ToLowerInvariant()).Replace("%20", "+");
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://www.music-map.com/{path}");
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return result;
string html = await response.Content.ReadAsStringAsync(cancellationToken);
var block = mapBlockRegex.Match(html ?? string.Empty);
if (!block.Success)
return result;
foreach (Match anchor in anchorRegex.Matches(block.Groups[1].Value))
{
// s0 — сам запрошенный артист, не кандидат
if (anchor.Groups[1].Value == "0")
continue;
string name = System.Net.WebUtility.HtmlDecode(anchor.Groups[2].Value).Trim();
if (!string.IsNullOrWhiteSpace(name))
result.Add(name);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
}
return result;
}
/// <summary>
/// Фильтр кандидатов карты: выбрасывает алиасы/проекты сид-артистов
/// (containment + fuzzy + потокенно — ловит «Myagi», «Mijagi»,
/// «Gruppa Skryptonite»), артистов истории (кросс-скрипт через транслит:
/// «Скриптонит» == «Scriptonite») и дубли по нормализованному имени.
/// Порядок карты (по близости) сохраняется.
/// </summary>
public static List<string> FilterCandidates(IEnumerable<string> candidates, IEnumerable<string> aliasReferences, IEnumerable<string> excludeNames)
{
var references = (aliasReferences ?? Enumerable.Empty<string>()).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var excludedRaw = (excludeNames ?? Enumerable.Empty<string>()).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var excluded = excludedRaw.Select(NormalizeName).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var candidate in candidates ?? Enumerable.Empty<string>())
{
string normalized = NormalizeName(candidate);
if (string.IsNullOrWhiteSpace(normalized) || !seen.Add(normalized))
continue;
if (references.Any(reference => IsAliasOf(candidate, reference)))
continue;
if (excluded.Any(name => name == normalized || FuzzyClose(name, normalized))
|| excludedRaw.Any(name => IsAliasOf(candidate, name)))
continue;
result.Add(candidate);
}
return result;
}
internal static bool IsAliasOf(string candidate, string reference)
{
string c = NormalizeName(candidate);
string r = NormalizeName(reference);
if (string.IsNullOrWhiteSpace(c) || string.IsNullOrWhiteSpace(r))
return string.Equals(candidate?.Trim(), reference?.Trim(), StringComparison.OrdinalIgnoreCase);
if (c.Contains(r, StringComparison.Ordinal) || r.Contains(c, StringComparison.Ordinal))
return true;
// опечаточные алиасы (Myagi/Mijagi) и транслит-варианты
if (FuzzyClose(c, r))
return true;
// потокенно: алиас внутри многословного имени («Gruppa Skryptonite»
// для сида «Скриптонит»)
return TokensOf(candidate).Any(token => FuzzyClose(token, r))
|| TokensOf(reference).Any(token => FuzzyClose(c, token));
}
internal static bool FuzzyClose(string a, string b)
=> Levenshtein(a, b) <= Math.Max(1, Math.Min(a.Length, b.Length) / 4);
static IEnumerable<string> TokensOf(string name)
=> Regex.Split(name ?? string.Empty, @"[^\p{L}\p{Nd}]+")
.Select(NormalizeName)
.Where(token => !string.IsNullOrWhiteSpace(token));
// RU->latin транслит + только [a-z0-9]: кросс-скрипт сравнение имён
// («Скриптонит» и «Scriptonite» сводятся к сравнимым строкам)
internal static string NormalizeName(string name)
{
var builder = new StringBuilder((name ?? string.Empty).Length);
foreach (char raw in (name ?? string.Empty).ToLowerInvariant())
{
if (translit.TryGetValue(raw, out string mapped))
builder.Append(mapped);
else if (raw is >= 'a' and <= 'z' or >= '0' and <= '9')
builder.Append(raw);
}
return builder.ToString();
}
internal static bool ContainsCyrillic(string value)
=> (value ?? string.Empty).Any(ch => ch is >= 'а' and <= 'я' or 'ё' or >= 'А' and <= 'Я' or 'Ё'
or >= 'і' and <= 'ї' or 'є' or 'ґ' or >= 'І' and <= 'Ї' or 'Є' or 'Ґ');
// Для поискового fallback нельзя использовать NormalizeName: он склеивает
// слова. MusicBrainz лучше понимает "maikl dzhekson", чем
// "maikldzhekson", поэтому сохраняем пробелы/разделители.
internal static string TransliterateCyrillicToLatinForSearch(string value)
{
var builder = new StringBuilder((value ?? string.Empty).Length);
bool lastWasSpace = true;
foreach (char raw in (value ?? string.Empty).ToLowerInvariant())
{
if (translit.TryGetValue(raw, out string mapped))
{
if (!string.IsNullOrEmpty(mapped))
{
builder.Append(mapped);
lastWasSpace = false;
}
continue;
}
if (raw is >= 'a' and <= 'z' or >= '0' and <= '9')
{
builder.Append(raw);
lastWasSpace = false;
continue;
}
if (!lastWasSpace)
{
builder.Append(' ');
lastWasSpace = true;
}
}
return builder.ToString().Trim();
}
static readonly Dictionary<char, string> translit = new()
{
['а'] = "a",
['б'] = "b",
['в'] = "v",
['г'] = "g",
['д'] = "d",
['е'] = "e",
['ё'] = "e",
['ж'] = "zh",
['з'] = "z",
['и'] = "i",
['й'] = "i",
['к'] = "k",
['л'] = "l",
['м'] = "m",
['н'] = "n",
['о'] = "o",
['п'] = "p",
['р'] = "r",
['с'] = "s",
['т'] = "t",
['у'] = "u",
['ф'] = "f",
['х'] = "h",
['ц'] = "c",
['ч'] = "ch",
['ш'] = "sh",
['щ'] = "sch",
['ъ'] = "",
['ы'] = "y",
['ь'] = "",
['э'] = "e",
['ю'] = "yu",
['я'] = "ya",
['і'] = "i",
['ї'] = "i",
['є'] = "e",
['ґ'] = "g"
};
static int Levenshtein(string a, string b)
{
a ??= string.Empty;
b ??= string.Empty;
if (a.Length < b.Length)
(a, b) = (b, a);
var previous = new int[b.Length + 1];
var current = new int[b.Length + 1];
for (int j = 0; j <= b.Length; j++)
previous[j] = j;
for (int i = 1; i <= a.Length; i++)
{
current[0] = i;
for (int j = 1; j <= b.Length; j++)
{
int substitution = previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
current[j] = Math.Min(Math.Min(previous[j] + 1, current[j - 1] + 1), substitution);
}
(previous, current) = (current, previous);
}
return previous[b.Length];
}
}
@@ -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<T>
{
public List<T> collection { get; set; } = new();
public string next_href { get; set; }
public string query_urn { get; set; }
}
public class SoundCloudNestedCollectionDto<T>
{
public List<T> collection { get; set; } = new();
}
public class SoundCloudChartSelectionDto
{
public string urn { get; set; }
public string title { get; set; }
public SoundCloudNestedCollectionDto<SoundCloudPlaylistDto> items { get; set; }
}
public class SoundCloudUserDto
{
public string urn { get; set; }
public string username { get; set; }
public string avatar_url { get; set; }
public string permalink_url { get; set; }
}
public class SoundCloudTrackDto
{
public string urn { get; set; }
public string title { get; set; }
public string artwork_url { get; set; }
public string permalink_url { get; set; }
public string stream_url { get; set; }
public string access { get; set; }
public int? duration { get; set; }
public int? full_duration { get; set; }
public string published_at { get; set; }
public SoundCloudUserDto user { get; set; }
public SoundCloudMediaDto media { get; set; }
}
public class SoundCloudPlaylistDto
{
public string urn { get; set; }
public string title { get; set; }
public string artwork_url { get; set; }
public string permalink_url { get; set; }
public int? track_count { get; set; }
public string published_at { get; set; }
public string display_date { get; set; }
public int? duration { get; set; }
public string genre { get; set; }
public SoundCloudUserDto user { get; set; }
public List<SoundCloudTrackDto> tracks { get; set; } = new();
}
public class SoundCloudMediaDto
{
public List<SoundCloudTranscodingDto> transcodings { get; set; } = new();
}
public class SoundCloudTranscodingDto
{
public string url { get; set; }
public string preset { get; set; }
public bool? snipped { get; set; }
public SoundCloudFormatDto format { get; set; }
}
public class SoundCloudFormatDto
{
public string protocol { get; set; }
public string mime_type { get; set; }
}
public class SoundCloudMatchPayload
{
public string track_id { get; set; }
public string urn { get; set; }
public string permalink_url { get; set; }
public string title { get; set; }
public string artist_name { get; set; }
public int? duration_ms { get; set; }
public string track_authorization { get; set; }
}
@@ -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<IReadOnlyList<MusicAudioMatch>> SearchAudioAsync(MusicTrack track, CancellationToken cancellationToken = default)
{
if (track == null)
return Array.Empty<MusicAudioMatch>();
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return Array.Empty<MusicAudioMatch>();
var results = new List<JsonElement>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string exactTrackId = ParseTrackApiId(track.id);
if (!string.IsNullOrWhiteSpace(exactTrackId))
{
var exact = await LoadTrackElementAsync(exactTrackId, clientId, cancellationToken);
if (exact.HasValue && IsPlayableAudioTrack(exact.Value))
{
var exactMatch = MapAudioMatch(exact.Value);
if (exactMatch != null)
return new[] { exactMatch };
}
return Array.Empty<MusicAudioMatch>();
}
foreach (string query in BuildAudioQueries(track))
{
var elements = await SearchTrackElementsAsync(query, clientId, 10, cancellationToken);
if (elements == null)
continue;
foreach (var item in elements)
{
string id = GetString(item, "id");
if (string.IsNullOrWhiteSpace(id) || !seen.Add(id) || !IsPlayableAudioTrack(item))
continue;
results.Add(item);
if (results.Count >= 16)
break;
}
if (results.Count >= 16)
break;
}
return RankAudioMatches(track, results);
}
public static async Task<IReadOnlyList<MusicPlaybackSource>> BuildAudioSourcesAsync(MusicAudioMatch match, CancellationToken cancellationToken = default)
{
if (match == null)
return Array.Empty<MusicPlaybackSource>();
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return Array.Empty<MusicPlaybackSource>();
var payload = ParseMatchPayload(match);
string trackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
if (string.IsNullOrWhiteSpace(trackId))
return Array.Empty<MusicPlaybackSource>();
var track = await LoadTrackElementAsync(trackId, clientId, cancellationToken);
if (!track.HasValue || !track.Value.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object)
return Array.Empty<MusicPlaybackSource>();
if (!media.TryGetProperty("transcodings", out var transcodings) || transcodings.ValueKind != JsonValueKind.Array)
return Array.Empty<MusicPlaybackSource>();
var sources = new List<MusicPlaybackSource>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var transcoding in OrderPlayableTranscodings(transcodings))
{
string streamUrl = await ResolveTranscodingUrlAsync(transcoding, clientId, payload?.track_authorization, cancellationToken);
if (string.IsNullOrWhiteSpace(streamUrl) || IsPreviewStreamUrl(streamUrl) || !seen.Add(streamUrl))
continue;
string mime = GetFormatMime(transcoding);
string protocol = GetFormatProtocol(transcoding);
sources.Add(new MusicPlaybackSource
{
provider_id = AudioProviderId,
url = streamUrl,
mime_type = NormalizeSourceMime(protocol, mime, streamUrl),
quality = BuildQualityLabel(protocol, mime),
bitrate = EstimateBitrate(streamUrl, mime)
});
}
return sources;
}
public static async Task<MusicPlaybackSource> BuildPreferredAudioSourceAsync(MusicAudioMatch match, CancellationToken cancellationToken = default)
{
if (match == null)
return null;
string cacheKey = BuildPreferredSourceCacheKey(match);
if (!string.IsNullOrWhiteSpace(cacheKey))
{
var cached = await MusicMetadataCacheService.GetAsync<MusicPlaybackSource>(AudioProviderId, "preferred_source", cacheKey, cancellationToken);
if (cached != null && !string.IsNullOrWhiteSpace(cached.url))
return cached;
}
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var payload = ParseMatchPayload(match);
string trackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
if (string.IsNullOrWhiteSpace(trackId))
return null;
var track = await LoadTrackElementAsync(trackId, clientId, cancellationToken);
if (!track.HasValue || !track.Value.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object)
return null;
if (!media.TryGetProperty("transcodings", out var transcodings) || transcodings.ValueKind != JsonValueKind.Array)
return null;
foreach (var transcoding in OrderPlayableTranscodings(transcodings))
{
string streamUrl = await ResolveTranscodingUrlAsync(transcoding, clientId, payload?.track_authorization, cancellationToken);
if (string.IsNullOrWhiteSpace(streamUrl) || IsPreviewStreamUrl(streamUrl))
continue;
string mime = GetFormatMime(transcoding);
string protocol = GetFormatProtocol(transcoding);
var source = new MusicPlaybackSource
{
provider_id = AudioProviderId,
url = streamUrl,
mime_type = NormalizeSourceMime(protocol, mime, streamUrl),
quality = BuildQualityLabel(protocol, mime),
bitrate = EstimateBitrate(streamUrl, mime)
};
if (!string.IsNullOrWhiteSpace(cacheKey))
await MusicMetadataCacheService.SaveAsync(AudioProviderId, "preferred_source", cacheKey, source, preferredSourceCacheTtl, cancellationToken);
return source;
}
return null;
}
public static async Task<MusicAudioMatch> GetExactAudioMatchAsync(MusicTrack track, CancellationToken cancellationToken = default)
{
if (track == null)
return null;
string exactTrackId = ParseTrackApiId(track.id);
if (string.IsNullOrWhiteSpace(exactTrackId))
return null;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var exact = await LoadTrackElementAsync(exactTrackId, clientId, cancellationToken);
if (!exact.HasValue || !IsPlayableAudioTrack(exact.Value))
return null;
return MapAudioMatch(exact.Value);
}
static bool IsPreviewStreamUrl(string streamUrl)
{
streamUrl = NormalizeValue(streamUrl);
if (string.IsNullOrWhiteSpace(streamUrl))
return false;
if (streamUrl.Contains("cf-preview-media.sndcdn.com", StringComparison.OrdinalIgnoreCase))
return true;
if (streamUrl.Contains("/preview/", StringComparison.OrdinalIgnoreCase))
return true;
if (streamUrl.Contains("cf-hls-media.sndcdn.com/playlist/0/30/", StringComparison.OrdinalIgnoreCase))
return true;
if (streamUrl.Contains("cf-hls-media.sndcdn.com/media/0/30/", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
public static bool IsRelevantAudioMatch(MusicTrack track, MusicAudioMatch match)
{
if (track == null || match == null)
return false;
var payload = ParseMatchPayload(match);
string expectedTrackId = ParseTrackApiId(track.id);
string actualTrackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
if (!string.IsNullOrWhiteSpace(expectedTrackId))
return string.Equals(expectedTrackId, actualTrackId, StringComparison.OrdinalIgnoreCase);
return ScoreAudioMatch(track, match) >= 40;
}
public static bool HasExactTrackId(MusicTrack track)
=> !string.IsNullOrWhiteSpace(ParseTrackApiId(track?.id));
static List<string> BuildAudioQueries(MusicTrack track)
{
var queries = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(string value)
{
string normalized = NormalizeValue(value);
if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized))
queries.Add(normalized);
}
string title = NormalizeValue(track?.title);
string primaryArtist = NormalizeValue(track?.artist_name);
string allArtists = track?.artists == null ? null : string.Join(", ", track.artists.Where(a => !string.IsNullOrWhiteSpace(a)).Distinct(StringComparer.OrdinalIgnoreCase));
Add($"{primaryArtist} {title}");
Add($"{allArtists} {title}");
Add(title);
return queries;
}
static IReadOnlyList<MusicAudioMatch> RankAudioMatches(MusicTrack expectedTrack, IEnumerable<JsonElement> candidates)
{
var ranked = new List<(MusicAudioMatch match, int score)>();
foreach (var candidate in candidates)
{
var match = MapAudioMatch(candidate);
if (match == null)
continue;
int score = ScoreAudioMatch(expectedTrack, match);
if (score < 40)
continue;
match.duration_ms ??= GetInt(candidate, "full_duration") ?? GetInt(candidate, "duration");
ranked.Add((match, score));
}
return ranked
.OrderByDescending(x => x.score)
.ThenBy(x => Math.Abs((x.match.duration_ms ?? 0) - (expectedTrack?.duration_ms ?? 0)))
.ThenBy(x => x.match.title, StringComparer.OrdinalIgnoreCase)
.Select(x => x.match)
.ToList();
}
static MusicAudioMatch MapAudioMatch(JsonElement track)
{
string trackId = GetString(track, "id");
string rawTitle = NormalizeValue(GetString(track, "title"));
var titleParts = SplitArtistTitleFromSoundCloudTitle(rawTitle);
string title = NormalizeValue(titleParts.title) ?? rawTitle;
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(title))
return null;
string artistName = null;
string permalinkUrl = GetString(track, "permalink_url");
string urn = GetString(track, "urn");
var artists = ExtractCandidateArtists(track);
artistName = artists.FirstOrDefault();
var payload = new SoundCloudMatchPayload
{
track_id = trackId,
urn = urn,
permalink_url = permalinkUrl,
title = title,
artist_name = artistName,
duration_ms = GetInt(track, "full_duration") ?? GetInt(track, "duration"),
track_authorization = NormalizeValue(GetString(track, "track_authorization"))
};
return new MusicAudioMatch
{
provider_id = AudioProviderId,
id = !string.IsNullOrWhiteSpace(urn) ? urn : $"soundcloud:tracks:{trackId}",
title = title,
artists = artists,
isrc = GetTrackIsrc(track),
duration_ms = payload.duration_ms,
payload = MusicJson.Serialize(payload)
};
}
static int ScoreAudioMatch(MusicTrack expectedTrack, MusicAudioMatch candidate)
{
if (expectedTrack == null || candidate == null)
return 0;
string expectedTitle = NormalizeSearchText(expectedTrack.title);
string candidateTitle = NormalizeSearchText(candidate.title);
string expectedArtist = NormalizeSearchText(expectedTrack.artist_name);
var expectedArtists = BuildArtistFragments(new[] { expectedTrack.artist_name }.Concat(expectedTrack.artists ?? Enumerable.Empty<string>()));
var candidateArtists = BuildArtistFragments(candidate.artists);
string candidateArtist = candidateArtists.FirstOrDefault() ?? NormalizeSearchText(candidate.artists.FirstOrDefault());
string expectedIsrc = MusicIsrc.Normalize(expectedTrack.isrc);
string candidateIsrc = MusicIsrc.Normalize(candidate.isrc);
int score = 0;
bool artistMatched = false;
bool durationMatched = false;
if (expectedIsrc != null && candidateIsrc != null)
{
if (!string.Equals(expectedIsrc, candidateIsrc, StringComparison.Ordinal))
return int.MinValue;
score += 120;
}
if (!string.IsNullOrWhiteSpace(expectedTitle) && !string.IsNullOrWhiteSpace(candidateTitle))
{
if (string.Equals(expectedTitle, candidateTitle, StringComparison.Ordinal))
score += 60;
else if (candidateTitle.Contains(expectedTitle, StringComparison.Ordinal) || expectedTitle.Contains(candidateTitle, StringComparison.Ordinal))
score += 35;
else
score += CountSharedTokens(expectedTitle, candidateTitle) * 8;
}
if (expectedArtists.Count > 0 && candidateArtists.Count > 0)
{
if (candidateArtists.Any(a => expectedArtists.Contains(a)))
{
score += 30;
artistMatched = true;
}
else if (candidateArtists.Any(a => expectedArtists.Any(e => a.Contains(e, StringComparison.Ordinal) || e.Contains(a, StringComparison.Ordinal))))
{
score += 16;
artistMatched = true;
}
else
{
int sharedArtistTokens = expectedArtists.Max(e => candidateArtists.Max(a => CountSharedTokens(e, a)));
score += sharedArtistTokens * 6;
artistMatched = sharedArtistTokens > 0;
}
}
else if (!string.IsNullOrWhiteSpace(expectedArtist) && !string.IsNullOrWhiteSpace(candidateArtist))
{
if (string.Equals(expectedArtist, candidateArtist, StringComparison.Ordinal))
{
score += 30;
artistMatched = true;
}
else if (candidateArtist.Contains(expectedArtist, StringComparison.Ordinal) || expectedArtist.Contains(candidateArtist, StringComparison.Ordinal))
{
score += 16;
artistMatched = true;
}
else
{
int sharedArtistTokens = CountSharedTokens(expectedArtist, candidateArtist);
score += sharedArtistTokens * 6;
artistMatched = sharedArtistTokens > 0;
}
}
if (!artistMatched && expectedArtists.Any(a => candidateTitle.Contains(a, StringComparison.Ordinal)))
{
score += 16;
artistMatched = true;
}
if (expectedArtists.Count > 1 && candidateArtists.Count > 0)
{
foreach (string artist in expectedArtists.Skip(1).Take(3))
{
if (candidateArtists.Any(a => a == artist || a.Contains(artist, StringComparison.Ordinal)))
score += 4;
}
}
int expectedDuration = expectedTrack.duration_ms ?? 0;
int candidateDuration = candidate.duration_ms ?? 0;
if (expectedDuration > 0 && candidateDuration > 0)
{
double durationRatio = candidateDuration / (double)expectedDuration;
if (durationRatio >= 2d || durationRatio <= 0.5d)
return int.MinValue;
int delta = Math.Abs(expectedDuration - candidateDuration);
if (delta <= 2_000)
{
score += 18;
durationMatched = true;
}
else if (delta <= 5_000)
{
score += 12;
durationMatched = true;
}
else if (delta <= 10_000)
{
score += 6;
durationMatched = true;
}
else if (durationRatio >= 1.5d || durationRatio <= 0.67d) score -= 54;
else if (durationRatio >= 1.2d || durationRatio <= 0.8d) score -= 24;
else if (delta >= 30_000) score -= 12;
}
if (expectedArtists.Count > 0 && !artistMatched && !durationMatched)
score -= 36;
score -= ComputeDescriptorPenalty(expectedTitle, candidateTitle);
return score;
}
static int ComputeDescriptorPenalty(string expectedText, string candidateText)
{
int penalty = 0;
foreach (var descriptor in hardDescriptorPenalties)
{
bool expectedHas = HasDescriptor(expectedText, descriptor.term);
bool candidateHas = HasDescriptor(candidateText, descriptor.term);
if (!expectedHas && candidateHas)
penalty += descriptor.penalty;
}
return penalty;
}
static readonly (string term, int penalty)[] hardDescriptorPenalties =
[
("fast", 42),
("sped", 42),
("sped up", 42),
("slowed", 42),
("slowed reverb", 54),
("nightcore", 42),
("mp3 wav", 54),
("mp3", 28),
("wav", 28),
("reverb", 18),
("cover", 28),
("karaoke", 28),
("instrumental", 28),
("snippet", 36),
("looped", 28),
("8d", 24),
("bass boosted", 24),
("live", 18),
("remix", 18),
("remastered", 12),
("1 hour", 72),
("hour version", 72),
("long version", 42),
("extended", 12),
("tiktok", 18)
];
static bool HasDescriptor(string text, string descriptor)
{
string normalizedText = NormalizeSearchText(text);
string normalizedDescriptor = NormalizeSearchText(descriptor);
if (string.IsNullOrWhiteSpace(normalizedText) || string.IsNullOrWhiteSpace(normalizedDescriptor))
return false;
if (normalizedDescriptor.Contains(' ', StringComparison.Ordinal))
return normalizedText.Contains(normalizedDescriptor, StringComparison.Ordinal);
var tokens = normalizedText.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return tokens.Contains(normalizedDescriptor, StringComparer.Ordinal);
}
static List<string> ExtractCandidateArtists(JsonElement track)
{
var artists = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void AddMany(string value)
{
foreach (string artist in SplitArtistNames(value))
{
if (!string.IsNullOrWhiteSpace(artist) && seen.Add(artist))
artists.Add(artist);
}
}
if (track.TryGetProperty("publisher_metadata", out var publisher) && publisher.ValueKind == JsonValueKind.Object)
AddMany(GetString(publisher, "artist"));
var titleParts = SplitArtistTitleFromSoundCloudTitle(GetString(track, "title"));
AddMany(titleParts.artist);
if (track.TryGetProperty("user", out var user) && user.ValueKind == JsonValueKind.Object)
AddMany(GetString(user, "username"));
return artists;
}
static List<string> SplitArtistNames(string value)
{
if (string.IsNullOrWhiteSpace(value))
return new List<string>();
return value
.Split(new[] { ",", "&", " x ", " feat. ", " feat ", " ft. ", " ft ", " and " }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(i => NormalizeValue(i))
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
static List<string> BuildArtistFragments(IEnumerable<string> artists)
{
var fragments = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (string artist in artists ?? Enumerable.Empty<string>())
{
foreach (string part in SplitArtistNames(artist))
{
string normalized = NormalizeSearchText(part);
if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized))
fragments.Add(normalized);
}
}
return fragments;
}
static string NormalizeSearchText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var chars = value
.Trim()
.ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) ? c : ' ')
.ToArray();
return string.Join(' ', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
static int CountSharedTokens(string left, string right)
{
if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
return 0;
var leftTokens = left.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var rightSet = right.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.Ordinal);
int shared = 0;
foreach (string token in leftTokens)
{
if (rightSet.Contains(token))
shared++;
}
return shared;
}
static bool IsPlayableAudioTrack(JsonElement track)
{
if (!track.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object)
return false;
if (!media.TryGetProperty("transcodings", out var transcodings) || transcodings.ValueKind != JsonValueKind.Array)
return false;
return OrderPlayableTranscodings(transcodings).Any();
}
static IEnumerable<JsonElement> OrderPlayableTranscodings(JsonElement transcodings)
{
if (transcodings.ValueKind != JsonValueKind.Array)
return Array.Empty<JsonElement>();
return transcodings.EnumerateArray()
.Where(IsPlainPlayableTranscoding)
.Select(t => t.Clone())
.OrderByDescending(GetTranscodingPriority)
.ToList();
}
static bool IsPlainPlayableTranscoding(JsonElement transcoding)
{
string protocol = GetFormatProtocol(transcoding);
string mime = GetFormatMime(transcoding);
if (string.IsNullOrWhiteSpace(protocol) || protocol.Contains("encrypted", StringComparison.OrdinalIgnoreCase))
return false;
return protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase)
|| protocol.Equals("hls", StringComparison.OrdinalIgnoreCase)
|| mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase)
|| mime.Contains("mp4", StringComparison.OrdinalIgnoreCase);
}
static int GetTranscodingPriority(JsonElement transcoding)
{
string protocol = GetFormatProtocol(transcoding);
string mime = GetFormatMime(transcoding);
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return 400;
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase))
return 300;
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return 200;
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase))
return 100;
return 0;
}
static string GetFormatProtocol(JsonElement transcoding)
{
if (transcoding.TryGetProperty("format", out var format) && format.ValueKind == JsonValueKind.Object)
return NormalizeValue(GetString(format, "protocol")) ?? string.Empty;
return string.Empty;
}
static string GetFormatMime(JsonElement transcoding)
{
if (transcoding.TryGetProperty("format", out var format) && format.ValueKind == JsonValueKind.Object)
return NormalizeValue(GetString(format, "mime_type")) ?? "audio/mpeg";
return "audio/mpeg";
}
static async Task<string> ResolveTranscodingUrlAsync(JsonElement transcoding, string clientId, string trackAuthorization, CancellationToken cancellationToken)
{
string transcodingUrl = NormalizeValue(GetString(transcoding, "url"));
if (string.IsNullOrWhiteSpace(transcodingUrl))
return null;
var query = new List<string>
{
$"client_id={Uri.EscapeDataString(clientId)}"
};
if (!string.IsNullOrWhiteSpace(trackAuthorization))
query.Add($"track_authorization={Uri.EscapeDataString(trackAuthorization)}");
string url = transcodingUrl.Contains('?')
? $"{transcodingUrl}&{string.Join("&", query)}"
: $"{transcodingUrl}?{string.Join("&", query)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("licenseAuthToken", out _))
return null;
return NormalizeValue(GetString(document.RootElement, "url"));
}
static SoundCloudMatchPayload ParseMatchPayload(MusicAudioMatch match)
{
if (string.IsNullOrWhiteSpace(match?.payload))
return null;
try
{
return MusicJson.Deserialize<SoundCloudMatchPayload>(match.payload);
}
catch
{
return null;
}
}
static string BuildQualityLabel(string protocol, string mime)
{
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return "MP3";
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return "HLS MP3";
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase) && mime.Contains("mp4", StringComparison.OrdinalIgnoreCase))
return "HLS AAC";
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase))
return "Direct";
return "SoundCloud";
}
static string NormalizeSourceMime(string protocol, string mime, string streamUrl)
{
if (!string.IsNullOrWhiteSpace(protocol) && protocol.Contains("hls", StringComparison.OrdinalIgnoreCase))
return "application/vnd.apple.mpegurl";
if (!string.IsNullOrWhiteSpace(mime))
return mime;
return streamUrl.Contains(".m3u8", StringComparison.OrdinalIgnoreCase) ? "application/vnd.apple.mpegurl" : "audio/mpeg";
}
static int? EstimateBitrate(string streamUrl, string mime)
{
if (!string.IsNullOrWhiteSpace(streamUrl))
{
var match = Regex.Match(streamUrl, @"[._-](\d{2,3})k(?:\.|/)", RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out int bitrate))
return bitrate;
}
return mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase) ? 128 : null;
}
static string BuildPreferredSourceCacheKey(MusicAudioMatch match)
{
var payload = ParseMatchPayload(match);
string trackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
return string.IsNullOrWhiteSpace(trackId) ? null : $"preferred:{trackId}";
}
}
@@ -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<string, string> ParseSavePayload(string payload)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(payload))
return result;
foreach (var part in payload.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
int sep = part.IndexOf('=');
if (sep <= 0)
continue;
string key = WebUtility.UrlDecode(part.Substring(0, sep));
string value = WebUtility.UrlDecode(part[(sep + 1)..]);
if (!string.IsNullOrWhiteSpace(key))
result[key] = value;
}
return result;
}
public static SoundCloudCredentials NormalizeCredentials(SoundCloudCredentials credentials)
{
if (credentials == null)
return null;
credentials.access_token = NormalizeValue(credentials.access_token);
credentials.refresh_token = NormalizeValue(credentials.refresh_token);
credentials.token_type = NormalizeValue(credentials.token_type);
credentials.scope = NormalizeValue(credentials.scope);
credentials.username = NormalizeValue(credentials.username);
credentials.user_urn = NormalizeValue(credentials.user_urn);
return credentials;
}
public static bool HasAccessToken(SoundCloudCredentials credentials)
=> !string.IsNullOrWhiteSpace(NormalizeCredentials(credentials)?.access_token);
public static bool IsTokenExpired(SoundCloudCredentials credentials)
{
var normalized = NormalizeCredentials(credentials);
if (normalized?.expires_at_unix == null || normalized.expires_at_unix <= 0)
return false;
return normalized.expires_at_unix <= DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
public static string BuildStateMessage(SoundCloudCredentials credentials)
{
if (!HasOAuthConfig())
return "SoundCloud OAuth is not configured.";
if (!HasAccessToken(credentials))
return "SoundCloud is not connected.";
return IsTokenExpired(credentials)
? "SoundCloud token is saved, but expired."
: "SoundCloud is connected.";
}
public static string BuildClientCredentialsPayload()
{
if (!HasClientCredentials())
return string.Empty;
var parts = new List<string>
{
"grant_type=client_credentials",
$"client_id={Uri.EscapeDataString(ModInit.conf.soundcloud_client_id)}",
$"client_secret={Uri.EscapeDataString(ModInit.conf.soundcloud_client_secret)}"
};
return string.Join("&", parts);
}
}
File diff suppressed because it is too large Load Diff
@@ -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<MusicUserPlaylistImportResult> ImportResolvedPlaylistAsync(JsonElement playlist, string clientId, CancellationToken cancellationToken)
{
int? trackCount = GetInt(playlist, "track_count");
int loadedTracks = GetArrayLength(playlist, "tracks");
string playlistId = GetString(playlist, "id");
bool knownIncompleteTrackList = trackCount.HasValue && loadedTracks < trackCount.Value;
if (!string.IsNullOrWhiteSpace(playlistId) && (!trackCount.HasValue || loadedTracks < trackCount.Value))
{
var hydrated = await LoadPlaylistByIdAsync(playlistId, clientId, cancellationToken);
if (hydrated.HasValue)
playlist = hydrated.Value;
else if (knownIncompleteTrackList)
return ImportUnavailable("SoundCloud плейлист не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
}
var hydrationIds = CollectTrackIdsForHydration(playlist);
var trackDetails = await LoadTrackDetailsAsync(hydrationIds, clientId, cancellationToken);
if (hydrationIds.Count > 0 && !trackDetails.complete)
return ImportUnavailable("SoundCloud плейлист не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
var album = MapResolvedPlaylist(playlist, trackDetails.tracks);
if (album == null)
return ImportUnavailable("SoundCloud плейлист не удалось прочитать.");
var tracks = DeduplicateTracks(album.tracks);
if (tracks.Count == 0)
return ImportUnavailable("В SoundCloud плейлисте не найдено треков.");
string sourceUrl = album.provider_refs?.FirstOrDefault(i => i?.provider == DiscoveryProviderId)?.external_id
?? GetString(playlist, "permalink_url");
return new MusicUserPlaylistImportResult
{
available = true,
title = string.IsNullOrWhiteSpace(album.title) ? "SoundCloud Playlist" : album.title,
track_count = tracks.Count,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = "soundcloud_playlist",
url = sourceUrl,
playlist_id = playlistId,
title = album.title
}
};
}
static async Task<MusicUserPlaylistImportResult> ImportUserLikesAsync(JsonElement user, string clientId, CancellationToken cancellationToken)
{
const int likesImportLimit = 200;
string userId = GetString(user, "id");
string userUrl = GetString(user, "permalink_url");
string username = NormalizeValue(GetString(user, "username")) ?? "SoundCloud";
if (string.IsNullOrWhiteSpace(userId))
return ImportUnavailable("SoundCloud профиль не удалось прочитать.");
var album = new MusicAlbum
{
id = $"soundcloud:likes:{userId}",
title = "Мои лайки (SoundCloud)",
artist_name = username,
images = BuildImages(GetString(user, "avatar_url"))
};
var likes = await LoadLikedTrackElementsAsync(userId, clientId, likesImportLimit, cancellationToken);
if (!likes.complete)
return ImportUnavailable("SoundCloud лайки не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
string fallbackArtwork = SelectFirstImageUrl(album.images);
int position = 1;
foreach (var item in likes.items)
{
var trackElement = ExtractLikedTrack(item);
if (!trackElement.HasValue)
continue;
var mapped = MapPlaylistTrack(trackElement.Value, album, position++, fallbackArtwork);
if (mapped != null)
album.tracks.Add(mapped);
}
var tracks = DeduplicateTracks(album.tracks);
if (tracks.Count == 0)
return ImportUnavailable("Лайки SoundCloud не найдены или скрыты.");
return new MusicUserPlaylistImportResult
{
available = true,
message = likes.truncated
? $"Импортированы последние {tracks.Count} треков SoundCloud. У профиля есть ещё лайки, они не вошли в лимит {likesImportLimit}."
: null,
title = album.title,
track_count = tracks.Count,
truncated = likes.truncated,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = "soundcloud_likes",
url = userUrl,
user_id = userId,
title = username
}
};
}
static async Task<SoundCloudCollectionPage> LoadLikedTrackElementsAsync(string userId, string clientId, int limit, CancellationToken cancellationToken)
{
SoundCloudCollectionPage incomplete = null;
foreach (var path in new[] { $"users/{userId}/likes", $"users/{userId}/track_likes", $"users/{userId}/likes/tracks" })
{
var page = await SafeLoadCollectionElementsAsync(path, clientId, limit, cancellationToken);
var tracks = page.items.Where(item => ExtractLikedTrack(item).HasValue).ToList();
if (tracks.Count > 0)
{
return new SoundCloudCollectionPage
{
items = tracks,
complete = page.complete,
hasMore = page.hasMore,
nextPage = page.nextPage,
truncated = page.truncated
};
}
if (!page.complete)
incomplete ??= page;
}
return incomplete == null
? SoundCloudCollectionPage.Empty()
: new SoundCloudCollectionPage
{
complete = false,
hasMore = incomplete.hasMore,
nextPage = incomplete.nextPage
};
}
static JsonElement? ExtractLikedTrack(JsonElement item)
{
if (item.ValueKind != JsonValueKind.Object)
return null;
if (item.TryGetProperty("track", out var track) && track.ValueKind == JsonValueKind.Object)
return track;
string kind = NormalizeValue(GetString(item, "kind"));
if (string.Equals(kind, "track", StringComparison.OrdinalIgnoreCase) || item.TryGetProperty("title", out _))
return item;
return null;
}
static bool LooksLikePlaylist(JsonElement element)
=> element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty("tracks", out var tracks)
&& tracks.ValueKind == JsonValueKind.Array
&& !string.IsNullOrWhiteSpace(GetString(element, "permalink_url"));
static bool LooksLikeUser(JsonElement element)
=> element.ValueKind == JsonValueKind.Object
&& !string.IsNullOrWhiteSpace(GetString(element, "username"))
&& !string.IsNullOrWhiteSpace(GetString(element, "permalink_url"));
static string NormalizeSoundCloudUrl(string value)
{
value = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(value))
return null;
if (!value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
value = "https://" + value.TrimStart('/');
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return null;
string host = uri.Host.Trim().ToLowerInvariant();
if (host == "m.soundcloud.com" || host == "www.soundcloud.com")
host = "soundcloud.com";
if (host != "soundcloud.com")
return null;
string path = uri.AbsolutePath.TrimEnd('/');
if (string.IsNullOrWhiteSpace(path) || path == "/")
return null;
return $"https://soundcloud.com{path}";
}
static async Task<string> ExpandSoundCloudShortUrlAsync(string value, CancellationToken cancellationToken)
{
value = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(value))
return null;
if (!value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
value = "https://" + value.TrimStart('/');
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return null;
string host = uri.Host.Trim().ToLowerInvariant();
if (host != "on.soundcloud.com")
return null;
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, uri);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
string finalUrl = response.RequestMessage?.RequestUri?.ToString();
return NormalizeSoundCloudUrl(finalUrl);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
public static async Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(string inputUrl, CancellationToken cancellationToken = default)
{
string soundCloudUrl = NormalizeSoundCloudUrl(inputUrl)
?? await ExpandSoundCloudShortUrlAsync(inputUrl, cancellationToken);
if (string.IsNullOrWhiteSpace(soundCloudUrl))
return ImportUnavailable("Вставь ссылку на SoundCloud профиль или плейлист.");
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return ImportUnavailable("Не удалось получить SoundCloud client_id.");
var resolved = await ResolveUrlElementAsync(soundCloudUrl, clientId, cancellationToken);
if (!resolved.HasValue)
return ImportUnavailable("SoundCloud ссылку не удалось открыть.");
var element = resolved.Value;
string kind = NormalizeValue(GetString(element, "kind"));
if (string.Equals(kind, "user", StringComparison.OrdinalIgnoreCase) || LooksLikeUser(element))
return await ImportUserLikesAsync(element, clientId, cancellationToken);
if (string.Equals(kind, "playlist", StringComparison.OrdinalIgnoreCase) || LooksLikePlaylist(element))
return await ImportResolvedPlaylistAsync(element, clientId, cancellationToken);
return ImportUnavailable("Поддерживаются только SoundCloud профиль и плейлист.");
}
public static Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
return Task.FromResult(ImportUnavailable("У плейлиста нет SoundCloud источника."));
return ImportPlaylistAsync(source.url, cancellationToken);
}
}
@@ -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*(?<artist>.+?)\s+(?:-|–|—|:)\s+(?<title>.+?)\s*$", RegexOptions.Compiled);
static readonly TimeSpan chartsCacheTtl = TimeSpan.FromHours(1);
static readonly TimeSpan playlistCacheTtl = TimeSpan.FromMinutes(30);
static readonly TimeSpan trackCacheTtl = TimeSpan.FromMinutes(30);
static readonly TimeSpan preferredSourceCacheTtl = TimeSpan.FromMinutes(20);
static readonly TimeSpan publicClientIdCacheTtl = TimeSpan.FromHours(6);
static readonly SemaphoreSlim publicClientIdLock = new(1, 1);
static string publicClientId;
static DateTime publicClientIdExpiresAt;
const string chartsCacheVersion = "v9";
static SoundCloudSupport()
{
httpClient.Timeout = TimeSpan.FromSeconds(20);
}
public static HttpClient HttpClient => httpClient;
public static bool IsModuleEnabled => ModInit.conf?.soundcloud_enabled == true;
public static bool IsDiscoveryEnabled => IsModuleEnabled && ModInit.conf?.soundcloud_discovery_enabled == true;
public static bool IsAudioEnabled => IsModuleEnabled && ModInit.conf?.soundcloud_audio_enabled == true;
public static bool IsAuthEnabled => IsModuleEnabled && ModInit.conf?.soundcloud_auth_enabled == true && HasOAuthConfig();
public static bool HasClientId() => !string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_client_id);
public static bool HasClientCredentials() => HasClientId() && !string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_client_secret);
public static bool HasOAuthConfig() => HasClientCredentials() && !string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_redirect_uri);
public static string Country => NormalizeCountry(ModInit.conf?.soundcloud_country);
public static string NormalizeCountry(string country)
{
country = country?.Trim().ToUpperInvariant();
return country is "GB" or "UK" ? "UK" : "US";
}
public static string BuildApiUrl(string path, IDictionary<string, string> query = null)
{
var baseUrl = $"{ApiBaseUrl.TrimEnd('/')}/{path?.TrimStart('/')}";
if (query == null || query.Count == 0)
return baseUrl;
var items = query
.Where(i => !string.IsNullOrWhiteSpace(i.Key) && !string.IsNullOrWhiteSpace(i.Value))
.Select(i => $"{Uri.EscapeDataString(i.Key)}={Uri.EscapeDataString(i.Value)}")
.ToList();
return items.Count == 0 ? baseUrl : $"{baseUrl}?{string.Join("&", items)}";
}
public static string BuildApiV2Url(string path, IDictionary<string, string> query = null)
{
var baseUrl = $"{ApiV2BaseUrl.TrimEnd('/')}/{path?.TrimStart('/')}";
if (query == null || query.Count == 0)
return baseUrl;
var items = query
.Where(i => !string.IsNullOrWhiteSpace(i.Key) && !string.IsNullOrWhiteSpace(i.Value))
.Select(i => $"{Uri.EscapeDataString(i.Key)}={Uri.EscapeDataString(i.Value)}")
.ToList();
return items.Count == 0 ? baseUrl : $"{baseUrl}?{string.Join("&", items)}";
}
public static string BuildChartsUrl(bool trending, string genre = "all-music")
{
string route = trending ? "new" : "";
string url = $"https://soundcloud.com/charts/{route}".TrimEnd('/');
return $"{url}?country={Uri.EscapeDataString(Country)}&genre={Uri.EscapeDataString(string.IsNullOrWhiteSpace(genre) ? "all-music" : genre)}";
}
public static Dictionary<string, string> CreatePublicHeaders(string accessToken = null)
{
var headers = new Dictionary<string, string>
{
["Accept"] = "application/json; charset=utf-8",
["User-Agent"] = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)"
};
if (!string.IsNullOrWhiteSpace(accessToken))
headers["Authorization"] = $"OAuth {accessToken.Trim()}";
return headers;
}
public static async Task<string> GetClientIdAsync(CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_client_id))
return ModInit.conf.soundcloud_client_id.Trim();
if (!string.IsNullOrWhiteSpace(publicClientId) && publicClientIdExpiresAt > DateTime.UtcNow)
return publicClientId;
await publicClientIdLock.WaitAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(publicClientId) && publicClientIdExpiresAt > DateTime.UtcNow)
return publicClientId;
string clientId = await FetchPublicClientIdAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(clientId))
{
publicClientId = clientId;
publicClientIdExpiresAt = DateTime.UtcNow.Add(publicClientIdCacheTtl);
}
return clientId;
}
finally
{
publicClientIdLock.Release();
}
}
static async Task<string> FetchPublicClientIdAsync(CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "https://soundcloud.com/");
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, cancellationToken);
string html = await response.Content.ReadAsStringAsync(cancellationToken);
string clientId = ExtractPublicClientId(html);
if (!string.IsNullOrWhiteSpace(clientId))
return clientId;
foreach (Match asset in publicAssetRegex.Matches(html ?? string.Empty).Take(12))
{
try
{
using var assetRequest = new HttpRequestMessage(HttpMethod.Get, asset.Value);
assetRequest.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
assetRequest.Headers.TryAddWithoutValidation("Accept", "application/javascript,*/*");
using var assetResponse = await httpClient.SendAsync(assetRequest, cancellationToken);
if (!assetResponse.IsSuccessStatusCode)
continue;
string script = await assetResponse.Content.ReadAsStringAsync(cancellationToken);
clientId = ExtractPublicClientId(script);
if (!string.IsNullOrWhiteSpace(clientId))
return clientId;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
// SoundCloud rotates asset bundles often; try the next one.
}
}
return null;
}
static string ExtractPublicClientId(string content)
{
if (string.IsNullOrWhiteSpace(content))
return null;
var legacy = apiClientIdRegex.Match(content);
if (legacy.Success)
return legacy.Groups[1].Value.Trim();
var current = publicClientIdRegex.Match(content);
return current.Success ? current.Groups[1].Value.Trim() : null;
}
static void InvalidatePublicClientIdOnUnauthorized(HttpStatusCode statusCode, string clientId)
{
if (statusCode != HttpStatusCode.Unauthorized || string.IsNullOrWhiteSpace(clientId))
return;
if (!string.Equals(publicClientId, clientId, StringComparison.Ordinal))
return;
publicClientId = null;
publicClientIdExpiresAt = DateTime.MinValue;
}
static async Task<JsonElement?> ResolveUrlElementAsync(string entityUrl, string clientId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(entityUrl) || string.IsNullOrWhiteSpace(clientId))
return null;
string url = BuildApiV2Url("resolve", new Dictionary<string, string>
{
["url"] = entityUrl,
["client_id"] = clientId
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
static async Task<JsonElement?> LoadPlaylistByIdAsync(string playlistId, string clientId, CancellationToken cancellationToken)
{
string url = BuildApiV2Url($"playlists/{playlistId}", new Dictionary<string, string>
{
["representation"] = "full",
["client_id"] = clientId
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
static async Task<SoundCloudTrackDetails> LoadTrackDetailsAsync(List<string> ids, string clientId, CancellationToken cancellationToken)
{
var result = new SoundCloudTrackDetails();
if (ids == null || ids.Count == 0 || string.IsNullOrWhiteSpace(clientId))
return result;
foreach (var chunk in ids.Distinct(StringComparer.OrdinalIgnoreCase).Chunk(50))
{
string url = BuildApiV2Url("tracks", new Dictionary<string, string>
{
["ids"] = string.Join(",", chunk),
["client_id"] = clientId
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
result.complete = false;
continue;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
result.complete = false;
continue;
}
foreach (var track in document.RootElement.EnumerateArray())
{
string id = GetString(track, "id");
if (!string.IsNullOrWhiteSpace(id))
result.tracks[id] = track.Clone();
}
}
return result;
}
static async Task<JsonElement?> LoadTrackElementAsync(string trackId, string clientId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(clientId))
return null;
string url = BuildApiV2Url($"tracks/{trackId}", new Dictionary<string, string>
{
["client_id"] = clientId
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
sealed class SoundCloudCollectionPage
{
public List<JsonElement> items { get; set; } = new();
public bool complete { get; set; } = true;
public bool hasMore { get; set; }
public string nextPage { get; set; }
public bool truncated { get; set; }
public static SoundCloudCollectionPage Empty() => new();
}
sealed class SoundCloudTrackDetails
{
public Dictionary<string, JsonElement> tracks { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public bool complete { get; set; } = true;
}
static async Task<SoundCloudCollectionPage> SafeLoadCollectionElementsAsync(
string path,
string clientId,
int limit,
CancellationToken cancellationToken,
IDictionary<string, string> query = null,
string pageUrl = null)
{
try
{
return await LoadCollectionElementsAsync(path, clientId, limit, cancellationToken, query, pageUrl);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new SoundCloudCollectionPage { complete = false };
}
}
static async Task<SoundCloudCollectionPage> LoadCollectionElementsAsync(
string path,
string clientId,
int limit,
CancellationToken cancellationToken,
IDictionary<string, string> query = null,
string pageUrl = null)
{
var results = new List<JsonElement>();
var result = new SoundCloudCollectionPage { items = results };
if ((string.IsNullOrWhiteSpace(path) && string.IsNullOrWhiteSpace(pageUrl)) || string.IsNullOrWhiteSpace(clientId))
return result;
int target = Math.Max(1, limit);
var queryParams = query == null
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string>(query, StringComparer.OrdinalIgnoreCase);
string url;
if (!string.IsNullOrWhiteSpace(pageUrl))
{
url = EnsureSoundCloudCollectionUrl(pageUrl, clientId);
}
else
{
queryParams["client_id"] = clientId;
queryParams["limit"] = target.ToString();
queryParams["linked_partitioning"] = "1";
url = BuildApiV2Url(path, queryParams);
}
for (int page = 0; page < 8 && !string.IsNullOrWhiteSpace(url) && results.Count < target; page++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
result.complete = false;
result.hasMore = true;
result.nextPage = CleanSoundCloudPageUrl(url);
return result;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in document.RootElement.EnumerateArray())
{
if (results.Count >= target)
break;
results.Add(item.Clone());
}
result.hasMore = false;
result.nextPage = null;
break;
}
if (!document.RootElement.TryGetProperty("collection", out var collection) || collection.ValueKind != JsonValueKind.Array)
{
result.complete = false;
break;
}
int beforeCount = results.Count;
foreach (var item in collection.EnumerateArray())
{
if (results.Count >= target)
break;
results.Add(item.Clone());
}
string nextUrl = GetString(document.RootElement, "next_href");
result.hasMore = !string.IsNullOrWhiteSpace(nextUrl) && results.Count > beforeCount;
result.nextPage = result.hasMore ? CleanSoundCloudPageUrl(nextUrl) : null;
result.truncated = result.hasMore && results.Count >= target;
url = result.hasMore && results.Count < target
? EnsureSoundCloudCollectionUrl(nextUrl, clientId)
: null;
}
if (!string.IsNullOrWhiteSpace(url) && results.Count < target)
{
result.complete = false;
result.hasMore = true;
result.nextPage = CleanSoundCloudPageUrl(url);
}
if (result.hasMore && results.Count >= target)
result.truncated = true;
return result;
}
static async Task<List<JsonElement>> LoadUserTrackElementsAsync(string userId, string clientId, int limit, CancellationToken cancellationToken)
{
var page = await LoadCollectionElementsAsync($"users/{userId}/tracks", clientId, limit, cancellationToken);
return page.items;
}
static string EnsureSoundCloudCollectionUrl(string url, string clientId)
{
if (string.IsNullOrWhiteSpace(url))
return url;
if (!url.StartsWith(ApiV2BaseUrl, StringComparison.OrdinalIgnoreCase))
return null;
url = AddQueryParameterIfMissing(url, "client_id", clientId);
return AddQueryParameterIfMissing(url, "linked_partitioning", "1");
}
static string CleanSoundCloudPageUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
if (!url.StartsWith(ApiV2BaseUrl, StringComparison.OrdinalIgnoreCase))
return null;
int queryIndex = url.IndexOf('?');
if (queryIndex < 0)
return url;
string path = url.Substring(0, queryIndex);
var query = url.Substring(queryIndex + 1)
.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(part => !part.StartsWith("client_id=", StringComparison.OrdinalIgnoreCase))
.ToList();
return query.Count == 0 ? path : $"{path}?{string.Join("&", query)}";
}
static string AddQueryParameterIfMissing(string url, string key, string value)
{
if (string.IsNullOrWhiteSpace(url) || string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
return url;
if (Regex.IsMatch(url, $@"[?&]{Regex.Escape(key)}=", RegexOptions.IgnoreCase))
return url;
char separator = url.Contains('?') ? '&' : '?';
return $"{url}{separator}{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}";
}
static async Task<List<JsonElement>> SearchTrackElementsAsync(string query, string clientId, int limit, CancellationToken cancellationToken)
{
return await SearchElementsAsync("search/tracks", query, clientId, limit, cancellationToken);
}
static async Task<List<JsonElement>> SearchElementsAsync(string path, string query, string clientId, int limit, CancellationToken cancellationToken)
{
var results = new List<JsonElement>();
if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(clientId))
return results;
try
{
string url = BuildApiV2Url(path, new Dictionary<string, string>
{
["q"] = query.Trim(),
["client_id"] = clientId,
["limit"] = Math.Max(1, limit).ToString(),
["linked_partitioning"] = "1"
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("collection", out var collection) || collection.ValueKind != JsonValueKind.Array)
return null;
foreach (var item in collection.EnumerateArray())
results.Add(item.Clone());
return results;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
static (string artist, string title) SplitArtistTitleFromSoundCloudTitle(string value)
{
string normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
return (null, null);
var match = titleArtistSeparatorRegex.Match(normalized);
if (!match.Success)
return (null, normalized);
string artist = NormalizeValue(match.Groups["artist"].Value);
string title = NormalizeValue(match.Groups["title"].Value);
if (string.IsNullOrWhiteSpace(artist) || string.IsNullOrWhiteSpace(title))
return (null, normalized);
return (artist, title);
}
static string ParseTrackApiId(string id)
{
if (string.IsNullOrWhiteSpace(id))
return null;
var match = Regex.Match(id.Trim(), @"soundcloud:tracks?:(\d+)$", RegexOptions.IgnoreCase);
return match.Success ? match.Groups[1].Value : null;
}
static string GetString(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value))
return null;
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.ToString(),
JsonValueKind.True => bool.TrueString,
JsonValueKind.False => bool.FalseString,
_ => null
};
}
static int? GetInt(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value))
return null;
if (value.ValueKind == JsonValueKind.Number)
{
if (value.TryGetInt32(out var intValue))
return intValue;
if (value.TryGetInt64(out var longValue) && longValue is <= int.MaxValue and >= int.MinValue)
return (int)longValue;
}
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out var parsed))
return parsed;
return null;
}
static string GetTrackIsrc(JsonElement track)
{
if (!track.TryGetProperty("publisher_metadata", out var publisher) || publisher.ValueKind != JsonValueKind.Object)
return null;
return MusicIsrc.Normalize(GetString(publisher, "isrc"));
}
static int GetArrayLength(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.Array)
return 0;
return value.GetArrayLength();
}
static string NormalizeValue(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
File diff suppressed because it is too large Load Diff
@@ -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);
}
@@ -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;
}
}
+359
View File
@@ -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 в публичный плейлист и импортировать по ссылке); прямой доступ к библиотеке требует логина и не реализован.
+614
View File
@@ -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; }
}
@@ -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);
}
}
@@ -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();
}
@@ -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";
}
}
@@ -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}";
}
@@ -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;
}
}
+121
View File
@@ -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;
}
}
}
@@ -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;
}
}
@@ -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()}";
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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; }
}
}
File diff suppressed because it is too large Load Diff
@@ -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();
}
@@ -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;
}
@@ -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();
}
}
@@ -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; }
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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; }
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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
};
}
}
@@ -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;
}
}
@@ -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();
}

Some files were not shown because too many files have changed in this diff Show More