diff --git a/AGENT.md b/AGENT.md index ae409bb..adb6e91 100644 --- a/AGENT.md +++ b/AGENT.md @@ -1,95 +1,114 @@ -# AGENT.md — инструкции для AI-агента +# AGENT.md — инструкции для ИИ-агентов -## О проекте +Работа с проектом: локальный Docker-развёртывание [Lampac NextGen](https://github.com/lampac-nextgen/lampac). Человек читает ответы **только на русском**. -Это **deployment-репозиторий** для локального развёртывания Lampac NextGen (активный форк заброшенного Lampac) в Docker на этой машине. Исходники форка сюда не клонируются — используется официальный образ `ghcr.io/lampac-nextgen/lampac`. +## Источники правды (порядок приоритета) -- Форк: https://github.com/lampac-nextgen/lampac (347+ звёзд, ежедневные коммиты, релизы) -- Документация: https://docs.lampac.dev -- Образ: `ghcr.io/lampac-nextgen/lampac:latest` (multi-arch: amd64/arm64) -- Порт: **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`, `mods/` (включённые модули), `modules/` (резерв, не монтируется), доки, `.gitignore` +1. **Официальная документация** — [docs.lampac.dev](https://docs.lampac.dev) (полный текст страниц: `https://docs.lampac.dev/llms-full.txt`, индекс: `llms.txt`). Не гадать по памяти — проверять в доках. +2. **Живой контейнер** — состояние модулей, манифестов и конфигов брать из контейнера: + ```bash + docker exec lampac sh -c 'cat /lampac/module/<Имя>/manifest.json' + docker exec lampac cat /lampac/current.conf # merged-конфиг: base + init + ``` +3. **Логи контейнера** — `docker logs lampac`. +4. Локальные файлы проекта: `docker-compose.yaml`, `config/init.conf`, `mods/`. -## Продакшн: Proxmox LXC +## Ключевые правила -Постоянное развёртывание — LXC `121` (`lampac`) на Proxmox `root@192.168.88.254`, контейнер `root@192.168.88.23` (DHCP на vmbr0), SSH по ключу `~/.ssh/id_rsa_prodik`. Проект лежит в `/opt/lampac` (rsync с рабочей машины, без `.git`). +### 1. Никогда не правь файлы внутри контейнера/образа -- **Unprivileged** LXC + `features: nesting=1,keyctl=1` (без этого Docker не стартует), `onboot: 1`, 2 core / 4 GB / 32 GB. -- **SMB `\\10.6.0.1\Torrents` для DLNA**: монтируется **на хосте Proxmox** в `/mnt/torrents` (fstab + креды `/root/.smb_cred`, chmod 600) и пробрасывается в контейнер `mp0`. Unprivileged LXC монтировать cifs **не может** (нет `CAP_SYS_ADMIN` в init userns — `mount error: Operation not permitted`, и это не AppArmor). В fstab обязателен `uid=101000,gid=101000`: маппинг LXC `0→100000`, иначе в контейнере файлы видны как root и приложение (uid 1000) не пишет превью (`Access to the path '/lampac/data/dlna/thumbs/' is denied`). -- Том заменён через **`docker-compose.override.yml`** (в контейнере, не в git): `services.lampac.volumes: - /mnt/torrents:/lampac/data/dlna`. Базовый compose хранит `./dlna` — rsync-обновления его не трогают. -- Обновление на сервере: rsync проекта → `cd /opt/lampac && docker compose up -d`. -- Проверки: `docker ps` → `healthy`; эндпоинты `/version?type=hash`, `/online.js`, `/ts/`, `/dlna`, `/extensions` → 200; в логах нет `denied|SqliteException`. - -## Файлы - -| Файл | Назначение | Правила | -| --- | --- | --- | -| `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/*` | Старые 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`, не удалять | -| `.gitignore` | Игнорируемые пути | в игноре: `config/passwd` (секрет), `cache/`, `database/`, `dlna/`, `.DS_Store`; остальное (init.conf, modules/, compose, доки) — переносимое | -| `README.md` / `AGENT.md` | Документация | обновлять при любых изменениях развёртывания | - -## Основные команды +Файлы в `/lampac/module/` (и любые другие в образе) **сгорают при обновлении** (`docker compose pull && up -d`). Включение встроенного модуля делается только пользовательской копией: ```bash -docker compose up -d # запуск -docker compose pull && docker compose up -d # обновление -docker compose restart lampac # рестарт (нужен только для passwd) -docker compose logs -f --tail 100 lampac # логи -docker compose ps # статус -curl -fsS "http://localhost:9118/version?type=hash" # health-check (ожидается 200 + hash) -curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:9118/online.js # ожидается 200 +docker cp lampac:/lampac/module/<Имя> "mods/<Имя>" +# в mods/<Имя>/manifest.json -> "enable": true +# в docker-compose.yaml -> "- ./mods/<Имя>:/lampac/mods/<Имя>" +# docker compose up -d ``` -Проверка работоспособности после любых изменений: health-check выше + контейнер в статусе `healthy`. +См. также официальные шаги: [Включить модуль](https://docs.lampac.dev/modules/overview), [Пользовательские модули](https://docs.lampac.dev/maintenance/custom-modules). -## Правила работы +### 2. Механика загрузки модуля (4 независимых фильтра) -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`, `ExternalBind` и др.) включаются только штатно: копия **всего каталога** модуля с `enable: true`, смонтированная в `/lampac/module//`, либо каталог в `mods//`. Одиночные тома `manifest.json`/`extensions.json` **не использовать** — docs не описывают таких томов (https://docs.lampac.dev/installation, https://docs.lampac.dev/modules/overview). Список модулей: https://docs.lampac.dev/modules/overview - - Текущее состояние (проверка: `docker logs lampac | grep -c "loaded module"` → **117**): `SkipModules` **пуст** — включены ВСЕ модули, включая группы `OnlineAnime`/`OnlineUKR`/`OnlinePaid`/`OnlineGEO` и `Transcoding`. 10 модулей с `enable: false` в образе подняты через копии в `mods/` (таблица «Файлы»). Если после обновления образа какой-то модуль из `mods/` перестал компилироваться — пересоздать копию: `docker cp lampac:/lampac/module/ ./mods/` и выставить `enable: true` в её manifest.json, затем `docker compose up -d`. - - Плагины Lampa: `/online.js`, `/sisi.js`, `/dlna.js`, `/ts.js` и т.д. Большинство Lampac сам подключает клиенту через `/lampainit.js` (см. ниже про `initPlugins`); при необходимости плагин можно добавить вручную по URL. - - **Два уровня включения модуля:** (1) `SkipModules`/manifest — модуль скомпилирован и работает на сервере; (2) `LampaWeb.initPlugins.` в `init.conf` — плагин попадает в список `{initiale}`, который `/lampainit.js` добавляет клиенту Lampa (`Lampa.Plugins.add`). Без второго флага сервер отвечает, но пункт в Lampa не появится (так было с `dlna` — флаг `initPlugins.dlna` добавлен в init.conf). Сейчас подключены: `dlna: true` (в `init.conf`), из `base.conf`: `online`, `sisi`, `torrserver`, `jacred`, `cubProxy`, `tmdbProxy`, `timecode`, `pirate_store`. Особый случай `jacred`: модуль `/jacred.js` не регистрирует — флаг подставляет `{jachost}` = хост Lampac в шаблон, клиент пишет его в `jackett_url` (проверка: `curl /api/v1.0/conf` → 200). - - **Override `plugins/override/lampainit.js` (том `./plugins/lampainit.js`) модули НЕ подключает**: `{initiale}` сервер собирает из `LampaWeb.initPlugins` и подставляет в шаблон после чтения (`ApiController.cs:695`), т.е. и при override тоже. Override — только для правки шаблона настроек клиента (`window.lampa_settings`); в проекте не используется. - - **Магазин плагинов** в Lampa (Настройки → Плагины) читает `/extensions` → образный `LampaWeb/plugins/extensions.json` (override-монтирование отключено). **Дефект образа:** 15 записей каталога ссылаются на `127.0.0.1:9118/plugins/*.js` (pubtorr, checker, hotkeys, torrents...) — HTTP-роута `/plugins/` в коде нет, все эти ссылки битые, устанавливать из магазина такие записи нельзя. Рабочий путь — прямой внешний URL (ручной установкой в Лампе или через `LampaWeb.customPlugins`; так подключён PubTorr: `https://lampame.github.io/main/pubtorr.js`). Если плагин уже установился с битым URL — удалить его в Лампе (Настройки → Плагины), иначе дедуп не даст добавить правильный (семейство `pubtorr.js`). Ответ кешируется `Staticache` на диске: `cache/static//` — после правки удалить соответствующий файл (`grep -rl secuses cache/static/`) или весь каталог static, затем перезапросить. - - **Плагины модулей для ручной установки в Лампе** (Настройки → Плагины → по URL): `/tracks.js`, `/music.js`, `/tg-notify.js`, `/watchtogether.js` (проверяются `curl -o /dev/null -w '%{http_code}' http://localhost:9118/.js` → 200). -5. Новые пользовательские модули и копии включаемых — класть в `mods//` (один том `./mods:/lampac/mods`), переопределения шаблона настроек клиента — `plugins/lampainit.js` → `/lampac/plugins/override/lampainit.js` (модули через это НЕ подключаются — см. п.4). -6. Не раскрывать пароль из `passwd`, не коммитить его, не выводить в логи. -7. Не открывать порт 9118 в интернет без файрвола/reverse proxy; модули `DLNA`, `Tracks`, `Transcoding`, `GStreamer`, `Catalog` не предназначены для публичного доступа. -8. Образ пиннить по тегу при необходимости воспроизводимости (в compose сейчас `:latest`). +Модуль грузится, только если выполнены **все**: -## Структура контейнера (важные пути) +| Фильтр | Где | +|---|---| +| `"enable": true` | `manifest.json` модуля (копия в `mods/` для встроенных) | +| Имя **не** в `BaseModule.SkipModules` | `base.conf` (в образе) + `config/init.conf` | +| Имя попадает в `BaseModule.LoadModules` | если allowlist задан (у нас не задан — грузится всё остальное) | +| Runtime-флаг | секция модуля в `config/init.conf`, например `"gst": { "enable": true }` | -```text -/lampac/ -├── init.conf # конфиг (смонтирован с хоста) -├── passwd # пароль root (смонтирован с хоста) -├── cache/ # кеш (смонтирован) -├── database/ # SQLite и бэкапы конфига (смонтирован) -├── mods/ # пользовательские модули (Roslyn компилирует .cs на лету) -├── module/ # встроенные модули образа -├── plugins/override/ # переопределения JS-плагинов -└── wwwroot/ # статика Lampa UI +`SkipModules` имеет приоритет над `LoadModules` — модуль из обоих списков **не** грузится. Паттерны: точное имя, группа (`OnlineUKR`), regex (`LME.*`), `.*`. + +### 3. `config/init.conf` — валидный JSON + +JSON **без** trailing comma. Перед применением правок проверять: + +```bash +python3 -m json.tool config/init.conf > /dev/null && echo OK ``` -Горячая перезагрузка: `init.conf` watcher каждую секунду; `mods/*.cs` перекомпилируются при изменении. +После правки конфига: `docker compose restart` (том смонтирован, `up -d` не нужен). После правки `manifest.json` в `mods/` с `dynamic: true` — тоже `restart` (или ждать hot-rebuild). После изменения томов в compose — `docker compose up -d`. -## Troubleshooting +### 4. Клиентские плагины Lampa -- **Контейнер нестабилен / 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` в образе — копия в `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`). -- **Обновление сломало конфиг** — свериться с https://github.com/lampac-nextgen/lampac/blob/main/config/example.init.conf (новые ключи добавляются в стартовый конфиг). +- Встроенные плагины: флаги `LampaWeb.initPlugins` (`tracks`, `dlna`, `catalog`, `sync`, `backup`, ...). +- Дополнительные: `LampaWeb.customPlugins` — записи со `status: 1` добавляются как есть; поддерживается плейсхолдер `{localhost}` (подставляется реальный адрес сервера). +- Если плагин «не виден» в веб-клиенте — сначала проверить HTTP-статус его URL на сервере (`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9118/<плагин>.js`), а не лезть в клиент. + +### 4.1 Каталог плагинов (store) `/extensions` + +- Раздел «Плагины» веб-клиента грузит каталог с `/extensions` (при `LampaWeb.initPlugins.pirate_store: true`). +- Источник: `/lampac/module/LampaWeb/plugins/extensions.json` — переопределён mount из `./plugins/extensions.json`. Править хост-файл, в контейнер не залезать. +- `{localhost}` в ссылках store сервер заменяет своим адресом при выдаче. +- Статика: `/lampac/plugins/` **не раздаётся** (проверено: файл → 404), корень `wwwroot` — раздаётся. Все плагины каталога — **локальный mirror** в `./plugins/`, смонтирован **одним directory-mount** в `/lampac/wwwroot/plugins/` → ссылки store `{localhost}/plugins/<имя>.js`. Не монтировать каталог целиком в корень `wwwroot` — скроется веб-клиент `lampa-main`. +- Обновление плагина: исходный URL — в `plugins/sources.json`; скачать, перезаписать файл, перезапуск не нужен (статика). +- При проверке скачанного плагина отличать: (а) реальный код, (б) лоадер `putScriptAsync` (абсолютные URL — безопасен локально; host от currentScript — **требуется патч**, см. showy.js), (в) пустой stub/placeholder (мёртвый плагин, из каталога удалять). +- **Кеш-ловушка**: ответ `/extensions` (и других LampaWeb-маршрутов) кешируется на диск в `cache/static//.json`; кеш переживает `docker compose restart`. После правки store: `rm -rf cache/static/59` (bucket `/extensions`), перезапуск не нужен. Заголовок `X-StatiCache-Status: HIT/MISS` показывает состояние. + +### 5. AdminPanel API из скриптов (root-авторизация) + +- Root-пароль = содержимое `config/passwd`. Механизм: cookie `accspasswd=<пароль>` (таков штатный сценарий: страница входа `auth.html` сама кладёт пароль в эту cookie). Заголовок `lcrqpasswd` — для внутренних вызовов, на `/adminpanel/api/*` с ним уходит 302 на `/adminpanel/auth`. +- Не передавать root-пароль в query string (предупреждение доков). +- Сервер может отвечать 404 вместо 401/403, чтобы не раскрывать админский endpoint. +- Основные маршруты: `GET /adminpanel/api/init` (чтение), `POST /adminpanel/api/init` (полная запись), `POST /adminpanel/api/init/section/{key}` (секция), `GET /adminpanel/api/current` (merged-конфиг), `GET /adminpanel/api/users-json`. +- Правка секций через API пишется в `init.conf` — watcher подхватит без рестарта (runtime-параметры). `customPlugins` — runtime-параметр: плагин появляется в `/lampainit.js` через ~1 с. + +### 6. Диагностика «модуль не найден» + +Чек-лист (в этом порядке): + +1. `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9118/<маршрут>` — 404 подтверждает, что модуль не загружен. +2. `docker logs lampac | grep -E "compilation|loaded module" | grep <Имя>` — скомпилирован ли и загружен ли. +3. Манифест: `enable: true`? (в `mods/` для встроенных) +4. Фильтры `SkipModules`/`LoadModules` в `config/init.conf`. +5. Runtime-секция модуля в `config/init.conf`. +6. В логе после старта ожидаются строки `compilation <Имя>`, `configure module: <Имя>` (если есть конфиг-секция), `loaded module: <Имя>`. + +### 7. Команды эксплуатации + +```bash +docker compose up -d # запуск / пересоздание (при изменении compose) +docker compose restart # после правки init.conf / mods +docker compose logs -f # логи +docker compose pull && docker compose up -d # обновление (сначала поменять тег в compose) +docker exec lampac sh -c '...' # осмотр состояния в контейнере +``` + +`container_name: lampac` — контейнер один; одновременный запуск нескольких compose-файлов с одним именем невозможен. `docker-compose.override.yml` (gitignore) живёт только на сервере Proxmox и не коммитится. + +### 8. Чего не трогать + +- `config/passwd`, `cache/`, `database/`, `dlna/` — в .gitignore, не коммитить и не «чинить» содержимое. + Исключение: `cache/static/` — одноразовый диск-кеш ответов LampaWeb; очистка (`rm -rf cache/static/`) — штатное действие после правки store/статических маршрутов (см. 4.1). +- Тег образа в `docker-compose.yaml` закреплён — при обновлении менять осознанно и проверить доки по новой версии. +- `LampaWeb.autoupdate` — веб-клиент Lampa сам обновляется из git (`yumata/lampa`), `wwwroot/lampa-main` в контейнере перезаписывается — туда не править. + +### 9. После изменений в проекте + +Обновлять `README.md` и `AGENT.md` в той же ревизии: структура, включённые модули, секции конфига, новые правила. + +### 10. Безопасность (не публиковать) + +`config/passwd`, cookie `accspasswd`, заголовок `lcrqpasswd`, `bot_token` в `TelegramBot`. Маршруты `/adminpanel`, `/weblog`, `Tracks`, `Catalog`, `DLNA`, proxy-модули — только для доверенной сети без WAF не открывать. diff --git a/README.md b/README.md index b8080c4..98e3344 100644 --- a/README.md +++ b/README.md @@ -1,260 +1,148 @@ -# Lampac NextGen — self-hosted backend для Lampa (Docker) +# Lampac NextGen — локальный Docker-развёртывание -Локальное развёртывание **[Lampac NextGen](https://github.com/lampac-nextgen/lampac)** — активного форка оригинального Lampac, чей основной репозиторий больше не поддерживается. - -## Почему этот форк - -Оригинальный Lampac (`Mirrowol/Lampac`) давно не обновляется. Из форков наибольшую популярность набрал **lampac-nextgen/lampac**: - -| Показатель | Значение | -| --- | --- | -| Репозиторий | https://github.com/lampac-nextgen/lampac | -| Stars / Forks | ~347 / ~80 (самый популярный форк Lampac) | -| Активность | ежедневные коммиты, релизы (последний `v1.61.2` — 30.09.2026) | -| Стек | ASP.NET Core, .NET 10, AGPL-3.0 | -| Docker-образ | `ghcr.io/lampac-nextgen/lampac` (amd64 + arm64) | -| Документация | https://docs.lampac.dev | -| Чат | https://t.me/LampacTalks/13998 | - -Что умеет: агрегация ссылок с 70+ VOD/аниме/18+ источников, встроенный TorrServer, DLNA, JacRed, транскодинг (GStreamer/FFmpeg), синхронизация закладок, хостинг Lampa UI, Telegram-уведомления, WAF и многое другое. - -## Структура проекта - -```text -Lampac/ -├── docker-compose.yaml # Единственный compose-файл (порт 9118) -├── config/ -│ ├── init.conf # Конфигурация сервера (JSON, hot-reload) -│ └── passwd # Пароль root (СЕКРЕТ, не коммитить) -├── modules/ # Резервные копии manifest-файлов (НЕ монтируются, см. ниже) -├── mods/ # Копии модулей с enable: true (том ./mods:/lampac/mods) -├── cache/ # Кеш (монтируется в /lampac/cache) -├── database/ # SQLite-БД: Sync, TimeCode, SISI (→ /lampac/database) -├── dlna/ # Данные DLNA (медиа/превью, → /lampac/data/dlna) -├── README.md # Эта документация -├── AGENT.md # Инструкции для AI-агента -└── .gitignore -``` - -**Важно:** том `./mods:/lampac/mods` в `docker-compose.yaml` **обязателен** — без него копии модулей не попадают в контейнер, и 10 модулей (Tracks, Music, Tg-notify.bot, WatchTogether, AdminPanel, ExternalBind, LogUserRequest-Lite, Telemetry, Transcoding, DatabaseEditor) не загружаются (в логах нет `loaded module: `, роуты вроде `/tracks.js` отдают 404, в Lampa при установке из магазина/по URL — ошибка «модуль нет»). +Локальное развёртывание [Lampac NextGen](https://github.com/lampac-nextgen/lampac) — самохостируемого бекенда для Lampa — в Docker. Официальная документация: [docs.lampac.dev](https://docs.lampac.dev). ## Быстрый старт -Требования: Docker 24+ с Compose v2 (проверка: `docker compose version`). - ```bash -# 1. Запуск -docker compose up -d - -# 2. Проверка -curl -fsS "http://localhost:9118/version?type=hash" # должен вернуть hash -curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:9118/online.js # 200 - -# 3. Логи / статус -docker compose logs -f lampac -docker compose ps +docker compose up -d # первый запуск +docker compose logs -f # логи (строки compilation / configure module / loaded module) +docker compose restart # после правки config/init.conf или манифестов в mods/ ``` -## Доступ +- Веб-клиент Lampa: **http://localhost:9118** +- Админ-панель: **http://localhost:9118/adminpanel** (root-пароль из `config/passwd`) +- TorrServer-прокси: `http://localhost:9118/ts/*` (порт TorrServer — 9085) -| Сервис | URL | -| --- | --- | -| Веб-интерфейс (Lampa UI) | http://localhost:9118/ | -| Плагин Online для Lampa | http://localhost:9118/online.js | -| Плагин SISI (18+) | http://localhost:9118/sisi.js | -| Плагин DLNA для Lampa | http://localhost:9118/dlna.js | -| Проверка версии | http://localhost:9118/version?type=hash | +По умолчанию сервер слушает порт **9118**; если порт занят — меняй `listen.port` в `config/init.conf` до первого запуска. -Чтобы подключить сервер к Lampa: откройте Lampa → Настройки → «Другие сервисы» (или вставьте адрес вручную) → `http://IP_МАШИНЫ:9118`. +## Структура проекта -Для доступа с других устройств локальной сети используйте LAN-IP машины, например `http://192.168.x.x:9118` (порт уже проброшен на `0.0.0.0`). +| Путь | Назначение | +|---|---| +| `docker-compose.yaml` | Основной сервис (образ закреплён на `ghcr.io/lampac-nextgen/lampac:1.68.0`) | +| `config/init.conf` | Конфигурация сервера (JSON, монтируется в `/lampac/init.conf`) | +| `config/passwd` | Пароль root для `/adminpanel` и `/weblog` (не в git) | +| `cache/` | Кеш (`/lampac/cache`) | +| `database/` | SQLite-базы Sync, TimeCode, Music, tg-notify (`/lampac/database`) | +| `dlna/` | Локальные медиа для DLNA (`/lampac/data/dlna`) | +| `plugins/extensions.json` | Каталог плагинов для веб-клиента (раздел «Плагины»), отдаётся через `/extensions` | +| `mods/<Имя>/` | Пользовательские копии встроенных модулей с `enable: true` | + +> `docker-compose.override.yml` (в .gitignore) — серверное override для Proxmox (том DLNA -> `/mnt/torrents`), живёт только на сервере. + +## Включённые модули из `mods/` + +Встроенные модули с `"enable": false` в `manifest.json` внутри образа **нельзя включать правкой файла в контейнере** — файлы пропадут при обновлении. Механика по официальной документации ([модули Lampac](https://docs.lampac.dev/modules/overview), [пользовательские модули](https://docs.lampac.dev/maintenance/custom-modules)): + +1. Скопировать модуль из образа в `mods/<Имя>/` на хосте: + ```bash + docker cp lampac:/lampac/module/<Имя> "mods/<Имя>" + ``` +2. В `mods/<Имя>/manifest.json` поставить `"enable": true`. +3. Добавить том в `docker-compose.yaml`: + ```yaml + - ./mods/<Имя>:/lampac/mods/<Имя> + ``` +4. При необходимости добавить секцию модуля в `config/init.conf` (runtime-флаг). +5. `docker compose up -d` (при добавлении тома) или `restart` (при правке манифеста с `dynamic: true`). + +### Сейчас подключены + +| Модуль | Что даёт | Секция в init.conf | +|---|---|---| +| `Tracks` | `/tracks.js`, `/ffprobe` — субтитры и дорожки | `"Tracks": { "enable": true }` | +| `AdminPanel` | `/adminpanel` — редактор init.conf, current.conf, users.json | `"AdminPanel": { "enable": true }` | +| `Music` | `/music.js`, `/music/*` — музыкальный каталог, плейлисты, стримы | секция `"Music"` (базовые дефолты) | +| `Tg-notify.bot` | `/tg-notify.js`, `/api/tg/*` — уведомления о новых сериях в Telegram | секция `"TelegramBot"` | + +Клиентские плагины `music.js` и `tg-notify.js` зарегистрированы в `LampaWeb.customPlugins`; `{localhost}` в URL автоматически подставляется сервером. + +### Ещё можно включить тем же способом + +В образе выключены также: `DatabaseEditor`, `ExternalBind`, `LogUserRequest-Lite`, `Transcoding`, `WatchTogether`, `TelegramAuth`, `TelegramAuthBot`. ## Конфигурация -### `config/init.conf` +- `config/init.conf` — JSON. После правки: `docker compose restart`. Следи за валидностью JSON (лишняя запятая ломает парсинг). +- `BaseModule.SkipModules` / `BaseModule.LoadModules` — фильтры загрузки модулей (имя, группа, regex). `SkipModules` имеет приоритет над `LoadModules`. +- `LampaWeb.initPlugins` — флаги встроенных плагинов веб-клиента; `LampaWeb.customPlugins` — дополнительные плагины (`status: 1`). +- `LampaWeb.autoupdate: true` — Lampa Web-клиент автообновляется из `yumata/lampa` (раз в 90 минут). -Основной конфиг (JSON). **Перечитывается автоматически без перезапуска** контейнера (watcher каждую секунду, бэкапы — внутри контейнера в `/lampac/database/backup/init/`). После правки достаточно сохранить файл. +## Установка плагинов по ссылке через веб (AdminPanel) -Ключевые разделы: +Список плагинов, который сервер раздаёт всем клиентам через `/lampainit.js` и `/on.js`, собирается по `LampaWeb.initPlugins` + `LampaWeb.customPlugins`. Watcher перечитывает `init.conf` без перезапуска (интервал ~1 с, runtime-параметры применяются на лету). -```jsonc -{ - "listen": { "ip": "0.0.0.0", "port": 9118, "scheme": "http" }, - "BaseModule": { - "SkipModules": [/* выключенные модули, см. ниже */] - }, - "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-защит - "online": { "name": "Lampac NextGen" }, - "sisi": { "NextHUB": true } -} +1. Открой **http://localhost:9118/adminpanel**, введи root-пароль (содержимое `config/passwd`) — пароль сохраняется в cookie `accspasswd` (штатный механизм root-авторизации). +2. В редакторе `init.conf` открой секцию **`LampaWeb`** → **`customPlugins`** и добавь запись: + ```json + { "url": "https://example.com/plugin.js", "status": 1, "name": "ИмяПлагина" } + ``` + Поддерживается плейсхолдер `{localhost}` — сервер подставит свой адрес. +3. Сохрани — перезапуск не нужен. Проверка: `curl -s http://localhost:9118/lampainit.js | grep <имя>`. + +Нюансы: +- `status: 1` — активная запись. +- Family-dedup: `name.js` и `name/js/{token}` считаются одним плагином; если в registry клиента уже есть один вариант, второй из серверного списка не добавляется. +- Внешние URL должны быть доступны из браузеров клиентов и отдавать CORS-заголовки. +- Альтернатива без сервера: вставить ссылку напрямую в веб-клиенте Lampa (настройки → «Плагины») — тогда плагин живёт только в localStorage этого браузера и грузится браузером напрямую. + +API (для скриптов): `GET/POST /adminpanel/api/init`, `POST /adminpanel/api/init/section/{key}` с cookie `accspasswd=`. + +## Каталог плагинов (раздел «Плагины» в веб-клиенте) + +В веб-клиенте Lampa раздел «Плагины» грузит каталог (store) с маршрута **`/extensions`** (включается при `LampaWeb.initPlugins.pirate_store: true`). Содержимое берётся из файла `LampaWeb/plugins/extensions.json` модуля — у нас он переопределён файловым mount из `./plugins/extensions.json` (см. `docker-compose.yaml`). Плейсхолдер `{localhost}` в ссылках сервер заменяет своим адресом. + +**Все плагины каталога размещены локально** (mirror): `.js`-файлы лежат в `./plugins/`, каталог целиком смонтирован в `/lampac/wwwroot/plugins/` (одна строка в compose) и ссылки в каталоге ведут на `{localhost}/plugins/<имя>.js`. (Целиком в корень `wwwroot` монтировать нельзя — перекроется веб-клиент `lampa-main`.) Сами файлы плагинов всегда локальные; исключение — плагин-лоадеры, подгружающие основной код с внешнего хоста: «Клубничка 18+» (rc.bwa.ad) и Showy (showy.online/online.js — файл локально патчен под абсолютный URL, иначе он бы подгружал `/online.js` с нашего сервера). Остальные скачаны целиком (для pubtorr / torrentmanager / td скачано тело, а не лоадер). + +- `plugins/sources.json` — карта «локальный файл → исходный URL». Для обновления плагина: скачать файл по исходному URL, перезаписать локальный, перезапуск не нужен. +- `plugins/modss.js` — локальная patched-копия MODS's (заменён вызов `Lampa.Utils.isPWA()`), раздаётся как `/plugins/modss.js`, но из каталога исключена (upstream мёртв: загрузчик отвечает stub-ошибкой «Используйте: lampa.mx»). Вернуть карточку можно, добавив запись с `link: "{localhost}/plugins/modss.js"`. + +Удалены из каталога (мёртвы upstream, проверено по факту выдачи): +- «MODS's» (`lampa.stream/modss`) — загрузчик отвечает stub-ошибкой; +- «Отзывы и рецензии» (`lampaplugins.github.io/store/o.js`) — файл заменён автором на пустой stub; +- «Вторая кнопка Торренты», «Поиск локального TorrServer», «Горячие кнопки», «Золотая Тема», «Тип управления», «Старый стиль», «Мультфильмы» — публичных копий нет; +- «Телевидение by Skaz» / «ТВ by skaz» / «ТВ by skaz 2.0» — файлы заменены placeholder-заглушками; +- «Импорт/Экспорт закладок, истории» (`skaz.tv/export.js`) — 404. + +Важный нюанс — **диск-кеш ответа**: маршруты LampaWeb кешируют ответы в `cache/static//.json` (том `./cache`), кеш переживает рестарт контейнера. После правки `extensions.json` для мгновенного применения удалить закешированную запись: + +```bash +rm -rf cache/static/59 # bucket /extensions; либо rm -rf cache/static ``` -### Текущий набор модулей +Перезапуск контейнера при этом **не требуется** (маршрут читает файл при каждом запросе, если нет кеш-записи). -**Включены все модули** (проверка: `docker logs lampac | grep -c "loaded module"` → 117): +## Tg-notify.bot: настройка бота -- `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`. +Секция `TelegramBot` в `config/init.conf`: -Клиентские плагины (что подключается в 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`. +| Поле | Назначение | +|---|---| +| `bot_token` | Токен от [@BotFather](https://t.me/BotFather). **Обязателен** — без него long polling не стартует (в логе будет предупреждение, сервер продолжит работать) | +| `tmdb_api_key` | Ключ TMDB v3 для обложек и описаний эпизодов | +| `trakt_client_id` | Client ID Trakt (основной источник новых серий; если пуст — только TMDB) | +| `lampac_host` | Базовый URL Lampac, по умолчанию `http://127.0.0.1:9118` | +| `lampac_token` | Токен `accsdb`, нужен если включена защита | +| `check_interval_minutes` | Период фоновой проверки (дефолт 60, первый прогон через 2 минуты) | +| `data_dir` | Каталог подписок, дефолт `database/tgnotify` | -### Установка плагинов через веб (Настройки → Плагины) +Привязка аккаунта: в Lampa во вкладке «TG Подписки» — deep-link `/start link_{uid}`. Команды бота: `/start`, `/list`, `/check`, `/help`, `/unlink`. -- **По 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 - -### TorrServer - -Включён (`TorrServer` в `init.conf`, бинарник сам скачивается с GitHub в `/lampac/data/ts/`). Доступ **только из локальной сети**: - -- внутренний порт TorrServer `9085` **не проброшен на хост** — снаружи он недоступен; -- внешний доступ идёт только через прокси `http://LAN_IP:9118/ts/` (порт 9118 в compose); -- из интернета сервер не доступен, пока порт 9118 закрыт файрволом/роутером (см. «Безопасность»). - -Пароль (`tspass`) генерируется при старте; Web-UI — `http://LAN_IP:9118/ts/`. - -### Tg-notify (Telegram-бот) - -Модуль `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 - -Чтобы плагин DLNA появился в Lampa, нужно две вещи: - -1. **Модуль** загружен (уже включён: `loaded module: DLNA`). -2. **Флаг `LampaWeb.initPlugins.dlna`** в `init.conf` — именно он решает, попадёт ли DLNA в список плагинов, который Lampac отдаёт клиенту (`/lampainit.js`). Сейчас выставлен `true`; без него Lampa плагин не увидит (модуль работает, а пункт в меню не появляется). - -```jsonc -"LampaWeb": { "initPlugins": { "dlna": true } } -``` - -Аналогично включаются флаги `tracks`, `catalog`, `dorama`, `sync`, `backup`, `watch_together` и др. (список — `LampaWeb/Models/InitPlugins.cs`, дефолты — в `current.conf`). - -После правки перезагрузите страницу Lampa — `lampainit.js` сам добавит плагин (`Lampa.Plugins.add`) и он появится в Настройки → Плагины (со статусом включён). Ручное добавление по ссылке тоже работает: `http://IP_МАШИНЫ:9118/dlna.js`. Прямой UI модуля — `http://IP_МАШИНЫ:9118/dlna`. - -**В магазине плагинов** (Настройки → Плагины/«Магазин») каталог отдаётся из `/extensions` — образного файла `LampaWeb/plugins/extensions.json` (DLNA по умолчанию нет, но плагин и так подключается автодобавлением из `initPlugins`). Магазин кешируется (`Staticache`) — после любых правок образного файла кеш надо сбросить: удалить `cache/static/63/*` (каталог по хешу URL) или весь `cache/static/`. - -### AdminPanel - -Веб-админка 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). -- Доступ к админке не открывайте в интернет без защиты — см. README модуля AdminPanel. - -### `config/passwd` - -Пароль учётной записи `root` в одну строку (используется для служебных функций и WebLog). Сгенерирован при установке; для смены отредактируйте файл и перезапустите контейнер: `docker compose restart lampac`. - -## Обновление +## Обновление Lampac ```bash docker compose pull docker compose up -d ``` -Данные (`init.conf`, `passwd`, `mods/`, `cache/`, `database/`, `dlna/`) переживают обновление, т.к. смонтированы томами. Внимание: содержимое `mods/` — копии из образа, при обновлении образа его вручную не переносят (при расхождении версий модуль может не скомпилироваться — тогда пересоздать копию из свежего контейнера, см. AGENT.md). - -## Перенос на другой сервер - -Игнорируемые пути (`.gitignore`): `config/passwd`, `cache/`, `database/`, `dlna/`, `.DS_Store`. - -**Копировать обязательно:** - -```bash -docker-compose.yaml -config/init.conf # вся конфигурация -config/passwd # СЕКРЕТ — переносить вне git (scp/rsync) -mods/ # копии модулей с enable: true (иначе 10 модулей не загрузятся) -README.md AGENT.md .gitignore -``` - -**Копировать по желанию** (данные пользователя, иначе создадутся заново): - -```bash -database/ # закладки Sync, позиции TimeCode, SISI, библиотека Music -dlna/ # медиа/превью DLNA -``` - -**Не копировать** — регенерируется автоматически: `cache/`, содержимое `/lampac/data` внутри контейнера (GeoLite2, ffmpeg/ffprobe, бинарник TorrServer, Lampa UI). - -Порядок переноса: скопировать файлы → `docker compose up -d` → health-check: `curl "http://localhost:9118/version?type=hash"`. Порт 9118 и пути в compose относительные — правок не требуется. - -## Развёртывание на Proxmox (LXC) - -Постоянный продакшн: LXC-контейнер `121` (`lampac`) на Proxmox `192.168.88.254`, IP контейнера **192.168.88.23** (DHCP). - -| Параметр | Значение | -| --- | --- | -| Шаблон | `local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst` | -| Тип | **unprivileged**, `features: nesting=1,keyctl=1` (нужно Docker), `onboot: 1` | -| Ресурсы | 2 cores / 4096 MB / 32 GB (`local-lvm`), swap 512 | -| Сеть | `vmbr0`, DHCP → `192.168.88.23` | -| Docker | `docker.io` + compose v2-плагин в `/usr/local/lib/docker/cli-plugins/docker-compose` | -| Проект | `/opt/lampac` (rsync с рабочей машины, `.git` не копируется) | -| Доступ | SSH по ключу `~/.ssh/id_rsa_prodik` (pubkey в `/root/.ssh/authorized_keys` контейнера) | - -**SMB для DLNA** — `\\10.6.0.1\Torrents`: - -- Монтируется **на хосте Proxmox** в `/mnt/torrents` (fstab, `credentials=/root/.smb_cred`, **umask 600** на креды) и пробрасывается в контейнер как `mp0: /mnt/torrents`. -- Причина: unprivileged LXC **не может монтировать cifs сам** — ядро требует `CAP_SYS_ADMIN` в init user namespace (`mount error: Operation not permitted`, не AppArmor). -- `uid=101000,gid=101000` в fstab: маппинг LXC `0→100000`, поэтому в контейнере файлы видны как `1000:1000` — приложение (`lampac`, uid 1000) может писать превью в `thumbs/`. -- Том подключён через **`docker-compose.override.yml`** (не в git): `services.lampac.volumes: [/mnt/torrents:/lampac/data/dlna]` — базовый `docker-compose.yaml` остаётся с `./dlna`, rsync-обновления его не ломают. - -**Проверка после развёртывания:** - -```bash -ssh root@192.168.88.23 -cd /opt/lampac && docker compose up -d -docker ps --filter name=lampac # healthy -for u in "/version?type=hash" /online.js /ts/ /dlna /extensions; do - curl -s -o /dev/null -w "%{http_code} $u\n" "http://127.0.0.1:9118$u"; done # все 200 -``` - -## Полезные команды - -```bash -docker compose restart lampac # перезапуск -docker compose stop lampac # остановить -docker compose down # остановить и удалить контейнер (тома сохраняются) -docker compose logs -f --tail 100 lampac # логи -docker compose exec lampac sh # шелл внутри контейнера -docker compose pull && docker compose up -d # обновление до свежего latest -``` +Перед обновлением проверить, что в `docker-compose.yaml` раскомментированы нужные тома. Данные сохраняются в примонтированных томах: `config/`, `cache/`, `database/`, `dlna/`, `mods/`. Изображение закреплено на конкретном теге — при обновлении поменяй тег в `docker-compose.yaml`. ## Безопасность -- Порт 9118 проброшен на все интерфейсы — при выходе в интернет закройте его файрволом или поднимите reverse proxy с аутентификацией. Внутренний порт TorrServer (9085) не проброшен вовсе: он доступен только контейнеру, внешний доступ к TorrServer идёт исключительно через `/ts/` на 9118. -- Модули `DLNA`, `Tracks`, `Transcoding`, `GStreamer`, `Catalog` не экранируют входящие запросы — не открывайте их публично без защиты. -- `config/passwd` содержит секрет — файл добавлен в `.gitignore`. +По предупреждению официальной документации: -## Git-репозиторий - -Код проекта хранится в Gitea: `ssh://git@gitea.prodik.ru:222/prodik/lampac.git`. - -- **`main`** (эта ветка) — относительные пути томов (подходит для любого сервера). -- **`synology`** — то же содержимое, но `docker-compose.yaml` адаптирован под Synology NAS: абсолютные пути `/volume1/docker/lampac/...`, тома `/volume4/Torrents` и `Курсы`. - -```bash -git remote -v # origin должен указывать на этот репозиторий -git add -A # секреты/данные не попадут (.gitignore) -git commit -m "описание" -git push origin main -``` - -В git попадают только: `docker-compose.yaml`, `config/init.conf`, `mods/**` (включённые модули), `modules/**` (резервные копии манифестов, не монтируются), `README.md`, `AGENT.md`, `.gitignore`. Секреты (`passwd`), `cache/`, `database/`, `dlna/` — в `.gitignore`. - -## Ссылки - -- Репозиторий форка: https://github.com/lampac-nextgen/lampac -- Документация: https://docs.lampac.dev -- Быстрый старт: https://docs.lampac.dev/quickstart -- Docker: https://docs.lampac.dev/deployment/docker -- Конфигурация: https://docs.lampac.dev/configuration/overview -- Релизы: https://github.com/lampac-nextgen/lampac/releases +- Не публикуй в интернет без аутентификации, WAF и ограничения сети: `/adminpanel`, `/weblog`, `/database-editor`, `Tracks`, `Transcoding`, `Catalog`, `DLNA`, proxy-модули. +- Нигде не раскрывай `config/passwd`, cookie `accspasswd`, заголовок `lcrqpasswd`. +- Для публикации за reverse proxy: [deployment/reverse-proxy](https://docs.lampac.dev/deployment/reverse-proxy) (HTTPS в ответах, реальный IP клиента, WebSocket, SSE, streaming). +- `shm_size: 4096mb` — рекомендованный минимум для Playwright/Chromium. diff --git a/config/init.conf b/config/init.conf index 76b9164..118bb8a 100644 --- a/config/init.conf +++ b/config/init.conf @@ -9,6 +9,19 @@ "BaseModule": { "SkipModules": [] }, + "Tracks": { + "enable": true + }, + "AdminPanel": { + "enable": true + }, + "Music": { + "useproxy": false, + "useproxystream": false, + "applemusic_country": "ru", + "spotify_discovery_enabled": true, + "soundcloud_country": "UK" + }, "TorrServer": { "enable": true, "tsport": 9085, @@ -24,16 +37,13 @@ "dlna": true, "tracks": true, "catalog": true, - "dorama": true, "sync": true, - "backup": true, - "watch_together": true + "backup": 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" } + { "url": "{localhost}/music.js", "status": 1, "name": "Music" }, + { "url": "{localhost}/tg-notify.js", "status": 1, "name": "Tg-notify" } ] }, "chromium": { diff --git a/docker-compose.yaml b/docker-compose.yaml index 56cc0f0..d2bed8f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,19 +3,21 @@ name: lampac services: lampac: - image: ghcr.io/lampac-nextgen/lampac:latest + image: ghcr.io/lampac-nextgen/lampac:1.68.0 container_name: lampac ports: - 9118:9118 restart: unless-stopped - shm_size: 1024mb + shm_size: 4096mb logging: options: tag: "{{.Name}}" volumes: - ./config/passwd:/lampac/passwd - ./config/init.conf:/lampac/init.conf + - ./plugins/extensions.json:/lampac/module/LampaWeb/plugins/extensions.json + - ./plugins:/lampac/wwwroot/plugins + - ./mods:/lampac/mods/ - ./cache:/lampac/cache - ./database:/lampac/database - ./dlna:/lampac/data/dlna - - ./mods:/lampac/mods diff --git a/mods/DatabaseEditor/DatabaseEditor.csproj b/mods/DatabaseEditor/DatabaseEditor.csproj deleted file mode 100644 index 4e40c28..0000000 --- a/mods/DatabaseEditor/DatabaseEditor.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net10.0 - library - true - DatabaseEditor - - - - - - - diff --git a/mods/DatabaseEditor/DatabaseEditorController.cs b/mods/DatabaseEditor/DatabaseEditorController.cs deleted file mode 100644 index f0687c8..0000000 --- a/mods/DatabaseEditor/DatabaseEditorController.cs +++ /dev/null @@ -1,328 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Shared; -using Shared.Attributes; -using System; -using System.IO; -using System.Text; -using System.Threading.Tasks; - -namespace DatabaseEditor; - -[Authorization(redirectUri: "/weblog/auth")] -public class DatabaseEditorPageController : BaseController -{ - [HttpGet] - [Route("/database-editor")] - public ActionResult Index() - { - return EditorFile("index.html", "text/html; charset=utf-8"); - } - - [HttpGet] - [Route("/database-editor/app.js")] - public ActionResult Script() - { - return EditorFile("app.js", "application/javascript; charset=utf-8"); - } - - [HttpGet] - [Route("/database-editor/style.css")] - public ActionResult Style() - { - return EditorFile("style.css", "text/css; charset=utf-8"); - } - - ActionResult EditorFile(string fileName, string contentType) - { - string path = Path.Combine(ModInit.modpath, fileName); - string content = System.IO.File.ReadAllText(path, Encoding.UTF8); - Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; - return Content(content, contentType); - } -} - -[Authorization(accessDeniedMessage: "{\"success\":false,\"error\":\"unauthorized\"}")] -public class DatabaseEditorApiController : BaseController -{ - [HttpGet] - [Route("/database-editor/api/summary")] - public async Task Summary() - { - try - { - return Json(new { success = true, databases = await DatabaseStore.GetSummaryAsync() }); - } - catch (Exception ex) - { - return Unexpected(ex, "summary"); - } - } - - [HttpGet] - [Route("/database-editor/api/records")] - public async Task Records(string database, string query = null, int page = 1, int pageSize = 25, string user = null) - { - try - { - var result = await DatabaseStore.GetRecordsAsync(database, query, page, pageSize, user); - return Json(new - { - success = true, - result.database, - result.page, - result.pageSize, - result.total, - result.pages, - result.records - }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "records"); - } - } - - [HttpGet] - [Route("/database-editor/api/users")] - public async Task Users(string database) - { - try - { - return Json(new { success = true, users = await DatabaseStore.GetUsersAsync(database) }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "users"); - } - } - - [HttpGet] - [Route("/database-editor/api/record")] - public async Task Record(string database, long id) - { - try - { - var record = await DatabaseStore.GetRecordAsync(database, id); - return record == null - ? NotFound(new { success = false, error = "record_not_found" }) - : Json(new { success = true, record }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "record"); - } - } - - [HttpGet] - [Route("/database-editor/api/sync-user")] - public async Task SyncUser(long id) - { - try - { - var user = await DatabaseStore.GetSyncUserAsync(id); - return user == null - ? NotFound(new { success = false, error = "record_not_found" }) - : Json(new - { - success = true, - user, - categories = DatabaseStore.SyncEditorCategories, - statuses = DatabaseStore.SyncStatusCategories - }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "sync-user"); - } - } - - [HttpPost] - [Route("/database-editor/api/save")] - public async Task Save([FromBody] SaveRecordRequest request) - { - if (!IsEditorRequest()) - return BadRequest(new { success = false, error = "invalid_editor_request" }); - - try - { - var record = await DatabaseStore.SaveAsync(request); - return Json(new { success = true, record }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "save"); - } - } - - [HttpPost] - [Route("/database-editor/api/delete")] - public async Task Delete([FromBody] DeleteRecordRequest request) - { - if (!IsEditorRequest()) - return BadRequest(new { success = false, error = "invalid_editor_request" }); - - try - { - bool deleted = await DatabaseStore.DeleteAsync(request?.database, request?.id ?? 0); - return deleted - ? Json(new { success = true }) - : NotFound(new { success = false, error = "record_not_found" }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "delete"); - } - } - - [HttpPost] - [Route("/database-editor/api/rename-user")] - public async Task RenameUser([FromBody] RenameUserRequest request) - { - if (!IsEditorRequest()) - return BadRequest(new { success = false, error = "invalid_editor_request" }); - - try - { - var result = await DatabaseStore.RenameUserAsync(request); - return Json(new { success = true, result }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "rename-user"); - } - } - - [HttpPost] - [Route("/database-editor/api/delete-user")] - public async Task DeleteUser([FromBody] DeleteUserRequest request) - { - if (!IsEditorRequest()) - return BadRequest(new { success = false, error = "invalid_editor_request" }); - - try - { - var result = await DatabaseStore.DeleteUserAsync(request); - return Json(new { success = true, result }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "delete-user"); - } - } - - [HttpPost] - [Route("/database-editor/api/sync-item/save")] - public async Task SaveSyncItem([FromBody] SaveSyncItemRequest request) - { - if (!IsEditorRequest()) - return BadRequest(new { success = false, error = "invalid_editor_request" }); - - try - { - var categories = await DatabaseStore.SaveSyncItemAsync(request); - return Json(new { success = true, categories }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "sync-item-save"); - } - } - - [HttpPost] - [Route("/database-editor/api/sync-item/delete")] - public async Task DeleteSyncItem([FromBody] DeleteSyncItemRequest request) - { - if (!IsEditorRequest()) - return BadRequest(new { success = false, error = "invalid_editor_request" }); - - try - { - bool deleted = await DatabaseStore.DeleteSyncItemAsync(request); - return deleted - ? Json(new { success = true }) - : NotFound(new { success = false, error = "card_not_found" }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "sync-item-delete"); - } - } - - [HttpPost] - [Route("/database-editor/api/backup")] - public async Task Backup([FromBody] BackupRequest request) - { - if (!IsEditorRequest()) - return BadRequest(new { success = false, error = "invalid_editor_request" }); - - try - { - if (string.IsNullOrWhiteSpace(request?.database) || string.Equals(request.database, "all", StringComparison.OrdinalIgnoreCase)) - { - var backups = await DatabaseStore.BackupAllAsync(); - return Json(new { success = true, backups }); - } - - string path = await DatabaseStore.BackupAsync(request?.database); - return Json(new { success = true, path, database = request.database }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "backup"); - } - } - - [HttpGet] - [Route("/database-editor/api/backups")] - public ActionResult Backups(string database) - { - try - { - return Json(new { success = true, backups = DatabaseStore.GetBackups(database) }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "backups"); - } - } - - [HttpPost] - [Route("/database-editor/api/restore")] - public async Task Restore([FromBody] RestoreBackupRequest request) - { - if (!IsEditorRequest()) - return BadRequest(new { success = false, error = "invalid_editor_request" }); - - try - { - var result = await DatabaseStore.RestoreAsync(request); - return Json(new { success = true, result }); - } - catch (Exception ex) - { - return KnownOrUnexpected(ex, "restore"); - } - } - - bool IsEditorRequest() => string.Equals(Request.Headers["X-Database-Editor"], "1", StringComparison.Ordinal); - - ActionResult KnownOrUnexpected(Exception ex, string operation) - { - if (ex is DatabaseEditorValidationException) - return BadRequest(new { success = false, error = ex.Message }); - - if (ex is DatabaseEditorConflictException) - return Conflict(new { success = false, error = ex.Message }); - - if (ex is DatabaseEditorBusyException) - return StatusCode(503, new { success = false, error = ex.Message }); - - return Unexpected(ex, operation); - } - - ActionResult Unexpected(Exception ex, string operation) - { - Serilog.Log.Error(ex, "DatabaseEditor {Operation} failed", operation); - return StatusCode(500, new { success = false, error = "internal_error" }); - } -} diff --git a/mods/DatabaseEditor/DatabaseStore.cs b/mods/DatabaseEditor/DatabaseStore.cs deleted file mode 100644 index 1b6c2c7..0000000 --- a/mods/DatabaseEditor/DatabaseStore.cs +++ /dev/null @@ -1,1512 +0,0 @@ -using Microsoft.Data.Sqlite; -using Shared.Services; -using System; -using System.Collections.Generic; -using System.Data; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Threading.Tasks; - -namespace DatabaseEditor; - -public sealed class SaveRecordRequest -{ - public string database { get; set; } - public long? id { get; set; } - public string user { get; set; } - public string card { get; set; } - public string item { get; set; } - public string data { get; set; } -} - -public sealed class DeleteRecordRequest -{ - public string database { get; set; } - public long id { get; set; } -} - -public sealed class BackupRequest -{ - public string database { get; set; } -} - -public sealed class RestoreBackupRequest -{ - public string database { get; set; } - public string file { get; set; } -} - -public sealed class RenameUserRequest -{ - public string oldUser { get; set; } - public string newUser { get; set; } -} - -public sealed class RenameUserResult -{ - public string oldUser { get; set; } - public string newUser { get; set; } - public int syncRecords { get; set; } - public int timecodeRecords { get; set; } -} - -public sealed class DeleteUserRequest -{ - public string user { get; set; } -} - -public sealed class DeleteUserResult -{ - public string user { get; set; } - public int syncRecords { get; set; } - public int timecodeRecords { get; set; } -} - -public sealed class SaveSyncItemRequest -{ - public long recordId { get; set; } - public string cardId { get; set; } - public string[] categories { get; set; } -} - -public sealed class DeleteSyncItemRequest -{ - public long recordId { get; set; } - public string cardId { get; set; } -} - -public sealed class DatabaseSummary -{ - public string database { get; set; } - public string title { get; set; } - public string file { get; set; } - public bool available { get; set; } - public long records { get; set; } - public long bytes { get; set; } - public string updated { get; set; } -} - -public sealed class DatabaseUserOption -{ - public string user { get; set; } - public long records { get; set; } -} - -public sealed class DatabaseRecord -{ - public long id { get; set; } - public string user { get; set; } - public string card { get; set; } - public string item { get; set; } - public string data { get; set; } - public string preview { get; set; } - public long dataLength { get; set; } - public string updated { get; set; } - public string title { get; set; } - public string poster { get; set; } - public string year { get; set; } - public string mediaType { get; set; } - public int? season { get; set; } - public int? episode { get; set; } - public double? position { get; set; } - public double? duration { get; set; } - public double? percent { get; set; } -} - -public sealed class SyncUserItem -{ - public string cardId { get; set; } - public string title { get; set; } - public string poster { get; set; } - public string year { get; set; } - public string mediaType { get; set; } - public List categories { get; set; } - public int order { get; set; } - public Dictionary categoryOrder { get; set; } -} - -public sealed class SyncUserDetails -{ - public long id { get; set; } - public string user { get; set; } - public string updated { get; set; } - public int total { get; set; } - public List items { get; set; } -} - -public sealed class DatabaseBackupResult -{ - public string database { get; set; } - public string path { get; set; } -} - -public sealed class DatabaseBackupFile -{ - public string database { get; set; } - public string file { get; set; } - public string path { get; set; } - public long bytes { get; set; } - public string created { get; set; } -} - -public sealed class DatabaseRestoreResult -{ - public string database { get; set; } - public string restoredFrom { get; set; } - public string safetyBackup { get; set; } -} - -public sealed class RecordsPage -{ - public string database { get; set; } - public int page { get; set; } - public int pageSize { get; set; } - public long total { get; set; } - public int pages { get; set; } - public List records { get; set; } -} - -public sealed class DatabaseEditorValidationException : Exception -{ - public DatabaseEditorValidationException(string message) : base(message) { } -} - -public sealed class DatabaseEditorConflictException : Exception -{ - public DatabaseEditorConflictException(string message) : base(message) { } -} - -public sealed class DatabaseEditorBusyException : Exception -{ - public DatabaseEditorBusyException(string message) : base(message) { } -} - -static class DatabaseStore -{ - const int MaxDataBytes = 8 * 1024 * 1024; - const int MaxKeyLength = 512; - - public static readonly string[] SyncCategories = - { - "history", "like", "watch", "wath", "book", "look", - "viewed", "scheduled", "continued", "thrown" - }; - - public static readonly string[] SyncEditorCategories = - { - "book", "like", "wath", "history", - "look", "viewed", "scheduled", "continued", "thrown" - }; - - public static readonly string[] SyncStatusCategories = - { - "look", "viewed", "scheduled", "continued", "thrown" - }; - - sealed class DatabaseSpec - { - public string key; - public string title; - public string path; - public string table; - public string semaphore; - public bool timecode; - - /// Колонка, по которой сортируем и берём MAX. - public string updatedColumn = "updated"; - - /// Как показать её строкой: у TimeCode это мс, а не ISO-текст. - public Func updatedText = expression => expression; - } - - /// - /// TimeCode хранит таймкод типизированными колонками, а редактор правит road-объект Lampa — - /// тот же контракт, что у легаси `/timecode/all`. Собираем его на стороне SQLite. - /// - const string TimeCodeRoad = "json_object('duration', duration, 'time', position, 'percent', percent, 'profile', profile, 'updated', watched_at)"; - - static string TimeCodeStamp(string expression) => $"strftime('%Y-%m-%dT%H:%M:%SZ', {expression} / 1000, 'unixepoch')"; - - /// Обратная операция к : road из редактора → колонки. - const string TimeCodeColumns = "position, duration, percent, profile, watched_at"; - - const string TimeCodeValues = - "COALESCE(json_extract(@data, '$.time'), 0), " + - "COALESCE(json_extract(@data, '$.duration'), 0), " + - "COALESCE(json_extract(@data, '$.percent'), 0), " + - "COALESCE(json_extract(@data, '$.profile'), 0), " + - "COALESCE(json_extract(@data, '$.updated'), 0)"; - - const string TimeCodeAssignments = - "position = COALESCE(json_extract(@data, '$.time'), 0), " + - "duration = COALESCE(json_extract(@data, '$.duration'), 0), " + - "percent = COALESCE(json_extract(@data, '$.percent'), 0), " + - "profile = COALESCE(json_extract(@data, '$.profile'), 0), " + - "watched_at = COALESCE(json_extract(@data, '$.updated'), 0)"; - - sealed class MediaMetadata - { - public string title; - public string poster; - public string year; - public string mediaType; - public List hashTitles; - public int seasons; - public int episodes; - } - - static readonly DatabaseSpec Sync = new() - { - key = "sync", - title = "Sync / закладки", - path = Path.Combine("database", "Sync.sql"), - table = "bookmarks", - semaphore = "Sync" - }; - - static readonly DatabaseSpec TimeCode = new() - { - key = "timecode", - title = "TimeCode / позиции", - path = Path.Combine("database", "TimeCode.sql"), - table = "timecodes", - semaphore = "TimeCode", - timecode = true, - updatedColumn = "updated_at", - updatedText = TimeCodeStamp - }; - - public static async Task> GetSummaryAsync() - { - var result = new List(2); - result.Add(await ReadSummaryAsync(TimeCode)); - result.Add(await ReadSummaryAsync(Sync)); - return result; - } - - public static async Task> GetUsersAsync(string database) - { - DatabaseSpec spec = GetSpec(database); - await using var connection = await OpenAsync(spec); - await using var command = connection.CreateCommand(); - command.CommandText = $"SELECT user, COUNT(*) FROM {spec.table} WHERE user IS NOT NULL AND TRIM(user) <> '' GROUP BY user COLLATE NOCASE ORDER BY user COLLATE NOCASE;"; - - var users = new List(); - await using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - users.Add(new DatabaseUserOption - { - user = ReadString(reader, 0), - records = reader.IsDBNull(1) ? 0 : reader.GetInt64(1) - }); - } - return users; - } - - public static async Task GetRecordsAsync(string database, string query, int page, int pageSize, string user = null) - { - DatabaseSpec spec = GetSpec(database); - page = Math.Max(1, page); - pageSize = pageSize is 25 or 50 or 100 ? pageSize : 25; - query = (query ?? string.Empty).Trim(); - if (query.Length > 256) - throw new DatabaseEditorValidationException("search_too_long"); - user = (user ?? string.Empty).Trim(); - if (user.Length > MaxKeyLength) - throw new DatabaseEditorValidationException("key_too_long"); - - await using var connection = await OpenAsync(spec); - string where = BuildWhere(spec, query, user, out string searchValue, out string selectedUser); - - long total; - await using (var count = connection.CreateCommand()) - { - count.CommandText = $"SELECT COUNT(*) FROM {spec.table}{where};"; - AddFilters(count, searchValue, selectedUser); - total = Convert.ToInt64(await count.ExecuteScalarAsync()); - } - - int pages = Math.Max(1, (int)Math.Ceiling(total / (double)pageSize)); - page = Math.Min(page, pages); - int offset = (page - 1) * pageSize; - var records = new List(pageSize); - - await using (var command = connection.CreateCommand()) - { - command.CommandText = spec.timecode - ? $"SELECT Id, user, card, item, length({TimeCodeRoad}), {TimeCodeRoad}, {spec.updatedText(spec.updatedColumn)} FROM {spec.table}{where} ORDER BY {spec.updatedColumn} DESC, Id DESC LIMIT @limit OFFSET @offset;" - : $"SELECT Id, user, length(data), substr(data, 1, 420), {spec.updatedText(spec.updatedColumn)} FROM {spec.table}{where} ORDER BY {spec.updatedColumn} DESC, Id DESC LIMIT @limit OFFSET @offset;"; - AddFilters(command, searchValue, selectedUser); - command.Parameters.AddWithValue("@limit", pageSize); - command.Parameters.AddWithValue("@offset", offset); - - await using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - int index = 0; - var record = new DatabaseRecord - { - id = reader.GetInt64(index++), - user = ReadString(reader, index++) - }; - - if (spec.timecode) - { - record.card = ReadString(reader, index++); - record.item = ReadString(reader, index++); - } - - record.dataLength = reader.IsDBNull(index) ? 0 : reader.GetInt64(index); - index++; - string dataValue = ReadString(reader, index++); - record.preview = BuildPreview(dataValue); - record.updated = ReadString(reader, index); - if (spec.timecode) - ReadPlaybackData(record, dataValue); - records.Add(record); - } - } - - if (spec.timecode && records.Count > 0) - await EnrichTimeCodeRecordsAsync(records); - - return new RecordsPage - { - database = spec.key, - page = page, - pageSize = pageSize, - total = total, - pages = pages, - records = records - }; - } - - public static async Task GetRecordAsync(string database, long id) - { - DatabaseSpec spec = GetSpec(database); - if (id <= 0) - throw new DatabaseEditorValidationException("invalid_id"); - - await using var connection = await OpenAsync(spec); - await using var command = connection.CreateCommand(); - command.CommandText = spec.timecode - ? $"SELECT Id, user, card, item, {TimeCodeRoad}, {spec.updatedText(spec.updatedColumn)} FROM {spec.table} WHERE Id = @id LIMIT 1;" - : $"SELECT Id, user, data, {spec.updatedText(spec.updatedColumn)} FROM {spec.table} WHERE Id = @id LIMIT 1;"; - command.Parameters.AddWithValue("@id", id); - - await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow); - if (!await reader.ReadAsync()) - return null; - - int index = 0; - var record = new DatabaseRecord - { - id = reader.GetInt64(index++), - user = ReadString(reader, index++) - }; - - if (spec.timecode) - { - record.card = ReadString(reader, index++); - record.item = ReadString(reader, index++); - } - - record.data = ReadString(reader, index++); - record.dataLength = Encoding.UTF8.GetByteCount(record.data ?? string.Empty); - record.updated = ReadString(reader, index); - return record; - } - - public static async Task GetSyncUserAsync(long id) - { - if (id <= 0) - throw new DatabaseEditorValidationException("invalid_id"); - - await using var connection = await OpenAsync(Sync); - await using var command = connection.CreateCommand(); - command.CommandText = "SELECT Id, user, data, updated FROM bookmarks WHERE Id = @id LIMIT 1;"; - command.Parameters.AddWithValue("@id", id); - - await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow); - if (!await reader.ReadAsync()) - return null; - - string data = ReadString(reader, 2); - var details = BuildSyncUserDetails(reader.GetInt64(0), ReadString(reader, 1), ReadString(reader, 3), data); - return details; - } - - public static async Task> SaveSyncItemAsync(SaveSyncItemRequest request) - { - if (request == null || request.recordId <= 0) - throw new DatabaseEditorValidationException("invalid_id"); - - string cardId = ValidateKey(request.cardId, "card_required"); - var selected = new HashSet(request.categories ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); - foreach (string category in selected) - { - if (Array.IndexOf(SyncEditorCategories, category) < 0) - throw new DatabaseEditorValidationException("unknown_category"); - } - - int selectedStatuses = 0; - foreach (string category in SyncStatusCategories) - { - if (selected.Contains(category)) - selectedStatuses++; - } - if (selectedStatuses > 1) - throw new DatabaseEditorValidationException("multiple_statuses"); - - await UpdateSyncJsonAsync(request.recordId, root => - { - JsonObject card = FindSyncCard(root, cardId); - if (card == null) - throw new DatabaseEditorValidationException("card_not_found"); - - foreach (string category in SyncCategories) - { - JsonArray array = EnsureArray(root, category); - bool isSelected = selected.Contains(category); - bool isPresent = ContainsCardId(array, cardId); - if (isSelected && !isPresent) - array.Insert(0, CreateCardIdNode(cardId)); - else if (!isSelected && isPresent) - RemoveCardId(array, cardId); - } - }); - - Serilog.Log.Information("DatabaseEditor updated Sync card {CardId} in record {RecordId}", cardId, request.recordId); - var result = new List(); - foreach (string category in SyncEditorCategories) - { - if (selected.Contains(category)) - result.Add(category); - } - return result; - } - - public static async Task DeleteSyncItemAsync(DeleteSyncItemRequest request) - { - if (request == null || request.recordId <= 0) - throw new DatabaseEditorValidationException("invalid_id"); - - string cardId = ValidateKey(request.cardId, "card_required"); - bool removed = false; - await UpdateSyncJsonAsync(request.recordId, root => - { - if (root["card"] is JsonArray cards) - { - for (int index = cards.Count - 1; index >= 0; index--) - { - if (cards[index] is JsonObject card && string.Equals(NodeText(card["id"]), cardId, StringComparison.Ordinal)) - { - cards.RemoveAt(index); - removed = true; - } - } - } - - foreach (string category in SyncCategories) - { - if (root[category] is JsonArray array) - removed |= RemoveCardId(array, cardId); - } - }); - - if (removed) - Serilog.Log.Information("DatabaseEditor deleted Sync card {CardId} from record {RecordId}", cardId, request.recordId); - return removed; - } - - public static async Task SaveAsync(SaveRecordRequest request) - { - if (request == null) - throw new DatabaseEditorValidationException("request_required"); - - DatabaseSpec spec = GetSpec(request.database); - string user = ValidateKey(request.user, "user_required"); - string card = spec.timecode ? ValidateKey(request.card, "card_required") : null; - string item = spec.timecode ? ValidateKey(request.item, "item_required") : null; - string data = NormalizeJson(request.data); - long id = request.id.GetValueOrDefault(); - if (id < 0) - throw new DatabaseEditorValidationException("invalid_id"); - - var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); - bool acquired = await semaphore.WaitAsync(); - if (!acquired) - throw new DatabaseEditorBusyException("database_busy"); - - try - { - await using var connection = await OpenAsync(spec); - using var transaction = connection.BeginTransaction(); - string updated = DateTime.UtcNow.ToString("O"); - - // Правка из админки обязана поднять курсор выше всех строк пользователя, иначе - // клиенты её не увидят: дельты они спрашивают по `updated_at > since`. - string stamp = $"MAX(strftime('%s', 'now') * 1000, (SELECT COALESCE(MAX(updated_at), 0) + 1 FROM {spec.table} WHERE user = @user))"; - - await using var command = connection.CreateCommand(); - command.Transaction = transaction; - if (id == 0) - { - command.CommandText = spec.timecode - ? $"INSERT INTO {spec.table} (user, card, item, {TimeCodeColumns}, updated_at) VALUES (@user, @card, @item, {TimeCodeValues}, {stamp}); SELECT last_insert_rowid();" - : $"INSERT INTO {spec.table} (user, data, updated) VALUES (@user, @data, @updated); SELECT last_insert_rowid();"; - } - else - { - command.CommandText = spec.timecode - ? $"UPDATE {spec.table} SET user = @user, card = @card, item = @item, {TimeCodeAssignments}, updated_at = {stamp} WHERE Id = @id;" - : $"UPDATE {spec.table} SET user = @user, data = @data, updated = @updated WHERE Id = @id;"; - command.Parameters.AddWithValue("@id", id); - } - - command.Parameters.AddWithValue("@user", user); - if (spec.timecode) - { - command.Parameters.AddWithValue("@card", card); - command.Parameters.AddWithValue("@item", item); - } - command.Parameters.AddWithValue("@data", data); - if (!spec.timecode) - command.Parameters.AddWithValue("@updated", updated); - - if (id == 0) - id = Convert.ToInt64(await command.ExecuteScalarAsync()); - else if (await command.ExecuteNonQueryAsync() == 0) - throw new DatabaseEditorValidationException("record_not_found"); - - transaction.Commit(); - Serilog.Log.Information("DatabaseEditor saved {Database} record {RecordId}", spec.key, id); - } - catch (SqliteException ex) when (ex.SqliteErrorCode == 19) - { - throw new DatabaseEditorConflictException("duplicate_record_key"); - } - finally - { - semaphore.Release(); - } - - return await GetRecordAsync(spec.key, id); - } - - public static async Task DeleteAsync(string database, long id) - { - DatabaseSpec spec = GetSpec(database); - if (id <= 0) - throw new DatabaseEditorValidationException("invalid_id"); - - var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); - bool acquired = await semaphore.WaitAsync(); - if (!acquired) - throw new DatabaseEditorBusyException("database_busy"); - - try - { - await using var connection = await OpenAsync(spec); - await using var command = connection.CreateCommand(); - command.CommandText = $"DELETE FROM {spec.table} WHERE Id = @id;"; - command.Parameters.AddWithValue("@id", id); - bool deleted = await command.ExecuteNonQueryAsync() > 0; - if (deleted) - Serilog.Log.Information("DatabaseEditor deleted {Database} record {RecordId}", spec.key, id); - return deleted; - } - finally - { - semaphore.Release(); - } - } - - public static async Task RenameUserAsync(RenameUserRequest request) - { - if (request == null) - throw new DatabaseEditorValidationException("request_required"); - - string oldUser = ValidateKey(request.oldUser, "old_user_required"); - string newUser = ValidateKey(request.newUser, "new_user_required"); - if (string.Equals(oldUser, newUser, StringComparison.Ordinal)) - throw new DatabaseEditorValidationException("user_name_unchanged"); - if (!File.Exists(Sync.path) || !File.Exists(TimeCode.path)) - throw new DatabaseEditorValidationException("database_not_found"); - - var syncSemaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20)); - var timecodeSemaphore = new SemaphorManager(TimeCode.semaphore, TimeSpan.FromSeconds(20)); - bool syncAcquired = await syncSemaphore.WaitAsync(); - if (!syncAcquired) - throw new DatabaseEditorBusyException("database_busy"); - - bool timecodeAcquired = false; - try - { - timecodeAcquired = await timecodeSemaphore.WaitAsync(); - if (!timecodeAcquired) - throw new DatabaseEditorBusyException("database_busy"); - - await using var connection = await OpenAsync(TimeCode, pooling: false); - await using (var attach = connection.CreateCommand()) - { - attach.CommandText = "ATTACH DATABASE @syncPath AS syncdb;"; - attach.Parameters.AddWithValue("@syncPath", Path.GetFullPath(Sync.path)); - await attach.ExecuteNonQueryAsync(); - } - - using var transaction = connection.BeginTransaction(); - await EnsureRenameTargetAvailableAsync(connection, transaction, "main.timecodes", oldUser, newUser); - await EnsureRenameTargetAvailableAsync(connection, transaction, "syncdb.bookmarks", oldUser, newUser); - - int timecodeRecords = await RenameUserRowsAsync(connection, transaction, "main.timecodes", oldUser, newUser); - int syncRecords = await RenameUserRowsAsync(connection, transaction, "syncdb.bookmarks", oldUser, newUser); - if (timecodeRecords == 0 && syncRecords == 0) - throw new DatabaseEditorValidationException("user_not_found"); - - transaction.Commit(); - Serilog.Log.Information("DatabaseEditor renamed user {OldUser} to {NewUser}: {SyncRecords} Sync, {TimecodeRecords} TimeCode records", oldUser, newUser, syncRecords, timecodeRecords); - return new RenameUserResult - { - oldUser = oldUser, - newUser = newUser, - syncRecords = syncRecords, - timecodeRecords = timecodeRecords - }; - } - catch (SqliteException ex) when (ex.SqliteErrorCode == 19) - { - throw new DatabaseEditorConflictException("rename_user_conflict"); - } - finally - { - if (timecodeAcquired) - timecodeSemaphore.Release(); - syncSemaphore.Release(); - } - } - - static async Task EnsureRenameTargetAvailableAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string oldUser, string newUser) - { - await using var command = connection.CreateCommand(); - command.Transaction = transaction; - command.CommandText = $"SELECT 1 FROM {table} WHERE user = @newUser COLLATE NOCASE AND user <> @oldUser COLLATE NOCASE LIMIT 1;"; - command.Parameters.AddWithValue("@oldUser", oldUser); - command.Parameters.AddWithValue("@newUser", newUser); - if (await command.ExecuteScalarAsync() != null) - throw new DatabaseEditorConflictException("rename_user_conflict"); - } - - static async Task RenameUserRowsAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string oldUser, string newUser) - { - await using var command = connection.CreateCommand(); - command.Transaction = transaction; - command.CommandText = $"UPDATE {table} SET user = @newUser WHERE user = @oldUser COLLATE NOCASE;"; - command.Parameters.AddWithValue("@oldUser", oldUser); - command.Parameters.AddWithValue("@newUser", newUser); - return await command.ExecuteNonQueryAsync(); - } - - public static async Task DeleteUserAsync(DeleteUserRequest request) - { - if (request == null) - throw new DatabaseEditorValidationException("request_required"); - - string user = ValidateKey(request.user, "user_required"); - if (!File.Exists(Sync.path) || !File.Exists(TimeCode.path)) - throw new DatabaseEditorValidationException("database_not_found"); - - var syncSemaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20)); - var timecodeSemaphore = new SemaphorManager(TimeCode.semaphore, TimeSpan.FromSeconds(20)); - bool syncAcquired = await syncSemaphore.WaitAsync(); - if (!syncAcquired) - throw new DatabaseEditorBusyException("database_busy"); - - bool timecodeAcquired = false; - try - { - timecodeAcquired = await timecodeSemaphore.WaitAsync(); - if (!timecodeAcquired) - throw new DatabaseEditorBusyException("database_busy"); - - await using var connection = await OpenAsync(TimeCode, pooling: false); - await using (var attach = connection.CreateCommand()) - { - attach.CommandText = "ATTACH DATABASE @syncPath AS syncdb;"; - attach.Parameters.AddWithValue("@syncPath", Path.GetFullPath(Sync.path)); - await attach.ExecuteNonQueryAsync(); - } - - using var transaction = connection.BeginTransaction(); - int timecodeRecords = await DeleteUserRowsAsync(connection, transaction, "main.timecodes", user); - int syncRecords = await DeleteUserRowsAsync(connection, transaction, "syncdb.bookmarks", user); - if (timecodeRecords == 0 && syncRecords == 0) - throw new DatabaseEditorValidationException("user_not_found"); - - transaction.Commit(); - Serilog.Log.Information("DatabaseEditor deleted user {User}: {SyncRecords} Sync, {TimecodeRecords} TimeCode records", user, syncRecords, timecodeRecords); - return new DeleteUserResult { user = user, syncRecords = syncRecords, timecodeRecords = timecodeRecords }; - } - finally - { - if (timecodeAcquired) - timecodeSemaphore.Release(); - syncSemaphore.Release(); - } - } - - static async Task DeleteUserRowsAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string user) - { - await using var command = connection.CreateCommand(); - command.Transaction = transaction; - command.CommandText = $"DELETE FROM {table} WHERE user = @user COLLATE NOCASE;"; - command.Parameters.AddWithValue("@user", user); - return await command.ExecuteNonQueryAsync(); - } - - public static async Task BackupAsync(string database) - { - DatabaseSpec spec = GetSpec(database); - var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); - bool acquired = await semaphore.WaitAsync(); - if (!acquired) - throw new DatabaseEditorBusyException("database_busy"); - - try - { - string directory = Path.Combine("database", "backup", "database-editor"); - Directory.CreateDirectory(directory); - string fileName = $"{Path.GetFileNameWithoutExtension(spec.path)}-{DateTime.Now:yyyyMMdd-HHmmssfff}.sql"; - string destinationPath = Path.Combine(directory, fileName); - if (File.Exists(destinationPath)) - destinationPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(spec.path)}-{DateTime.Now:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.sql"); - - await using var source = await OpenAsync(spec); - var destinationBuilder = new SqliteConnectionStringBuilder - { - DataSource = destinationPath, - Mode = SqliteOpenMode.ReadWriteCreate, - Pooling = false - }; - await using var destination = new SqliteConnection(destinationBuilder.ToString()); - await destination.OpenAsync(); - source.BackupDatabase(destination); - Serilog.Log.Information("DatabaseEditor backed up {Database} to {BackupPath}", spec.key, destinationPath); - return destinationPath.Replace('\\', '/'); - } - finally - { - semaphore.Release(); - } - } - - public static async Task> BackupAllAsync() - { - var result = new List(2) - { - new() { database = TimeCode.key, path = await BackupAsync(TimeCode.key) }, - new() { database = Sync.key, path = await BackupAsync(Sync.key) } - }; - return result; - } - - public static List GetBackups(string database) - { - DatabaseSpec spec = GetSpec(database); - string directory = Path.Combine("database", "backup", "database-editor"); - var result = new List(); - if (!Directory.Exists(directory)) - return result; - - string prefix = Path.GetFileNameWithoutExtension(spec.path) + "-"; - foreach (string path in Directory.GetFiles(directory, prefix + "*.sql")) - { - var file = new FileInfo(path); - result.Add(new DatabaseBackupFile - { - database = spec.key, - file = file.Name, - path = path.Replace('\\', '/'), - bytes = file.Length, - created = file.LastWriteTimeUtc.ToString("O") - }); - } - result.Sort((left, right) => string.CompareOrdinal(right.created, left.created)); - return result; - } - - public static async Task RestoreAsync(RestoreBackupRequest request) - { - if (request == null) - throw new DatabaseEditorValidationException("request_required"); - - DatabaseSpec spec = GetSpec(request.database); - string fileName = Path.GetFileName((request.file ?? string.Empty).Trim()); - if (string.IsNullOrEmpty(fileName) || !string.Equals(fileName, request.file?.Trim(), StringComparison.Ordinal)) - throw new DatabaseEditorValidationException("invalid_backup_file"); - - string prefix = Path.GetFileNameWithoutExtension(spec.path) + "-"; - if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || !fileName.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) - throw new DatabaseEditorValidationException("invalid_backup_file"); - - string directory = Path.GetFullPath(Path.Combine("database", "backup", "database-editor")); - string sourcePath = Path.GetFullPath(Path.Combine(directory, fileName)); - if (!sourcePath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(sourcePath)) - throw new DatabaseEditorValidationException("backup_not_found"); - - var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20)); - bool acquired = await semaphore.WaitAsync(); - if (!acquired) - throw new DatabaseEditorBusyException("database_busy"); - - try - { - await ValidateBackupAsync(spec, sourcePath); - string safetyPath = await CreateBackupLockedAsync(spec, "before-restore"); - - var sourceBuilder = new SqliteConnectionStringBuilder { DataSource = sourcePath, Mode = SqliteOpenMode.ReadOnly, Pooling = false }; - await using (var source = new SqliteConnection(sourceBuilder.ToString())) - { - await source.OpenAsync(); - await using var destination = await OpenAsync(spec); - source.BackupDatabase(destination); - } - SqliteConnection.ClearAllPools(); - - Serilog.Log.Warning("DatabaseEditor restored {Database} from {BackupPath}; safety backup: {SafetyPath}", spec.key, sourcePath, safetyPath); - return new DatabaseRestoreResult - { - database = spec.key, - restoredFrom = sourcePath.Replace('\\', '/'), - safetyBackup = safetyPath - }; - } - finally - { - semaphore.Release(); - } - } - - static async Task ValidateBackupAsync(DatabaseSpec spec, string sourcePath) - { - try - { - var builder = new SqliteConnectionStringBuilder { DataSource = sourcePath, Mode = SqliteOpenMode.ReadOnly, Pooling = false }; - await using var connection = new SqliteConnection(builder.ToString()); - await connection.OpenAsync(); - await using var integrity = connection.CreateCommand(); - integrity.CommandText = "PRAGMA quick_check;"; - if (!string.Equals(Convert.ToString(await integrity.ExecuteScalarAsync()), "ok", StringComparison.OrdinalIgnoreCase)) - throw new DatabaseEditorValidationException("backup_integrity_failed"); - - await using var schema = connection.CreateCommand(); - schema.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = @table LIMIT 1;"; - schema.Parameters.AddWithValue("@table", spec.table); - if (await schema.ExecuteScalarAsync() == null) - throw new DatabaseEditorValidationException("backup_schema_mismatch"); - } - catch (SqliteException) - { - throw new DatabaseEditorValidationException("invalid_backup_database"); - } - } - - static async Task CreateBackupLockedAsync(DatabaseSpec spec, string marker) - { - string directory = Path.Combine("database", "backup", "database-editor"); - Directory.CreateDirectory(directory); - string baseName = Path.GetFileNameWithoutExtension(spec.path); - string destinationPath = Path.Combine(directory, $"{baseName}-{marker}-{DateTime.Now:yyyyMMdd-HHmmssfff}.sql"); - await using var source = await OpenAsync(spec); - var builder = new SqliteConnectionStringBuilder { DataSource = destinationPath, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }; - await using var destination = new SqliteConnection(builder.ToString()); - await destination.OpenAsync(); - source.BackupDatabase(destination); - return destinationPath.Replace('\\', '/'); - } - - static async Task EnrichTimeCodeRecordsAsync(List records) - { - if (!File.Exists(Sync.path)) - return; - - var users = new HashSet(StringComparer.Ordinal); - foreach (DatabaseRecord record in records) - { - if (!string.IsNullOrEmpty(record.user)) - users.Add(record.user); - if (TryParseTimeCodeCard(record.card, out _, out string mediaType)) - record.mediaType = mediaType; - } - - if (users.Count == 0) - return; - - var metadata = new Dictionary(StringComparer.Ordinal); - await using var connection = await OpenAsync(Sync); - await using var command = connection.CreateCommand(); - var placeholders = new List(users.Count); - int parameterIndex = 0; - foreach (string user in users) - { - string parameter = "@user" + parameterIndex++; - placeholders.Add(parameter); - command.Parameters.AddWithValue(parameter, user); - } - command.CommandText = $"SELECT user, data FROM bookmarks WHERE user IN ({string.Join(",", placeholders)});"; - - await using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - string user = ReadString(reader, 0); - JsonObject root = ParseRoot(ReadString(reader, 1)); - if (root?["card"] is not JsonArray cards) - continue; - - foreach (JsonNode node in cards) - { - if (node is not JsonObject card) - continue; - string cardId = NodeText(card["id"]); - if (string.IsNullOrEmpty(cardId)) - continue; - metadata[MediaKey(user, cardId)] = ReadMediaMetadata(card); - } - } - - foreach (DatabaseRecord record in records) - { - if (!TryParseTimeCodeCard(record.card, out string cardId, out string mediaType)) - continue; - - record.mediaType = mediaType; - if (!metadata.TryGetValue(MediaKey(record.user, cardId), out MediaMetadata media)) - continue; - - record.title = media.title; - record.poster = media.poster; - record.year = media.year; - if (string.IsNullOrEmpty(record.mediaType)) - record.mediaType = media.mediaType; - if (string.Equals(record.mediaType, "tv", StringComparison.OrdinalIgnoreCase) && - TryFindEpisode(media, record.item, out int season, out int episode)) - { - record.season = season; - record.episode = episode; - } - } - } - - static SyncUserDetails BuildSyncUserDetails(long id, string user, string updated, string data) - { - JsonObject root = ParseRoot(data); - var categoryMap = new Dictionary>(StringComparer.Ordinal); - var categoryOrderMap = new Dictionary>(StringComparer.Ordinal); - var allCategoryIds = new List(); - - foreach (string category in SyncCategories) - { - var ids = new HashSet(StringComparer.Ordinal); - var positions = new Dictionary(StringComparer.Ordinal); - if (root?[category] is JsonArray array) - { - foreach (JsonNode node in array) - { - string cardId = NodeText(node); - if (!string.IsNullOrEmpty(cardId) && ids.Add(cardId)) - { - positions[cardId] = positions.Count; - allCategoryIds.Add(cardId); - } - } - } - categoryMap[category] = ids; - categoryOrderMap[category] = positions; - } - - var items = new List(); - var seen = new HashSet(StringComparer.Ordinal); - if (root?["card"] is JsonArray cards) - { - foreach (JsonNode node in cards) - { - if (node is not JsonObject card) - continue; - string cardId = NodeText(card["id"]); - if (string.IsNullOrEmpty(cardId) || !seen.Add(cardId)) - continue; - MediaMetadata media = ReadMediaMetadata(card); - items.Add(new SyncUserItem - { - cardId = cardId, - title = media.title, - poster = media.poster, - year = media.year, - mediaType = media.mediaType, - categories = CategoriesFor(cardId, categoryMap), - order = items.Count, - categoryOrder = CategoryOrderFor(cardId, categoryOrderMap) - }); - } - } - - foreach (string cardId in allCategoryIds) - { - if (!seen.Add(cardId)) - continue; - items.Add(new SyncUserItem - { - cardId = cardId, - title = "Карточка #" + cardId, - categories = CategoriesFor(cardId, categoryMap), - order = items.Count, - categoryOrder = CategoryOrderFor(cardId, categoryOrderMap) - }); - } - - return new SyncUserDetails - { - id = id, - user = user, - updated = updated, - total = items.Count, - items = items - }; - } - - static async Task UpdateSyncJsonAsync(long recordId, Action update) - { - var semaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20)); - bool acquired = await semaphore.WaitAsync(); - if (!acquired) - throw new DatabaseEditorBusyException("database_busy"); - - try - { - await using var connection = await OpenAsync(Sync); - using var transaction = connection.BeginTransaction(); - string data; - await using (var select = connection.CreateCommand()) - { - select.Transaction = transaction; - select.CommandText = "SELECT data FROM bookmarks WHERE Id = @id LIMIT 1;"; - select.Parameters.AddWithValue("@id", recordId); - data = Convert.ToString(await select.ExecuteScalarAsync()); - } - if (string.IsNullOrEmpty(data)) - throw new DatabaseEditorValidationException("record_not_found"); - - JsonObject root = ParseRoot(data) ?? throw new DatabaseEditorValidationException("invalid_json"); - update(root); - string updatedData = root.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); - if (Encoding.UTF8.GetByteCount(updatedData) > MaxDataBytes) - throw new DatabaseEditorValidationException("data_too_large"); - - await using var save = connection.CreateCommand(); - save.Transaction = transaction; - save.CommandText = "UPDATE bookmarks SET data = @data, updated = @updated WHERE Id = @id;"; - save.Parameters.AddWithValue("@data", updatedData); - save.Parameters.AddWithValue("@updated", DateTime.UtcNow.ToString("O")); - save.Parameters.AddWithValue("@id", recordId); - if (await save.ExecuteNonQueryAsync() == 0) - throw new DatabaseEditorValidationException("record_not_found"); - transaction.Commit(); - } - finally - { - semaphore.Release(); - } - } - - static MediaMetadata ReadMediaMetadata(JsonObject card) - { - string mediaType = NodeText(card?["media_type"]); - if (string.IsNullOrEmpty(mediaType)) - mediaType = card?["name"] != null && card?["title"] == null ? "tv" : "movie"; - - string date = FirstNotEmpty(NodeText(card?["release_date"]), NodeText(card?["first_air_date"])); - return new MediaMetadata - { - title = FirstNotEmpty(NodeText(card?["title"]), NodeText(card?["name"]), NodeText(card?["original_title"]), NodeText(card?["original_name"])), - poster = FirstNotEmpty(NodeText(card?["img"]), NodeText(card?["poster_path"])), - year = !string.IsNullOrEmpty(date) && date.Length >= 4 ? date.Substring(0, 4) : date, - mediaType = mediaType, - hashTitles = DistinctValues( - NodeText(card?["original_name"]), - NodeText(card?["original_title"]), - NodeText(card?["name"]), - NodeText(card?["title"])), - seasons = ReadPositiveInt(card?["number_of_seasons"]), - episodes = ReadPositiveInt(card?["number_of_episodes"]) - }; - } - - static bool TryFindEpisode(MediaMetadata media, string item, out int season, out int episode) - { - season = 0; - episode = 0; - if (media?.hashTitles == null || media.hashTitles.Count == 0 || - !long.TryParse(item, NumberStyles.None, CultureInfo.InvariantCulture, out long target) || - target < 0 || target > 2147483648L) - return false; - - int maxSeasons = media.seasons > 0 ? Math.Min(media.seasons, 100) : 50; - int maxEpisodes = media.episodes > 0 ? Math.Min(media.episodes, 3000) : 500; - maxSeasons = Math.Max(maxSeasons, 10); - maxEpisodes = Math.Max(maxEpisodes, 100); - - if (FindEpisode(media.hashTitles, target, maxSeasons, maxEpisodes, out season, out episode)) - return true; - - // Long-running daily shows and anime can exceed the compact first pass. - if (maxSeasons < 100 || maxEpisodes < 3000) - return FindEpisode(media.hashTitles, target, 100, 3000, out season, out episode); - - return false; - } - - static bool FindEpisode(List titles, long target, int maxSeasons, int maxEpisodes, out int season, out int episode) - { - var titleHashes = new List<(int hash, int power)>(titles.Count); - foreach (string title in titles) - { - int power = 1; - for (int index = 0; index < title.Length; index++) - power = unchecked(power * 31); - titleHashes.Add((JsHash(title), power)); - } - - for (int seasonNumber = 0; seasonNumber <= maxSeasons; seasonNumber++) - { - string separator = seasonNumber > 10 ? ":" : string.Empty; - string seasonPrefix = seasonNumber.ToString(CultureInfo.InvariantCulture) + separator; - for (int episodeNumber = 0; episodeNumber <= maxEpisodes; episodeNumber++) - { - int prefixHash = JsHash(seasonPrefix + episodeNumber.ToString(CultureInfo.InvariantCulture)); - foreach ((int titleHash, int power) in titleHashes) - { - int hash = unchecked(prefixHash * power + titleHash); - if (Math.Abs((long)hash) != target) - continue; - season = seasonNumber; - episode = episodeNumber; - return true; - } - } - } - - season = 0; - episode = 0; - return false; - } - - static int JsHash(string value) - { - int hash = 0; - foreach (char character in value ?? string.Empty) - hash = unchecked(hash * 31 + character); - return hash; - } - - static List DistinctValues(params string[] values) - { - var result = new List(); - var seen = new HashSet(StringComparer.Ordinal); - foreach (string value in values) - { - if (!string.IsNullOrWhiteSpace(value) && seen.Add(value)) - result.Add(value); - } - return result; - } - - static int ReadPositiveInt(JsonNode node) - { - return int.TryParse(NodeText(node), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0 - ? value - : 0; - } - - static List CategoriesFor(string cardId, Dictionary> categoryMap) - { - var result = new List(); - foreach (string category in SyncEditorCategories) - { - if (categoryMap[category].Contains(cardId)) - result.Add(category); - } - return result; - } - - static Dictionary CategoryOrderFor(string cardId, Dictionary> categoryOrderMap) - { - var result = new Dictionary(StringComparer.Ordinal); - foreach (string category in SyncEditorCategories) - { - if (categoryOrderMap[category].TryGetValue(cardId, out int position)) - result[category] = position; - } - return result; - } - - static JsonObject FindSyncCard(JsonObject root, string cardId) - { - if (root?["card"] is not JsonArray cards) - return null; - foreach (JsonNode node in cards) - { - if (node is JsonObject card && string.Equals(NodeText(card["id"]), cardId, StringComparison.Ordinal)) - return card; - } - return null; - } - - static JsonArray EnsureArray(JsonObject root, string name) - { - if (root[name] is JsonArray array) - return array; - array = new JsonArray(); - root[name] = array; - return array; - } - - static bool RemoveCardId(JsonArray array, string cardId) - { - bool removed = false; - for (int index = array.Count - 1; index >= 0; index--) - { - if (string.Equals(NodeText(array[index]), cardId, StringComparison.Ordinal)) - { - array.RemoveAt(index); - removed = true; - } - } - return removed; - } - - static bool ContainsCardId(JsonArray array, string cardId) - { - foreach (JsonNode node in array) - { - if (string.Equals(NodeText(node), cardId, StringComparison.Ordinal)) - return true; - } - return false; - } - - static JsonNode CreateCardIdNode(string cardId) - { - if (long.TryParse(cardId, out long numericId)) - return JsonValue.Create(numericId); - return JsonValue.Create(cardId); - } - - static JsonObject ParseRoot(string data) - { - if (string.IsNullOrWhiteSpace(data)) - return null; - try - { - return JsonNode.Parse(data) as JsonObject; - } - catch (JsonException) - { - return null; - } - } - - static string NodeText(JsonNode node) - { - if (node == null) - return null; - if (node is JsonValue value && value.TryGetValue(out string text)) - return text; - return node.ToJsonString().Trim('"'); - } - - static string FirstNotEmpty(params string[] values) - { - foreach (string value in values) - { - if (!string.IsNullOrWhiteSpace(value)) - return value; - } - return null; - } - - static string MediaKey(string user, string cardId) => (user ?? string.Empty) + "\u001f" + cardId; - - static bool TryParseTimeCodeCard(string value, out string cardId, out string mediaType) - { - cardId = null; - mediaType = null; - if (string.IsNullOrWhiteSpace(value)) - return false; - int separator = value.LastIndexOf('_'); - if (separator <= 0 || separator == value.Length - 1) - return false; - string suffix = value.Substring(separator + 1); - if (!string.Equals(suffix, "movie", StringComparison.OrdinalIgnoreCase) && - !string.Equals(suffix, "tv", StringComparison.OrdinalIgnoreCase)) - return false; - cardId = value.Substring(0, separator); - mediaType = suffix.ToLowerInvariant(); - return true; - } - - static void ReadPlaybackData(DatabaseRecord record, string data) - { - if (string.IsNullOrWhiteSpace(data)) - return; - try - { - using var document = JsonDocument.Parse(data); - JsonElement root = document.RootElement; - record.position = ReadDouble(root, "time"); - record.duration = ReadDouble(root, "duration"); - record.percent = ReadDouble(root, "percent"); - } - catch (JsonException) { } - } - - static double? ReadDouble(JsonElement root, string name) - { - if (!root.TryGetProperty(name, out JsonElement value)) - return null; - if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out double number)) - return number; - if (value.ValueKind == JsonValueKind.String && double.TryParse(value.GetString(), out number)) - return number; - return null; - } - - static async Task ReadSummaryAsync(DatabaseSpec spec) - { - var summary = new DatabaseSummary - { - database = spec.key, - title = spec.title, - file = spec.path.Replace('\\', '/'), - available = File.Exists(spec.path), - bytes = File.Exists(spec.path) ? new FileInfo(spec.path).Length : 0 - }; - - if (!summary.available) - return summary; - - await using var connection = await OpenAsync(spec); - await using var command = connection.CreateCommand(); - command.CommandText = $"SELECT COUNT(*), {spec.updatedText($"MAX({spec.updatedColumn})")} FROM {spec.table};"; - await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow); - if (await reader.ReadAsync()) - { - summary.records = reader.GetInt64(0); - summary.updated = ReadString(reader, 1); - } - - return summary; - } - - static DatabaseSpec GetSpec(string database) - { - if (string.Equals(database, Sync.key, StringComparison.OrdinalIgnoreCase)) - return Sync; - if (string.Equals(database, TimeCode.key, StringComparison.OrdinalIgnoreCase)) - return TimeCode; - throw new DatabaseEditorValidationException("unknown_database"); - } - - static async Task OpenAsync(DatabaseSpec spec, bool pooling = true) - { - if (!File.Exists(spec.path)) - throw new DatabaseEditorValidationException("database_not_found"); - - var builder = new SqliteConnectionStringBuilder - { - DataSource = spec.path, - Mode = SqliteOpenMode.ReadWrite, - Cache = SqliteCacheMode.Shared, - DefaultTimeout = 10, - Pooling = pooling - }; - var connection = new SqliteConnection(builder.ToString()); - await connection.OpenAsync(); - return connection; - } - - static string BuildWhere(DatabaseSpec spec, string query, string user, out string searchValue, out string selectedUser) - { - var clauses = new List(); - if (string.IsNullOrEmpty(query)) - { - searchValue = null; - } - else - { - searchValue = "%" + query.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_") + "%"; - clauses.Add(spec.timecode - ? "(CAST(Id AS TEXT) LIKE @search ESCAPE '\\' OR user LIKE @search ESCAPE '\\' COLLATE NOCASE OR identity LIKE @search ESCAPE '\\' COLLATE NOCASE OR card LIKE @search ESCAPE '\\' COLLATE NOCASE OR item LIKE @search ESCAPE '\\' COLLATE NOCASE)" - : "(CAST(Id AS TEXT) LIKE @search ESCAPE '\\' OR user LIKE @search ESCAPE '\\' COLLATE NOCASE OR data LIKE @search ESCAPE '\\' COLLATE NOCASE)"); - } - - selectedUser = string.IsNullOrEmpty(user) ? null : user; - if (selectedUser != null) - clauses.Add("user = @selectedUser COLLATE NOCASE"); - - return clauses.Count == 0 ? string.Empty : " WHERE " + string.Join(" AND ", clauses); - } - - static void AddFilters(SqliteCommand command, string searchValue, string selectedUser) - { - if (searchValue != null) - command.Parameters.AddWithValue("@search", searchValue); - if (selectedUser != null) - command.Parameters.AddWithValue("@selectedUser", selectedUser); - } - - static string ValidateKey(string value, string error) - { - value = (value ?? string.Empty).Trim(); - if (value.Length == 0) - throw new DatabaseEditorValidationException(error); - if (value.Length > MaxKeyLength) - throw new DatabaseEditorValidationException("key_too_long"); - return value; - } - - static string NormalizeJson(string value) - { - if (string.IsNullOrWhiteSpace(value)) - throw new DatabaseEditorValidationException("data_required"); - if (Encoding.UTF8.GetByteCount(value) > MaxDataBytes) - throw new DatabaseEditorValidationException("data_too_large"); - - try - { - using var document = JsonDocument.Parse(value); - if (document.RootElement.ValueKind != JsonValueKind.Object) - throw new DatabaseEditorValidationException("data_must_be_json_object"); - return JsonSerializer.Serialize(document.RootElement); - } - catch (JsonException) - { - throw new DatabaseEditorValidationException("invalid_json"); - } - } - - static string BuildPreview(string value) - { - if (string.IsNullOrEmpty(value)) - return string.Empty; - string preview = value.Replace("\r", " ").Replace("\n", " ").Replace("\t", " "); - while (preview.Contains(" ", StringComparison.Ordinal)) - preview = preview.Replace(" ", " ", StringComparison.Ordinal); - return preview.Length <= 240 ? preview : preview.Substring(0, 240) + "…"; - } - - static string ReadString(SqliteDataReader reader, int index) => reader.IsDBNull(index) ? null : reader.GetString(index); -} diff --git a/mods/DatabaseEditor/ModInit.cs b/mods/DatabaseEditor/ModInit.cs deleted file mode 100644 index 91f53d1..0000000 --- a/mods/DatabaseEditor/ModInit.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Shared.Models.Events; -using Shared.Models.Module; -using Shared.Models.Module.Interfaces; -using System; -using System.IO; -using System.Text; -using System.Threading.Tasks; - -namespace DatabaseEditor; - -public class ModInit : IModuleLoaded -{ - public static string modpath; - - static readonly Func> MiddlewareHandler = OnMiddleware; - - public void Loaded(InitspaceModel initspace) - { - modpath = initspace.path; - EventListener.MiddlewareAsync += MiddlewareHandler; - } - - public void Dispose() - { - EventListener.MiddlewareAsync -= MiddlewareHandler; - } - - static async Task OnMiddleware(bool first, EventMiddleware e) - { - if (first || !HttpMethods.IsGet(e.httpContext.Request.Method)) - return true; - - string path = (e.httpContext.Request.Path.Value ?? string.Empty).TrimEnd('/'); - if (!string.Equals(path, "/weblog", StringComparison.OrdinalIgnoreCase)) - return true; - - string weblogPath = Path.Combine(AppContext.BaseDirectory, "module", "WebLog", "index.html"); - if (!File.Exists(weblogPath)) - return true; - - string html = File.ReadAllText(weblogPath, Encoding.UTF8); - if (!html.Contains("href=\"/database-editor\"", StringComparison.OrdinalIgnoreCase)) - { - const string activeLink = "Weblog"; - const string databaseLink = activeLink + "\n Базы"; - - if (html.Contains(activeLink, StringComparison.Ordinal)) - html = html.Replace(activeLink, databaseLink, StringComparison.Ordinal); - else - { - const string navEnd = ""; - int navIndex = html.IndexOf(navEnd, StringComparison.OrdinalIgnoreCase); - if (navIndex >= 0) - html = html.Insert(navIndex, "Базы\n "); - } - } - - e.httpContext.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; - e.httpContext.Response.ContentType = "text/html; charset=utf-8"; - await e.httpContext.Response.WriteAsync(html, e.httpContext.RequestAborted); - return false; - } -} diff --git a/mods/DatabaseEditor/README.md b/mods/DatabaseEditor/README.md deleted file mode 100644 index a3f71cf..0000000 --- a/mods/DatabaseEditor/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# DatabaseEditor - -Защищённый root-паролем WebLog редактор баз `database/TimeCode.sql` и -`database/Sync.sql`. - -## Маршруты - -- `/database-editor` — интерфейс редактора. -- `/database-editor/api/summary` — состояние и размер баз. -- `/database-editor/api/records` — поиск и постраничный список. -- `/database-editor/api/record` — чтение одной записи. -- `/database-editor/api/sync-user` — карточки и категории одного Sync-пользователя. -- `/database-editor/api/sync-item/save` — точечное изменение категорий карточки Sync. -- `/database-editor/api/sync-item/delete` — удаление одной карточки из Sync. -- `/database-editor/api/save` — создание или изменение записи. -- `/database-editor/api/delete` — удаление записи. -- `/database-editor/api/rename-user` — атомарное переименование пользователя во всех строках `bookmarks` (Sync) и `timecodes` (TimeCode). -- `/database-editor/api/delete-user` — атомарное удаление пользователя и всех его строк из `bookmarks` (Sync) и `timecodes` (TimeCode). -- `/database-editor/api/backup` — online-backup одной базы или обеих баз при - `database: "all"` в `database/backup/database-editor`. -- `/database-editor/api/backups` — список доступных резервных копий выбранной базы. -- `/database-editor/api/restore` — проверка и восстановление выбранной SQLite-копии; перед заменой автоматически создаётся страховочная копия текущей базы с маркером `before-restore`. - -Изменяющие запросы требуют заголовок `X-Database-Editor: 1`. Все SQL-запросы -параметризованы; запись синхронизируется штатными семафорами `TimeCode` и -`Sync`. В корне поля `data` допускается только JSON-объект. Список TimeCode -дополняется названиями и постерами из карточек Sync того же пользователя. - -TimeCode с версии типизированной схемы не хранит блоб: редактор по-прежнему -показывает и принимает road-объект Lampa (`time`, `duration`, `percent`, -`profile`, `updated`), но собирает его из колонок и раскладывает обратно. -Правка поднимает `updated_at` выше всех строк пользователя — иначе клиенты -не увидят её, они спрашивают дельты по `updated_at > since`. - -Редактор Sync повторяет категории Lampa: `book` (Закладки), `like` (Нравится), -`wath` (Позже), `history` (История просмотров). Поле `wath` является актуальным -именем Lampa, несмотря на опечатку в английском ключе. Статусы `look` (Смотрю), -`viewed`, `scheduled`, `continued`, `thrown` взаимоисключающие. Служебное поле -`watch` сохраняется в поддерживаемой схеме Lampac, но в редакторе не выводится. - -Мод также добавляет вкладку `Базы` в `/weblog` на лету, не изменяя файлы -штатного модуля WebLog. diff --git a/mods/DatabaseEditor/app.js b/mods/DatabaseEditor/app.js deleted file mode 100644 index 4658417..0000000 --- a/mods/DatabaseEditor/app.js +++ /dev/null @@ -1,649 +0,0 @@ -(function () { - 'use strict'; - - var categoryLabels = { - book: 'Закладки', like: 'Нравится', wath: 'Позже', history: 'История просмотров', - look: 'Смотрю', viewed: 'Просмотрено', scheduled: 'Запланировано', - continued: 'Продолжение следует', thrown: 'Брошено' - }; - var state = { - database: 'timecode', page: 1, pageSize: 25, pages: 1, total: 0, query: '', selectedUser: '', loading: false, - editingId: 0, editingDatabase: 'timecode', syncUser: null, syncItems: [], syncCategories: [], syncStatuses: [], - syncPage: 1, syncPageSize: 48, syncQuery: '', syncCategory: '', syncItem: null - }; - var $ = function (id) { return document.getElementById(id); }; - var els = { - listView: $('listView'), summary: $('summary'), loading: $('loading'), head: $('tableHead'), body: $('tableBody'), - empty: $('empty'), pagerInfo: $('pagerInfo'), pageLabel: $('pageLabel'), prev: $('prevBtn'), next: $('nextBtn'), - search: $('searchInput'), userFilter: $('timecodeUserFilter'), pageSize: $('pageSize'), recordModal: $('recordModal'), recordTitle: $('recordModalTitle'), - recordId: $('recordModalId'), user: $('userField'), card: $('cardField'), item: $('itemField'), data: $('dataField'), - jsonStatus: $('jsonStatus'), saveRecord: $('saveRecordBtn'), deleteRecord: $('deleteRecordBtn'), toasts: $('toasts'), - syncDetail: $('syncDetail'), syncUserName: $('syncUserName'), syncUserMeta: $('syncUserMeta'), syncSearch: $('syncSearch'), - syncCategory: $('syncCategory'), syncResultCount: $('syncResultCount'), syncGrid: $('syncGrid'), syncEmpty: $('syncEmpty'), - syncPrev: $('syncPrev'), syncNext: $('syncNext'), syncPageLabel: $('syncPageLabel'), categoryModal: $('categoryModal'), - categoryHead: $('categoryCardHead'), categoryOptions: $('categoryOptions'), backupModal: $('backupModal'), - backupResults: $('backupResults'), renameUserModal: $('renameUserModal'), oldUser: $('oldUserField'), newUser: $('newUserField'), - confirmRenameUser: $('confirmRenameUserBtn'), deleteUserModal: $('deleteUserModal'), deleteUser: $('deleteUserField'), - confirmDeleteUser: $('confirmDeleteUserBtn'), restoreModal: $('restoreModal'), restoreResults: $('restoreResults'), restoreEmpty: $('restoreEmpty') - }; - var errorMessages = { - unauthorized: 'Требуется повторный вход', unknown_database: 'Неизвестная база', database_not_found: 'Файл базы не найден', - database_busy: 'База занята, повторите попытку', invalid_json: 'JSON содержит ошибку', - data_must_be_json_object: 'В корне JSON должен быть объект', data_required: 'JSON не может быть пустым', - data_too_large: 'JSON превышает 8 МБ', user_required: 'Укажите пользователя', card_required: 'Укажите карточку', - item_required: 'Укажите элемент', duplicate_record_key: 'Запись с таким уникальным ключом уже существует', - record_not_found: 'Запись уже удалена', card_not_found: 'Карточка не найдена', unknown_category: 'Неизвестная категория', - multiple_statuses: 'Для карточки можно выбрать только один статус', old_user_required: 'Укажите текущее имя пользователя', - new_user_required: 'Укажите новое имя пользователя', user_name_unchanged: 'Новое имя совпадает с текущим', - user_not_found: 'Пользователь не найден ни в Sync, ни в TimeCode', rename_user_conflict: 'Новое имя уже занято или создаёт конфликт записей', - invalid_backup_file: 'Недопустимое имя файла резервной копии', backup_not_found: 'Резервная копия не найдена', - backup_integrity_failed: 'Проверка целостности резервной копии не пройдена', backup_schema_mismatch: 'Копия относится к другой базе', - invalid_backup_database: 'Файл не является исправной SQLite-базой', - internal_error: 'Внутренняя ошибка сервера', invalid_response: 'Сервер вернул неожиданный ответ' - }; - - function api(path, options) { - options = options || {}; - options.credentials = 'same-origin'; - options.headers = Object.assign({ Accept: 'application/json' }, options.headers || {}); - if (options.body) { - options.headers['Content-Type'] = 'application/json'; - options.headers['X-Database-Editor'] = '1'; - } - return fetch('/database-editor/api/' + path, options).then(function (response) { - var type = response.headers.get('content-type') || ''; - if (response.redirected && response.url.indexOf('/weblog/auth') >= 0) { - location.href = response.url; - throw new Error('unauthorized'); - } - if (type.indexOf('application/json') < 0) throw new Error(response.status === 401 ? 'unauthorized' : 'invalid_response'); - return response.json().then(function (data) { - if (!response.ok || data.success === false) throw new Error(data.error || 'request_failed'); - return data; - }); - }); - } - - function el(tag, className, text) { - var node = document.createElement(tag); - if (className) node.className = className; - if (text !== undefined && text !== null) node.textContent = text; - return node; - } - function toast(message, bad) { - var node = el('div', 'toast ' + (bad ? 'bad' : 'good'), message); - els.toasts.appendChild(node); - setTimeout(function () { node.remove(); }, 4500); - } - function showError(error) { - var key = error && error.message || 'internal_error'; - toast(errorMessages[key] || key, true); - } - function setLoading(value) { - state.loading = value; - els.loading.classList.toggle('active', value); - els.prev.disabled = value || state.page <= 1; - els.next.disabled = value || state.page >= state.pages; - } - function formatBytes(bytes) { - if (!bytes) return '0 Б'; - var units = ['Б', 'КБ', 'МБ', 'ГБ']; - var index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), 3); - return (bytes / Math.pow(1024, index)).toLocaleString('ru-RU', { maximumFractionDigits: index ? 1 : 0 }) + ' ' + units[index]; - } - function formatDate(value) { - if (!value) return '—'; - var normalized = String(value).trim(); - var sqliteDateWithoutZone = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/; - if (sqliteDateWithoutZone.test(normalized)) { - normalized = normalized.replace(' ', 'T').replace(/(\.\d{3})\d+$/, '$1') + 'Z'; - } - var date = new Date(normalized); - return isNaN(date.getTime()) ? value : date.toLocaleString('ru-RU'); - } - function formatTime(seconds) { - seconds = Math.max(0, Math.round(Number(seconds) || 0)); - var hours = Math.floor(seconds / 3600); - var minutes = Math.floor((seconds % 3600) / 60); - var rest = seconds % 60; - return (hours ? hours + ':' + String(minutes).padStart(2, '0') : minutes) + ':' + String(rest).padStart(2, '0'); - } - function posterUrl(value) { - if (!value) return ''; - if (/^https?:\/\//i.test(value)) return value; - return '/tmdb/img/t/p/w300' + (value.charAt(0) === '/' ? value : '/' + value); - } - function posterNode(value, title) { - if (!value) return el('div', 'poster-placeholder', '◇'); - var image = el('img', 'poster'); - image.loading = 'lazy'; - image.alt = title || ''; - image.src = posterUrl(value); - image.addEventListener('error', function () { image.replaceWith(el('div', 'poster-placeholder', '◇')); }, { once: true }); - return image; - } - function typeLabel(type) { return type === 'tv' ? 'Сериал' : type === 'movie' ? 'Фильм' : ''; } - function fallbackTitle(record) { - if (record.title) return record.title; - return typeLabel(record.mediaType) || 'Карточка'; - } - - function loadSummary() { - return api('summary').then(function (data) { - els.summary.textContent = ''; - (data.databases || []).forEach(function (db) { - var card = el('article', 'summary-card'); - var head = el('div', 'summary-head'); - head.appendChild(el('span', '', db.title)); - head.appendChild(el('span', db.available ? 'available' : 'missing', db.available ? 'доступна' : 'нет файла')); - card.appendChild(head); - card.appendChild(el('div', 'summary-value', Number(db.records || 0).toLocaleString('ru-RU') + ' записей')); - var foot = el('div', 'summary-foot'); - foot.appendChild(el('div', 'summary-meta', formatBytes(db.bytes) + ' · ' + db.file + ' · ' + formatDate(db.updated))); - var backup = el('button', 'summary-backup', 'Backup этой базы'); - backup.addEventListener('click', function () { createBackup(db.database); }); - foot.appendChild(backup); - card.appendChild(foot); - els.summary.appendChild(card); - }); - }).catch(showError); - } - - function renderHead() { - els.head.textContent = ''; - var table = els.head.closest('table'); - table.classList.toggle('timecode-table', state.database === 'timecode'); - table.classList.toggle('sync-table', state.database === 'sync'); - var columns = state.database === 'timecode' - ? [['ID', 'col-id'], ['Пользователь', 'col-user'], ['Карточка', 'col-media'], ['Позиция', 'col-progress'], ['Обновлено', 'col-date'], ['', 'col-actions']] - : [['ID', 'col-id'], ['Пользователь', 'col-user'], ['Обновлено', 'col-date'], ['', 'col-actions']]; - columns.forEach(function (column) { els.head.appendChild(el('th', column[1], column[0])); }); - } - - function renderRows(records) { - els.body.textContent = ''; - els.empty.classList.toggle('show', !records.length); - records.forEach(function (record) { - var row = document.createElement('tr'); - row.appendChild(el('td', 'mono', '#' + record.id)); - var userCell = el('td'); - userCell.appendChild(el('div', 'record-user', record.user || '—')); - userCell.appendChild(el('div', 'record-sub', formatBytes(record.dataLength || 0))); - row.appendChild(userCell); - - if (state.database === 'timecode') { - var mediaCell = el('td'); - var media = el('div', 'media-cell'); - var title = fallbackTitle(record); - media.appendChild(posterNode(record.poster, title)); - var copy = el('div', 'media-copy'); - copy.appendChild(el('div', 'media-title', title)); - var meta = el('div', 'media-meta'); - if (record.mediaType) meta.appendChild(el('span', 'type-badge', typeLabel(record.mediaType))); - var mediaDetails = []; - if (record.mediaType === 'tv' && record.season != null && record.episode != null) { - mediaDetails.push('Сезон ' + record.season, 'Серия ' + record.episode); - } - if (record.year) mediaDetails.push(record.year); - if (mediaDetails.length) meta.appendChild(document.createTextNode(mediaDetails.join(' · '))); - copy.appendChild(meta); - media.appendChild(copy); - mediaCell.appendChild(media); - row.appendChild(mediaCell); - - var progressCell = el('td'); - var percent = Number(record.percent); - if (!isFinite(percent) && Number(record.duration) > 0) percent = Number(record.position) / Number(record.duration) * 100; - percent = Math.max(0, Math.min(100, isFinite(percent) ? percent : 0)); - progressCell.appendChild(el('div', 'record-user', Math.round(percent) + '%')); - var track = el('div', 'progress-track'); - var fill = el('div', 'progress-fill'); - fill.style.width = percent + '%'; - track.appendChild(fill); - progressCell.appendChild(track); - var progressCopy = el('div', 'progress-copy'); - progressCopy.appendChild(el('span', '', formatTime(record.position))); - progressCopy.appendChild(el('span', '', formatTime(record.duration))); - progressCell.appendChild(progressCopy); - row.appendChild(progressCell); - } - - row.appendChild(el('td', '', formatDate(record.updated))); - var actions = el('td', 'row-actions'); - if (state.database === 'sync') { - var open = el('button', 'mini-btn', 'Открыть'); - open.addEventListener('click', function () { openSyncUser(record.id); }); - actions.appendChild(open); - } - var edit = el('button', 'mini-btn', 'JSON'); - edit.addEventListener('click', function () { openRecord(record.id, state.database); }); - var remove = el('button', 'mini-btn delete', '×'); - remove.title = 'Удалить всю запись'; - remove.addEventListener('click', function () { deleteRecord(record.id, state.database); }); - actions.appendChild(edit); - actions.appendChild(remove); - row.appendChild(actions); - els.body.appendChild(row); - }); - } - - function loadRecords() { - if (state.loading) return; - setLoading(true); - var params = new URLSearchParams({ database: state.database, page: String(state.page), pageSize: String(state.pageSize) }); - if (state.query) params.set('query', state.query); - if (state.database === 'timecode' && state.selectedUser) params.set('user', state.selectedUser); - api('records?' + params.toString()).then(function (data) { - state.page = Number(data.page || 1); state.pages = Number(data.pages || 1); state.total = Number(data.total || 0); - renderHead(); renderRows(data.records || []); - var from = state.total ? (state.page - 1) * state.pageSize + 1 : 0; - var to = Math.min(state.page * state.pageSize, state.total); - els.pagerInfo.textContent = from + '–' + to + ' из ' + state.total.toLocaleString('ru-RU'); - els.pageLabel.textContent = state.page + ' / ' + state.pages; - }).catch(showError).finally(function () { setLoading(false); }); - } - - function loadTimeCodeUsers() { - return api('users?database=timecode').then(function (data) { - var selected = state.selectedUser; - els.userFilter.textContent = ''; - var all = document.createElement('option'); - all.value = ''; - all.textContent = 'Все пользователи TimeCode (' + (data.users || []).length.toLocaleString('ru-RU') + ')'; - els.userFilter.appendChild(all); - (data.users || []).forEach(function (entry) { - var option = document.createElement('option'); - option.value = entry.user; - option.textContent = entry.user + ' (' + Number(entry.records || 0).toLocaleString('ru-RU') + ')'; - els.userFilter.appendChild(option); - }); - if (selected && Array.from(els.userFilter.options).some(function (option) { return option.value === selected; })) { - els.userFilter.value = selected; - } else { - state.selectedUser = ''; - if (selected && state.database === 'timecode') { state.page = 1; loadRecords(); } - } - }).catch(showError); - } - - function switchDatabase(database) { - if (database === state.database) return; - state.database = database; state.page = 1; - document.querySelectorAll('.db-tab').forEach(function (tab) { tab.classList.toggle('active', tab.dataset.db === database); }); - els.userFilter.hidden = database !== 'timecode'; - loadRecords(); - } - - function openSyncUser(id, preserveFilters) { - var previousQuery = preserveFilters ? state.syncQuery : ''; - var previousCategory = preserveFilters ? state.syncCategory : ''; - api('sync-user?id=' + encodeURIComponent(id)).then(function (data) { - state.syncUser = data.user; - state.syncItems = data.user.items || []; - state.syncCategories = data.categories || []; - state.syncStatuses = data.statuses || []; - state.syncPage = 1; state.syncQuery = previousQuery; state.syncCategory = previousCategory; - els.syncSearch.value = previousQuery; - buildCategorySelect(); - if (Array.from(els.syncCategory.options).some(function (option) { return option.value === previousCategory; })) { - els.syncCategory.value = previousCategory; - } else { - state.syncCategory = ''; - } - els.syncUserName.textContent = data.user.user || '—'; - els.syncUserMeta.textContent = Number(data.user.total || 0).toLocaleString('ru-RU') + ' карточек · обновлено ' + formatDate(data.user.updated); - els.listView.hidden = true; - els.syncDetail.hidden = false; - renderSyncItems(); - window.scrollTo(0, 0); - }).catch(showError); - } - - function buildCategorySelect() { - els.syncCategory.textContent = ''; - var all = document.createElement('option'); all.value = ''; all.textContent = 'Все категории'; els.syncCategory.appendChild(all); - var generalGroup = document.createElement('optgroup'); generalGroup.label = 'Категории'; - var statusGroup = document.createElement('optgroup'); statusGroup.label = 'Статус'; - state.syncCategories.forEach(function (category) { - var option = document.createElement('option'); option.value = category; option.textContent = categoryLabels[category] || category; - (state.syncStatuses.indexOf(category) >= 0 ? statusGroup : generalGroup).appendChild(option); - }); - els.syncCategory.appendChild(generalGroup); - els.syncCategory.appendChild(statusGroup); - } - - function syncItemOrder(item, category) { - var value = item.categoryOrder && item.categoryOrder[category]; - return value == null ? Number.MAX_SAFE_INTEGER : Number(value); - } - - function filteredSyncItems() { - var query = state.syncQuery.toLowerCase(); - var filtered = state.syncItems.filter(function (item) { - var matchesText = !query || String(item.title || '').toLowerCase().indexOf(query) >= 0 || String(item.cardId || '').toLowerCase().indexOf(query) >= 0; - var matchesCategory = !state.syncCategory || (item.categories || []).indexOf(state.syncCategory) >= 0; - return matchesText && matchesCategory; - }); - return filtered.sort(function (left, right) { - if (state.syncCategory) return syncItemOrder(left, state.syncCategory) - syncItemOrder(right, state.syncCategory); - var leftHistory = syncItemOrder(left, 'history'); - var rightHistory = syncItemOrder(right, 'history'); - if (leftHistory !== rightHistory) return leftHistory - rightHistory; - return Number(left.order || 0) - Number(right.order || 0); - }); - } - - function renderSyncItems() { - var filtered = filteredSyncItems(); - var pages = Math.max(1, Math.ceil(filtered.length / state.syncPageSize)); - state.syncPage = Math.min(state.syncPage, pages); - var start = (state.syncPage - 1) * state.syncPageSize; - var visible = filtered.slice(start, start + state.syncPageSize); - els.syncGrid.textContent = ''; - els.syncEmpty.classList.toggle('show', !visible.length); - els.syncResultCount.textContent = filtered.length.toLocaleString('ru-RU') + ' из ' + state.syncItems.length.toLocaleString('ru-RU'); - els.syncPageLabel.textContent = state.syncPage + ' / ' + pages; - els.syncPrev.disabled = state.syncPage <= 1; - els.syncNext.disabled = state.syncPage >= pages; - - visible.forEach(function (item) { - var card = el('article', 'sync-card'); - card.appendChild(posterNode(item.poster, item.title)); - var body = el('div', 'sync-card-body'); - body.appendChild(el('div', 'sync-card-title', item.title || 'Карточка #' + item.cardId)); - body.appendChild(el('div', 'sync-card-meta', [typeLabel(item.mediaType), item.year, '#' + item.cardId].filter(Boolean).join(' · '))); - var tags = el('div', 'category-tags'); - if (item.categories && item.categories.length) item.categories.forEach(function (category) { tags.appendChild(el('span', 'category-tag', categoryLabels[category] || category)); }); - else tags.appendChild(el('span', 'category-tag empty-tag', 'Без категории')); - body.appendChild(tags); - var actions = el('div', 'sync-card-actions'); - var edit = el('button', 'btn', 'Изменить категории'); - edit.addEventListener('click', function () { openCategoryModal(item); }); - actions.appendChild(edit); body.appendChild(actions); card.appendChild(body); els.syncGrid.appendChild(card); - }); - } - - function openCategoryModal(item) { - state.syncItem = item; - els.categoryHead.textContent = ''; - els.categoryHead.appendChild(posterNode(item.poster, item.title)); - var copy = el('div'); - copy.appendChild(el('div', 'media-title', item.title || 'Карточка #' + item.cardId)); - copy.appendChild(el('div', 'media-meta mono', '#' + item.cardId)); - els.categoryHead.appendChild(copy); - els.categoryOptions.textContent = ''; - state.syncCategories.forEach(function (category, index) { - var isStatus = state.syncStatuses.indexOf(category) >= 0; - if (isStatus && category === state.syncStatuses[0]) { - els.categoryOptions.appendChild(el('div', 'category-section-title', 'Статус')); - } - var wrap = el('div', 'category-option' + (isStatus ? ' status-option' : '')); - var input = document.createElement('input'); - input.type = 'checkbox'; input.id = 'sync-category-' + index; input.value = category; - if (isStatus) input.dataset.status = '1'; - input.checked = (item.categories || []).indexOf(category) >= 0; - if (isStatus) input.addEventListener('change', function () { - if (!input.checked) return; - els.categoryOptions.querySelectorAll('input[data-status="1"]').forEach(function (other) { - if (other !== input) other.checked = false; - }); - }); - var label = document.createElement('label'); label.htmlFor = input.id; label.textContent = categoryLabels[category] || category; - wrap.appendChild(input); wrap.appendChild(label); els.categoryOptions.appendChild(wrap); - }); - openModal('categoryModal'); - } - - function saveSyncItem() { - if (!state.syncUser || !state.syncItem) return; - var categories = Array.from(els.categoryOptions.querySelectorAll('input:checked')).map(function (input) { return input.value; }); - $('saveSyncItemBtn').disabled = true; - api('sync-item/save', { method: 'POST', body: JSON.stringify({ recordId: state.syncUser.id, cardId: state.syncItem.cardId, categories: categories }) }) - .then(function (data) { - closeModal('categoryModal'); toast('Категории и статус карточки сохранены'); loadSummary(); - openSyncUser(state.syncUser.id, true); - }).catch(showError).finally(function () { $('saveSyncItemBtn').disabled = false; }); - } - - function deleteSyncItem() { - if (!state.syncUser || !state.syncItem) return; - if (!confirm('Удалить карточку «' + (state.syncItem.title || state.syncItem.cardId) + '» у этого пользователя?')) return; - api('sync-item/delete', { method: 'POST', body: JSON.stringify({ recordId: state.syncUser.id, cardId: state.syncItem.cardId }) }) - .then(function () { - state.syncItems = state.syncItems.filter(function (item) { return item.cardId !== state.syncItem.cardId; }); - state.syncUser.total = state.syncItems.length; - closeModal('categoryModal'); renderSyncItems(); toast('Карточка удалена'); loadSummary(); - }).catch(showError); - } - - function prettyJson(value) { return JSON.stringify(JSON.parse(value), null, 2); } - function validateJson() { - try { - var parsed = JSON.parse(els.data.value); - if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('object'); - els.jsonStatus.textContent = 'JSON корректен'; els.jsonStatus.className = 'json-status ok'; return true; - } catch (_) { - els.jsonStatus.textContent = 'Ошибка JSON'; els.jsonStatus.className = 'json-status bad'; return false; - } - } - function setRecordDatabase(database) { - document.querySelectorAll('.timecode-field').forEach(function (node) { node.style.display = database === 'timecode' ? 'flex' : 'none'; }); - } - function openRecord(id, database) { - api('record?database=' + encodeURIComponent(database) + '&id=' + id).then(function (data) { - var record = data.record; state.editingId = record.id; state.editingDatabase = database; setRecordDatabase(database); - els.recordTitle.textContent = 'Редактирование ' + (database === 'sync' ? 'Sync' : 'TimeCode'); els.recordId.textContent = '#' + record.id; - els.user.value = record.user || ''; els.card.value = record.card || ''; els.item.value = record.item || ''; - try { els.data.value = prettyJson(record.data || '{}'); } catch (_) { els.data.value = record.data || ''; } - els.deleteRecord.hidden = false; validateJson(); openModal('recordModal'); - }).catch(showError); - } - function saveRecord() { - if (!validateJson()) return; - var payload = { database: state.editingDatabase, id: state.editingId || null, user: els.user.value, card: els.card.value, item: els.item.value, data: els.data.value }; - els.saveRecord.disabled = true; - api('save', { method: 'POST', body: JSON.stringify(payload) }).then(function () { - toast('Запись сохранена'); closeModal('recordModal'); state.page = 1; loadRecords(); loadSummary(); - if (state.editingDatabase === 'timecode') loadTimeCodeUsers(); - if (state.syncUser && state.editingDatabase === 'sync') openSyncUser(state.syncUser.id); - }).catch(showError).finally(function () { els.saveRecord.disabled = false; }); - } - function deleteRecord(id, database) { - if (!confirm('Удалить всю запись #' + id + ' из ' + database + '?')) return; - api('delete', { method: 'POST', body: JSON.stringify({ database: database, id: id }) }).then(function () { - toast('Запись удалена'); closeModal('recordModal'); loadRecords(); loadSummary(); - if (database === 'timecode') loadTimeCodeUsers(); - }).catch(showError); - } - - function createBackup(database) { - $('backupAllBtn').disabled = true; - api('backup', { method: 'POST', body: JSON.stringify({ database: database }) }).then(function (data) { - var backups = data.backups || [{ database: data.database || database, path: data.path }]; - els.backupResults.textContent = ''; - backups.forEach(function (backup) { - var result = el('div', 'backup-result'); - result.appendChild(el('strong', '', backup.database === 'timecode' ? 'TimeCode.sql' : 'Sync.sql')); - result.appendChild(el('div', 'backup-path', backup.path)); - els.backupResults.appendChild(result); - }); - openModal('backupModal'); toast(backups.length === 2 ? 'Созданы копии TimeCode и Sync' : 'Резервная копия создана'); - }).catch(showError).finally(function () { $('backupAllBtn').disabled = false; }); - } - var restoreDatabase = 'sync'; - function openRestore() { - restoreDatabase = 'sync'; - document.querySelectorAll('[data-restore-db]').forEach(function (tab) { tab.classList.toggle('active', tab.dataset.restoreDb === restoreDatabase); }); - openModal('restoreModal'); loadBackups(); - } - function loadBackups() { - els.restoreResults.textContent = ''; els.restoreEmpty.classList.remove('show'); - els.restoreResults.appendChild(el('div', 'muted', 'Загрузка…')); - api('backups?database=' + encodeURIComponent(restoreDatabase)).then(function (data) { - els.restoreResults.textContent = ''; - var backups = data.backups || []; - els.restoreEmpty.classList.toggle('show', !backups.length); - backups.forEach(function (backup) { - var row = el('div', 'backup-result restore-result'); - var info = el('div', 'restore-info'); - info.appendChild(el('strong', '', backup.file)); - info.appendChild(el('div', 'backup-path', formatBytes(backup.bytes) + ' · ' + formatDate(backup.created))); - var button = el('button', 'btn danger', 'Восстановить'); - button.addEventListener('click', function () { restoreBackup(backup, button); }); - row.appendChild(info); row.appendChild(button); els.restoreResults.appendChild(row); - }); - }).catch(showError); - } - function restoreBackup(backup, button) { - var title = backup.database === 'sync' ? 'Sync' : 'TimeCode'; - if (!confirm('Восстановить базу ' + title + ' из файла «' + backup.file + '»?\n\nТекущее состояние сначала будет сохранено автоматически. Данные, записанные после даты этой копии, будут заменены.')) return; - button.disabled = true; - api('restore', { method: 'POST', body: JSON.stringify({ database: backup.database, file: backup.file }) }).then(function (data) { - closeModal('restoreModal'); - toast(title + ' восстановлена. Страховочная копия: ' + data.result.safetyBackup); - setTimeout(function () { location.reload(); }, 1600); - }).catch(showError).finally(function () { button.disabled = false; }); - } - function openRenameUser() { - var selected = state.syncUser ? state.syncUser.user || '' : state.selectedUser || ''; - els.oldUser.textContent = ''; - var loadingOption = document.createElement('option'); loadingOption.value = ''; loadingOption.textContent = 'Загрузка пользователей…'; - els.oldUser.appendChild(loadingOption); - els.newUser.value = ''; - els.confirmRenameUser.disabled = true; - openModal('renameUserModal'); - Promise.all([api('users?database=sync'), api('users?database=timecode')]).then(function (responses) { - var users = new Map(); - responses.forEach(function (response, databaseIndex) { - (response.users || []).forEach(function (entry) { - var key = (entry.user || '').toLowerCase(); - if (!key) return; - var current = users.get(key) || { user: entry.user, sync: 0, timecode: 0 }; - current[databaseIndex === 0 ? 'sync' : 'timecode'] += Number(entry.records || 0); - users.set(key, current); - }); - }); - var list = Array.from(users.values()).sort(function (left, right) { return left.user.localeCompare(right.user, 'ru', { sensitivity: 'base' }); }); - els.oldUser.textContent = ''; - var placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = list.length ? 'Выберите пользователя' : 'Пользователей нет'; - els.oldUser.appendChild(placeholder); - list.forEach(function (entry) { - var option = document.createElement('option'); option.value = entry.user; - option.textContent = entry.user + ' · Sync: ' + entry.sync + ', TimeCode: ' + entry.timecode; - els.oldUser.appendChild(option); - }); - var match = list.find(function (entry) { return entry.user.toLowerCase() === selected.toLowerCase(); }); - if (match) els.oldUser.value = match.user; - els.confirmRenameUser.disabled = !list.length; - setTimeout(function () { (els.oldUser.value ? els.newUser : els.oldUser).focus(); }, 0); - }).catch(function (error) { - els.oldUser.textContent = ''; - var failed = document.createElement('option'); failed.value = ''; failed.textContent = 'Не удалось загрузить пользователей'; els.oldUser.appendChild(failed); - showError(error); - }); - } - function openDeleteUser() { - var selected = state.syncUser ? state.syncUser.user || '' : state.selectedUser || ''; - els.deleteUser.textContent = ''; - var loading = document.createElement('option'); loading.value = ''; loading.textContent = 'Загрузка пользователей…'; els.deleteUser.appendChild(loading); - els.confirmDeleteUser.disabled = true; - openModal('deleteUserModal'); - Promise.all([api('users?database=sync'), api('users?database=timecode')]).then(function (responses) { - var users = new Map(); - responses.forEach(function (response, databaseIndex) { - (response.users || []).forEach(function (entry) { - var key = (entry.user || '').toLowerCase(); - if (!key) return; - var current = users.get(key) || { user: entry.user, sync: 0, timecode: 0 }; - current[databaseIndex === 0 ? 'sync' : 'timecode'] += Number(entry.records || 0); users.set(key, current); - }); - }); - var list = Array.from(users.values()).sort(function (left, right) { return left.user.localeCompare(right.user, 'ru', { sensitivity: 'base' }); }); - els.deleteUser.textContent = ''; - var placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = list.length ? 'Выберите пользователя' : 'Пользователей нет'; els.deleteUser.appendChild(placeholder); - list.forEach(function (entry) { var option = document.createElement('option'); option.value = entry.user; option.textContent = entry.user + ' · Sync: ' + entry.sync + ', TimeCode: ' + entry.timecode; els.deleteUser.appendChild(option); }); - var match = list.find(function (entry) { return entry.user.toLowerCase() === selected.toLowerCase(); }); - if (match) els.deleteUser.value = match.user; - els.confirmDeleteUser.disabled = !list.length; - setTimeout(function () { els.deleteUser.focus(); }, 0); - }).catch(function (error) { - els.deleteUser.textContent = ''; var failed = document.createElement('option'); failed.value = ''; failed.textContent = 'Не удалось загрузить пользователей'; els.deleteUser.appendChild(failed); showError(error); - }); - } - function deleteUser() { - var user = els.deleteUser.value.trim(); - if (!user) return showError(new Error('user_required')); - if (!confirm('Безвозвратно удалить пользователя «' + user + '», все его закладки Sync и позиции TimeCode?')) return; - els.confirmDeleteUser.disabled = true; - api('delete-user', { method: 'POST', body: JSON.stringify({ user: user }) }).then(function (data) { - var result = data.result; closeModal('deleteUserModal'); state.selectedUser = ''; - if (state.syncUser && state.syncUser.user.toLowerCase() === user.toLowerCase()) state.syncUser = null; - els.syncDetail.hidden = true; els.listView.hidden = false; - loadRecords(); loadSummary(); loadTimeCodeUsers(); - toast('Пользователь удалён: Sync — ' + result.syncRecords + ', TimeCode — ' + result.timecodeRecords); - }).catch(showError).finally(function () { els.confirmDeleteUser.disabled = false; }); - } - function renameUser() { - var oldUser = els.oldUser.value.trim(); - var newUser = els.newUser.value.trim(); - if (!oldUser) return showError(new Error('old_user_required')); - if (!newUser) return showError(new Error('new_user_required')); - if (!confirm('Перед продолжением выйдите из учётной записи «' + oldUser + '» на всех устройствах или измените её логин/account_email в Lampa и init/AccsDB.\n\nАктивная сессия со старым именем создаст пользователя повторно.\n\nПереименовать «' + oldUser + '» в «' + newUser + '»?')) return; - els.confirmRenameUser.disabled = true; - api('rename-user', { method: 'POST', body: JSON.stringify({ oldUser: oldUser, newUser: newUser }) }).then(function (data) { - var result = data.result; - closeModal('renameUserModal'); - state.selectedUser = ''; - if (state.syncUser && state.syncUser.user.toLowerCase() === oldUser.toLowerCase()) state.syncUser = null; - els.syncDetail.hidden = true; els.listView.hidden = false; - loadRecords(); loadSummary(); loadTimeCodeUsers(); - toast('Пользователь переименован: Sync — ' + result.syncRecords + ', TimeCode — ' + result.timecodeRecords); - }).catch(showError).finally(function () { els.confirmRenameUser.disabled = false; }); - } - function openModal(id) { $(id).classList.add('open'); } - function closeModal(id) { $(id).classList.remove('open'); } - function applyTheme(theme) { - document.documentElement.dataset.theme = theme; - var metaTheme = document.getElementById('metaTheme'); - if (metaTheme) metaTheme.setAttribute('content', theme === 'dark' ? '#0c0e12' : '#f4f5f8'); - localStorage.setItem('lampac-theme', theme); - } - - document.querySelectorAll('.db-tab').forEach(function (tab) { tab.addEventListener('click', function () { switchDatabase(tab.dataset.db); }); }); - var searchTimer; - els.search.addEventListener('input', function () { clearTimeout(searchTimer); searchTimer = setTimeout(function () { state.query = els.search.value.trim(); state.page = 1; loadRecords(); }, 280); }); - els.userFilter.addEventListener('change', function () { state.selectedUser = els.userFilter.value; state.page = 1; loadRecords(); }); - els.pageSize.addEventListener('change', function () { state.pageSize = Number(els.pageSize.value); state.page = 1; loadRecords(); }); - els.prev.addEventListener('click', function () { if (state.page > 1) { state.page--; loadRecords(); } }); - els.next.addEventListener('click', function () { if (state.page < state.pages) { state.page++; loadRecords(); } }); - $('refreshBtn').addEventListener('click', function () { loadRecords(); loadSummary(); if (state.database === 'timecode') loadTimeCodeUsers(); }); - $('backupAllBtn').addEventListener('click', function () { createBackup('all'); }); - $('restoreBtn').addEventListener('click', openRestore); - document.querySelectorAll('[data-restore-db]').forEach(function (tab) { tab.addEventListener('click', function () { restoreDatabase = tab.dataset.restoreDb; document.querySelectorAll('[data-restore-db]').forEach(function (item) { item.classList.toggle('active', item === tab); }); loadBackups(); }); }); - $('renameUserBtn').addEventListener('click', openRenameUser); - els.confirmRenameUser.addEventListener('click', renameUser); - $('deleteUserBtn').addEventListener('click', openDeleteUser); - els.confirmDeleteUser.addEventListener('click', deleteUser); - $('backToUsers').addEventListener('click', function () { els.syncDetail.hidden = true; els.listView.hidden = false; state.syncUser = null; loadRecords(); loadSummary(); }); - $('rawSyncBtn').addEventListener('click', function () { if (state.syncUser) openRecord(state.syncUser.id, 'sync'); }); - $('refreshSyncBtn').addEventListener('click', function () { if (state.syncUser) openSyncUser(state.syncUser.id, true); }); - els.syncSearch.addEventListener('input', function () { state.syncQuery = els.syncSearch.value.trim(); state.syncPage = 1; renderSyncItems(); }); - els.syncCategory.addEventListener('change', function () { state.syncCategory = els.syncCategory.value; state.syncPage = 1; renderSyncItems(); }); - els.syncPrev.addEventListener('click', function () { if (state.syncPage > 1) { state.syncPage--; renderSyncItems(); window.scrollTo(0, 0); } }); - els.syncNext.addEventListener('click', function () { state.syncPage++; renderSyncItems(); window.scrollTo(0, 0); }); - $('saveSyncItemBtn').addEventListener('click', saveSyncItem); - $('deleteSyncItemBtn').addEventListener('click', deleteSyncItem); - $('formatBtn').addEventListener('click', function () { try { els.data.value = prettyJson(els.data.value); } catch (_) { } validateJson(); }); - els.data.addEventListener('input', validateJson); - els.saveRecord.addEventListener('click', saveRecord); - els.deleteRecord.addEventListener('click', function () { deleteRecord(state.editingId, state.editingDatabase); }); - document.querySelectorAll('[data-close]').forEach(function (button) { button.addEventListener('click', function () { closeModal(button.dataset.close); }); }); - document.querySelectorAll('.modal-backdrop').forEach(function (modal) { modal.addEventListener('click', function (event) { if (event.target === modal) closeModal(modal.id); }); }); - document.addEventListener('keydown', function (event) { - if (event.key === 'Escape') document.querySelectorAll('.modal-backdrop.open').forEach(function (modal) { closeModal(modal.id); }); - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's' && els.recordModal.classList.contains('open')) { event.preventDefault(); saveRecord(); } - }); - $('themeBtn').addEventListener('click', function () { applyTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light'); }); - applyTheme(localStorage.getItem('lampac-theme') || 'dark'); - loadSummary(); loadTimeCodeUsers(); renderHead(); loadRecords(); -})(); diff --git a/mods/DatabaseEditor/index.html b/mods/DatabaseEditor/index.html deleted file mode 100644 index e7f80c4..0000000 --- a/mods/DatabaseEditor/index.html +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - Базы — Lampac - - - - - - -
-
- -
- -
- -
- -
-
-
-

