Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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`** | Интерфейс входа и панели. |
|
||||
@@ -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
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"enable": true,
|
||||
"dynamic": true,
|
||||
"tree": [
|
||||
"AdminPanelController.cs",
|
||||
"ConfigSectionGroups.cs",
|
||||
"ModInit.cs"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enable": true,
|
||||
"references": [
|
||||
"Microsoft.Data.Sqlite"
|
||||
]
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`; контроллеров и статики в модуле нет.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enable": true,
|
||||
"dynamic": true,
|
||||
"tree": [
|
||||
"ModInit.cs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LogUserRequest;
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>' }[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>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enable": true,
|
||||
"version": 1,
|
||||
"namespace": "LogUserRequest",
|
||||
"logDay": 90
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 в публичный плейлист и импортировать по ссылке); прямой доступ к библиотеке требует логина и не реализован.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user