Редактор TimeCode и Sync

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

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

🔐 Вход

-
-
- - -
-
- - - \ No newline at end of file diff --git a/mods/LogUserRequest-Lite/index.html b/mods/LogUserRequest-Lite/index.html deleted file mode 100644 index 5383bb5..0000000 --- a/mods/LogUserRequest-Lite/index.html +++ /dev/null @@ -1,487 +0,0 @@ - - - - - - LogUserRequest Lite - - - - - - - -
-
-
- - -
-
- - -
-
- -
- - - -
- - - -
-
Загрузка...
-
-
- - - - - - - \ No newline at end of file diff --git a/mods/LogUserRequest-Lite/manifest.json b/mods/LogUserRequest-Lite/manifest.json deleted file mode 100644 index 3e02531..0000000 --- a/mods/LogUserRequest-Lite/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "enable": true, - "version": 1, - "namespace": "LogUserRequest", - "logDay": 90 -} diff --git a/mods/Telemetry/AppDbContext.cs b/mods/Telemetry/AppDbContext.cs deleted file mode 100644 index cc0f684..0000000 --- a/mods/Telemetry/AppDbContext.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.IO; -using Microsoft.EntityFrameworkCore; -using Telemetry.Models; - -namespace Telemetry; - -public class AppDbContext : DbContext -{ - public DbSet Logs { get; set; } = null!; - public DbSet Users { get; set; } = null!; - - public AppDbContext() : base() { } - public AppDbContext(DbContextOptions options) : base(options) { } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - if (!optionsBuilder.IsConfigured) - { - var dbPath = ModInit.DbPath; - var dbDir = Path.GetDirectoryName(dbPath); - if (dbDir != null && !Directory.Exists(dbDir)) Directory.CreateDirectory(dbDir); - optionsBuilder.UseSqlite($"Data Source={dbPath};Cache=Shared"); - optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); - } - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity() - .HasIndex(l => l.Time); - - modelBuilder.Entity() - .HasIndex(l => new { l.Uid, l.Time }); - - modelBuilder.Entity() - .HasIndex(l => new { l.Balancer, l.Time }); - - modelBuilder.Entity() - .HasIndex(l => l.UnfoId); - - modelBuilder.Entity() - .HasKey(u => u.Id); - - modelBuilder.Entity() - .HasIndex(u => u.Ip); - } -} diff --git a/mods/Telemetry/Controllers/TelemetryController.cs b/mods/Telemetry/Controllers/TelemetryController.cs deleted file mode 100644 index d775287..0000000 --- a/mods/Telemetry/Controllers/TelemetryController.cs +++ /dev/null @@ -1,539 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Shared; -using Shared.Attributes; -using System; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Telemetry.Models; -using System.Collections.Generic; - -namespace Telemetry.Controllers; - -[Authorization(redirectUri: "/adminpanel/auth")] -[Route("/telemetry")] -public class TelemetryController : BaseController -{ - private readonly IDbContextFactory _contextFactory; - - public TelemetryController(IDbContextFactory contextFactory) - { - _contextFactory = contextFactory; - } - - private class MovieRawDto - { - public string? Title { get; set; } - public string? Tmdb { get; set; } - public string? Kp { get; set; } - public string? Imdb { get; set; } - public bool IsTv { get; set; } - public int Count { get; set; } - } - - private static (DateTime start, DateTime end) GetPeriod(int days) - { - var now = DateTime.UtcNow.Date; - var periodStart = days == 0 ? now : (days == -1 ? now.AddDays(-1) : now.AddDays(-days)); - var periodEnd = days == -1 ? now : DateTime.UtcNow; - return (periodStart, periodEnd); - } - - private static List MapMovies(List raw) - { - return raw.Select(m => new - { - title = m.Title, - id = m.Tmdb ?? m.Kp ?? m.Imdb ?? m.Title, - tmdb = m.Tmdb, - kp = m.Kp, - imdb = m.Imdb, - count = m.Count, - source = m.Tmdb != null ? "tmdb" : m.Kp != null ? "kinopoisk" : m.Imdb != null ? "imdb" : "unknown", - isTv = m.IsTv - }).Cast().ToList(); - } - - private IQueryable ApplySearchFilter(AppDbContext db, IQueryable query, string? search) - { - if (string.IsNullOrEmpty(search)) return query; - - var rawSearch = search; - var encodedSearch = Uri.EscapeDataString(search); - var encodedSearchPlus = encodedSearch.Replace("%20", "+"); - - return query.Where(l => - l.Uid.Contains(rawSearch) || - l.Uri.Contains(rawSearch) || - l.Uri.Contains(encodedSearch) || - l.Uri.Contains(encodedSearchPlus) || - (l.Balancer != null && l.Balancer.Contains(rawSearch)) || - db.Users.Any(u => u.Id == l.UnfoId && (u.Ip.Contains(rawSearch) || u.UserAgent.Contains(rawSearch))) - ); - } - - [HttpGet] - [Route("")] - public ActionResult Index() - { - var path = Path.Combine(ModInit.modpath, "index.html"); - if (!System.IO.File.Exists(path)) return Content("UI missing", "text/plain"); - var html = System.IO.File.ReadAllText(path, Encoding.UTF8); - return Content(html, "text/html; charset=utf-8"); - } - - [HttpGet("api/stats")] - public async Task Stats(string? search, int days = 30) - { - await using var db = await _contextFactory.CreateDbContextAsync(); - var query = db.Logs.AsNoTracking(); - query = ApplySearchFilter(db, query, search); - - var (periodStart, periodEnd) = GetPeriod(days); - var now = DateTime.UtcNow.Date; - - int total; - int today; - if (days == 0) - { - today = await query.Where(l => l.Time >= now.Date && l.Time < periodEnd).CountAsync(); - total = today; - } - else if (days == -1) - { - total = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd).CountAsync(); - today = 0; - } - else - { - total = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd).CountAsync(); - today = await query.Where(l => l.Time >= now.Date && l.Time < periodEnd).CountAsync(); - } - - var uids = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd && l.Uid != "anonymous" && !string.IsNullOrEmpty(l.Uid)).Select(l => l.Uid).Distinct().CountAsync(); - - var validLogs = query.Where(l => l.Time >= periodStart && l.Time < periodEnd && l.UnfoId != null); - var ips = await validLogs.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip).Where(ip => ip != null && ip != "").Distinct().CountAsync(); - var uas = await validLogs.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().CountAsync(); - - return new JsonResult(new { success = true, data = new { today, total, uids, ips, uas } }); - } - - [HttpGet("api/logs")] - public async Task Logs(string? search, int days = 30, int skip = 0, int take = 100) - { - take = Math.Min(take, 1000); - await using var db = await _contextFactory.CreateDbContextAsync(); - - var query = db.Logs.AsNoTracking(); - var (periodStart, periodEnd) = GetPeriod(days); - - query = query.Where(l => l.Time >= periodStart && l.Time < periodEnd); - query = ApplySearchFilter(db, query, search); - - var logs = await query.OrderByDescending(l => l.Time).Skip(skip).Take(take) - .GroupJoin(db.Users, l => l.UnfoId, u => u.Id, (l, users) => new { Log = l, Users = users }) - .SelectMany(x => x.Users.DefaultIfEmpty(), (x, u) => new - { - id = x.Log.Id, - time = x.Log.Time, - uri = x.Log.Uri, - uid = x.Log.Uid, - ip = u != null ? u.Ip : "unknown", - userAgent = u != null ? u.UserAgent : "unknown", - balancer = x.Log.Balancer, - statusCode = x.Log.StatusCode, - duration = x.Log.DurationMs - }) - .ToListAsync(); - - return new JsonResult(new { success = true, data = logs }); - } - - [HttpGet("api/chart")] - public async Task Chart(string? search, int days = 30) - { - var (periodStart, periodEnd) = GetPeriod(days); - - await using var db = await _contextFactory.CreateDbContextAsync(); - var query = db.Logs.AsNoTracking(); - query = ApplySearchFilter(db, query, search); - - var data = await query - .Where(l => l.Time >= periodStart && l.Time < periodEnd) - .GroupBy(l => l.Time.Date) - .Select(g => new { date = g.Key, count = g.Count() }) - .OrderBy(x => x.date) - .ToListAsync(); - - return new JsonResult(new { success = true, data = data }); - } - - [HttpGet("api/tops")] - public async Task GetTops(string? search, [FromQuery] int days = 7) - { - await using var db = await _contextFactory.CreateDbContextAsync(); - var (periodStart, periodEnd) = GetPeriod(days); - - var query = db.Logs.AsNoTracking(); - query = ApplySearchFilter(db, query, search); - var baseQuery = query.Where(l => l.Time >= periodStart && l.Time < periodEnd); - - var topBalancers = await baseQuery - .Where(l => l.Balancer != null && l.Balancer != "" && !l.Balancer.StartsWith("18+ ")) - .GroupBy(l => l.Balancer) - .Select(g => new { balancer = g.Key, count = g.Count() }) - .OrderByDescending(x => x.count) - .Take(10) - .ToListAsync(); - - var topAdult = await baseQuery - .Where(l => l.Balancer != null && l.Balancer.StartsWith("18+ ")) - .GroupBy(l => l.Balancer) - .Select(g => new { balancer = g.Key!.Substring(4), count = g.Count() }) - .OrderByDescending(x => x.count) - .Take(10) - .ToListAsync(); - - var topUsers = await baseQuery - .Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "") - .GroupBy(l => l.Uid) - .Select(g => new { uid = g.Key, count = g.Count() }) - .OrderByDescending(x => x.count) - .Take(10) - .ToListAsync(); - - var topMoviesRaw = await baseQuery - .Where(l => l.MovieTitle != null && l.MovieTitle != "") - .GroupBy(l => l.MovieTitle) - .Select(g => new MovieRawDto - { - Title = g.Key, - Tmdb = g.Max(l => l.TmdbId), - Kp = g.Max(l => l.KpId), - Imdb = g.Max(l => l.ImdbId), - IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1, - Count = g.Count() - }) - .OrderByDescending(x => x.Count) - .Take(10) - .ToListAsync(); - - var topMovies = MapMovies(topMoviesRaw); - - var userAgents = await baseQuery - .Where(l => l.UnfoId != null) - .Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent) - .GroupBy(ua => ua) - .Select(g => new { ua = g.Key, count = g.Count() }) - .OrderByDescending(x => x.count) - .Take(1000) - .ToListAsync(); - - var devices = userAgents - .Select(x => new { name = ParseDevice(x.ua), count = x.count }) - .GroupBy(x => x.name) - .Select(g => new { name = g.Key, count = g.Sum(x => x.count) }) - .OrderByDescending(x => x.count) - .ToList(); - - var hours = await baseQuery - .GroupBy(l => l.Time.Hour) - .Select(g => new { hour = g.Key, count = g.Count() }) - .OrderBy(x => x.hour) - .ToListAsync(); - - var seriesCount = await baseQuery.Where(l => l.IsTv).CountAsync(); - var moviesCount = await baseQuery.Where(l => l.MovieTitle != null && l.MovieTitle != "" && !l.IsTv).CountAsync(); - var animeCount = await baseQuery.Where(l => l.Uri.Contains("anime=1") || l.Uri.Contains("anime=true")).CountAsync(); - - var contentTypes = new[] { - new { name = "Фильмы", count = moviesCount }, - new { name = "Сериалы", count = seriesCount }, - new { name = "Аниме", count = animeCount } - }.Where(x => x.count > 0).ToList(); - - return new JsonResult(new { success = true, data = new { balancers = topBalancers, adult = topAdult, users = topUsers, movies = topMovies, devices, hours, contentTypes } }); - } - - private string ParseDevice(string? ua) - { - if (ua == null) return "Другие"; - ua = ua.ToLower(); - if (ua.Contains("tizen") || ua.Contains("samsung")) return "Tizen"; - if (ua.Contains("web0s") || ua.Contains("webos")) return "WebOS"; - if (ua.Contains("android")) return "Android"; - if (ua.Contains("iphone") || ua.Contains("ipad") || ua.Contains("mac os")) return "Apple"; - if (ua.Contains("windows")) return "Windows"; - if (ua.Contains("linux")) return "Linux"; - return "Другие"; - } - - [HttpGet("api/user")] - public async Task UserInfo(string uid, int days = 30) - { - if (string.IsNullOrEmpty(uid)) return BadRequest(); - - await using var db = await _contextFactory.CreateDbContextAsync(); - var (periodStart, periodEnd) = GetPeriod(days); - - var baseQuery = db.Logs.AsNoTracking().Where(l => l.Uid == uid && l.Time >= periodStart && l.Time < periodEnd); - var count = await baseQuery.CountAsync(); - - if (count == 0) return new JsonResult(new { success = false }); - - var totalRequests = count; - var firstSeen = await baseQuery.MinAsync(l => l.Time); - var lastSeen = await baseQuery.MaxAsync(l => l.Time); - - var ips = await baseQuery.Where(l => l.UnfoId != null).Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip).Where(ip => ip != null && ip != "").Distinct().ToListAsync(); - var userAgents = await baseQuery.Where(l => l.UnfoId != null).Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().ToListAsync(); - - var balancers = await baseQuery.Where(l => l.Balancer != null && l.Balancer != "") - .GroupBy(l => l.Balancer) - .Select(g => new { name = g.Key, count = g.Count() }) - .OrderByDescending(x => x.count) - .ToListAsync(); - - var isHourly = days <= 1; - var timeline = await (isHourly - ? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync() - : baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()); - - var moviesRaw = await baseQuery - .Where(l => l.MovieTitle != null && l.MovieTitle != "") - .GroupBy(l => l.MovieTitle) - .Select(g => new MovieRawDto - { - Title = g.Key, - Tmdb = g.Max(l => l.TmdbId), - Kp = g.Max(l => l.KpId), - Imdb = g.Max(l => l.ImdbId), - IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1, - Count = g.Count() - }) - .OrderByDescending(x => x.Count) - .Take(10) - .ToListAsync(); - - var movies = MapMovies(moviesRaw); - - return new JsonResult(new - { - success = true, - data = new - { - totalRequests, - firstSeen, - lastSeen, - ips, - userAgents, - balancers, - timeline, - movies - } - }); - } - - [HttpGet("api/ip")] - public async Task IpInfo(string ip, int days = 30) - { - if (string.IsNullOrEmpty(ip)) return BadRequest(); - - await using var db = await _contextFactory.CreateDbContextAsync(); - var (periodStart, periodEnd) = GetPeriod(days); - - var baseQuery = db.Logs.AsNoTracking().Where(l => l.Time >= periodStart && l.Time < periodEnd && l.UnfoId != null && db.Users.Any(u => u.Id == l.UnfoId && u.Ip == ip)); - var count = await baseQuery.CountAsync(); - - if (count == 0) return new JsonResult(new { success = false }); - - var totalRequests = count; - var firstSeen = await baseQuery.MinAsync(l => l.Time); - var lastSeen = await baseQuery.MaxAsync(l => l.Time); - - var uids = await baseQuery.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "").Select(l => l.Uid).Distinct().ToListAsync(); - var userAgents = await baseQuery.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().ToListAsync(); - - var balancers = await baseQuery.Where(l => l.Balancer != null && l.Balancer != "") - .GroupBy(l => l.Balancer) - .Select(g => new { name = g.Key, count = g.Count() }) - .OrderByDescending(x => x.count) - .ToListAsync(); - - var isHourly = days <= 1; - var timeline = await (isHourly - ? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync() - : baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()); - - var moviesRaw = await baseQuery - .Where(l => l.MovieTitle != null && l.MovieTitle != "") - .GroupBy(l => l.MovieTitle) - .Select(g => new MovieRawDto - { - Title = g.Key, - Tmdb = g.Max(l => l.TmdbId), - Kp = g.Max(l => l.KpId), - Imdb = g.Max(l => l.ImdbId), - IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1, - Count = g.Count() - }) - .OrderByDescending(x => x.Count) - .Take(10) - .ToListAsync(); - - var movies = MapMovies(moviesRaw); - - return new JsonResult(new - { - success = true, - data = new - { - totalRequests, - firstSeen, - lastSeen, - uids, - userAgents, - balancers, - timeline, - movies - } - }); - } - - [HttpGet("api/settings")] - public IActionResult GetSettings() - { - return new JsonResult(new - { - success = true, - data = new - { - prefixes = string.Join("\n", RequestListener.SkipPrefixes), - extensions = string.Join("\n", RequestListener.SkipExtensions), - regexes = string.Join("\n", RequestListener.RegexFilters), - ips = string.Join("\n", RequestListener.BlacklistIps), - rateLimit = RequestListener.RateLimitMs - } - }); - } - - [HttpPost("api/settings")] - public IActionResult UpdateSettings([FromForm] string? prefixes, [FromForm] string? extensions, [FromForm] string? regexes, [FromForm] string? ips, [FromForm] int? rateLimit) - { - if (prefixes != null) RequestListener.SkipPrefixes = prefixes.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); - if (extensions != null) RequestListener.SkipExtensions = extensions.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); - if (regexes != null) - { - var patterns = regexes.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); - var invalid = new List(); - foreach (var p in patterns) - { - try { System.Text.RegularExpressions.Regex.Match("", p, System.Text.RegularExpressions.RegexOptions.None, TimeSpan.FromMilliseconds(50)); } - catch { invalid.Add(p); } - } - if (invalid.Count > 0) - return new JsonResult(new { success = false, error = $"Невалидные regex: {string.Join(", ", invalid)}" }); - RequestListener.RegexFilters = patterns; - } - if (ips != null) RequestListener.BlacklistIps = ips.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); - if (rateLimit.HasValue) RequestListener.RateLimitMs = rateLimit.Value; - - RequestListener.SaveSettings(); - - return new JsonResult(new { success = true }); - } - - [HttpGet("api/balancer")] - public async Task BalancerInfo(string balancer, int days = 30) - { - if (string.IsNullOrEmpty(balancer)) return BadRequest(); - - await using var db = await _contextFactory.CreateDbContextAsync(); - var (periodStart, periodEnd) = GetPeriod(days); - var now = DateTime.UtcNow.Date; - - var baseQuery = db.Logs.AsNoTracking().Where(l => l.Balancer == balancer && l.Time >= periodStart && l.Time < periodEnd); - - var total = await baseQuery.CountAsync(); - var today = await baseQuery.Where(l => l.Time >= now.Date).CountAsync(); - - var topUids = await baseQuery.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "") - .GroupBy(l => l.Uid) - .Select(g => new { uid = g.Key, count = g.Count() }) - .OrderByDescending(x => x.count).Take(10).ToListAsync(); - - var topIps = await baseQuery.Where(l => l.UnfoId != null) - .Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip) - .Where(ip => ip != null && ip != "") - .GroupBy(ip => ip) - .Select(g => new { ip = g.Key, count = g.Count() }) - .OrderByDescending(x => x.count).Take(10).ToListAsync(); - - var isHourly = days <= 1; - var timeline = await (isHourly - ? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync() - : baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()); - - var moviesRaw = await baseQuery - .Where(l => l.MovieTitle != null && l.MovieTitle != "") - .GroupBy(l => l.MovieTitle) - .Select(g => new MovieRawDto - { - Title = g.Key, - Tmdb = g.Max(l => l.TmdbId), - Kp = g.Max(l => l.KpId), - Imdb = g.Max(l => l.ImdbId), - IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1, - Count = g.Count() - }) - .OrderByDescending(x => x.Count) - .Take(10) - .ToListAsync(); - - var movies = MapMovies(moviesRaw); - - return new JsonResult(new { success = true, data = new { total, today, uids = topUids, ips = topIps, timeline, movies } }); - } - - [HttpPost("api/logs/delete")] - public async Task DeleteLogs([FromForm] string? search, [FromForm] int days = 30) - { - await using var db = await _contextFactory.CreateDbContextAsync(); - var now = DateTime.UtcNow.Date; - var periodStart = days == 0 ? now : (days == -1 ? now.AddDays(-1) : now.AddDays(-days)); - var periodEnd = days == -1 ? now : DateTime.UtcNow; - - var query = db.Logs.Where(l => l.Time >= periodStart && l.Time < periodEnd); - query = ApplySearchFilter(db, query, search); - - var deleted = await query.ExecuteDeleteAsync(); - return new JsonResult(new { success = true, deleted }); - } - - [HttpGet("api/export")] - public async Task ExportData() - { - await using var db = await _contextFactory.CreateDbContextAsync(); - - var result = await db.Logs.AsNoTracking() - .OrderByDescending(l => l.Id) - .Take(50000) - .GroupJoin(db.Users, l => l.UnfoId, u => u.Id, (l, users) => new { Log = l, Users = users }) - .SelectMany(x => x.Users.DefaultIfEmpty(), (x, u) => new - { - time = x.Log.Time, - uri = x.Log.Uri, - uid = x.Log.Uid, - ip = u != null ? u.Ip : "", - userAgent = u != null ? u.UserAgent : "", - balancer = x.Log.Balancer, - statusCode = x.Log.StatusCode - }) - .ToListAsync(); - - return new JsonResult(result); - } -} diff --git a/mods/Telemetry/ModInit.cs b/mods/Telemetry/ModInit.cs deleted file mode 100644 index f3cb8f1..0000000 --- a/mods/Telemetry/ModInit.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Shared; -using Shared.Models; -using Shared.Models.Base; -using Shared.Models.Events; -using Shared.Models.Module; -using Shared.Models.Module.Interfaces; -using Telemetry.Models; - -namespace Telemetry; - -public class ModInit : IModuleLoaded, IModuleConfigure -{ - public static string modpath { get; private set; } = string.Empty; - public static readonly string DbPath = Path.Combine(AppContext.BaseDirectory, "database", "Telemetry", "stats.db"); - private static Timer? _updateDbTimer; - private static Timer? _cleanupTimer; - private static int _updatingDb = 0; - - public void Configure(ConfigureModel app) - { - app.services.AddDbContextFactory(options => - { - options.UseSqlite($"Data Source={DbPath};Cache=Shared"); - options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); - }); - } - - public void Loaded(InitspaceModel initspace) - { - modpath = initspace.path; - - var dbDir = Path.GetDirectoryName(DbPath); - if (dbDir != null) Directory.CreateDirectory(dbDir); - - using (var sqlDb = new AppDbContext()) - { - sqlDb.Database.EnsureCreated(); - try - { - sqlDb.Database.ExecuteSqlRaw("PRAGMA journal_mode = WAL;"); - sqlDb.Database.ExecuteSqlRaw("PRAGMA synchronous = NORMAL;"); - sqlDb.Database.ExecuteSqlRaw("PRAGMA cache_size = -64000;"); - sqlDb.Database.ExecuteSqlRaw("PRAGMA temp_store = MEMORY;"); - sqlDb.Database.ExecuteSqlRaw("PRAGMA mmap_size = 33554432;"); - } - catch { } - } - - _updateDbTimer?.Dispose(); - _cleanupTimer?.Dispose(); - - _updateDbTimer = new Timer(UpdateDb, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)); - _cleanupTimer = new Timer(CleanupDb, null, TimeSpan.FromMinutes(1), TimeSpan.FromHours(12)); - - RequestListener.LoadSettings(); - - EventListener.MiddlewareAsync -= RequestListener.InvokeAsync; - EventListener.MiddlewareAsync += RequestListener.InvokeAsync; - - Console.WriteLine("[Telemetry] Module loaded."); - } - - private static void UpdateDb(object? state) - { - if (Interlocked.Exchange(ref _updatingDb, 1) == 1) return; - try - { - using var sqlDb = new AppDbContext(); - sqlDb.ChangeTracker.AutoDetectChangesEnabled = false; - - var batch = new List<(LogModelSql log, UserInfoModelSql user)>(); - while (batch.Count < 2000 && RequestListener.Queue.TryDequeue(out var item)) - { - RequestListener.DequeueItem(); - batch.Add(item); - } - - if (batch.Count == 0) return; - - var userIds = batch.Select(b => b.user.Id).Distinct().ToList(); - var existingUsers = sqlDb.Users.Where(u => userIds.Contains(u.Id)).Select(u => u.Id).ToHashSet(); - - foreach (var item in batch.OrderBy(i => i.log.Time)) - { - if (!existingUsers.Contains(item.user.Id)) - { - sqlDb.Users.Add(item.user); - existingUsers.Add(item.user.Id); - } - sqlDb.Logs.Add(item.log); - } - sqlDb.SaveChanges(); - } - catch (Exception ex) - { - Console.WriteLine($"[Telemetry] DB Update Error: {ex.Message}"); - } - finally { Interlocked.Exchange(ref _updatingDb, 0); } - } - - private static void CleanupDb(object? state) - { - try - { - // Settings should be configurable, hardcoded for now: 90 days or max 500MB DB - int logDay = 90; - var cutoff = DateTime.UtcNow.AddDays(-logDay); - - using var sqlDb = new AppDbContext(); - int batchSize = 5000; - - // Delete old records - while (true) - { - var ids = sqlDb.Logs.Where(l => l.Time < cutoff).Select(l => l.Id).Take(batchSize).ToList(); - if (ids.Count == 0) break; - sqlDb.Logs.Where(l => ids.Contains(l.Id)).ExecuteDelete(); - if (ids.Count < batchSize) break; - } - - // Delete orphaned users who have no logs left - sqlDb.Users.Where(u => !sqlDb.Logs.Any(l => l.UnfoId == u.Id)).ExecuteDelete(); - - // Run VACUUM to reclaim deleted disk space - try { sqlDb.Database.ExecuteSqlRaw("VACUUM;"); } catch { } - } - catch (Exception ex) - { - Console.WriteLine($"[Telemetry] DB Cleanup Error: {ex.Message}"); - } - } - - public void Dispose() - { - EventListener.MiddlewareAsync -= RequestListener.InvokeAsync; - _updateDbTimer?.Dispose(); - _cleanupTimer?.Dispose(); - Console.WriteLine("[Telemetry] Module disposed."); - } -} diff --git a/mods/Telemetry/Models/SqlModels.cs b/mods/Telemetry/Models/SqlModels.cs deleted file mode 100644 index b7f06a9..0000000 --- a/mods/Telemetry/Models/SqlModels.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Telemetry.Models; - -public class LogModelSql -{ - [Key] - public long Id { get; set; } - - public DateTime Time { get; set; } - - [MaxLength(2048)] - public string Uri { get; set; } = string.Empty; - - [MaxLength(256)] - public string Uid { get; set; } = string.Empty; - - [MaxLength(64)] - public string? UnfoId { get; set; } - - public int DurationMs { get; set; } - - [MaxLength(128)] - public string? Balancer { get; set; } - - public int StatusCode { get; set; } - - [MaxLength(256)] - public string? MovieTitle { get; set; } - - [MaxLength(32)] - public string? TmdbId { get; set; } - - [MaxLength(32)] - public string? KpId { get; set; } - - [MaxLength(32)] - public string? ImdbId { get; set; } - - public bool IsTv { get; set; } -} - -public class UserInfoModelSql -{ - [Key] - [MaxLength(64)] - public string Id { get; set; } = string.Empty; - - [MaxLength(64)] - public string Ip { get; set; } = string.Empty; - - [MaxLength(1024)] - public string UserAgent { get; set; } = string.Empty; -} diff --git a/mods/Telemetry/RequestListener.cs b/mods/Telemetry/RequestListener.cs deleted file mode 100644 index 5cb892a..0000000 --- a/mods/Telemetry/RequestListener.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Caching.Memory; -using Shared; -using Shared.Models; -using Shared.Models.Base; -using Shared.Models.Events; -using Shared.Models.Module; -using Shared.Models.Module.Interfaces; -using Telemetry.Models; - -namespace Telemetry; - -public static class RequestListener -{ - public static readonly ConcurrentQueue<(LogModelSql log, UserInfoModelSql user)> Queue = new(); - private static int _queueSize = 0; - private const int MaxQueueSize = 50000; - - private static readonly MemoryCache _rateLimitCache = new(new MemoryCacheOptions { SizeLimit = 100_000 }); - private static readonly MemoryCache _balancerDedupCache = new(new MemoryCacheOptions { SizeLimit = 50_000 }); - private static readonly MemoryCache _userIdCache = new(new MemoryCacheOptions { SizeLimit = 50_000 }); - - private static readonly string _settingsPath = Path.Combine(AppContext.BaseDirectory, "database", "Telemetry", "settings.json"); - - public static volatile string[] SkipPrefixes = { - "/.well-known", "/admin/health", "/admin/ping", - "/lite/telemetry", "/telemetry", "/adminpanel", "/stats", "/weblog", - "/storage/", "/timecode/", "/bookmark/", "/tmdb/api/", "/lite/events", "/externalids", "/sisi" - }; - public static volatile string[] SkipExtensions = { ".js", ".css", ".svg", ".png", ".jpg", ".woff", ".ico" }; - public static volatile string[] RegexFilters = { }; - private static volatile HashSet _blacklistIps = new(); - public static volatile int RateLimitMs = 33; - - public static string[] BlacklistIps - { - get => _blacklistIps.ToArray(); - set => _blacklistIps = new HashSet(value, StringComparer.OrdinalIgnoreCase); - } - - public static bool IsBlacklistedIp(string ip) => _blacklistIps.Contains(ip); - - public static void DequeueItem() => Interlocked.Decrement(ref _queueSize); - - public static Task InvokeAsync(bool first, EventMiddleware e) - { - if (first) - { - e.httpContext.Items["Telemetry_StartTime"] = DateTime.UtcNow; - return Task.FromResult(true); - } - - var httpContext = e.httpContext; - var requestInfo = httpContext.Features.Get(); - - if (requestInfo == null || requestInfo.IsLocalRequest || requestInfo.IsAnonymousRequest) - return Task.FromResult(true); - - try - { - var path = httpContext.Request.Path.Value ?? ""; - - // Configurable Regex filters with ReDoS protection (timeout 100ms) - try - { - if (RegexFilters.Any(pattern => Regex.IsMatch(path, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(100)))) - return Task.FromResult(true); - } - catch (RegexMatchTimeoutException) - { - // ReDoS protection triggered, skip this request and log warning - return Task.FromResult(true); - } - - // Extension filter - if (SkipExtensions.Any(ext => path.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) - return Task.FromResult(true); - - // Prefix filter - if (SkipPrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) - return Task.FromResult(true); - - var realIP = requestInfo.IP; - if (string.IsNullOrEmpty(realIP) || realIP == "127.0.0.1" || realIP == "::1") - return Task.FromResult(true); - - // IP Blacklist check - if (IsBlacklistedIp(realIP)) - return Task.FromResult(true); - - // Rate limit - if (RateLimitMs > 0) - { - var rateLimitKey = $"{realIP}:{path}"; - if (_rateLimitCache.TryGetValue(rateLimitKey, out _)) - return Task.FromResult(true); - - _rateLimitCache.Set(rateLimitKey, true, new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(RateLimitMs), - Size = 1 - }); - } - - string userAgent = httpContext.Request.Headers["User-Agent"].FirstOrDefault() ?? "unknown"; - if (userAgent.Length > 1024) userAgent = userAgent[..1024]; - - string userUid = httpContext.Request.Query["uid"].FirstOrDefault() ?? httpContext.Request.Cookies["uid"] ?? "anonymous"; - if (userUid.Length > 256) userUid = userUid[..256]; - - var cacheKey = $"{realIP}:{userAgent}"; - if (!_userIdCache.TryGetValue(cacheKey, out string? userId)) - { - userId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(cacheKey))).ToLower(); - _userIdCache.Set(cacheKey, userId, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(2), Size = 1 }); - } - - var user = new UserInfoModelSql - { - Id = userId!, - Ip = realIP, - UserAgent = userAgent - }; - - var fullUri = path + httpContext.Request.QueryString; - if (fullUri.Length > 2048) fullUri = fullUri[..2048]; - - string balancer = ""; - if (path.StartsWith("/lite/")) balancer = path[6..].Split('/', '?')[0]; - else if (path.StartsWith("/rc/")) balancer = path[4..].Split('/', '?')[0]; - else - { - // Parse 18+ (sisi) modules which sit at the root - string[] adultRoutes = { "bgs", "chu", "elo", "epr", "hqr", "phub", "phubgay", "phubsml", "phubprem", "ptx", "runetki", "sbg", "tizam", "xmr", "xmrgay", "xmrsml", "xnx", "xds", "xdsgay", "xdssml", "xdsred", "nexthub" }; - var rootSegment = path.TrimStart('/').Split('/', '?')[0].ToLowerInvariant(); - - if (adultRoutes.Contains(rootSegment)) - { - balancer = "18+ " + rootSegment; - } - } - - // Filter out system endpoints that are not actual movie balancers - string[] systemEndpoints = { "events", "sync", "auth", "ws", "subs", "vtt", "search", "suggest", "torrent", "tracker", "info", "mdl" }; - if (systemEndpoints.Contains(balancer.ToLowerInvariant())) - { - balancer = ""; - } - - // Deduplication for balancers (First + Last within 30 seconds) - if (!string.IsNullOrEmpty(balancer)) - { - var dedupKey = $"{userUid}:{balancer}"; - var lastSeen = _balancerDedupCache.Get(dedupKey); - - if (lastSeen.HasValue && (DateTime.UtcNow - lastSeen.Value).TotalSeconds < 30) - { - return Task.FromResult(true); - } - else - { - _balancerDedupCache.Set(dedupKey, DateTime.UtcNow, new MemoryCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromSeconds(30), - Size = 1 - }); - } - } - - int durationMs = 0; - if (httpContext.Items.TryGetValue("Telemetry_StartTime", out var startTimeObj) && startTimeObj is DateTime startTime) - { - durationMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds; - } - - string? mTitle = null, mTmdb = null, mKp = null, mImdb = null; - bool mIsTv = false; - - var reqQuery = httpContext.Request.Query; - if (reqQuery.TryGetValue("title", out var titleVal)) mTitle = titleVal.ToString(); - if (reqQuery.TryGetValue("id", out var idVal)) mTmdb = idVal.ToString(); - if (reqQuery.TryGetValue("kinopoisk_id", out var kpVal)) mKp = kpVal.ToString(); - if (reqQuery.TryGetValue("imdb_id", out var imdbVal)) mImdb = imdbVal.ToString(); - if (reqQuery.TryGetValue("serial", out var serialVal)) mIsTv = serialVal.ToString() == "1" || serialVal.ToString().Equals("true", StringComparison.OrdinalIgnoreCase); - - var log = new LogModelSql - { - Time = DateTime.UtcNow, - Uri = fullUri, - Uid = userUid, - UnfoId = user.Id, - DurationMs = durationMs, - Balancer = balancer, - StatusCode = httpContext.Response?.StatusCode ?? 0, - MovieTitle = mTitle, - TmdbId = mTmdb, - KpId = mKp, - ImdbId = mImdb, - IsTv = mIsTv - }; - - if (Interlocked.Increment(ref _queueSize) <= MaxQueueSize) - { - try { Queue.Enqueue((log, user)); } - catch { Interlocked.Decrement(ref _queueSize); } - } - else - { - Interlocked.Decrement(ref _queueSize); - // Dropped - } - } - catch { } - - return Task.FromResult(true); - } - - public static void SaveSettings() - { - try - { - var dir = Path.GetDirectoryName(_settingsPath); - if (dir != null) Directory.CreateDirectory(dir); - - var data = new Dictionary - { - ["prefixes"] = SkipPrefixes, - ["extensions"] = SkipExtensions, - ["regexes"] = RegexFilters, - ["ips"] = BlacklistIps, - ["rateLimit"] = RateLimitMs - }; - File.WriteAllText(_settingsPath, System.Text.Json.JsonSerializer.Serialize(data, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); - } - catch (Exception ex) - { - Console.WriteLine($"[Telemetry] SaveSettings Error: {ex.Message}"); - } - } - - public static void LoadSettings() - { - try - { - if (!File.Exists(_settingsPath)) return; - var json = File.ReadAllText(_settingsPath); - var doc = System.Text.Json.JsonDocument.Parse(json); - var root = doc.RootElement; - - if (root.TryGetProperty("prefixes", out var p)) SkipPrefixes = p.EnumerateArray().Select(e => e.GetString()!).ToArray(); - if (root.TryGetProperty("extensions", out var e2)) SkipExtensions = e2.EnumerateArray().Select(e => e.GetString()!).ToArray(); - if (root.TryGetProperty("regexes", out var r)) RegexFilters = r.EnumerateArray().Select(e => e.GetString()!).ToArray(); - if (root.TryGetProperty("ips", out var i)) BlacklistIps = i.EnumerateArray().Select(e => e.GetString()!).ToArray(); - if (root.TryGetProperty("rateLimit", out var rl)) RateLimitMs = rl.GetInt32(); - - Console.WriteLine("[Telemetry] Settings loaded from disk."); - } - catch (Exception ex) - { - Console.WriteLine($"[Telemetry] LoadSettings Error: {ex.Message}"); - } - } -} diff --git a/mods/Telemetry/Telemetry.csproj b/mods/Telemetry/Telemetry.csproj deleted file mode 100644 index bbd09a8..0000000 --- a/mods/Telemetry/Telemetry.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - net10.0 - Library - Telemetry - enable - enable - - - - - - diff --git a/mods/Telemetry/index.html b/mods/Telemetry/index.html deleted file mode 100644 index d5569bc..0000000 --- a/mods/Telemetry/index.html +++ /dev/null @@ -1,2337 +0,0 @@ - - - - - - - Telemetry — Lampac - - - - - - - -
-
-
- -
- -
-
- - - - -
-
-
- -
-
-
-
Запросов сегодня
-
-
-
-
-
Запросов за период
-
-
-
-
-
Уникальных IP
-
-
-
-
-
Уникальных устройств
-
-
-
-
- -
- -
- -
-
- -
- Нет данных -
-
-
- -
- Нет данных -
-
-
- -
- Нет данных -
-
-
- -
- -
- Нет данных -
-
- -
-
- -
- Нет данных -
-
-
- -
- Нет данных -
-
-
- -
- Нет данных -
-
-
- -
- -
- - - -
- -
- - - - - - - - - - - - - - - - -
ВремяUIDIPПутьБалансирСтатус
- Загрузка... -
-
-
-
- - - - - - - - - - - - diff --git a/mods/Telemetry/manifest.json b/mods/Telemetry/manifest.json deleted file mode 100644 index 7a950f3..0000000 --- a/mods/Telemetry/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "enable": true, - "dynamic": true, - "tree": [ - "Controllers", - "Models", - "AppDbContext.cs", - "ModInit.cs", - "RequestListener.cs" - ] -} diff --git a/mods/Tracks/Controller.cs b/mods/Tracks/Controller.cs index 202261f..54e5c66 100644 --- a/mods/Tracks/Controller.cs +++ b/mods/Tracks/Controller.cs @@ -5,6 +5,7 @@ using Shared.Attributes; using Shared.Services; using Shared.Services.Utilities; using System; +using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -49,8 +50,21 @@ public class TracksController : BaseController { media = Regex.Replace(media, "[^a-z0-9_:\\-\\/\\.\\=\\?\\&\\%\\@]+", "", RegexOptions.IgnoreCase); - if (media.Contains("/stream/") && ModInit.conf.tsuri != null) - media = Regex.Replace(media, "^https?://[^/]+", ModInit.conf.tsuri, RegexOptions.IgnoreCase); + if (media.Contains("/stream/")) + { + if (ModInit.conf.tsuri != null) + media = Regex.Replace(media, "^https?://[^/]+", ModInit.conf.tsuri, RegexOptions.IgnoreCase); + + // TorrServer returns an empty body for preload/stat/m3u; ffprobe needs the play stream + int q = media.IndexOf('?'); + if (q > 0) + { + var args = media[(q + 1)..].Split('&', StringSplitOptions.RemoveEmptyEntries) + .Where(a => !Regex.IsMatch(a, "^(preload|stat|m3u|play)(=|$)", RegexOptions.IgnoreCase)); + + media = media[..q] + "?" + string.Join("&", args.Append("play")); + } + } } else if (media.Contains("/proxy/") && media.Contains(".mkv")) { diff --git a/mods/Tracks/plugin.js b/mods/Tracks/plugin.js index 5181040..d95ebac 100644 --- a/mods/Tracks/plugin.js +++ b/mods/Tracks/plugin.js @@ -1,401 +1,416 @@ (function () { - 'use strict'; + 'use strict'; - var connect_host = '{localhost}'; - var list_opened = false; - - var unic_id = Lampa.Storage.get('lampac_unic_id', ''); - if (!unic_id) { - unic_id = Lampa.Utils.uid(8).toLowerCase(); - Lampa.Storage.set('lampac_unic_id', unic_id); - } + var connect_host = '{localhost}'; + var list_opened = false; - function reguest(params, callback) { - if (params.ffprobe) { - setTimeout(function () { - callback({ - streams: params.ffprobe - }); - }, 200); - } else { - var net = new Lampa.Reguest(); - net.timeout(1000 * 15); - var url = connect_host + '/ffprobe?media=' + encodeURIComponent(params.url); - - if (url.indexOf('account_email=') == -1) { - var email = Lampa.Storage.get('account_email'); - if (email) url = Lampa.Utils.addUrlComponent(url, 'account_email=' + encodeURIComponent(email)); - } + var unic_id = Lampa.Storage.get('lampac_unic_id', ''); + if (!unic_id) { + unic_id = Lampa.Utils.uid(8).toLowerCase(); + Lampa.Storage.set('lampac_unic_id', unic_id); + } - if (url.indexOf('uid=') == -1) { - var uid = Lampa.Storage.get('lampac_unic_id', ''); - if (uid) url = Lampa.Utils.addUrlComponent(url, 'uid=' + encodeURIComponent(uid)); - } - - if (url.indexOf('token=') == -1) { - var token = '{token}'; - if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token={token}'); - } - - net["native"](url, function (str) { - var json = {}; - - try { - json = JSON.parse(str); - } catch (e) {} - - if (json.streams) callback(json); - }, false, false, { - dataType: 'text' + function reguest(params, callback) { + if (params.ffprobe) { + setTimeout(function () { + callback({ + streams: params.ffprobe }); + }, 200); + } else { + var net = new Lampa.Reguest(); + net.timeout(1000 * 15); + var url = connect_host + '/ffprobe?media=' + encodeURIComponent(params.url); + + if (url.indexOf('account_email=') == -1) { + var email = Lampa.Storage.get('account_email'); + if (email) url = Lampa.Utils.addUrlComponent(url, 'account_email=' + encodeURIComponent(email)); + } + + if (url.indexOf('uid=') == -1) { + var uid = Lampa.Storage.get('lampac_unic_id', ''); + if (uid) url = Lampa.Utils.addUrlComponent(url, 'uid=' + encodeURIComponent(uid)); + } + + if (url.indexOf('token=') == -1) { + var token = '{token}'; + if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token={token}'); + } + + net["native"](url, function (str) { + var json = {}; + + try { + json = JSON.parse(str); + } catch (e) { } + + callback(json.streams && json.streams.length ? json : null); + }, function () { + callback(null); + }, false, { + dataType: 'text' + }); + } + } + + function subscribeTracks(data) { + var inited = false; + var inited_parse = false; + var webos_replace = {}; + + function log() { + console.log.apply(console.log, arguments); + } + + function getTracks() { + var video = Lampa.PlayerVideo.video(); + return video.audioTracks || []; + } + + function getSubs() { + var video = Lampa.PlayerVideo.video(); + return video.textTracks || []; + } + + log('Tracks', 'start'); + + function setTracks() { + if (inited_parse) { + var new_tracks = []; + var video_tracks = getTracks(); + var parse_tracks = inited_parse.streams.filter(function (a) { + return a.codec_type == 'audio'; + }); + var minus = 1; + log('Tracks', 'set tracks:', video_tracks.length); + if (parse_tracks.length !== video_tracks.length) parse_tracks = parse_tracks.filter(function (a) { + return a.codec_name !== 'dts'; + }); + parse_tracks = parse_tracks.filter(function (a) { + return a.tags; + }); + log('Tracks', 'filtred tracks:', parse_tracks.length); + parse_tracks.forEach(function (track) { + var orig = video_tracks[track.index - minus]; + var elem = { + index: track.index - minus, + language: track.tags.language, + label: track.tags.title || track.tags.handler_name, + ghost: orig ? false : true, + selected: orig ? orig.selected == true || orig.enabled == true : false + }; + console.log('Tracks', 'tracks original', orig); + Object.defineProperty(elem, "enabled", { + set: function set(v) { + if (v) { + var aud = getTracks(); + var trk = aud[elem.index]; + + for (var i = 0; i < aud.length; i++) { + aud[i].enabled = false; + aud[i].selected = false; + } + + if (trk) { + trk.enabled = true; + trk.selected = true; + } + } + }, + get: function get() { } + }); + new_tracks.push(elem); + }); + if (parse_tracks.length) Lampa.PlayerPanel.setTracks(new_tracks); } } - function subscribeTracks(data) { - var inited = false; - var inited_parse = false; - var webos_replace = {}; + function setSubs() { + if (inited_parse) { + var new_subs = []; + var video_subs = getSubs(); + var parse_subs = inited_parse.streams.filter(function (a) { + return a.codec_type == 'subtitle'; + }); + var minus = inited_parse.streams.filter(function (a) { + return a.codec_type == 'audio'; + }).length + 1; + log('Tracks', 'set subs:', video_subs.length); + parse_subs = parse_subs.filter(function (a) { + return a.tags; + }); + log('Tracks', 'filtred subs:', parse_subs.length); + parse_subs.forEach(function (track) { + var orig = video_subs[track.index - minus]; + var elem = { + index: track.index - minus, + language: track.tags.language, + label: track.tags.title || track.tags.handler_name, + ghost: video_subs[track.index - minus] ? false : true, + selected: orig ? orig.selected == true || orig.mode == 'showing' : false + }; + console.log('Tracks', 'subs original', orig); + Object.defineProperty(elem, "mode", { + set: function set(v) { + if (v) { + var txt = getSubs(); + var sub = txt[elem.index]; - function log() { - console.log.apply(console.log, arguments); - } + for (var i = 0; i < txt.length; i++) { + txt[i].mode = 'disabled'; + txt[i].selected = false; + } - function getTracks() { - var video = Lampa.PlayerVideo.video(); - return video.audioTracks || []; - } - - function getSubs() { - var video = Lampa.PlayerVideo.video(); - return video.textTracks || []; - } - - log('Tracks', 'start'); - - function setTracks() { - if (inited_parse) { - var new_tracks = []; - var video_tracks = getTracks(); - var parse_tracks = inited_parse.streams.filter(function (a) { - return a.codec_type == 'audio'; - }); - var minus = 1; - log('Tracks', 'set tracks:', video_tracks.length); - if (parse_tracks.length !== video_tracks.length) parse_tracks = parse_tracks.filter(function (a) { - return a.codec_name !== 'dts'; + if (sub) { + sub.mode = 'showing'; + sub.selected = true; + } + } + }, + get: function get() { } }); + new_subs.push(elem); + }); + if (parse_subs.length) Lampa.PlayerPanel.setSubs(new_subs); + } + } + + function listenTracks() { + log('Tracks', 'tracks video event'); + setTracks(); + Lampa.PlayerVideo.listener.remove('tracks', listenTracks); + } + + function listenSubs() { + log('Tracks', 'subs video event'); + setSubs(); + Lampa.PlayerVideo.listener.remove('subs', listenSubs); + } + + function canPlay() { + log('Tracks', 'canplay video event'); + if (webos_replace.tracks) setWebosTracks(webos_replace.tracks); else setTracks(); + if (webos_replace.subs) setWebosSubs(webos_replace.subs); else setSubs(); + Lampa.PlayerVideo.listener.remove('canplay', canPlay); + } + + function setWebosTracks(video_tracks) { + if (inited_parse) { + var parse_tracks = inited_parse.streams.filter(function (a) { + return a.codec_type == 'audio'; + }); + log('Tracks', 'webos set tracks:', video_tracks.length); + + if (parse_tracks.length !== video_tracks.length) { parse_tracks = parse_tracks.filter(function (a) { - return a.tags; + return a.codec_name !== 'truehd'; }); - log('Tracks', 'filtred tracks:', parse_tracks.length); - parse_tracks.forEach(function (track) { - var orig = video_tracks[track.index - minus]; - var elem = { - index: track.index - minus, - language: track.tags.language, - label: track.tags.title || track.tags.handler_name, - ghost: orig ? false : true, - selected: orig ? orig.selected == true || orig.enabled == true : false - }; - console.log('Tracks', 'tracks original', orig); - Object.defineProperty(elem, "enabled", { - set: function set(v) { - if (v) { - var aud = getTracks(); - var trk = aud[elem.index]; - - for (var i = 0; i < aud.length; i++) { - aud[i].enabled = false; - aud[i].selected = false; - } - - if (trk) { - trk.enabled = true; - trk.selected = true; - } - } - }, - get: function get() {} - }); - new_tracks.push(elem); - }); - if (parse_tracks.length) Lampa.PlayerPanel.setTracks(new_tracks); - } - } - - function setSubs() { - if (inited_parse) { - var new_subs = []; - var video_subs = getSubs(); - var parse_subs = inited_parse.streams.filter(function (a) { - return a.codec_type == 'subtitle'; - }); - var minus = inited_parse.streams.filter(function (a) { - return a.codec_type == 'audio'; - }).length + 1; - log('Tracks', 'set subs:', video_subs.length); - parse_subs = parse_subs.filter(function (a) { - return a.tags; - }); - log('Tracks', 'filtred subs:', parse_subs.length); - parse_subs.forEach(function (track) { - var orig = video_subs[track.index - minus]; - var elem = { - index: track.index - minus, - language: track.tags.language, - label: track.tags.title || track.tags.handler_name, - ghost: video_subs[track.index - minus] ? false : true, - selected: orig ? orig.selected == true || orig.mode == 'showing' : false - }; - console.log('Tracks', 'subs original', orig); - Object.defineProperty(elem, "mode", { - set: function set(v) { - if (v) { - var txt = getSubs(); - var sub = txt[elem.index]; - - for (var i = 0; i < txt.length; i++) { - txt[i].mode = 'disabled'; - txt[i].selected = false; - } - - if (sub) { - sub.mode = 'showing'; - sub.selected = true; - } - } - }, - get: function get() {} - }); - new_subs.push(elem); - }); - if (parse_subs.length) Lampa.PlayerPanel.setSubs(new_subs); - } - } - - function listenTracks() { - log('Tracks', 'tracks video event'); - setTracks(); - Lampa.PlayerVideo.listener.remove('tracks', listenTracks); - } - - function listenSubs() { - log('Tracks', 'subs video event'); - setSubs(); - Lampa.PlayerVideo.listener.remove('subs', listenSubs); - } - - function canPlay() { - log('Tracks', 'canplay video event'); - if (webos_replace.tracks) setWebosTracks(webos_replace.tracks);else setTracks(); - if (webos_replace.subs) setWebosSubs(webos_replace.subs);else setSubs(); - Lampa.PlayerVideo.listener.remove('canplay', canPlay); - } - - function setWebosTracks(video_tracks) { - if (inited_parse) { - var parse_tracks = inited_parse.streams.filter(function (a) { - return a.codec_type == 'audio'; - }); - log('Tracks', 'webos set tracks:', video_tracks.length); if (parse_tracks.length !== video_tracks.length) { parse_tracks = parse_tracks.filter(function (a) { - return a.codec_name !== 'truehd'; + return a.codec_name !== 'dts'; }); - - if (parse_tracks.length !== video_tracks.length) { - parse_tracks = parse_tracks.filter(function (a) { - return a.codec_name !== 'dts'; - }); - } } - - parse_tracks = parse_tracks.filter(function (a) { - return a.tags; - }); - log('Tracks', 'webos tracks', video_tracks); - parse_tracks.forEach(function (track, i) { - if (video_tracks[i]) { - video_tracks[i].language = track.tags.language; - video_tracks[i].label = track.tags.title || track.tags.handler_name; - } - }); } - } - function setWebosSubs(video_subs) { - if (inited_parse) { - var parse_subs = inited_parse.streams.filter(function (a) { - return a.codec_type == 'subtitle'; - }); - log('Tracks', 'webos set subs:', video_subs.length); - if (parse_subs.length !== video_subs.length - 1) parse_subs = parse_subs.filter(function (a) { - return a.codec_name !== 'hdmv_pgs_subtitle'; - }); - parse_subs = parse_subs.filter(function (a) { - return a.tags; - }); - parse_subs.forEach(function (track, a) { - var i = a + 1; - - if (video_subs[i]) { - video_subs[i].language = track.tags.language; - video_subs[i].label = track.tags.title || track.tags.handler_name; - } - }); - } - } - - function listenWebosSubs(_data) { - log('Tracks', 'webos subs event'); - webos_replace.subs = _data.subs; - if (inited_parse) setWebosSubs(_data.subs); - } - - function listenWebosTracks(_data) { - log('Tracks', 'webos tracks event'); - webos_replace.tracks = _data.tracks; - if (inited_parse) setWebosTracks(_data.tracks); - } - - function listenStart() { - inited = true; - reguest(data, function (result) { - log('Tracks', 'parsed', inited_parse); - inited_parse = result; - - if (inited) { - if (webos_replace.subs) setWebosSubs(webos_replace.subs);else setSubs(); - if (webos_replace.tracks) setWebosTracks(webos_replace.tracks);else setTracks(); + parse_tracks = parse_tracks.filter(function (a) { + return a.tags; + }); + log('Tracks', 'webos tracks', video_tracks); + parse_tracks.forEach(function (track, i) { + if (video_tracks[i]) { + video_tracks[i].language = track.tags.language; + video_tracks[i].label = track.tags.title || track.tags.handler_name; } }); } - - function listenDestroy() { - inited = false; - Lampa.Player.listener.remove('destroy', listenDestroy); - Lampa.PlayerVideo.listener.remove('tracks', listenTracks); - Lampa.PlayerVideo.listener.remove('subs', listenSubs); - Lampa.PlayerVideo.listener.remove('webos_subs', listenWebosSubs); - Lampa.PlayerVideo.listener.remove('webos_tracks', listenWebosTracks); - Lampa.PlayerVideo.listener.remove('canplay', canPlay); - log('Tracks', 'end'); - } - - Lampa.Player.listener.follow('destroy', listenDestroy); - Lampa.PlayerVideo.listener.follow('tracks', listenTracks); - Lampa.PlayerVideo.listener.follow('subs', listenSubs); - Lampa.PlayerVideo.listener.follow('webos_subs', listenWebosSubs); - Lampa.PlayerVideo.listener.follow('webos_tracks', listenWebosTracks); - Lampa.PlayerVideo.listener.follow('canplay', canPlay); - listenStart(); } - function parseMetainfo(data) { - var loading = Lampa.Template.get('tracks_loading'); - data.item.after(loading); - reguest(data.element, function (result) { - if (list_opened) { - var append = function append(name, fields) { - if (fields.length) { - var block = Lampa.Template.get('tracks_metainfo_block', {}); - block.find('.tracks-metainfo__label').text(Lampa.Lang.translate(name == 'video' ? 'extensions_hpu_video' : name == 'audio' ? 'player_tracks' : 'player_' + name)); - fields.forEach(function (data) { - var item = $('
'); - item.on('hover:focus', function (e) { - Lampa.Modal.scroll().update(item); - }); + function setWebosSubs(video_subs) { + if (inited_parse) { + var parse_subs = inited_parse.streams.filter(function (a) { + return a.codec_type == 'subtitle'; + }); + log('Tracks', 'webos set subs:', video_subs.length); + if (parse_subs.length !== video_subs.length - 1) parse_subs = parse_subs.filter(function (a) { + return a.codec_name !== 'hdmv_pgs_subtitle'; + }); + parse_subs = parse_subs.filter(function (a) { + return a.tags; + }); + parse_subs.forEach(function (track, a) { + var i = a + 1; - for (var i in data) { - var div = $('
'); - div.text(data[i]); - item.append(div); - } - - block.find('.tracks-metainfo__info').append(item); - }); - html.append(block); - } - }; - - var video = []; - var audio = []; - var subs = []; - var codec_video = result.streams.filter(function (a) { - return a.codec_type == 'video'; - }); - var codec_audio = result.streams.filter(function (a) { - return a.codec_type == 'audio'; - }); - var codec_subs = result.streams.filter(function (a) { - return a.codec_type == 'subtitle'; - }); - codec_video.slice(0, 1).forEach(function (v) { - var line = {}; - if (v.width && v.height) line.video = v.width + 'х' + v.height; - if (v.codec_name) line.codec = v.codec_name.toUpperCase(); - if (Boolean(v.is_avc)) line.avc = 'AVC'; - if (Lampa.Arrays.getKeys(line).length) video.push(line); - }); - codec_audio.forEach(function (a, i) { - var line = { - num: i + 1 - }; - - if (a.tags) { - line.lang = (a.tags.language || '').toUpperCase(); - } - - line.name = a.tags ? a.tags.title || a.tags.handler_name : ''; - if (a.codec_name) line.codec = a.codec_name.toUpperCase(); - if (a.channel_layout) line.channels = a.channel_layout.replace('(side)', '').replace('stereo', '2.0'); - var bit = a.bit_rate ? a.bit_rate : a.tags && (a.tags.BPS || a.tags["BPS-eng"]) ? a.tags.BPS || a.tags["BPS-eng"] : '--'; - line.rate = bit == '--' ? bit : Math.round(bit / 1000) + ' ' + Lampa.Lang.translate('speed_kb'); - if (Lampa.Arrays.getKeys(line).length) audio.push(line); - }); - codec_subs.forEach(function (a, i) { - var line = { - num: i + 1 - }; - - if (a.tags) { - line.lang = (a.tags.language || '').toUpperCase(); - } - - line.name = a.tags ? a.tags.title || a.tags.handler_name : ''; - if (Lampa.Arrays.getKeys(line).length) subs.push(line); - }); - var html = Lampa.Template.get('tracks_metainfo', {}); - append('video', video); - append('audio', audio); - append('subs', subs); - loading.remove(); - - if (video.length || audio.length || subs.length) { - data.item.after(html); + if (video_subs[i]) { + video_subs[i].language = track.tags.language; + video_subs[i].label = track.tags.title || track.tags.handler_name; } + }); + } + } - if (Lampa.Controller.enabled().name == 'modal') Lampa.Controller.toggle('modal'); + function listenWebosSubs(_data) { + log('Tracks', 'webos subs event'); + webos_replace.subs = _data.subs; + if (inited_parse) setWebosSubs(_data.subs); + } + + function listenWebosTracks(_data) { + log('Tracks', 'webos tracks event'); + webos_replace.tracks = _data.tracks; + if (inited_parse) setWebosTracks(_data.tracks); + } + + function listenStart() { + inited = true; + reguest(data, function (result) { + if (!result) return log('Tracks', 'no metadata'); + log('Tracks', 'parsed', inited_parse); + inited_parse = result; + + if (inited) { + if (webos_replace.subs) setWebosSubs(webos_replace.subs); else setSubs(); + if (webos_replace.tracks) setWebosTracks(webos_replace.tracks); else setTracks(); } }); } - Lampa.Player.listener.follow('start', function (data) { - if (data.torrent_hash) subscribeTracks(data); - }); - Lampa.Listener.follow('torrent_file', function (data) { - if (data.type == 'list_open') list_opened = true; - if (data.type == 'list_close') list_opened = false; + function listenDestroy() { + inited = false; + Lampa.Player.listener.remove('destroy', listenDestroy); + Lampa.PlayerVideo.listener.remove('tracks', listenTracks); + Lampa.PlayerVideo.listener.remove('subs', listenSubs); + Lampa.PlayerVideo.listener.remove('webos_subs', listenWebosSubs); + Lampa.PlayerVideo.listener.remove('webos_tracks', listenWebosTracks); + Lampa.PlayerVideo.listener.remove('canplay', canPlay); + log('Tracks', 'end'); + } - if (data.type == 'render' && data.items.length == 1 && list_opened) { - parseMetainfo(data); + Lampa.Player.listener.follow('destroy', listenDestroy); + Lampa.PlayerVideo.listener.follow('tracks', listenTracks); + Lampa.PlayerVideo.listener.follow('subs', listenSubs); + Lampa.PlayerVideo.listener.follow('webos_subs', listenWebosSubs); + Lampa.PlayerVideo.listener.follow('webos_tracks', listenWebosTracks); + Lampa.PlayerVideo.listener.follow('canplay', canPlay); + listenStart(); + } + + function parseMetainfo(data) { + var loading = Lampa.Template.get('tracks_loading'); + data.item.after(loading); + reguest(data.element, function (result) { + if (!result) { + loading.addClass('tracks-loading--error').find('span').text(Lampa.Lang.translate('tracks_nometa')); + return; + } + + if (list_opened) { + var append = function append(name, fields) { + if (fields.length) { + var block = Lampa.Template.get('tracks_metainfo_block', {}); + block.find('.tracks-metainfo__label').text(Lampa.Lang.translate(name == 'video' ? 'extensions_hpu_video' : name == 'audio' ? 'player_tracks' : 'player_' + name)); + fields.forEach(function (data) { + var item = $('
'); + item.on('hover:focus', function (e) { + Lampa.Modal.scroll().update(item); + }); + + for (var i in data) { + var div = $('
'); + div.text(data[i]); + item.append(div); + } + + block.find('.tracks-metainfo__info').append(item); + }); + html.append(block); + } + }; + + var video = []; + var audio = []; + var subs = []; + var codec_video = result.streams.filter(function (a) { + return a.codec_type == 'video'; + }); + var codec_audio = result.streams.filter(function (a) { + return a.codec_type == 'audio'; + }); + var codec_subs = result.streams.filter(function (a) { + return a.codec_type == 'subtitle'; + }); + codec_video.slice(0, 1).forEach(function (v) { + var line = {}; + if (v.width && v.height) line.video = v.width + 'х' + v.height; + if (v.codec_name) line.codec = v.codec_name.toUpperCase(); + if (Boolean(v.is_avc)) line.avc = 'AVC'; + if (Lampa.Arrays.getKeys(line).length) video.push(line); + }); + codec_audio.forEach(function (a, i) { + var line = { + num: i + 1 + }; + + if (a.tags) { + line.lang = (a.tags.language || '').toUpperCase(); + } + + line.name = a.tags ? a.tags.title || a.tags.handler_name : ''; + if (a.codec_name) line.codec = a.codec_name.toUpperCase(); + if (a.channel_layout) line.channels = a.channel_layout.replace('(side)', '').replace('stereo', '2.0'); + var bit = a.bit_rate ? a.bit_rate : a.tags && (a.tags.BPS || a.tags["BPS-eng"]) ? a.tags.BPS || a.tags["BPS-eng"] : '--'; + line.rate = bit == '--' ? bit : Math.round(bit / 1000) + ' ' + Lampa.Lang.translate('speed_kb'); + if (Lampa.Arrays.getKeys(line).length) audio.push(line); + }); + codec_subs.forEach(function (a, i) { + var line = { + num: i + 1 + }; + + if (a.tags) { + line.lang = (a.tags.language || '').toUpperCase(); + } + + line.name = a.tags ? a.tags.title || a.tags.handler_name : ''; + if (Lampa.Arrays.getKeys(line).length) subs.push(line); + }); + var html = Lampa.Template.get('tracks_metainfo', {}); + append('video', video); + append('audio', audio); + append('subs', subs); + loading.remove(); + + if (video.length || audio.length || subs.length) { + data.item.after(html); + } + + if (Lampa.Controller.enabled().name == 'modal') Lampa.Controller.toggle('modal'); } }); - Lampa.Template.add('tracks_loading', "\n
\n #{loading}...\n
\n"); - Lampa.Template.add('tracks_metainfo', "\n
\n"); - Lampa.Template.add('tracks_metainfo_block', "\n
\n
\n
\n
\n"); - Lampa.Template.add('tracks_css', "\n \n"); - $('body').append(Lampa.Template.get('tracks_css', {}, true)); + } + + Lampa.Player.listener.follow('start', function (data) { + if (data.torrent_hash) subscribeTracks(data); + }); + Lampa.Listener.follow('torrent_file', function (data) { + if (data.type == 'list_open') list_opened = true; + if (data.type == 'list_close') list_opened = false; + + if (data.type == 'render' && data.items.length == 1 && list_opened) { + parseMetainfo(data); + } + }); + Lampa.Lang.add({ + tracks_nometa: { + ru: 'Не удалось получить сведения о дорожках', + uk: 'Не вдалося отримати відомості про доріжки', + en: 'Failed to get track info' + } + }); + Lampa.Template.add('tracks_loading', "\n
\n #{loading}...\n
\n"); + Lampa.Template.add('tracks_metainfo', "\n
\n"); + Lampa.Template.add('tracks_metainfo_block', "\n
\n
\n
\n
\n"); + Lampa.Template.add('tracks_css', "\n \n"); + $('body').append(Lampa.Template.get('tracks_css', {}, true)); })(); diff --git a/mods/Transcoding/Controller.cs b/mods/Transcoding/Controller.cs deleted file mode 100644 index 0577747..0000000 --- a/mods/Transcoding/Controller.cs +++ /dev/null @@ -1,594 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.StaticFiles; -using Newtonsoft.Json; -using Shared; -using Shared.Services; -using Shared.Services.Pools; -using Shared.Models.Templates; -using System; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using System.Web; -using Shared.Services.Utilities; -using Shared.Services.Pools.Json; - -namespace Transcoding; - -[ApiController] -[Route("transcoding")] -public sealed class TranscodingController : Controller -{ - #region static - static readonly Serilog.ILogger Log = Serilog.Log.ForContext(); - - readonly TranscodingService _service = TranscodingService.Instance; - - static readonly FileExtensionContentTypeProvider provider = new FileExtensionContentTypeProvider() - { - Mappings = { - [".m4s"] = "video/mp4", - [".ts"] = "video/mp2t", - [".mp4"] = "video/mp4", - [".m2ts"] = "video/MP2T" - } - }; - #endregion - - #region transcoding.js - [AllowAnonymous] - [HttpGet("/transcoding.js")] - [HttpGet("js/{token}")] - public ActionResult TranscodingJs(string token) - { - HttpContext.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; // HTTP 1.1. - HttpContext.Response.Headers["Pragma"] = "no-cache"; // HTTP 1.0. - HttpContext.Response.Headers["Expires"] = "0"; // Proxies. - - var plugin = FileCache.ReadAllText($"{ModInit.modpath}/plugin.js", "transcoding.js") - .Replace("{localhost}", CoreInit.Host(HttpContext)) - .Replace("{token}", HttpUtility.UrlEncode(token)); - - return Content(plugin, "application/javascript; charset=utf-8"); - } - #endregion - - - #region Start - [HttpGet("start.m3u8")] - public async Task StartM3u8(string src, int a, int s, bool subtitles, bool live) - { - if (string.IsNullOrEmpty(src)) - return BadRequest(new { error = "src" }); - - if (src.Contains("/proxy/") && src.Contains(".mkv")) - { - string hash = Regex.Match(src, "/proxy/([^\n\r]+\\.mkv)").Groups[1].Value; - src = ProxyLink.Decrypt(hash.AsSpan(), null)?.uri; - if (string.IsNullOrWhiteSpace(src)) - return BadRequest(new { error = "src decrypt" }); - } - - var defaults = ModInit.conf; - - var (job, error) = await _service.Start(new TranscodingStartRequest() - { - src = src, - live = live, - subtitles = subtitles, - audio = new TranscodingAudioOptions() { index = a }, - hls = new TranscodingHlsOptions() { seek = s } - }); - - if (job == null) - return BadRequest(new { error }); - - string uri = $"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/{(live ? "live" : "main")}.m3u8"; - - return Redirect(AccsDbInvk.Args(uri, HttpContext)); - } - - [HttpPost("start")] - public async Task Start([FromBody] TranscodingStartRequest request) - { - if (request == null) - return BadRequest(new { error = "Request body is required" }); - - var (job, error) = await _service.Start(request); - if (job == null) - return BadRequest(new { error }); - - return Ok(new - { - job.StreamId, - playlistUrl = AccsDbInvk.Args($"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/{(job.Context.live ? "live" : "main")}.m3u8", HttpContext), - subtitlesUrl = job.Context.subtitles ? AccsDbInvk.Args($"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/subtitles", HttpContext) : null, - hls_timeout_seconds = 60 - }); - } - #endregion - - #region Seek - [HttpGet("{streamId}/seek/{ss}")] - public async Task Seek(string streamId, int ss) - { - if (!_service.TryResolveJob(streamId, out var job)) - return NotFound(); - - if (!job.Context.live) - return BadRequest(new { error = "Context not live" }); - - if (ss < 0) - return BadRequest(new { error = "ss must be greater or equal 0" }); - - var (success, error) = await _service.SeekAsync(streamId, ss); - if (!success) - return BadRequest(new { error }); - - return Ok(); - } - #endregion - - #region Live - [HttpGet("{streamId}/live.m3u8")] - public async Task Live(string streamId) - { - if (!_service.TryResolveJob(streamId, out var job)) - return NotFound(); - - if (!job.Context.live) - return BadRequest(new { error = "Context not live" }); - - _service.Touch(job); - - string path = Path.Combine(job.Context.OutputDirectory, "index.m3u8"); - - var fileExistsTimeout = TimeSpan.FromSeconds(60); - var sw = System.Diagnostics.Stopwatch.StartNew(); - - bool fileExists = System.IO.File.Exists(path); - - while (!fileExists && sw.Elapsed < fileExistsTimeout) - { - await Task.Delay(250); - fileExists = System.IO.File.Exists(path); - if (fileExists) - break; - } - - if (!fileExists) - return NotFound(); - - string m3u8 = null; - - sw.Restart(); - while (sw.Elapsed < fileExistsTimeout) - { - try - { - m3u8 = System.IO.File.ReadAllText(path); - } - catch - { - m3u8 = null; - } - - if (!string.IsNullOrEmpty(m3u8) && Regex.IsMatch(m3u8, "seg_[0-9]+\\.(m4s|ts)")) - break; - - await Task.Delay(250); - } - - if (string.IsNullOrEmpty(m3u8)) - return NotFound(); - - m3u8 = Regex.Replace(m3u8, "#EXT-X-MAP:URI=[^\n\r]+", $"#EXT-X-MAP:URI=\"{AccsDbInvk.Args("init.mp4", HttpContext)}\""); - m3u8 = Regex.Replace(m3u8, "(seg_[0-9]+\\.(m4s|ts))", r => - { - string file = r.Groups[1].Value; - return AccsDbInvk.Args(file, HttpContext); - }); - - return Content(m3u8, "application/vnd.apple.mpegurl"); - } - #endregion - - #region Playlist - [HttpGet("{streamId}/main.m3u8")] - public async Task Playlist(string streamId) - { - if (!_service.TryResolveJob(streamId, out var job)) - return NotFound(); - - if (job.Context.live) - return BadRequest(new { error = "Context not playlist" }); - - if (!int.TryParse(job.Context.ffprobe["format"].Value("duration").Split('.')[0].Split(',')[0], out int duration) || duration == 0) - return BadRequest(new { error = "duration" }); - - _service.Touch(job); - - var fileExistsTimeout = TimeSpan.FromSeconds(60); - var sw = System.Diagnostics.Stopwatch.StartNew(); - - while (sw.Elapsed < fileExistsTimeout) - { - if (Directory.GetFiles(job.Context.OutputDirectory).Length > 2) - break; - - await Task.Delay(250); - } - - int segDur = job.Context.HlsOptions.segDur; - - var builder = new StringBuilder(); - builder.AppendLine("#EXTM3U"); - builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); - builder.AppendLine($"#EXT-X-VERSION:{(job.Context.HlsOptions.fmp4 ? 7 : 3)}"); - builder.AppendLine($"#EXT-X-TARGETDURATION:{segDur}"); - builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); - - if (job.Context.HlsOptions.fmp4) - builder.AppendLine($"#EXT-X-MAP:URI=\"{AccsDbInvk.Args("init.mp4", HttpContext)}\""); - - for (int i = 0; i < (duration / segDur); i++) - { - builder.AppendLine($"#EXTINF:{segDur}.0,"); - builder.AppendLine(AccsDbInvk.Args($"seg_{i:d5}.{(job.Context.HlsOptions.fmp4 ? "m4s" : "ts")}", HttpContext)); - } - - builder.AppendLine("#EXT-X-ENDLIST"); - - return Content(builder.ToString(), "application/vnd.apple.mpegurl"); - } - #endregion - - #region Segment - [HttpGet("{streamId}/{file}")] - public async Task Segment(string streamId, string file) - { - if (!_service.TryResolveJob(streamId, out var job)) - return NotFound(); - - _service.Touch(job); - - int? segmentIndex = null; - if (!job.Context.live && file != null) - { - var matchSegment = Regex.Match(file, @"seg_(\d+)\.(m4s|ts)$", RegexOptions.IgnoreCase); - if (matchSegment.Success && int.TryParse(matchSegment.Groups[1].Value, out var idx)) - segmentIndex = idx; - } - - var fileExistsTimeout = TimeSpan.FromSeconds(60); - var sw = System.Diagnostics.Stopwatch.StartNew(); - - string resolved = _service.GetFilePath(job, file); - - if (job.Context.live == false && resolved == null && !file.Contains(".vtt")) - { - #region SeekAsync - if (segmentIndex.HasValue) - { - int segDur = job.Context.HlsOptions.segDur; - int ss = segmentIndex.Value * segDur; - - if (job.Context.HlsOptions.seek == 0 && 30 > ss) - { - // первые 30 секунд без seek-а - не трогаем - } - else if (job.Context.HlsOptions.seek == ss) - { - // ffmpeg на текущем сегменте - } - else - { - // работает дальше чем текущий сегмент - bool goSeek = job.Context.HlsOptions.seek > ss; - - string extension = Path.GetExtension(file); - int segmentsPerMinute = (int)Math.Ceiling(30.0 / segDur); - int startIndex = Math.Max(0, segmentIndex.Value - segmentsPerMinute); - - if (goSeek == false) - { - goSeek = true; - - for (int i = startIndex; i < segmentIndex.Value; i++) - { - string candidate = $"seg_{i:d5}{extension}"; - if (_service.GetFilePath(job, candidate) != null) - { - // есть сегменты в пределах последних 30 секунд - goSeek = false; - break; - } - } - } - - if (goSeek) - await _service.SeekAsync(streamId, ss, segmentIndex.Value); - } - } - #endregion - - do - { - try - { - await Task.Delay(200); - resolved = _service.GetFilePath(job, file); - if (resolved != null) - break; - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_om7pdvok"); - } - } - while (sw.Elapsed < fileExistsTimeout); - } - - if (resolved == null) - return NotFound(); - - #region ждем появления следующего сегмента - while (segmentIndex.HasValue && sw.Elapsed < fileExistsTimeout) - { - try - { - string seg = Path.Combine(job.OutputDirectory, $"seg_{segmentIndex.Value + 1:d5}"); - if (System.IO.File.Exists($"{seg}.{(job.Context.HlsOptions.fmp4 ? "m4s" : "ts")}")) - break; - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_4pxipm5i"); - } - - await Task.Delay(200); - } - #endregion - - #region FileStream - FileStream fs = null; - do - { - try - { - fs = file.Contains(".vtt") - ? new FileStream(resolved, FileMode.Open, FileAccess.Read, FileShare.ReadWrite) - : System.IO.File.OpenRead(resolved); - break; - } - catch (IOException) - { - await Task.Delay(200); - } - catch (UnauthorizedAccessException) - { - await Task.Delay(200); - } - } - while (sw.Elapsed < fileExistsTimeout); - - if (fs == null) - return NotFound(); - #endregion - - if (!provider.TryGetContentType(resolved, out var contentType)) - contentType = "application/octet-stream"; - - if (segmentIndex.HasValue) - _service.ReportSegmentAccess(job, segmentIndex.Value); - - return File(fs, contentType, enableRangeProcessing: true); - } - #endregion - - #region Subtitles - [HttpGet("{streamId}/subtitles")] - public IActionResult Subtitles(string streamId) - { - if (!_service.TryResolveJob(streamId, out var job)) - return NotFound(); - - _service.Touch(job); - - var subsTpl = new SubtitleTpl(); - - if (job.Context.subtitles && job.Context.ffprobe.ContainsKey("streams")) - { - foreach (var s in job.Context.ffprobe["streams"]) - { - if (s.Value("codec_type") != "subtitle") - continue; - - if (s.Value("codec_name") is "subrip" or "webvtt" or "ass" or "ssa" or "mov_text" or "ttml" or "sami") - { - int subIndex = s.Value("index"); - if (subIndex == 0) - continue; - - string uri = $"{CoreInit.Host(HttpContext)}/transcoding/{streamId}/{AccsDbInvk.Args($"subs_{subIndex}.vtt", HttpContext)}"; - - string name = s["tags"].Value("title") - ?? s["language"].Value("title") - ?? $"sub_{subIndex}"; - - subsTpl.Append(name, uri); - } - } - } - - return Ok(subsTpl.ToObject()); - } - #endregion - - #region Heartbeat - [HttpGet("{streamId}/heartbeat")] - public IActionResult Heartbeat(string streamId) - { - if (!_service.TryResolveJob(streamId, out var job)) - return NotFound(); - - _service.Touch(job); - return Ok(); - } - #endregion - - #region StopAsync - [HttpGet("{streamId}/stop")] - public async Task StopAsync(string streamId) - { - var stopped = await _service.StopAsync(streamId); - return stopped ? Ok() : NotFound(); - } - #endregion - - #region Status - [HttpGet("{streamId}/status")] - public IActionResult Status(string streamId) - { - if (!_service.TryResolveJob(streamId, out var job)) - return NotFound(); - - var now = DateTime.UtcNow; - var state = job.Process.HasExited ? TranscodingJobState.Stopped : TranscodingJobState.Running; - - var uptime = now - job.StartedUtc; - - int? exitCode = null; - try - { - if (job.Process.HasExited) - exitCode = job.Process.ExitCode; - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_iuxl2cac"); - } - - var snapshotLog = job.SnapshotLog(); - - ulong time_ms = 0; - foreach (string line in snapshotLog.Reverse()) - { - if (!line.Contains("out_time_ms=")) - continue; - - if (!ulong.TryParse(Regex.Match(line, "out_time_ms=([0-9]+)").Groups[1].Value, out time_ms)) - continue; - - break; - } - - return Content(JsonConvertPool.SerializeObject(new - { - job.StreamId, - state = state.ToString(), - job.OutputDirectory, - ffmpeg = string.Join(" ", job.Process.StartInfo.ArgumentList), - startedUtc = job.StartedUtc, - lastAccessUtc = job.LastAccessUtc, - uptime = uptime.TotalSeconds, - positionSec = (ulong)(job.Context.HlsOptions.seek + (time_ms > 0 ? (time_ms / 1000000.0) : 0)), - exitCode, - job.Context.ffprobe, - log = snapshotLog - }), "application/json; charset=utf-8"); - } - #endregion - - - #region DOC - [HttpGet("")] - public IActionResult DOC() - { - var endpoints = new object[] - { - new { - path = "/transcoding/start.m3u8", - method = "GET", - query = new object[] { - new { name = "src", type = "string", required = true, description = "Source URL or local path to media" }, - new { name = "a", type = "int", required = false, description = "Audio index (optional)" }, - new { name = "s", type = "int", required = false, description = "Seek position in seconds (optional)" }, - new { name = "subtitles", type = "bool", required = false, description = "subtitles on/off" }, - new { name = "live", type = "bool", required = false, description = "Context live/playlist" } - }, - description = "Start transcoding with query parameters and redirect to the generated HLS playlist" - }, - new { - path = "/transcoding/start", - method = "POST", - contentType = "application/json", - body = new { - src = "https://example.com/media.mp4", - videoFormat = "", - live = false, - subtitles = false, - headers = new { referer = "https://example.com", userAgent = "HlsProxy/1.0" }, - audio = ModInit.conf.audioOptions, - hls = ModInit.conf.hlsOptions - }, - description = "Start transcoding by POSTing a JSON body. Returns StreamId and playlist URL" - }, - new { - path = "/transcoding/{streamId}/live.m3u8", - method = "GET", - route = new { name = "streamId", type = "string", required = true }, - description = "Returns the HLS live for the given transcoding job" - }, - new { - path = "/transcoding/{streamId}/main.m3u8", - method = "GET", - route = new { name = "streamId", type = "string", required = true }, - description = "Returns the HLS master/variant playlist for the given transcoding job" - }, - new { - path = "/transcoding/{streamId}/{file}", - method = "GET", - route = new object[] { - new { name = "streamId", type = "string", required = true }, - new { name = "file", type = "string", required = true, description = "Requested segment or asset (e.g. init.mp4, seg_1.m4s, index.m3u8)" } - }, - description = "Serves individual segment files, init files and playlists produced by the transcoder. Supports range requests." - }, - new { - path = "/transcoding/{streamId}/seek/{ss}", - method = "GET", - route = new object[] { - new { name = "streamId", type = "string", required = true }, - new { name = "ss", type = "int", required = true, description = "Seek position in seconds (>= 0)" } - }, - description = "Request the transcoder to seek to the specified position (async). Returns 200 on success." - }, - new { - path = "/transcoding/{streamId}/heartbeat", - method = "GET", - route = new { name = "streamId", type = "string", required = true }, - description = "Touch the job to keep it alive. Returns 200 if job exists." - }, - new { - path = "/transcoding/{streamId}/stop", - method = "GET", - route = new { name = "streamId", type = "string", required = true }, - description = "Stop the transcoding job. Returns 200 if stopped or 404 if job not found." - }, - new { - path = "/transcoding/{streamId}/status", - method = "GET", - query = new { name = "log", type = "bool", required = false, description = "Include job log snapshot when true" }, - route = new { name = "streamId", type = "string", required = true }, - description = "Return current job status, uptime, position and optional log snapshot." - } - }; - - return Ok(JsonConvert.SerializeObject(endpoints, Formatting.Indented)); - } - #endregion -} diff --git a/mods/Transcoding/ModInit.cs b/mods/Transcoding/ModInit.cs deleted file mode 100644 index 53c89f9..0000000 --- a/mods/Transcoding/ModInit.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Shared; -using Shared.Services; -using Shared.Models.AppConf; -using Shared.Models.Module; -using Shared.Models.Module.Interfaces; -using System.Collections.Generic; -using System.IO; -using Shared.Models.Events; - -namespace Transcoding; - -public class ModInit : IModuleLoaded -{ - public static string modpath; - public static ModuleConf conf; - - - public void Loaded(InitspaceModel initspace) - { - modpath = initspace.path; - - updateConf(); - EventListener.UpdateInitFile += updateConf; - - foreach (var m in conf.limit_map) - CoreInit.conf.WAF.limit_map.Insert(0, m); - - Directory.CreateDirectory("database/tracks"); - Directory.CreateDirectory(conf.tempRoot); - - TranscodingService.Instance.Configure(conf); - } - - public void Dispose() - { - EventListener.UpdateInitFile -= updateConf; - TranscodingService.Instance.StopAll(); - } - - void updateConf() - { - conf = ModuleInvoke.Init("transcoding", new ModuleConf() - { - tempRoot = Path.Combine("cache", "transcoding"), - idleTimeoutSec = 60 * 5, - idleTimeoutSec_live = 120, - maxConcurrentJobs = 5, - limit_map = new List() - { - new("^/transcoding/", new WafLimitMap { limit = 50, second = 1 }) - } - }); - } -} diff --git a/mods/Transcoding/ModuleConf.cs b/mods/Transcoding/ModuleConf.cs deleted file mode 100644 index 277c333..0000000 --- a/mods/Transcoding/ModuleConf.cs +++ /dev/null @@ -1,153 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Shared.Models.Module; -using System; -using System.Collections.Generic; - -namespace Transcoding; - -public class ModuleConf : ModuleBaseConf -{ - public string ffmpeg { get; set; } - - public string tsuri { get; set; } - - public string tempRoot { get; set; } - - public int idleTimeoutSec { get; set; } - - public int idleTimeoutSec_live { get; set; } - - public bool defaultSubtitles { get; set; } - - public int maxConcurrentJobs { get; set; } - - public string[] allowHosts { get; set; } = Array.Empty(); - - public TranscodingHlsOptions hlsOptions { get; set; } = new(); - - public TranscodingAudioOptions audioOptions { get; set; } = new(); - - public TranscodingSubtitleOptions subtitleOptions { get; set; } = new(); - - public TranscodingPlaylistOptions playlistOptions { get; set; } = new(); - - public TranscodingConvertOptions convertOptions { get; set; } = new(); - - [JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] - public Dictionary comand { get; set; } = new Dictionary() - { - ["demuxer"] = ["-threads 0", "-fflags +genpts"], - ["input"] = ["-avoid_negative_ts disabled"], - ["output"] = [ - "-map 0:v:0", "-map 0:a:{audio_index}", - "-dn", "-sn", - "-map_metadata -1", "-map_chapters -1", "-max_muxing_queue_size 2048" - ], - }; -} - -public class TranscodingHlsOptions -{ - [JsonIgnore] - public int seek { get; set; } - - /// - /// hls_time - /// - public int segDur { get; set; } = 6; - - /// - /// hls_list_size - /// - public int winSize { get; set; } = 10; - - /// - /// hls_segment_type fmp4 / mpegts - /// - public bool fmp4 { get; set; } = true; - - [JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] - public Dictionary comand { get; set; } = new Dictionary() - { - ["output"] = ["-max_delay 5000000"], - ["segment_mpegts"] = ["-bsf:v h264_mp4toannexb"], - }; -} - -public class TranscodingAudioOptions -{ - [JsonIgnore] - public int index { get; set; } - - public int bitrateKbps { get; set; } = 192; - - public bool stereo { get; set; } = true; - - [JsonProperty("codec_copy", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] - public string[] codec_copy { get; set; } = Array.Empty(); - - [JsonProperty("comand_transcode", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] - public Dictionary comand_transcode { get; set; } = new Dictionary() - { - ["default"] = ["-c:a aac", "-ac {stereo}", "-b:a {bitrateKbps}", "-profile:a aac_low"] - }; -} - -public class TranscodingPlaylistOptions -{ - /// - /// sped - /// - public double readrate { get; set; } = 1.6; - - /// - /// 10 MB - /// - public int burst { get; set; } = 10485760; - - public bool delete_segments { get; set; } = true; -} - -public class TranscodingConvertOptions -{ - public bool transcodeVideo { get; set; } - - [JsonProperty("codec", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] - public string[] codec { get; set; } = { "mpeg4", "msmpeg4v3", "flv1", "av1" }; - - [JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] - public Dictionary comand { get; set; } = new Dictionary() - { - ["default"] = ["-c:v libx264", "-preset veryfast", "-tune zerolatency", "-pix_fmt yuv420p"], - - ["h264_yuv420p10le"] = [ - "-vf", "scale=in_color_matrix=bt2020nc:out_color_matrix=bt709:in_range=pc:out_range=tv,format=yuv420p", - "-c:v libx264", "-preset veryfast", "-tune zerolatency", "-pix_fmt yuv420p", - "-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709", - "-color_primaries bt709", "-color_trc bt709", "-colorspace bt709", "-color_range tv" - ] - }; -} - -public class TranscodingSubtitleOptions -{ - [JsonProperty("codec", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] - public string[] codec { get; set; } = { "subrip", "webvtt", "ass", "ssa", "mov_text", "ttml", "sami" }; - - [JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)] - public string[] comand { get; set; } = ["-map 0:{subIndex}", "-an -vn", "-c:s webvtt", "-flush_packets 1", "-max_interleave_delta 0", "-muxpreload 0", "-muxdelay 0", "-f webvtt", "subs_{subIndex}.vtt"]; -} - -public record TranscodingStartContext( - Uri Source, - string UserAgent, - string Referer, - TranscodingHlsOptions HlsOptions, - TranscodingAudioOptions Audio, - bool live, - bool subtitles, - string OutputDirectory, - int? startNumber, - JObject ffprobe -); diff --git a/mods/Transcoding/README.md b/mods/Transcoding/README.md deleted file mode 100644 index de26310..0000000 --- a/mods/Transcoding/README.md +++ /dev/null @@ -1,35 +0,0 @@ -Актуальная реализация:
-https://github.com/lampac-nextgen/lampac/blob/main/Modules/GStreamer/README.md - - -# Transcoding (legacy) - -Серверное **перекодирование потоков** (FFmpeg и связанная логика): очередь задач, каталог временных файлов, ограничение параллельных заданий и HTTP API под **`/transcoding`**. - -## Назначение - -- Путь модуля сохраняется в **`Transcoding.modpath`**. -- **`TranscodingService`** конфигурируется при загрузке модуля и останавливает все задачи при выгрузке (`StopAll`). -- Создаются каталоги **`database/tracks`** (общий паттерн с Tracks) и **`tempRoot`** для промежуточных файлов (по умолчанию `cache/transcoding`). - -## Конфигурация - -Секция в `init.conf`: **`transcoding`** (имя ключа в нижнем регистре в `ModuleInvoke.Init`). - -Параметры по умолчанию в **`ModInit`**: - -| Параметр | Значение в коде | -|----------|-----------------| -| **`tempRoot`** | **`cache/transcoding`** | -| **`idleTimeoutSec`** | **300** с | -| **`idleTimeoutSec_live`** | **120** с | -| **`maxConcurrentJobs`** | **5** | -| **`limit_map`** | **`^/transcoding/`** — **50** запросов / **1** с | - -## HTTP - -Базовый префикс контроллера: **`transcoding`** — дочерние маршруты см. **`Controller.cs`**. - -## Зависимости - -- Доступность **ffmpeg** на машине (или иной исполнитель, зашитый в `TranscodingService`). diff --git a/mods/Transcoding/Services/TranscodingJob.cs b/mods/Transcoding/Services/TranscodingJob.cs deleted file mode 100644 index 22f95a0..0000000 --- a/mods/Transcoding/Services/TranscodingJob.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; - -namespace Transcoding; - -internal sealed class TranscodingJob : IDisposable -{ - static readonly Serilog.ILogger Log = Serilog.Log.ForContext(); - - private const int MaxLogLines = 200; - - private readonly LinkedList _log = new(); - private readonly object _logSync = new(); - private readonly CancellationTokenSource _cts = new(); - private int _lastSegmentIndex = -1; - - public TranscodingJob(string id, string streamId, string outputDirectory, Process process, TranscodingStartContext context) - { - Id = id; - StreamId = streamId; - OutputDirectory = outputDirectory; - Process = process; - Context = context; - StartedUtc = DateTime.UtcNow; - LastAccessUtc = StartedUtc; - } - - public string Id { get; } - - public string StreamId { get; } - - public string OutputDirectory { get; } - - public Process Process { get; } - - public TranscodingStartContext Context { get; } - - public DateTime StartedUtc { get; } - - public DateTime LastAccessUtc { get; private set; } - - public int? ExitCode { get; private set; } - - public bool HasExited => Process.HasExited; - - public CancellationToken CancellationToken => _cts.Token; - - public void UpdateLastAccess() => LastAccessUtc = DateTime.UtcNow; - - public int LastSegmentIndex => Volatile.Read(ref _lastSegmentIndex); - - public void UpdateLastSegmentIndex(int segmentIndex) - { - if (segmentIndex < 0) - return; - - while (true) - { - var current = Volatile.Read(ref _lastSegmentIndex); - if (segmentIndex <= current) - return; - - if (Interlocked.CompareExchange(ref _lastSegmentIndex, segmentIndex, current) == current) - return; - } - } - - - public void AppendLog(string line) - { - if (string.IsNullOrWhiteSpace(line)) - return; - - lock (_logSync) - { - foreach (var part in line.Split('\n')) - { - string trimmed = part.TrimEnd('\r'); - if (string.IsNullOrWhiteSpace(trimmed)) - continue; - - if (trimmed.Length > 2000) - trimmed = trimmed[..2000]; - - _log.AddLast(trimmed); - if (_log.Count > MaxLogLines) - _log.RemoveFirst(); - } - } - } - - public string[] SnapshotLog() - { - lock (_logSync) - return _log.ToArray(); - } - - public void SignalExit() - { - if (Process.HasExited) - ExitCode = Process.ExitCode; - } - - public void StopBackground() - => _cts.Cancel(); - - public void Dispose() - { - try - { - StopBackground(); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_p5i63hde"); - } - - try - { - Process.Dispose(); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_6fb2ahbb"); - } - } -} diff --git a/mods/Transcoding/Services/TranscodingModels.cs b/mods/Transcoding/Services/TranscodingModels.cs deleted file mode 100644 index 8499d71..0000000 --- a/mods/Transcoding/Services/TranscodingModels.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; - -namespace Transcoding; - -public sealed class TranscodingStartRequest -{ - public string src { get; set; } = string.Empty; - - public TranscodingAudioOptions audio { get; set; } - - public TranscodingHlsOptions hls { get; set; } - - public Dictionary headers { get; set; } - - public bool live { get; set; } - - public bool? subtitles { get; set; } -} - -public enum TranscodingJobState -{ - Running, - Idle, - Stopped -} diff --git a/mods/Transcoding/Services/TranscodingService.cs b/mods/Transcoding/Services/TranscodingService.cs deleted file mode 100644 index 4f9e945..0000000 --- a/mods/Transcoding/Services/TranscodingService.cs +++ /dev/null @@ -1,1003 +0,0 @@ -using Newtonsoft.Json.Linq; -using Shared; -using Shared.Services; -using Shared.Models.Base; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using System.Web; - -namespace Transcoding; - -internal sealed class TranscodingService -{ - static readonly Serilog.ILogger Log = Serilog.Log.ForContext(); - - static readonly Lazy _lazy = new(() => new TranscodingService()); - - readonly ConcurrentDictionary _jobs = new(); - readonly Regex _safeFileNameRegex = new("^[A-Za-z0-9_.-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant); - readonly Regex _segmentFileRegex = new("^seg_(\\d+)\\.(m4s|ts)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - - int _segmentCleanupRunning; - readonly Timer _segmentCleanupTimer; - - byte[] _hmacKey = RandomNumberGenerator.GetBytes(32); - static string _ffmpegPath; - - public static TranscodingService Instance => _lazy.Value; - - private TranscodingService() - { - _segmentCleanupTimer = new Timer(_ => CleanupSegments(), null, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(20)); - } - - public void Configure(ModuleConf config) - { - if (config == null) - return; - - _ffmpegPath = string.IsNullOrWhiteSpace(config.ffmpeg) - ? (CoreInit.Win32NT ? "data/ffmpeg.exe" : (File.Exists("data/ffmpeg") ? "data/ffmpeg" : "ffmpeg")) - : config.ffmpeg; - - if (string.IsNullOrWhiteSpace(config.tempRoot)) - config.tempRoot = Path.Combine("cache", "transcoding"); - - try - { - if (!Directory.Exists(config.tempRoot)) - { - Directory.CreateDirectory(config.tempRoot); - } - else - { - foreach (var dir in Directory.GetDirectories(config.tempRoot)) - { - try - { - Directory.Delete(dir, true); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_g6yuafvi"); - } - } - } - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_quspivc7"); - } - } - - public ICollection Jobs => _jobs.Values; - - async public Task<(TranscodingJob job, string error)> Start(TranscodingStartRequest request) - { - var config = GetConfig(); - - if (request == null || string.IsNullOrWhiteSpace(request.src)) - return (null!, "Source URL is required"); - - if (_jobs.Count >= Math.Max(1, config.maxConcurrentJobs)) - return (null!, "Maximum concurrent jobs reached"); - - if (!TryValidateSource(request.src, config, out var source, out var error)) - return (null!, error); - - var id = Guid.NewGuid().ToString("N"); - var streamId = BuildToken(id); - - var outputDir = Path.Combine(config.tempRoot!, id); - Directory.CreateDirectory(outputDir); - - #region ffprobe - var ffprobe = await Http.Get($"http://{CoreInit.conf.listen.localhost}:{CoreInit.conf.listen.port}/ffprobe?media={HttpUtility.UrlEncode(request.src)}", headers: HeadersModel.Init("lcrqpasswd", CoreInit.rootPasswd)); - - if (ffprobe == null || !ffprobe.ContainsKey("format")) - return (null!, "ffprobe"); - #endregion - - var context = new TranscodingStartContext( - source, - SanitizeHeader(GetHeader(request.headers, "userAgent"), Http.UserAgent), - SanitizeHeader(GetHeader(request.headers, "referer")), - MergeHlsOptions(config, request.hls), - MergeAudioOptions(config, request.audio), - request.live, - request.subtitles ?? config.defaultSubtitles, - outputDir, - null, - ffprobe - ); - - var process = CreateProcess(context); - - try - { - if (!process.Start()) - { - process.Dispose(); - return (null!, "Failed to start ffmpeg"); - } - } - catch (System.Exception ex) - { - Serilog.Log.Error(ex, "CatchId={CatchId}", "id_71c20640"); - process.Dispose(); - return (null!, $"Failed to start ffmpeg: {ex.Message}"); - } - - var job = new TranscodingJob(id, streamId, outputDir, process, context); - if (!_jobs.TryAdd(id, job)) - { - try - { - process.Kill(true); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_avj4m3h6"); - } - - process.Dispose(); - try - { - if (Directory.Exists(outputDir)) - Directory.Delete(outputDir, true); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_q5jm90jq"); - } - return (null!, "Failed to register job"); - } - - _ = Task.Run(() => PumpStdErrAsync(job)); - _ = Task.Run(() => IdleWatchdogAsync(job, config)); - - process.EnableRaisingEvents = true; - process.Exited += (_, _) => - { - if (process.EnableRaisingEvents) - OnProcessExit(job); - }; - - return (job, string.Empty); - } - - public bool TryResolveJob(string streamId, out TranscodingJob job) - { - job = null!; - if (string.IsNullOrWhiteSpace(streamId)) - return false; - - if (!TryParseToken(streamId, out var id)) - return false; - - if (!_jobs.TryGetValue(id, out job)) - return false; - - return true; - } - - public async Task<(bool success, string error)> SeekAsync(string streamId, int seconds, int? startSegment = null) - { - if (seconds < 0) - return (false, "ss must be greater or equal 0"); - - if (!TryResolveJob(streamId, out var job)) - return (false, "Job not found"); - - var config = GetConfig(); - - var newContext = job.Context with - { - HlsOptions = new TranscodingHlsOptions - { - seek = seconds, - segDur = job.Context.HlsOptions.segDur, - winSize = job.Context.HlsOptions.winSize, - fmp4 = job.Context.HlsOptions.fmp4 - }, - startNumber = startSegment - }; - - job.Process.EnableRaisingEvents = false; - await StopJobAsync(job, forced: true, cleanup: false); - - Directory.CreateDirectory(newContext.OutputDirectory); - - var process = CreateProcess(newContext); - - try - { - if (!process.Start()) - { - process.Dispose(); - return (false, "Failed to start ffmpeg"); - } - } - catch (System.Exception ex) - { - Serilog.Log.Error(ex, "CatchId={CatchId}", "id_1af34286"); - process.Dispose(); - return (false, $"Failed to start ffmpeg: {ex.Message}"); - } - - var newJob = new TranscodingJob(job.Id, job.StreamId, job.OutputDirectory, process, newContext); - - if (_jobs.AddOrUpdate(job.Id, newJob, (k, v) => newJob) == null) - { - try - { - process.Kill(true); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_rteqssej"); - } - - process.Dispose(); - return (false, "Failed to restart job"); - } - - _ = Task.Run(() => PumpStdErrAsync(newJob)); - _ = Task.Run(() => IdleWatchdogAsync(newJob, config)); - - process.EnableRaisingEvents = true; - process.Exited += (_, _) => - { - if (process.EnableRaisingEvents) - OnProcessExit(newJob); - }; - - return (true, string.Empty); - } - - public async Task StopAsync(string streamId) - { - if (!TryResolveJob(streamId, out var job)) - return false; - - await StopJobAsync(job); - return true; - } - - public void Touch(TranscodingJob job) => job.UpdateLastAccess(); - - public string GetFilePath(TranscodingJob job, string file) - { - if (string.IsNullOrWhiteSpace(file) || !_safeFileNameRegex.IsMatch(file)) - return null; - - var candidate = Path.Combine(job.OutputDirectory, file); - if (!candidate.StartsWith(job.OutputDirectory, StringComparison.Ordinal)) - return null; - - return File.Exists(candidate) ? candidate : null; - } - - public void ReportSegmentAccess(TranscodingJob job, int segmentIndex) - { - job?.UpdateLastSegmentIndex(segmentIndex); - } - - private async Task StopJobAsync(TranscodingJob job, bool forced = false, bool cleanup = true) - { - try - { - if (!job.Process.HasExited) - { - if (forced) - { - job.Process.Kill(true); - } - else - { - try - { - await job.Process.StandardInput.WriteLineAsync("q"); - await job.Process.StandardInput.FlushAsync(); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_a5h7gduz"); - } - - var waitTask = job.Process.WaitForExitAsync(); - var timeout = Task.Delay(TimeSpan.FromMilliseconds(1500)); - var completed = await Task.WhenAny(waitTask, timeout); - if (completed != waitTask) - { - try - { - job.Process.Kill(true); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_9lgmzpo5"); - } - - try - { - await job.Process.WaitForExitAsync(); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_zmkmg1y6"); - } - } - else - { - try - { - await waitTask; - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_a56zgctr"); - } - } - } - } - } - finally - { - if (cleanup) - Cleanup(job); - } - } - - private void Cleanup(TranscodingJob job) - { - var removed = _jobs.TryRemove(job.Id, out _); - - try - { - job.StopBackground(); - job.SignalExit(); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_9c5you07"); - } - - if (!removed) - return; - - try - { - if (Directory.Exists(job.OutputDirectory)) - Directory.Delete(job.OutputDirectory, true); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_z4e0wm3m"); - } - - job.Dispose(); - } - - private ModuleConf GetConfig() - { - return ModInit.conf; - } - - private static string GetHeader(Dictionary headers, string key) - { - if (headers == null || headers.Count == 0) - return null; - - foreach (var (k, v) in headers) - { - if (string.Equals(k, key, StringComparison.OrdinalIgnoreCase)) - return v; - } - - return null; - } - - private static TranscodingHlsOptions MergeHlsOptions(ModuleConf config, TranscodingHlsOptions request) - { - var opt = request ?? config.hlsOptions; - - return new TranscodingHlsOptions - { - seek = opt?.seek > 0 ? opt.seek : 0, - segDur = opt?.segDur > 1 ? opt.segDur : 1, - winSize = opt?.winSize > 5 ? opt.winSize : 5, - fmp4 = opt?.fmp4 ?? true - }; - } - - private static TranscodingAudioOptions MergeAudioOptions(ModuleConf config, TranscodingAudioOptions request) - { - var opt = request ?? config.audioOptions; - - return new TranscodingAudioOptions - { - index = opt?.index >= 0 ? opt.index : 0, - bitrateKbps = opt?.bitrateKbps is > 0 and <= 512 ? opt.bitrateKbps : 160, - stereo = opt?.stereo ?? true, - codec_copy = opt?.codec_copy ?? config.audioOptions.codec_copy - }; - } - - private static bool TryValidateSource(string src, ModuleConf config, out Uri uri, out string error) - { - error = string.Empty; - uri = null!; - - if (!Uri.TryCreate(src, UriKind.Absolute, out uri) || - (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) - { - error = "Only http/https URLs are allowed"; - return false; - } - - if (config.allowHosts != null && config.allowHosts.Length > 0) - { - if (!config.allowHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase)) - { - error = "Source host is not allowed"; - return false; - } - } - - return true; - } - - public void StopAll() - { - try - { - foreach (var job in _jobs.Values.ToArray()) - { - try - { - _ = StopJobAsync(job, forced: true).ConfigureAwait(false); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_sp9w94ug"); - } - } - } - catch (System.Exception ex) - { - Serilog.Log.Error(ex, "{Class} {CatchId}", "ModInit", "id_ley70nc7"); - } - } - - private Process CreateProcess(TranscodingStartContext context) - { - var config = GetConfig(); - - var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = _ffmpegPath, - UseShellExecute = false, - RedirectStandardError = true, - RedirectStandardInput = true, - RedirectStandardOutput = false, - CreateNoWindow = true, - WorkingDirectory = context.OutputDirectory - } - }; - - var args = process.StartInfo.ArgumentList; - - /* --hide_banner — отключает вывод баннера ffmpeg (версия/конфигурация) в stderr, чтобы логи были чище. --user_agent {context.UserAgent} — задаёт заголовок User-Agent для HTTP-запросов к входному URL. --headers "Referer: {context.Referer}\n" — добавляет произвольные HTTP-заголовки (здесь: Referer) при обращении к входному URL. --re — читает входной поток в реальном времени; полезно при трансляции/стриминге, чтобы не читать вход быстрее реального времени. --threads 0 — позволяет ffmpeg автоматически выбрать количество потоков (CPU) для кодирования/декодирования. --fflags +genpts — генерирует PTS для кадров, если их нет (избегает проблем с отсутствующими временными метками). --i {Source} — указывает входной источник (URL/файл). --map 0:v:0 — маппит первый видеопоток первого входа в выход. --map 0:a:0 — маппит первый аудиопоток первого входа в выход. --sn (опционально, при subtitles == false) — исключить субтитры из вывода. --dn — исключить data-потоки (metadata/data tracks). --map_metadata -1 — не копировать глобальные метаданные в выход (очищает метаданные). --map_chapters -1 — не копировать главы (удаляет chapters). --c:v copy — копирование видеопотока без перекодирования (для минимальной нагрузки и без потери качества). - -Аудио: -Если transcodeToAac: --c:a aac — кодировать аудио в AAC. --ac 2 / -ac 1 — число каналов (стерео/моно) по настройке. --b:a {N}k — битрейт аудио. --profile:a aac_low — профиль AAC (обычно LC для совместимости и низкой задержки). -Иначе: -c:a copy — копирование аудио без перекодирования. - -HLS / контейнер: --avoid_negative_ts disabled — не сдвигать временные метки для предотвращения отрицательных TS; сохраняет исходные PTS при HLS-сегментации. --max_muxing_queue_size 2048 — увеличивает очередь пакетов при мультиплексировании (помогает против “Too many packets buffered”). --f hls — формат выхода HLS (HTTP Live Streaming). --max_delay 5000000 — максимальная задержка буфера (в мкс; зависит от контекста), снижает риск избыточного буферинга. --hls_segment_type fmp4 или mpegts — тип сегментов HLS (CMAF/fMP4 или MPEG-TS). -для mpegts: -bsf:v h264_mp4toannexb — конвертирует H.264 к Annex-B, как требуется для TS. --hls_time {segDur} — длительность сегмента в секундах. --hls_flags append_list+omit_endlist — -append_list — дописывать записи в плейлист; -omit_endlist — не добавлять #EXT-X-ENDLIST, чтобы плейлист считался “живым”. --hls_list_size {winSize} — размер окна плейлиста (сколько последних сегментов видит клиент). --master_pl_name index.m3u8 — имя master-плейлиста (если используется). --hls_fmp4_init_filename {init.mp4} — имя init-сегмента для fMP4. --hls_segment_filename {seg_%05d.m4s | seg_%05d.ts} — шаблон имени файлов сегментов. --y {PlaylistPath} — перезаписать выходной файл без подтверждения (путь итогового плейлиста/файла). - */ - - args.Add("-hide_banner"); - - if (context.UserAgent != null) - { - args.Add("-user_agent"); - args.Add(context.UserAgent); - } - - if (!string.IsNullOrWhiteSpace(context.Referer)) - { - args.Add("-headers"); - args.Add($"Referer: {context.Referer}\\r\\n"); - } - - if (context.HlsOptions.seek > 0) - { - args.Add("-ss"); - args.Add(context.HlsOptions.seek.ToString()); - args.Add("-noaccurate_seek"); - } - - args.Add("-nostats"); - args.Add("-progress"); - args.Add("pipe:2"); - args.Add("-stats_period"); - args.Add(context.live ? "1" : "5"); - - #region demuxer - foreach (var c in config.comand["demuxer"]) - { - foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - args.Add(a); - } - #endregion - - #region readrate - if (context.live) - { - args.Add("-re"); - } - else if (config.playlistOptions.readrate > 0) - { - args.Add("-readrate"); - args.Add(config.playlistOptions.readrate.ToString().Replace(",", ".")); - - if (config.playlistOptions.burst > 0) - { - args.Add("-readrate_initial_burst"); - args.Add(config.playlistOptions.burst.ToString()); - } - } - #endregion - - args.Add("-i"); - args.Add(context.Source.AbsoluteUri); - - #region input - foreach (var c in config.comand["input"]) - { - foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - args.Add(a); - } - #endregion - - #region subtitles map - if (context.subtitles && !context.live && 0 >= config.playlistOptions.readrate && context.ffprobe.ContainsKey("streams")) - { - args.Add("-copyts"); - - foreach (var s in context.ffprobe["streams"]) - { - if (s.Value("codec_type") != "subtitle") - continue; - - string codec_name = s.Value("codec_name"); - if (!string.IsNullOrEmpty(codec_name) && config.subtitleOptions.codec.Contains(codec_name)) - { - int subIndex = s.Value("index"); - if (subIndex == 0) - continue; - - foreach (var c in config.subtitleOptions.comand) - { - foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - args.Add(a.Replace("{subIndex}", subIndex.ToString())); - } - } - } - } - #endregion - - #region HLS map - foreach (var c in config.comand["output"]) - { - foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - args.Add(a.Replace("{audio_index}", $"{(0 >= context.Audio.index ? 0 : context.Audio.index)}")); - } - - #region -c:v - if (config.convertOptions.transcodeVideo && config.convertOptions.codec != null && - context.ffprobe != null && context.ffprobe.ContainsKey("streams") && - config.convertOptions.comand != null && config.convertOptions.comand.Count > 0) - { - try - { - bool convert = false; - - string codec_name = context.ffprobe["streams"].First.Value("codec_name") ?? ""; - if (!string.IsNullOrEmpty(codec_name) && config.convertOptions.codec.Contains(codec_name)) - convert = true; - - string pix_fmt = context.ffprobe["streams"].First.Value("pix_fmt") ?? ""; - if (!string.IsNullOrEmpty(pix_fmt) && config.convertOptions.codec.Contains(pix_fmt)) - convert = true; - - if (config.convertOptions.codec.Contains($"{codec_name}_{pix_fmt}")) - convert = true; - - if (convert) - { - string[] comand = config.convertOptions.comand["default"]; - - foreach (string key in new string[] { $"{codec_name}_{pix_fmt}", pix_fmt, codec_name }) - { - if (config.convertOptions.comand.ContainsKey(key)) - { - comand = config.convertOptions.comand[key]; - break; - } - } - - foreach (var c in comand) - { - foreach (var t in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - args.Add(t); - } - } - else - { - args.Add("-c:v"); - args.Add("copy"); - } - } - catch - { - args.Add("-c:v"); - args.Add("copy"); - } - } - else - { - args.Add("-c:v"); - args.Add("copy"); - } - #endregion - - #region -c:a - { - bool convert = true; - - var audioCodec = context.ffprobe["streams"].FirstOrDefault(i => i.Value("index") == ((0 >= context.Audio.index ? 0 : context.Audio.index) + 1)); - - string codec_name = audioCodec?.Value("codec_name") ?? string.Empty; - if (!string.IsNullOrEmpty(codec_name) && context.Audio.codec_copy.Contains(codec_name)) - convert = false; - - string channel = audioCodec?.Value("channels")?.ToString() ?? string.Empty; - if (!string.IsNullOrEmpty(channel) && context.Audio.codec_copy.Contains(channel)) - convert = false; - - if (context.Audio.codec_copy.Contains($"{codec_name}_{channel}")) - convert = false; - - if (convert) - { - string[] comand = config.audioOptions.comand_transcode["default"]; - - foreach (string key in new string[] { $"{codec_name}_{channel}", channel, codec_name }) - { - if (config.audioOptions.comand_transcode.ContainsKey(key)) - { - comand = config.audioOptions.comand_transcode[key]; - break; - } - } - - foreach (var c in comand) - { - foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - args.Add(a.Replace("{stereo}", context.Audio.stereo ? "2" : "1").Replace("{bitrateKbps}", $"{Math.Clamp(context.Audio.bitrateKbps, 32, 512)}k")); - } - } - else - { - args.Add("-c:a"); - args.Add("copy"); - } - } - #endregion - - args.Add("-f"); - args.Add("hls"); - - foreach (var c in config.hlsOptions.comand["output"]) - { - foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - args.Add(a); - } - - #region -hls_segment_type - args.Add("-hls_segment_type"); - - if (context.HlsOptions.fmp4) - { - args.Add("fmp4"); - } - else - { - args.Add("mpegts"); - - foreach (var c in config.hlsOptions.comand["segment_mpegts"]) - { - foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - args.Add(a); - } - } - #endregion - - args.Add("-hls_time"); - args.Add(context.HlsOptions.segDur.ToString(CultureInfo.InvariantCulture)); - - if (context.live) - { - args.Add("-hls_flags"); - args.Add("append_list+omit_endlist+delete_segments"); - } - - args.Add("-hls_list_size"); - args.Add(context.HlsOptions.winSize.ToString(CultureInfo.InvariantCulture)); - - #region -start_number - int? startNumber = context.startNumber; - if (!startNumber.HasValue && context.HlsOptions.seek > 0) - { - int segDur = Math.Max(1, context.HlsOptions.segDur); - startNumber = context.HlsOptions.seek / segDur; - } - - if (startNumber.HasValue) - { - args.Add("-start_number"); - args.Add(startNumber.Value.ToString()); - } - #endregion - - args.Add("-master_pl_name"); - args.Add("index.m3u8"); - - if (context.HlsOptions.fmp4) - { - args.Add("-hls_fmp4_init_filename"); - args.Add("init.mp4"); - } - else - { - args.Add("-hls_playlist_type"); - args.Add("vod"); - } - - args.Add("-hls_segment_filename"); - args.Add(context.HlsOptions.fmp4 ? "seg_%05d.m4s" : "seg_%05d.ts"); - - args.Add("-y"); - args.Add("index.m3u8"); - #endregion - - return process; - } - - private async Task PumpStdErrAsync(TranscodingJob job) - { - try - { - string line; - while ((line = await job.Process.StandardError.ReadLineAsync()) != null) - job.AppendLog(line); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_9kis6e5h"); - } - } - - private async Task IdleWatchdogAsync(TranscodingJob job, ModuleConf config) - { - var idle = TimeSpan.FromSeconds(Math.Max(180, config.idleTimeoutSec)); - var idle_live = TimeSpan.FromSeconds(Math.Max(20, config.idleTimeoutSec_live)); - - try - { - while (!job.CancellationToken.IsCancellationRequested && !job.Process.HasExited) - { - await Task.Delay(TimeSpan.FromSeconds(1), job.CancellationToken); - - if (job.Context.live) - { - if (config.idleTimeoutSec_live == -1) - continue; - - if (DateTime.UtcNow - job.LastAccessUtc > idle_live) - { - await StopJobAsync(job); - break; - } - } - else - { - - if (config.idleTimeoutSec == -1) - continue; - - if (DateTime.UtcNow - job.LastAccessUtc > idle) - { - await StopJobAsync(job); - break; - } - } - } - } - catch (TaskCanceledException) - { - } - } - - private void OnProcessExit(TranscodingJob job) - { - if (job.Context.live) - { - job.SignalExit(); - Cleanup(job); - } - } - - private bool TryParseToken(string streamId, out string id) - { - id = string.Empty; - var parts = streamId.Split('.', 2); - if (parts.Length != 2) - return false; - - id = parts[0]; - if (id.Length != 32) - return false; - - try - { - var padded = parts[1].PadRight(parts[1].Length + (4 - parts[1].Length % 4) % 4, '='); - var hmac = Convert.FromBase64String(padded.Replace('-', '+').Replace('_', '/')); - var expected = ComputeHmac(id); - if (!CryptographicOperations.FixedTimeEquals(hmac, expected)) - return false; - } - catch - { - return false; - } - - return true; - } - - private string BuildToken(string id) - { - var mac = ComputeHmac(id); - var b64 = Convert.ToBase64String(mac).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - return $"{id}.{b64}"; - } - - private byte[] ComputeHmac(string id) - { - using var hmac = new HMACSHA256(_hmacKey); - return hmac.ComputeHash(Encoding.UTF8.GetBytes(id)); - } - - private static string SanitizeHeader(string value, string fallback = "") - { - if (string.IsNullOrWhiteSpace(value)) - return fallback; - - var clean = value.Replace("\r", string.Empty).Replace("\n", string.Empty); - return string.IsNullOrWhiteSpace(clean) ? fallback : clean; - } - - private void CleanupSegments() - { - if (Interlocked.Exchange(ref _segmentCleanupRunning, 1) == 1) - return; - - try - { - if (!(ModInit.conf?.playlistOptions?.delete_segments ?? false)) - return; - - foreach (var job in _jobs.Values) - { - if (job.Context.live) - continue; - - var lastIndex = job.LastSegmentIndex; - if (0 >= lastIndex) - continue; - - try - { - if (!Directory.Exists(job.OutputDirectory)) - continue; - - foreach (var file in Directory.GetFiles(job.OutputDirectory, "seg_*")) - { - try - { - var name = Path.GetFileName(file); - var match = _segmentFileRegex.Match(name); - if (!match.Success) - continue; - - if (!int.TryParse(match.Groups[1].Value, out var index)) - continue; - - if (index >= lastIndex - 1) - continue; - - // первые 10 сегментов оставляем - if (10 >= index) - continue; - - File.Delete(file); - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_nwuuqesw"); - } - } - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_x7fqvn06"); - } - } - } - catch (System.Exception ex) - { - Log.Error(ex, "CatchId={CatchId}", "id_zr6i3b00"); - } - finally - { - Interlocked.Exchange(ref _segmentCleanupRunning, 0); - } - } -} diff --git a/mods/Transcoding/Transcoding.csproj b/mods/Transcoding/Transcoding.csproj deleted file mode 100644 index c5b2704..0000000 --- a/mods/Transcoding/Transcoding.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - net10.0 - library - true - - - - - - - - - Always - - - - diff --git a/mods/Transcoding/manifest.json b/mods/Transcoding/manifest.json deleted file mode 100644 index bb27dbc..0000000 --- a/mods/Transcoding/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "enable": true, - "tree": [ - "Services", - "Controller.cs", - "ModInit.cs", - "ModuleConf.cs" - ] -} diff --git a/mods/Transcoding/plugin.js b/mods/Transcoding/plugin.js deleted file mode 100644 index d30edf8..0000000 --- a/mods/Transcoding/plugin.js +++ /dev/null @@ -1,356 +0,0 @@ -(function () { - var API_BASE = '{localhost}'; - - var activeJob = null; - var heartbeatTimer = null; - var waitHandle = null; - - function account(url) { - url = url + ''; - if (url.indexOf('account_email=') == -1) { - var email = Lampa.Storage.get('account_email'); - if (email) url = Lampa.Utils.addUrlComponent(url, 'account_email=' + encodeURIComponent(email)); - } - if (url.indexOf('uid=') == -1) { - var uid = Lampa.Storage.get('lampac_unic_id', ''); - if (uid) url = Lampa.Utils.addUrlComponent(url, 'uid=' + encodeURIComponent(uid)); - } - if (url.indexOf('token=') == -1) { - var token = '{token}'; - if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token={token}'); - } - return url; - } - - function showWait(message) { - Lampa.Loading.start(function(){}, message); - } - - function hideWait() { - Lampa.Loading.stop(); - } - - - function log() { - try { - var args = Array.prototype.slice.call(arguments); - args.unshift('Transcoder'); - console.log.apply(console, args); - } - catch (err) { - /* noop */ - } - } - - function notify(message) { - if (window.Lampa && Lampa.Noty && Lampa.Noty.show) { - Lampa.Noty.show(message); - } - else { - log(message); - } - } - - function resolveMediaUrl(data) { - if (data && data.url) return data.url; - return ''; - } - - function isMkvSource(data) { - var url = resolveMediaUrl(data); - if (!url) return false; - return /\.(mkv|avi|flv)($|\?|#)/i.test(url) || /\/lite\/pidtor\//i.test(url); - } - - function requestFfprobe(mediaUrl, onSuccess, onError) { - if (!mediaUrl) { - if (onError) onError(new Error('Empty media url')); - return; - } - - var net = new Lampa.Reguest(); - var target = account(API_BASE + '/ffprobe?media=' + encodeURIComponent(mediaUrl)); - - log('request ffprobe', target); - - net.native(target, function (response) { - try { - var json = typeof response === 'string' ? JSON.parse(response) : response; - try { - var streams = json && Array.isArray(json.streams) ? json.streams : []; - var v = streams.find(function(s){ return s.codec_type === 'video'; }); - var rawCodec = (v && (v.codec_name || (v.tags && v.tags.codec_name))) || ''; - } catch(e) { /* noop */ } - if (onSuccess) onSuccess(json || {}); - } catch(e) { - if (onError) onError(e); - } - }, function (error) { - hideWait(); - if (onError) onError(error || new Error('ffprobe request failed')); - }, null, { - dataType: 'text', - timeout: 1000 * 40 - }); - } - - function formatAudioItem(track, index) { - var tags = track && track.tags ? track.tags : {}; - var title = tags.title || tags.handler_name || ('Audio #' + (index + 1)); - var lang = (tags.language || '').toUpperCase(); - var codec = (track.codec_name || '').toUpperCase(); - var channels = (track.channel_layout || '').replace('(side)', '').replace('stereo', '2.0'); - var rate = track.bit_rate ? Math.round(track.bit_rate / 1000) + ' kbps' : ''; - - var subtitleParts = []; - if (lang) subtitleParts.push(lang); - if (codec) subtitleParts.push(codec); - if (channels) subtitleParts.push(channels); - if (rate) subtitleParts.push(rate); - - return { - title: title, - subtitle: subtitleParts.join(' • '), - track: track - }; - } - - function selectAutoAudioTrack(audioTracks) { - for (var i = 0; i < audioTracks.length; i++) { - var disposition = audioTracks[i] && audioTracks[i].disposition; - if (disposition && (disposition.default === 1 || disposition.default === true)) { - return audioTracks[i]; - } - } - - var language = String(Lampa.Storage.get('language', 'ru') || 'ru').toLowerCase().split('-')[0]; - var aliases = { - ru: ['ru', 'rus'], - en: ['en', 'eng'], - uk: ['uk', 'ukr'], - be: ['be', 'bel'], - bg: ['bg', 'bul'], - he: ['he', 'heb'], - cs: ['cs', 'ces', 'cze'], - ro: ['ro', 'ron', 'rum'], - de: ['de', 'deu', 'ger'], - fr: ['fr', 'fra', 'fre'], - es: ['es', 'spa'], - it: ['it', 'ita'], - pt: ['pt', 'por'], - pl: ['pl', 'pol'], - tr: ['tr', 'tur'], - ja: ['ja', 'jpn'], - ko: ['ko', 'kor'], - zh: ['zh', 'zho', 'chi'] - }; - var preferred = aliases[language] || (language ? [language] : []); - - for (var j = 0; j < audioTracks.length; j++) { - var tags = audioTracks[j] && audioTracks[j].tags ? audioTracks[j].tags : {}; - var trackLanguage = String(tags.language || '').toLowerCase(); - if (preferred.indexOf(trackLanguage) !== -1) { - return audioTracks[j]; - } - } - - return audioTracks[0]; - } - - function showAudioSelector(data, audioTracks) { - if (!audioTracks.length) { - notify('Не удалось найти аудиодорожки для MKV файла'); - return; - } - - var manualSelection = Lampa.Storage.get('transcoding_audio_prompt', false); - if (!(manualSelection === true || manualSelection === 'true')) { - startTranscoding(data, selectAutoAudioTrack(audioTracks)); - return; - } - - var items = audioTracks.map(function (track, index) { - return formatAudioItem(track, index); - }); - - var last_controller = Lampa.Controller.enabled().name - - Lampa.Select.show({ - title: 'Выберите аудиодорожку', - items: items, - onSelect: function (item) { - Lampa.Select.close(); - if (!item || !item.track) { - notify('Выбрана пустая дорожка'); - return; - } - startTranscoding(data, item.track); - }, - onBack: function () { - Lampa.Controller.toggle(last_controller) - } - }); - } - - function startTranscoding(data, track) { - stopHeartbeat(); - ensureJobStopped(true); - - var payload = { - src: resolveMediaUrl(data), - audio: { index: track.index -1 }, - live: false - }; - - /*if (data && data.subtitles) { - payload.subtitles = !!data.subtitles; - }*/ - - var net = new Lampa.Reguest(); - net.native(account(API_BASE + '/transcoding/start'), function (response) { - var json = typeof response === 'string' ? JSON.parse(response) : response; - if (!json || !json.streamId || !json.playlistUrl) { - notify('Некорректный ответ от сервера перекодирования'); - log('invalid start response', response); - return; - } - - activeJob = { - streamId: json.streamId, - playlistUrl: json.playlistUrl - }; - - var playback = data || {}; - playback.transcoding = true; - playback.ffprobe = null; - playback.url = json.playlistUrl; - playback.subtitles_call = json.subtitlesUrl; - playback.hls_manifest_timeout = json.hls_timeout_seconds * 1000; - Lampa.Player.play(playback); - - }, function (error) { - hideWait(); - notify('Не удалось запустить перекодирование MKV'); - log('start error', error); - }, JSON.stringify(payload), { - dataType: 'json', - type: 'POST', - timeout: 1000 * 20, - headers: { - 'Content-Type': 'application/json' - } - }); - } - - function sendHeartbeat() { - if (!activeJob) return; - - var net = new Lampa.Reguest(); - net.native(account(API_BASE + '/transcoding/' + activeJob.streamId + '/heartbeat'), function () { - log('heartbeat sent'); - }, function (error) { - hideWait(); - log('heartbeat error', error); - }, null, { - dataType: 'text', - timeout: 1000 * 10 - }); - } - - function stopHeartbeat() { - if (heartbeatTimer) { - clearInterval(heartbeatTimer); - heartbeatTimer = null; - } - } - - function ensureJobStopped(sendRemote) { - if (!activeJob) return; - - var job = activeJob; - activeJob = null; - stopHeartbeat(); - - if (sendRemote) { - var net = new Lampa.Reguest(); - net.native(account(API_BASE + '/transcoding/' + job.streamId + '/stop'), function () { - log('stop sent'); - }, function (error) { - hideWait(); - log('stop error', error); - }, null, { - dataType: 'text', - timeout: 1000 * 10 - }); - } - } - - function registerSettings() { - if (window.lampac_transcoding_audio_setting || !Lampa.SettingsApi || !Lampa.SettingsApi.addParam) return; - window.lampac_transcoding_audio_setting = true; - Lampa.SettingsApi.addParam({ - component: 'player', - param: { - name: 'transcoding_audio_prompt', - type: 'trigger', - values: '', - default: false - }, - field: { - name: 'Спрашивать аудиодорожку при транскодировании', - description: 'Включите для ручного выбора дорожки перед запуском MKV' - } - }); - } - - function handlePlayerStart(e) { - if (!isMkvSource(e.data) || e.data.transcoding || /\/transcoding\//i.test(e.data.url)) return; - - e.abort(); - e.data.url = e.data.url.replace(/&(preload|stat|m3u)/g, '&play'); - - log('intercept mkv playback', e.data.url); - - showWait('Получение списка аудио дорожек...'); - - requestFfprobe(resolveMediaUrl(e.data), function (info) { - hideWait(); - var streams = info && Array.isArray(info.streams) ? info.streams : []; - var audioTracks = streams.filter(function (track) { - return track.codec_type === 'audio'; - }); - showAudioSelector(e.data, audioTracks); - }, function (error) { - hideWait(); - notify('Ошибка анализа MKV файла'); - log('ffprobe error', error); - }); - } - - function handlePlayerDestroy() { - ensureJobStopped(true); - } - - function handleVideoPause() { - if (!activeJob) return; - - stopHeartbeat(); - heartbeatTimer = setInterval(sendHeartbeat, 1000 * 10); - } - - function handleVideoPlay() { - if (!activeJob) return; - - stopHeartbeat(); - } - - if (!window.lampac_transcoding_plugin) { - window.lampac_transcoding_plugin = true; - registerSettings(); - - Lampa.Player.listener.follow('create', handlePlayerStart); - Lampa.Player.listener.follow('destroy', handlePlayerDestroy); - Lampa.PlayerVideo.listener.follow('pause', handleVideoPause); - Lampa.PlayerVideo.listener.follow('play', handleVideoPlay); - } -})(); \ No newline at end of file diff --git a/mods/WatchTogether/ModInit.cs b/mods/WatchTogether/ModInit.cs deleted file mode 100644 index 00fc54e..0000000 --- a/mods/WatchTogether/ModInit.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Shared; -using Shared.Models.AppConf; -using Shared.Models.Events; -using Shared.Models.Module; -using Shared.Models.Module.Interfaces; -using System.Collections.Generic; -using Shared.Services; - -namespace WatchTogether -{ - public class WatchTogetherConf - { - public bool enable { get; set; } = true; - public bool allow_anonymous { get; set; } = true; - public int relay_max_channels { get; set; } = 500; - public int relay_max_clients_per_channel { get; set; } = 32; - public int gc_empty_timeout_minutes { get; set; } = 30; - public int gc_max_lifetime_hours { get; set; } = 12; - - public List limit_map { get; set; } = new() - { - new("^/watchtogether/", new WafLimitMap { limit = 30, second = 1 }), - new("^/wt/c/", new WafLimitMap { limit = 10, second = 1 }) - }; - } - - /// - /// WatchTogether: a lampac module that acts as both the lparty relay and a - /// shadow host keeping rooms alive when their creator disconnects. - /// - public class ModInit : IModuleLoaded, IModuleConfigure - { - public static string modpath; - public static WatchTogetherConf conf; - - public void Configure(ConfigureModel app) - { - app.services.AddTransient(); - } - - public void Loaded(InitspaceModel baseconf) - { - modpath = baseconf.path; - UpdateConf(); - EventListener.UpdateInitFile += UpdateConf; - ShadowHost.Start(); - } - - public void Dispose() - { - EventListener.UpdateInitFile -= UpdateConf; - ShadowHost.Stop(); - } - - private static bool isWafInjected = false; - - private void UpdateConf() - { - conf = ModuleInvoke.Init("WatchTogether", new WatchTogetherConf()); - - if (!isWafInjected && conf.limit_map != null) - { - foreach (var m in conf.limit_map) - { - CoreInit.conf.WAF.limit_map.Insert(0, m); - } - isWafInjected = true; - } - } - } -} diff --git a/mods/WatchTogether/README.md b/mods/WatchTogether/README.md deleted file mode 100644 index 49e74e9..0000000 --- a/mods/WatchTogether/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# WatchTogether - -Модуль синхронизированного просмотра для Lampac. Работает одновременно как **хранитель состояния** и как **тихий lparty-клиент**: комната живёт в канале реле, а сервер незаметно подхватывает роль хоста, если создатель отключился. - -Клиентский плагин основан на [lparty.js](https://nrsua.github.io/lampa-nrs/lparty.js) (nrsua, levende) и полностью wire-совместим с его экосистемой — JustPlus Player, lparty-web и стоковый lparty.js подключаются к тем же комнатам без правок. - -## Как это работает - -1. **Создание комнаты.** Человек создаёт комнату в любом lparty-клиенте — его клиент открывает канал `wss:///wt/c/lparty-r-` и является хостом. -2. **Тень.** Сервер видит адрес подключения и начинает пассивно слушать кадры канала: копирует url, метаданные, позицию, скорость и список участников. В списке зрителей он виден как `Lampac`, но ничего не рассылает. -3. **Подмена.** Как только соединение хоста рвётся, сервер мгновенно шлёт кадр `host` и продолжает timeline с последнего хартбита (лаг ≤ 2с, пауза — точно). Попытки других клиентов перехватить хостство отклоняются повторным `host=сервер`. -4. **Возврат.** Создатель переподключился — сервер отдаёт ему роль и снова уходит в тень. -5. **GC.** Пустые комнаты удаляются через `gc_empty_timeout_minutes`, любые — через `gc_max_lifetime_hours`. - -## HTTP (обзор) - -| Маршрут | Описание | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `GET /watchtogether.js` | Плагин для клиента Lampa (адаптированный lparty.js). | -| `GET /watch_together/` | Веб-плеер для анонимных гостей (адаптированный lparty-web). Поддерживает `?room=` и `?relay=`. | -| `WS /wt/c/{channel}` | LParty-совместимый релей: каналы, кадры join/leave/message, эхо с серверным временем. | - -## Скорость воспроизведения - -Наши клиенты передают скорость поле `sp` в кадрах `state`/`sync`/`act`/`hold`/`go` и применяют её к `playbackRate`. Стоковые клиенты поле молча игнорируют — обратная совместимость не нарушается. Если автор JustPlus добавит поддержку `sp`, синхронизация заработает у него автоматически. - -## Конфигурация - -Секция в `init.conf`: **`WatchTogether`**. - -| Поле | Смысл | -| ------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `enable` | Включение модуля (по умолчанию **true**). | -| `allow_anonymous` | Доступ к плагину `/watchtogether.js` и веб-плееру lparty-web без авторизации (по умолчанию **true**). | -| `relay_max_channels` | Лимит одновременных каналов реле (по умолчанию **500**). | -| `relay_max_clients_per_channel` | Лимит участников одного канала (по умолчанию **32**). | -| `gc_empty_timeout_minutes` | Удаление пустой комнаты через N минут (по умолчанию **30**). | -| `gc_max_lifetime_hours` | Удаление любой комнаты через N часов (по умолчанию **12**). | - -## Подключение клиентов - -- Плагин Lampa: `/watchtogether.js` — реле уже настроено на собственный сервер lampac. -- JustPlus Player / lparty.js: в настройках клиента указать адрес реле `ws(s)://<адрес-lampac>/wt/c/`. -- Веб-гость: открыть `https:///watch_together/?room=` — ссылку формирует плагин. diff --git a/mods/WatchTogether/RelayServer.cs b/mods/WatchTogether/RelayServer.cs deleted file mode 100644 index a1c7e10..0000000 --- a/mods/WatchTogether/RelayServer.cs +++ /dev/null @@ -1,456 +0,0 @@ -using Core.Middlewares; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Net.WebSockets; -using System.Text.Json; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace WatchTogether -{ - /// - /// Maps the lparty-compatible relay endpoint (/wt/c/{channel}) at app startup. - /// Modules cannot map endpoints themselves, so this hooks the pipeline - /// through an IStartupFilter registered in ModInit.Configure. The branch - /// runs before the global pipeline (like /nws) so auth middlewares cannot - /// gate raw websocket clients; RequestInfo is applied in-branch because - /// the WAF reads its output. - /// - public class RelayStartupFilter : IStartupFilter - { - public Action Configure(Action next) - { - return app => - { - app.Map("/wt/c", relayApp => - { - relayApp.UseRequestInfo(); - relayApp.UseWAF(); - relayApp.UseWebSockets(); - relayApp.Run(RelayServer.HandleWebSocketAsync); - }); - - next(app); - }; - } - } - - public class RelayMember - { - public string Uid { get; set; } - public string Alias { get; set; } - public bool Echo { get; set; } - public WebSocket Socket { get; set; } - public SemaphoreSlim SendLock { get; } = new(1, 1); - - /// LParty peer id (the u field of the member's messages). - public string Pid { get; set; } - public string Name { get; set; } - - public long RateSecond; - public int RateCount; - } - - public class RelayChannel - { - public string Name { get; set; } - public readonly object Lock = new(); - public readonly Dictionary Members = new(); - } - - /// - /// Wire-compatible emulation of the itty.ws channel relay used by lparty - /// clients: stateless broadcast groups with join/leave announcements, a - /// member list and a server date on every frame (the date is what lparty - /// uses for clock sync via message echo). - /// - public static class RelayServer - { - static long uidSeq = 0; - static readonly ConcurrentDictionary Channels = new(); - - public static RelayChannel TryGetChannel(string name) => - Channels.TryGetValue(name, out var channel) ? channel : null; - - /// Member uids excluding the server's virtual member. - public static List HumanUids(RelayChannel channel) - { - lock (channel.Lock) - return channel.Members.Keys - .Where(u => !string.Equals(u, ShadowHost.ServerUid, StringComparison.Ordinal)) - .ToList(); - } - - const int MaxMessageBytes = 16 * 1024; - const int MaxMessagesPerSecond = 50; - - public static async Task HandleWebSocketAsync(HttpContext context) - { - if (!ModInit.conf.enable) - { - context.Response.StatusCode = 404; - return; - } - - if (!context.WebSockets.IsWebSocketRequest) - { - context.Response.StatusCode = 400; - return; - } - - // Inside Map("/wt/c") the request path is already without the prefix: - // "/{channel}", so only the leading slash is stripped here. - string path = context.Request.Path.Value ?? "/"; - string channelName = path.Length > 1 ? Uri.UnescapeDataString(path[1..]) : string.Empty; - if (string.IsNullOrWhiteSpace(channelName) || channelName.Length > 128) - { - context.Response.StatusCode = 404; - return; - } - - bool echo = string.Equals(context.Request.Query["echo"].ToString(), "true", StringComparison.OrdinalIgnoreCase); - string alias = context.Request.Query["as"].ToString() ?? string.Empty; - if (alias.Length > 64) alias = alias[..64]; - - int maxChannels = Math.Max(1, ModInit.conf.relay_max_channels); - if (!Channels.ContainsKey(channelName) && Channels.Count >= maxChannels) - { - context.Response.StatusCode = 429; - return; - } - - var socket = await context.WebSockets.AcceptWebSocketAsync(); - - var member = new RelayMember - { - Uid = "u" + Interlocked.Increment(ref uidSeq), - Alias = alias, - Echo = echo, - Socket = socket - }; - - var channel = Channels.GetOrAdd(channelName, _ => new RelayChannel { Name = channelName }); - - // A server-hosted room keeps its virtual member across channel - // recreation: joiners must see total >= 2 or lparty clients - // declare the room dead on arrival. - if (ShadowHost.ShouldHostChannel(channelName)) - EnsureServerMember(channel); - - bool full = false; - List users; - int total; - lock (channel.Lock) - { - int maxClients = Math.Max(2, ModInit.conf.relay_max_clients_per_channel); - if (channel.Members.Count >= maxClients) - { - full = true; - users = new List(); - total = 0; - } - else - { - channel.Members[member.Uid] = member; - users = new List(channel.Members.Values); - total = users.Count; - } - } - - if (full) - { - _ = SendAsync(member, BuildErrorFrame(ServerNowMs(), "channel_full")); - try { await socket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "channel_full", CancellationToken.None); } catch { } - return; - } - - long now = ServerNowMs(); - _ = SendAsync(member, BuildSelfJoinFrame(member.Uid, now, total, users)); - - byte[] joinFrame = BuildForeignJoinFrame(member.Uid, member.Alias, now, total); - Broadcast(channel, member.Uid, joinFrame); - - ShadowHost.OnMemberJoined(channelName, channel, member); - - try - { - await ReceiveLoop(channelName, channel, member); - } - catch { } - finally - { - RemoveMember(channelName, channel, member); - } - } - - /// The server's virtual lparty member inside a channel: a - /// silent observer while a real host is alive, the acting host after - /// the host is gone. - public static void EnsureServerMember(RelayChannel channel) - { - bool added = false; - int total; - lock (channel.Lock) - { - if (!channel.Members.ContainsKey(ShadowHost.ServerUid)) - { - channel.Members[ShadowHost.ServerUid] = new RelayMember { Uid = ShadowHost.ServerUid, Alias = "Lampac" }; - added = true; - } - total = channel.Members.Count; - } - - if (added) - Broadcast(channel, null, BuildForeignJoinFrame(ShadowHost.ServerUid, "Lampac", ServerNowMs(), total)); - } - - static async Task ReceiveLoop(string channelName, RelayChannel channel, RelayMember member) - { - var socket = member.Socket; - var buffer = new byte[8192]; - - while (socket.State == WebSocketState.Open) - { - var result = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - if (result.MessageType == WebSocketMessageType.Close) break; - - using var ms = new MemoryStream(); - ms.Write(buffer, 0, result.Count); - while (!result.EndOfMessage) - { - if (ms.Length > MaxMessageBytes) throw new IOException("relay message too large"); - result = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - if (result.MessageType == WebSocketMessageType.Close) return; - ms.Write(buffer, 0, result.Count); - } - - if (ms.Length == 0 || ms.Length > MaxMessageBytes) continue; - - if (!CheckRate(member)) - { - _ = SendAsync(member, BuildErrorFrame(ServerNowMs(), "rate_limited")); - continue; - } - - JsonElement message; - try - { - using var doc = JsonDocument.Parse(ms.ToArray()); - if (doc.RootElement.ValueKind != JsonValueKind.Object) continue; - message = doc.RootElement.Clone(); - } - catch (JsonException) - { - continue; - } - - long now = ServerNowMs(); - - // The echo feeds clock sync: it happens for every message. - // Frames are broadcast verbatim; the shadow host only listens. - if (member.Echo) - { - byte[] echoFrame = BuildMessageFrame(member.Uid, member.Alias, ServerNowMs(), message); - _ = SendAsync(member, echoFrame); - } - - byte[] frame = BuildMessageFrame(member.Uid, member.Alias, now, message); - Broadcast(channel, member.Uid, frame); - - ShadowHost.OnMessage(channelName, channel, member, message, now); - } - } - - static bool CheckRate(RelayMember member) - { - long second = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - if (Interlocked.CompareExchange(ref member.RateSecond, second, second) != second) - { - member.RateSecond = second; - member.RateCount = 0; - } - member.RateCount++; - return member.RateCount <= MaxMessagesPerSecond; - } - - public static void Broadcast(RelayChannel channel, string excludeUid, byte[] frame) - { - RelayMember[] targets; - lock (channel.Lock) - { - if (channel.Members.Count == 0) return; - targets = new RelayMember[channel.Members.Count]; - channel.Members.Values.CopyTo(targets, 0); - } - - foreach (var target in targets) - { - if (string.Equals(target.Uid, excludeUid, StringComparison.Ordinal)) continue; - _ = SendAsync(target, frame); - } - } - - static void RemoveMember(string channelName, RelayChannel channel, RelayMember member) - { - int total; - bool tornDown = false; - lock (channel.Lock) - { - if (!channel.Members.Remove(member.Uid, out _)) return; - total = channel.Members.Count; - bool onlyServer = total == 1 && channel.Members.ContainsKey(ShadowHost.ServerUid); - - if (total == 0 || onlyServer) - { - tornDown = true; - if (Channels.TryRemove(channel.Name, out var stale) && !ReferenceEquals(stale, channel)) - { - // A newer instance took the name; put it back. - Channels.TryAdd(channel.Name, stale); - } - if (onlyServer) - channel.Members.Remove(ShadowHost.ServerUid, out _); - } - } - - byte[] leaveFrame = BuildLeaveFrame(member.Uid, member.Alias, ServerNowMs(), total); - Broadcast(channel, member.Uid, leaveFrame); - - ShadowHost.OnMemberLeft(channelName, channel, member, tornDown); - - try { member.Socket.Dispose(); } catch { } - } - - public static async Task SendAsync(RelayMember member, byte[] payload) - { - try - { - await member.SendLock.WaitAsync(); - try - { - if (member.Socket.State != WebSocketState.Open) return; - await member.Socket.SendAsync(new ArraySegment(payload), WebSocketMessageType.Text, true, CancellationToken.None); - } - finally - { - member.SendLock.Release(); - } - } - catch { } - } - - static long ServerNowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - static byte[] BuildSelfJoinFrame(string uid, long date, int total, List users) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "join"); - w.WriteBoolean("self", true); - w.WriteString("uid", uid); - w.WriteNumber("date", date); - w.WriteNumber("total", total); - w.WriteStartArray("users"); - foreach (var u in users) - { - w.WriteStartObject(); - w.WriteString("uid", u.Uid); - w.WriteString("alias", u.Alias ?? string.Empty); - w.WriteEndObject(); - } - w.WriteEndArray(); - w.WriteEndObject(); - } - return ms.ToArray(); - } - - static byte[] BuildForeignJoinFrame(string uid, string alias, long date, int total) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "join"); - w.WriteBoolean("self", false); - w.WriteString("uid", uid); - w.WriteString("alias", alias ?? string.Empty); - w.WriteNumber("date", date); - w.WriteNumber("total", total); - w.WriteEndObject(); - } - return ms.ToArray(); - } - - static byte[] BuildLeaveFrame(string uid, string alias, long date, int total) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "leave"); - w.WriteString("uid", uid); - w.WriteString("alias", alias ?? string.Empty); - w.WriteNumber("date", date); - w.WriteNumber("total", total); - w.WriteEndObject(); - } - return ms.ToArray(); - } - - static byte[] BuildErrorFrame(long date, string message) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "error"); - w.WriteNumber("date", date); - w.WriteString("message", message); - w.WriteEndObject(); - } - return ms.ToArray(); - } - - public static byte[] BuildMessageFrame(string uid, string alias, long date, JsonElement message) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "message"); - w.WriteString("uid", uid); - w.WriteString("alias", alias ?? string.Empty); - w.WriteNumber("date", date); - w.WritePropertyName("message"); - message.WriteTo(w); - w.WriteEndObject(); - } - return ms.ToArray(); - } - - public static byte[] BuildMessageFrame(string uid, string alias, long date, object message) - { - using var ms = new MemoryStream(); - using (var w = new Utf8JsonWriter(ms)) - { - w.WriteStartObject(); - w.WriteString("type", "message"); - w.WriteString("uid", uid); - w.WriteString("alias", alias ?? string.Empty); - w.WriteNumber("date", date); - w.WritePropertyName("message"); - JsonSerializer.Serialize(w, message, message.GetType()); - w.WriteEndObject(); - } - return ms.ToArray(); - } - } -} diff --git a/mods/WatchTogether/ShadowHost.cs b/mods/WatchTogether/ShadowHost.cs deleted file mode 100644 index 52234b4..0000000 --- a/mods/WatchTogether/ShadowHost.cs +++ /dev/null @@ -1,826 +0,0 @@ -using Shared; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace WatchTogether -{ - /// - /// Server-side state of a room the server eavesdrops on. The server never - /// creates rooms: a room exists because a client opened its channel and a - /// host announced a stream. Until the host vanishes, the server is a - /// silent member; when the host is gone, it seamlessly takes over. - /// - public class ShadowRoom - { - public readonly object Lock = new(); - - public string Channel; - - // host bookkeeping - public string HostUid; - public string HostPid; - public string CreatorPid; - public long HostSeen; - public bool Hosting; - public bool Stalled; - - // timeline - public string State = "paused"; - public double Position; - public long AtServerTime; - public double Speed = 1.0; - - // metadata - public string RoomName; - public string Url; - public string Title; - public string Poster; - public string Source; - public string Type; - public int Tmdb; - - /// Room code as announced by the host in the lobby (ad frames); - /// lets the server list and re-announce rooms it keeps alive. - public string Code; - - // server-side hold barrier (only while Hosting) - public HashSet HoldWaiting; - public double HoldPosition; - public Timer HoldTimer; - - public DateTime CreatedUtc = DateTime.UtcNow; - public DateTime LastActivityUtc = DateTime.UtcNow; - - /// Display name of the member that announced the room. - public string OwnerName; - } - - /// A room advertisement overheard in the lobby channel. - public class LobbyAd - { - public string SenderUid; - public long Seen; - public string Id; - public string Name; - public string Title; - public string Poster; - public string Owner; - public int Members; - public int Tmdb; - public string Type; - public bool Pwd; - } - - /// - /// The shadow host: the WatchTogether module acting as a state-keeping - /// lparty client. While a real host is alive it silently listens and keeps - /// a snapshot of the room (url, metadata, timeline). The moment the host's - /// connection drops, the server steps in as the host on the wire - the - /// members see one "host changed" toast and playback continues without a - /// gap. When the original creator returns, the role is handed back. - /// - public static class ShadowHost - { - public const string ServerUid = "lampac"; - - const string LobbyChannel = "lparty-lobby-v1"; - const string RoomPrefix = "lparty-r-"; - const int MetronomeMs = 2000; - const int GcEveryMs = 60000; - const double MaxAdvancableSeconds = 3600; - const int HoldMaxMs = 15000; - const long ReconnectGraceMs = 60000; - const int AdTtlMs = 120000; - - static Timer metronomeTimer; - static Timer gcTimer; - static int initialized; - - static readonly ConcurrentDictionary rooms = new(); - - /// Room advertisements overheard in the lobby channel: the - /// host announces its code publicly, so the server can keep listing - /// rooms (including ones it keeps alive) without knowing passwords. - static readonly ConcurrentDictionary lobbyAds = new(); - - /// pid -> last hello, per channel; used to skip the join hold - /// for members that are reconnecting rather than new. - static readonly ConcurrentDictionary> knownPids = new(); - - /// The wire channel name for a room code. With an empty - /// password this is computable server-side, which lets the server link - /// a lobby advertisement to the room it belongs to. - static string RoomChannelHash(string roomId, string password) - { - if (string.IsNullOrEmpty(roomId)) return null; - string input = roomId.ToUpperInvariant() + "|" + (password ?? string.Empty); - using var sha = SHA256.Create(); - byte[] hashBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); - return RoomPrefix + Convert.ToHexString(hashBytes).ToLowerInvariant()[..24]; - } - - // ---------------------------------------------------------------- lifecycle - - public static void Start() - { - if (Interlocked.Exchange(ref initialized, 1) == 1) return; - metronomeTimer = new Timer(Metronome, null, MetronomeMs, MetronomeMs); - gcTimer = new Timer(Gc, null, GcEveryMs, GcEveryMs); - } - - public static void Stop() - { - if (Interlocked.Exchange(ref initialized, 0) == 0) return; - metronomeTimer?.Dispose(); - gcTimer?.Dispose(); - metronomeTimer = gcTimer = null; - rooms.Clear(); - knownPids.Clear(); - } - - // ---------------------------------------------------------------- relay hooks - - /// Whether the server's virtual member must exist in this - /// channel right now (it is the acting host). - public static bool ShouldHostChannel(string channelName) - { - if (channelName == LobbyChannel) return false; - return rooms.TryGetValue(channelName, out var room) && room.Hosting; - } - - public static void OnMemberJoined(string channelName, RelayChannel channel, RelayMember member) - { - if (channelName == LobbyChannel) return; - if (!rooms.TryGetValue(channelName, out var room)) return; - - room.LastActivityUtc = DateTime.UtcNow; - - RelayServer.EnsureServerMember(channel); - - // Server-hosted room: answer the joiner like a host would. - if (room.Hosting) - { - var pids = knownPids.GetOrAdd(channelName, _ => new ConcurrentDictionary()); - AnswerHelloAsHost(room, channel, member, pids, 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - } - } - - public static void OnMemberLeft(string channelName, RelayChannel channel, RelayMember member, bool tornDown) - { - if (channelName == LobbyChannel) - { - // A lobby agent disconnected: its ads die with it. - foreach (var key in lobbyAds.Keys.ToList()) - if (lobbyAds.TryGetValue(key, out var ad) && string.Equals(ad.SenderUid, member.Uid, StringComparison.Ordinal)) - lobbyAds.TryRemove(key, out var dead); - return; - } - - if (!rooms.TryGetValue(channelName, out var room)) return; - - if (!room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal)) - BecomeHost(room, channel, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - } - - public static void OnMessage(string channelName, RelayChannel channel, RelayMember member, JsonElement msg, long now) - { - if (channelName == LobbyChannel) - { - HandleLobby(member, msg, now); - return; - } - - var room = rooms.GetOrAdd(channelName, _ => new ShadowRoom { Channel = channelName }); - room.LastActivityUtc = DateTime.UtcNow; - - string t = GetString(msg, "t"); - string pid = GetString(msg, "u"); - string name = GetString(msg, "n"); - - if (!string.IsNullOrEmpty(pid)) member.Pid = pid; - if (!string.IsNullOrEmpty(name)) member.Name = name; - - switch (t) - { - case "state": ObserveState(room, channel, member, msg, now); break; - case "sync": ObserveSync(room, member, msg, now); break; - case "act": ObserveAct(room, msg, now); break; - case "url": ObserveUrl(room, member, msg); break; - case "buf": ObserveBuf(room, member, msg); break; - case "host": HandleHostClaim(room, channel, member, msg, now); break; - case "hello": HandleHello(room, channel, member, msg, now); break; - case "ready": HandleReady(room, channel, member, now); break; - case "bye": HandleBye(room, channel, member, now); break; - } - } - - /// The lobby is server-answered: hosts advertise their code - /// in `ad` frames, the server caches them and re-announces rooms it - /// keeps alive - so the list survives a host handover. - static void HandleLobby(RelayMember member, JsonElement msg, long now) - { - string t = GetString(msg, "t"); - - if (t == "ad") - { - if (msg.TryGetProperty("r", out var r) && r.ValueKind == JsonValueKind.Object) - { - string id = GetString(r, "id"); - if (!string.IsNullOrWhiteSpace(id) && id.Length <= 16) - { - lobbyAds[id] = new LobbyAd - { - SenderUid = member.Uid, - Seen = now, - Id = id, - Name = GetString(r, "name") ?? id, - Title = GetString(r, "title") ?? string.Empty, - Poster = GetString(r, "poster") ?? string.Empty, - Owner = GetString(r, "owner") ?? string.Empty, - Members = GetInt(r, "members"), - Tmdb = GetInt(r, "tmdb"), - Type = GetString(r, "type") ?? "movie", - Pwd = GetInt(r, "pwd") != 0, - }; - } - } - return; - } - - if (t != "who") return; - - var listed = new HashSet(StringComparer.Ordinal); - - // Server-kept rooms first, with live member counts. - foreach (var room in rooms.Values) - { - string code, name, title, poster, owner, type; - int tmdb; - bool hasUrl; - lock (room.Lock) - { - code = room.Code; - hasUrl = !string.IsNullOrEmpty(room.Url); - name = room.RoomName; - title = room.Title; - poster = room.Poster; - owner = room.OwnerName; - type = room.Type; - tmdb = room.Tmdb; - } - - if (string.IsNullOrWhiteSpace(code) || listed.Contains(code) || !hasUrl) continue; - - var channel = RelayServer.TryGetChannel(room.Channel); - int members = channel == null ? 0 : RelayServer.HumanUids(channel).Count; - SendLobbyAd(member, now, code, name, title, poster, owner, members, true, tmdb, type); - listed.Add(code); - } - - // Cached ads from hosts. Provably alive (passwordless) rooms - // outlive the TTL; the rest go stale with their announcer. - foreach (var ad in lobbyAds.Values.OrderByDescending(a => a.Seen)) - { - if (listed.Contains(ad.Id)) continue; - - string hash = RoomChannelHash(ad.Id, string.Empty); - bool tracked = hash != null && rooms.ContainsKey(hash); - if (now - ad.Seen > AdTtlMs && !tracked) continue; - - int members = ad.Members; - if (tracked) - { - var channel = RelayServer.TryGetChannel(hash); - if (channel != null) members = RelayServer.HumanUids(channel).Count; - } - - SendLobbyAd(member, now, ad.Id, ad.Name, ad.Title, ad.Poster, ad.Owner, members, true, ad.Tmdb, ad.Type); - listed.Add(ad.Id); - } - } - - static void SendLobbyAd(RelayMember target, long now, string id, string name, string title, string poster, string owner, int members, bool pwd, int tmdb, string type) - { - var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new - { - t = "ad", - r = new { id, name, title, poster, owner, members, pwd = pwd ? 1 : 0, tmdb, type } - }); - _ = RelayServer.SendAsync(target, frame); - } - - // ---------------------------------------------------------------- observation - - static void ObserveState(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) - { - // While hosting, only the creator may take the room back. - if (room.Hosting) - { - if (!string.Equals(member.Pid, room.CreatorPid, StringComparison.Ordinal)) - { - AssertHost(room, channel, now); - return; - } - Handback(room, channel, member, now); - } - - string url = GetString(msg, "url"); - if (string.IsNullOrWhiteSpace(url)) return; - - lock (room.Lock) - { - room.HostUid = member.Uid; - room.HostPid = GetString(msg, "own") ?? member.Pid ?? member.Uid; - room.CreatorPid = room.HostPid; - room.HostSeen = now; - room.Hosting = false; - room.Stalled = false; - - room.RoomName = GetString(msg, "rn") ?? room.RoomName; - room.Url = url.Trim(); - room.Title = GetString(msg, "ti") ?? room.Title; - room.Poster = GetString(msg, "po") ?? room.Poster; - room.Tmdb = GetInt(msg, "tm"); - room.Source = GetString(msg, "src") ?? room.Source; - room.Type = GetString(msg, "ty") ?? room.Type; - room.Code = GetString(msg, "cd") ?? room.Code; - room.OwnerName = member.Alias ?? room.OwnerName; - - room.State = GetString(msg, "s") == "playing" ? "playing" : "paused"; - double p = GetDouble(msg, "p"); - if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; - room.Speed = GetSpeedOr(msg, room.Speed); - room.AtServerTime = now; - } - - RelayServer.EnsureServerMember(channel); - } - - static void ObserveSync(ShadowRoom room, RelayMember member, JsonElement msg, long now) - { - string s = GetString(msg, "s") == "playing" ? "playing" : "paused"; - double p = GetDouble(msg, "p"); - double sp = GetSpeedOr(msg, room.Speed); - - lock (room.Lock) - { - if (room.Hosting) return; - - bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || - (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); - - if (!fromHost && string.IsNullOrEmpty(room.HostUid)) - { - // No known host yet: adopt the sender. - room.HostUid = member.Uid; - room.HostPid = member.Pid; - room.CreatorPid = member.Pid; - fromHost = true; - } - - if (!fromHost) return; - - room.State = s; - if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; - room.Speed = sp; - room.AtServerTime = now; - room.HostSeen = now; - } - } - - static void ObserveAct(ShadowRoom room, JsonElement msg, long now) - { - // Acts from anyone move the room; the host's next sync re-asserts. - if (room.Hosting) return; - - string s = GetString(msg, "s") == "playing" ? "playing" : "paused"; - double p = GetDouble(msg, "p"); - - lock (room.Lock) - { - room.State = s; - if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; - room.Speed = GetSpeedOr(msg, room.Speed); - room.AtServerTime = now; - } - } - - static void ObserveUrl(ShadowRoom room, RelayMember member, JsonElement msg) - { - lock (room.Lock) - { - bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || - (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); - if (!fromHost || room.Hosting) return; - - string url = GetString(msg, "url"); - if (string.IsNullOrWhiteSpace(url)) return; - - room.Url = url.Trim(); - string ti = GetString(msg, "ti"); - if (!string.IsNullOrEmpty(ti)) room.Title = ti.Trim(); - room.State = "paused"; - room.Position = 0; - room.AtServerTime = 0; - room.Stalled = false; - } - } - - static void ObserveBuf(ShadowRoom room, RelayMember member, JsonElement msg) - { - lock (room.Lock) - { - bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || - (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); - if (!fromHost) return; - - room.Stalled = GetBool(msg, "v"); - } - } - - static void HandleHostClaim(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) - { - if (room.Hosting) - { - // Claims are dropped; the server re-asserts itself. - AssertHost(room, channel, now); - return; - } - - string claimer = GetString(msg, "u"); - if (string.IsNullOrEmpty(claimer)) return; - - lock (room.Lock) - { - room.HostUid = member.Uid; - room.HostPid = claimer; - room.CreatorPid = claimer; - room.HostSeen = now; - room.Hosting = false; - } - } - - static void HandleHello(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) - { - string pid = GetString(msg, "u") ?? member.Pid; - string name = GetString(msg, "n"); - string cd = GetString(msg, "cd"); - - var pids = knownPids.GetOrAdd(channel.Name, _ => new ConcurrentDictionary()); - long seenBefore = 0; - if (!string.IsNullOrEmpty(pid)) - { - pids.TryGetValue(pid, out seenBefore); - pids[pid] = now; - } - - // The first participant stating the code links the room to its listing. - if (!string.IsNullOrWhiteSpace(cd)) - { - lock (room.Lock) - { - if (string.IsNullOrEmpty(room.Code)) room.Code = cd.Trim().ToUpperInvariant(); - } - } - - if (!room.Hosting) return; - - if (!string.IsNullOrEmpty(pid) && string.Equals(pid, room.CreatorPid, StringComparison.Ordinal)) - { - Handback(room, channel, member, now); - return; - } - - AnswerHelloAsHost(room, channel, member, pids, seenBefore, now); - } - - /// Server-hosting room, newcomer arrived: introduce the room - /// and, like a real host, freeze it while they buffer. - static void AnswerHelloAsHost(ShadowRoom room, RelayChannel channel, RelayMember member, ConcurrentDictionary pids, long seenBefore, long now) - { - if (!room.Hosting) return; - - RelayServer.EnsureServerMember(channel); - SendStateFrame(room, member); - - bool reconnected = now - seenBefore < ReconnectGraceMs; - - bool playing; - lock (room.Lock) playing = room.State == "playing" && !string.IsNullOrEmpty(room.Url); - - if (!playing) return; - if (reconnected) return; - - StartHold(room, channel, now); - } - - static void HandleReady(ShadowRoom room, RelayChannel channel, RelayMember member, long now) - { - lock (room.Lock) - { - if (!room.Hosting || room.HoldWaiting == null) return; - room.HoldWaiting.Remove(member.Uid); - if (room.HoldWaiting.Count > 0) return; - } - ReleaseHold(room, channel, now); - } - - static void HandleBye(ShadowRoom room, RelayChannel channel, RelayMember member, long now) - { - bool wasHost; - lock (room.Lock) - wasHost = !room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal); - - if (wasHost) BecomeHost(room, channel, now); - } - - // ---------------------------------------------------------------- host transitions - - static void BecomeHost(ShadowRoom room, RelayChannel channel, long now) - { - if (room.Hosting) return; - - lock (room.Lock) - { - room.Hosting = true; - room.HostUid = ServerUid; - room.HostSeen = now; - room.Stalled = false; - - if (room.State == "playing" && room.AtServerTime > 0) - { - // Continue exactly where the dead host was last heard. - double elapsed = (now - room.AtServerTime) / 1000.0; - if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) - room.Position += elapsed * room.Speed; - room.AtServerTime = now; - } - } - - RelayServer.EnsureServerMember(channel); - AssertHost(room, channel, now); - - double pos; - string state; - double speed; - lock (room.Lock) { state = room.State; pos = room.Position; speed = room.Speed; } - var sync = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed }); - RelayServer.Broadcast(channel, null, sync); - } - - static void Handback(ShadowRoom room, RelayChannel channel, RelayMember member, long now) - { - lock (room.Lock) - { - room.Hosting = false; - room.HostUid = member.Uid; - room.HostPid = member.Pid; - room.HostSeen = now; - room.Stalled = false; - } - - var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = member.Pid, n = member.Name ?? member.Alias ?? string.Empty }); - RelayServer.Broadcast(channel, null, frame); - } - - static void AssertHost(ShadowRoom room, RelayChannel channel, long now) - { - var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = ServerUid, n = "Lampac" }); - RelayServer.Broadcast(channel, null, frame); - } - - // ---------------------------------------------------------------- hold barrier (server as host) - - static void StartHold(ShadowRoom room, RelayChannel channel, long now) - { - lock (room.Lock) - { - if (room.HoldWaiting != null) return; // already holding - - AdvanceClock(room, now); - room.HoldPosition = room.Position; - - room.HoldWaiting = new HashSet(RelayServer.HumanUids(channel), StringComparer.Ordinal); - if (room.HoldWaiting.Count == 0) return; - } - - var holdFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "hold", u = ServerUid, p = room.HoldPosition, sp = room.Speed }); - RelayServer.Broadcast(channel, null, holdFrame); - - lock (room.Lock) - { - room.HoldTimer?.Dispose(); - room.HoldTimer = new Timer(_ => - { - var ch = RelayServer.TryGetChannel(room.Channel); - if (ch != null) ReleaseHold(room, ch, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - }, null, HoldMaxMs, Timeout.Infinite); - } - } - - /// Freeze the clock base at the current position. - static void AdvanceClock(ShadowRoom room, long now) - { - if (room.State == "playing" && room.AtServerTime > 0) - { - double elapsed = (now - room.AtServerTime) / 1000.0; - if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) room.Position += elapsed * room.Speed; - } - room.AtServerTime = now; - } - - static void ReleaseHold(ShadowRoom room, RelayChannel channel, long now) - { - double position; - lock (room.Lock) - { - room.HoldWaiting = null; - room.HoldTimer?.Dispose(); - room.HoldTimer = null; - room.State = "playing"; - room.Position = room.HoldPosition; - room.AtServerTime = now; - position = room.HoldPosition; - } - - var goFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "go", u = ServerUid, p = position, sp = room.Speed }); - RelayServer.Broadcast(channel, null, goFrame); - } - - // ---------------------------------------------------------------- metronome - - /// While the server hosts, nobody's player drives the clock: - /// the metronome advances the room by wall time and beats every couple - /// of seconds, exactly like the host heartbeats it replaces. - static void Metronome(object _) - { - try - { - long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - foreach (var room in rooms.Values) - { - if (!room.Hosting) continue; - var channel = RelayServer.TryGetChannel(room.Channel); - if (channel == null) continue; - - bool playing; - lock (room.Lock) - { - playing = room.State == "playing" && !room.Stalled; - if (playing && room.AtServerTime > 0) - { - double elapsed = (now - room.AtServerTime) / 1000.0; - if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) - room.Position += elapsed * room.Speed; - room.AtServerTime = now; - } - } - - if (!playing) continue; - - double pos, speed; - string state; - lock (room.Lock) { pos = room.Position; speed = room.Speed; state = room.State; } - - var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed }); - RelayServer.Broadcast(channel, null, frame); - } - } - catch { } - } - - // ---------------------------------------------------------------- gc - - static void Gc(object _) - { - try - { - var emptyLimit = DateTime.UtcNow.AddMinutes(-Math.Max(5, ModInit.conf.gc_empty_timeout_minutes)); - var lifeLimit = DateTime.UtcNow.AddHours(-Math.Max(1, ModInit.conf.gc_max_lifetime_hours)); - - foreach (var room in rooms.Values.ToArray()) - { - bool hasHumans = false; - var channel = RelayServer.TryGetChannel(room.Channel); - if (channel != null) - { - lock (channel.Lock) - hasHumans = channel.Members.Keys.Any(u => !string.Equals(u, ServerUid, StringComparison.Ordinal)); - } - - bool expired = room.CreatedUtc < lifeLimit || - (!hasHumans && room.LastActivityUtc < emptyLimit); - - if (expired) - { - rooms.TryRemove(room.Channel, out var deadRoom); - knownPids.TryRemove(room.Channel, out var deadPids); - } - } - - long cutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ReconnectGraceMs; - foreach (var pids in knownPids.Values) - { - foreach (var key in pids.Keys.ToList()) - { - if (!pids.TryGetValue(key, out var seen) || seen < cutoff) - pids.TryRemove(key, out var deadPid); - } - } - - long adCutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - AdTtlMs; - foreach (var key in lobbyAds.Keys.ToList()) - { - if (!lobbyAds.TryGetValue(key, out var ad) || ad.Seen < adCutoff) - lobbyAds.TryRemove(key, out var deadAd); - } - } - catch { } - } - - // ---------------------------------------------------------------- helpers - - static void SendStateFrame(ShadowRoom room, RelayMember target) - { - long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - double pos; - string state; - double speed; - lock (room.Lock) - { - pos = room.Position; - speed = room.Speed; - state = room.State; - if (state == "playing" && room.AtServerTime > 0) - { - double elapsed = (now - room.AtServerTime) / 1000.0; - if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) pos += elapsed * room.Speed; - } - } - - var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new - { - t = "state", - rn = room.RoomName ?? string.Empty, - own = ServerUid, - url = room.Url ?? string.Empty, - ti = room.Title ?? string.Empty, - po = room.Poster ?? string.Empty, - tm = room.Tmdb, - src = room.Source ?? string.Empty, - ty = room.Type ?? "movie", - s = state, - p = pos, - sp = speed, - }); - - _ = RelayServer.SendAsync(target, frame); - } - - static string GetString(JsonElement obj, string name) - { - if (obj.ValueKind != JsonValueKind.Object) return null; - if (!obj.TryGetProperty(name, out var el)) return null; - if (el.ValueKind == JsonValueKind.String) return el.GetString(); - if (el.ValueKind == JsonValueKind.Null) return null; - return el.ToString(); - } - - static double GetDouble(JsonElement obj, string name) - { - if (obj.ValueKind != JsonValueKind.Object) return 0; - if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetDouble(out var val)) - return val; - return 0; - } - - static double GetSpeedOr(JsonElement obj, double fallback) - { - double v = GetDouble(obj, "sp"); - return v >= 0.25 && v <= 4.0 ? v : fallback; - } - - static int GetInt(JsonElement obj, string name) - { - if (obj.ValueKind != JsonValueKind.Object) return 0; - if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var val)) - return val; - return 0; - } - - static bool GetBool(JsonElement obj, string name) - { - if (obj.ValueKind != JsonValueKind.Object) return false; - return obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.True; - } - } -} diff --git a/mods/WatchTogether/WatchTogether.csproj b/mods/WatchTogether/WatchTogether.csproj deleted file mode 100644 index 74cb30d..0000000 --- a/mods/WatchTogether/WatchTogether.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net10.0 - enable - true - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - - diff --git a/mods/WatchTogether/WatchTogetherController.cs b/mods/WatchTogether/WatchTogetherController.cs deleted file mode 100644 index 698cda3..0000000 --- a/mods/WatchTogether/WatchTogetherController.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; -using Shared; -using System; -using System.IO; - -namespace WatchTogether -{ - /// - /// Static file service: the Lampa plugin (adapted lparty.js) and the web - /// guest page (adapted lparty-web), with lampac hosts injected as - /// placeholders. The relay itself lives at /c/ (RelayStartupFilter). - /// - [Route("watchtogether")] - public class WatchTogetherController : BaseController - { - private bool CheckAuth() - { - if (ModInit.conf.allow_anonymous) return true; - if (!CoreInit.conf.accsdb.enable) return true; - return requestInfo.user != null; - } - - [AllowAnonymous] - [HttpGet] - [Route("/watchtogether.js")] - public ActionResult GetPlugin() - { - if (!CheckAuth()) return StatusCode(401); - return ServeFile("plugin.js", "application/javascript; charset=utf-8", js => - js.Replace("{relay}", RelayAddress()) - .Replace("{invite}", InviteAddress())); - } - - [AllowAnonymous] - [HttpGet] - [Route("/watch_together")] - [Route("/watch_together/{file}")] - public ActionResult GetWebPlayer(string file) - { - if (!CheckAuth()) return StatusCode(401); - - switch (file ?? string.Empty) - { - case "": - case "index.html": - return ServeFile("web/index.html", "text/html; charset=utf-8", html => - html.Replace("{relay}", RelayAddress())); - case "lparty-web.js": - return ServeFile("web/lparty-web.js", "application/javascript; charset=utf-8", js => - js.Replace("{relay}", RelayAddress())); - case "i18n.js": - return ServeFile("web/i18n.js", "application/javascript; charset=utf-8", null); - case "names.js": - return ServeFile("web/names.js", "application/javascript; charset=utf-8", null); - default: - return NotFound(); - } - } - - string RelayAddress() - { - return (host.StartsWith("https", StringComparison.Ordinal) ? "wss://" : "ws://") + - host.Replace("https://", "").Replace("http://", "").TrimEnd('/') + "/wt/c/"; - } - - string InviteAddress() - { - return host.TrimEnd('/') + "/watch_together/"; - } - - ActionResult ServeFile(string name, string contentType, Func transform) - { - string path = Path.Combine(ModInit.modpath, name); - if (!System.IO.File.Exists(path)) - return Content("

" + name + " not found

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

-
- - - -
- -
-

- - -
-
- -
- -
-

-
- -

-
- - -
- -
- - -
-
-
- -
-

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