Каталог плагинов: полный локальный mirror в ./plugins (один directory-mount в wwwroot/plugins), доки и вычистка неиспользуемых модулей

- plugins/: скачаны все 30 плагинов store локально (sources.json - карта исходных URL);
  патчи: showy.js (абсолютный хост подгрузки), pubtorr/td/torrentmanager - тела вместо лоадеров,
  modss.js - local-patched копия (из каталога исключена, upstream мёртв)
- extensions.json: 32 позиции, все ссылки {localhost}/plugins/<файл>.js, удалены мёртвые
  (Skaz TV x3, export.js, hotkeys/gold_theme/select_weapon/want/mult, MODS's, o.js, др.)
- compose: ./plugins:/lampac/wwwroot/plugins (один маунт вместо 30 file-mount)
- вычистка: удалены неиспользуемые mods (DatabaseEditor, ExternalBind, LogUserRequest-Lite,
  Telemetry, Transcoding, WatchTogether) и дубль каталога modules/
- config/init.conf: секции Tracks/AdminPanel/Music, customPlugins без gst.js
- mods/Tracks: правки Controller.cs/plugin.js
- README.md / AGENT.md: mirror, кеш static, правила mount
This commit is contained in:
2026-10-02 16:37:30 +03:00
parent 7b8e28b3bf
commit 257b42f518
98 changed files with 62636 additions and 21113 deletions
+96 -77
View File
@@ -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/<Name>/`, либо каталог в `mods/<Name>/`. Одиночные тома `manifest.json`/`extensions.json` **не использовать** — docs не описывают таких томов (https://docs.lampac.dev/installation, https://docs.lampac.dev/modules/overview). Список модулей: https://docs.lampac.dev/modules/overview
- Текущее состояние (проверка: `docker logs lampac | grep -c "loaded module"` → **117**): `SkipModules` **пуст** — включены ВСЕ модули, включая группы `OnlineAnime`/`OnlineUKR`/`OnlinePaid`/`OnlineGEO` и `Transcoding`. 10 модулей с `enable: false` в образе подняты через копии в `mods/` (таблица «Файлы»). Если после обновления образа какой-то модуль из `mods/` перестал компилироваться — пересоздать копию: `docker cp lampac:/lampac/module/<Name> ./mods/<Name>` и выставить `enable: true` в её manifest.json, затем `docker compose up -d`.
- Плагины Lampa: `/online.js`, `/sisi.js`, `/dlna.js`, `/ts.js` и т.д. Большинство Lampac сам подключает клиенту через `/lampainit.js` (см. ниже про `initPlugins`); при необходимости плагин можно добавить вручную по URL.
- **Два уровня включения модуля:** (1) `SkipModules`/manifest — модуль скомпилирован и работает на сервере; (2) `LampaWeb.initPlugins.<name>` в `init.conf` — плагин попадает в список `{initiale}`, который `/lampainit.js` добавляет клиенту Lampa (`Lampa.Plugins.add`). Без второго флага сервер отвечает, но пункт в Lampa не появится (так было с `dlna` — флаг `initPlugins.dlna` добавлен в init.conf). Сейчас подключены: `dlna: true` (в `init.conf`), из `base.conf`: `online`, `sisi`, `torrserver`, `jacred`, `cubProxy`, `tmdbProxy`, `timecode`, `pirate_store`. Особый случай `jacred`: модуль `/jacred.js` не регистрирует — флаг подставляет `{jachost}` = хост Lampac в шаблон, клиент пишет его в `jackett_url` (проверка: `curl /api/v1.0/conf` → 200).
- **Override `plugins/override/lampainit.js` (том `./plugins/lampainit.js`) модули НЕ подключает**: `{initiale}` сервер собирает из `LampaWeb.initPlugins` и подставляет в шаблон после чтения (`ApiController.cs:695`), т.е. и при override тоже. Override — только для правки шаблона настроек клиента (`window.lampa_settings`); в проекте не используется.
- **Магазин плагинов** в Lampa (Настройки → Плагины) читает `/extensions` → образный `LampaWeb/plugins/extensions.json` (override-монтирование отключено). **Дефект образа:** 15 записей каталога ссылаются на `127.0.0.1:9118/plugins/*.js` (pubtorr, checker, hotkeys, torrents...) — HTTP-роута `/plugins/` в коде нет, все эти ссылки битые, устанавливать из магазина такие записи нельзя. Рабочий путь — прямой внешний URL (ручной установкой в Лампе или через `LampaWeb.customPlugins`; так подключён PubTorr: `https://lampame.github.io/main/pubtorr.js`). Если плагин уже установился с битым URL — удалить его в Лампе (Настройки → Плагины), иначе дедуп не даст добавить правильный (семейство `pubtorr.js`). Ответ кешируется `Staticache` на диске: `cache/static/<bucket>/` — после правки удалить соответствующий файл (`grep -rl secuses cache/static/`) или весь каталог static, затем перезапросить.
- **Плагины модулей для ручной установки в Лампе** (Настройки → Плагины → по URL): `/tracks.js`, `/music.js`, `/tg-notify.js`, `/watchtogether.js` (проверяются `curl -o /dev/null -w '%{http_code}' http://localhost:9118/<name>.js` → 200).
5. Новые пользовательские модули и копии включаемых — класть в `mods/<Name>/` (один том `./mods:/lampac/mods`), переопределения шаблона настроек клиента — `plugins/lampainit.js` → `/lampac/plugins/override/lampainit.js` (модули через это НЕ подключаются — см. п.4).
6. Не раскрывать пароль из `passwd`, не коммитить его, не выводить в логи.
7. Не открывать порт 9118 в интернет без файрвола/reverse proxy; модули `DLNA`, `Tracks`, `Transcoding`, `GStreamer`, `Catalog` не предназначены для публичного доступа.
8. Образ пиннить по тегу при необходимости воспроизводимости (в compose сейчас `:latest`).
Модуль грузится, только если выполнены **все**:
## Структура контейнера (важные пути)
| Фильтр | Где |
|---|---|
| `"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/<bucket>/<id>.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/<bucket>`) — штатное действие после правки 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 не открывать.
+115 -227
View File
@@ -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: <Name>`, роуты вроде `/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=<root-пароль>`.
## Каталог плагинов (раздел «Плагины» в веб-клиенте)
В веб-клиенте 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/<bucket>/<id>.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.
+16 -6
View File
@@ -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": {
+5 -3
View File
@@ -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
-14
View File
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
<RootNamespace>DatabaseEditor</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>
@@ -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<ActionResult> Summary()
{
try
{
return Json(new { success = true, databases = await DatabaseStore.GetSummaryAsync() });
}
catch (Exception ex)
{
return Unexpected(ex, "summary");
}
}
[HttpGet]
[Route("/database-editor/api/records")]
public async Task<ActionResult> Records(string database, string query = null, int page = 1, int pageSize = 25, string user = null)
{
try
{
var result = await DatabaseStore.GetRecordsAsync(database, query, page, pageSize, user);
return Json(new
{
success = true,
result.database,
result.page,
result.pageSize,
result.total,
result.pages,
result.records
});
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "records");
}
}
[HttpGet]
[Route("/database-editor/api/users")]
public async Task<ActionResult> Users(string database)
{
try
{
return Json(new { success = true, users = await DatabaseStore.GetUsersAsync(database) });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "users");
}
}
[HttpGet]
[Route("/database-editor/api/record")]
public async Task<ActionResult> Record(string database, long id)
{
try
{
var record = await DatabaseStore.GetRecordAsync(database, id);
return record == null
? NotFound(new { success = false, error = "record_not_found" })
: Json(new { success = true, record });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "record");
}
}
[HttpGet]
[Route("/database-editor/api/sync-user")]
public async Task<ActionResult> SyncUser(long id)
{
try
{
var user = await DatabaseStore.GetSyncUserAsync(id);
return user == null
? NotFound(new { success = false, error = "record_not_found" })
: Json(new
{
success = true,
user,
categories = DatabaseStore.SyncEditorCategories,
statuses = DatabaseStore.SyncStatusCategories
});
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "sync-user");
}
}
[HttpPost]
[Route("/database-editor/api/save")]
public async Task<ActionResult> Save([FromBody] SaveRecordRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
var record = await DatabaseStore.SaveAsync(request);
return Json(new { success = true, record });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "save");
}
}
[HttpPost]
[Route("/database-editor/api/delete")]
public async Task<ActionResult> Delete([FromBody] DeleteRecordRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
bool deleted = await DatabaseStore.DeleteAsync(request?.database, request?.id ?? 0);
return deleted
? Json(new { success = true })
: NotFound(new { success = false, error = "record_not_found" });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "delete");
}
}
[HttpPost]
[Route("/database-editor/api/rename-user")]
public async Task<ActionResult> RenameUser([FromBody] RenameUserRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
var result = await DatabaseStore.RenameUserAsync(request);
return Json(new { success = true, result });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "rename-user");
}
}
[HttpPost]
[Route("/database-editor/api/delete-user")]
public async Task<ActionResult> DeleteUser([FromBody] DeleteUserRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
var result = await DatabaseStore.DeleteUserAsync(request);
return Json(new { success = true, result });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "delete-user");
}
}
[HttpPost]
[Route("/database-editor/api/sync-item/save")]
public async Task<ActionResult> SaveSyncItem([FromBody] SaveSyncItemRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
var categories = await DatabaseStore.SaveSyncItemAsync(request);
return Json(new { success = true, categories });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "sync-item-save");
}
}
[HttpPost]
[Route("/database-editor/api/sync-item/delete")]
public async Task<ActionResult> DeleteSyncItem([FromBody] DeleteSyncItemRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
bool deleted = await DatabaseStore.DeleteSyncItemAsync(request);
return deleted
? Json(new { success = true })
: NotFound(new { success = false, error = "card_not_found" });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "sync-item-delete");
}
}
[HttpPost]
[Route("/database-editor/api/backup")]
public async Task<ActionResult> Backup([FromBody] BackupRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
if (string.IsNullOrWhiteSpace(request?.database) || string.Equals(request.database, "all", StringComparison.OrdinalIgnoreCase))
{
var backups = await DatabaseStore.BackupAllAsync();
return Json(new { success = true, backups });
}
string path = await DatabaseStore.BackupAsync(request?.database);
return Json(new { success = true, path, database = request.database });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "backup");
}
}
[HttpGet]
[Route("/database-editor/api/backups")]
public ActionResult Backups(string database)
{
try
{
return Json(new { success = true, backups = DatabaseStore.GetBackups(database) });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "backups");
}
}
[HttpPost]
[Route("/database-editor/api/restore")]
public async Task<ActionResult> Restore([FromBody] RestoreBackupRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
var result = await DatabaseStore.RestoreAsync(request);
return Json(new { success = true, result });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "restore");
}
}
bool IsEditorRequest() => string.Equals(Request.Headers["X-Database-Editor"], "1", StringComparison.Ordinal);
ActionResult KnownOrUnexpected(Exception ex, string operation)
{
if (ex is DatabaseEditorValidationException)
return BadRequest(new { success = false, error = ex.Message });
if (ex is DatabaseEditorConflictException)
return Conflict(new { success = false, error = ex.Message });
if (ex is DatabaseEditorBusyException)
return StatusCode(503, new { success = false, error = ex.Message });
return Unexpected(ex, operation);
}
ActionResult Unexpected(Exception ex, string operation)
{
Serilog.Log.Error(ex, "DatabaseEditor {Operation} failed", operation);
return StatusCode(500, new { success = false, error = "internal_error" });
}
}
File diff suppressed because it is too large Load Diff
-64
View File
@@ -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<bool, EventMiddleware, Task<bool>> MiddlewareHandler = OnMiddleware;
public void Loaded(InitspaceModel initspace)
{
modpath = initspace.path;
EventListener.MiddlewareAsync += MiddlewareHandler;
}
public void Dispose()
{
EventListener.MiddlewareAsync -= MiddlewareHandler;
}
static async Task<bool> OnMiddleware(bool first, EventMiddleware e)
{
if (first || !HttpMethods.IsGet(e.httpContext.Request.Method))
return true;
string path = (e.httpContext.Request.Path.Value ?? string.Empty).TrimEnd('/');
if (!string.Equals(path, "/weblog", StringComparison.OrdinalIgnoreCase))
return true;
string weblogPath = Path.Combine(AppContext.BaseDirectory, "module", "WebLog", "index.html");
if (!File.Exists(weblogPath))
return true;
string html = File.ReadAllText(weblogPath, Encoding.UTF8);
if (!html.Contains("href=\"/database-editor\"", StringComparison.OrdinalIgnoreCase))
{
const string activeLink = "<a href=\"/weblog\" aria-current=\"page\">Weblog</a>";
const string databaseLink = activeLink + "\n <a href=\"/database-editor\">Базы</a>";
if (html.Contains(activeLink, StringComparison.Ordinal))
html = html.Replace(activeLink, databaseLink, StringComparison.Ordinal);
else
{
const string navEnd = "</nav>";
int navIndex = html.IndexOf(navEnd, StringComparison.OrdinalIgnoreCase);
if (navIndex >= 0)
html = html.Insert(navIndex, "<a href=\"/database-editor\">Базы</a>\n ");
}
}
e.httpContext.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
e.httpContext.Response.ContentType = "text/html; charset=utf-8";
await e.httpContext.Response.WriteAsync(html, e.httpContext.RequestAborted);
return false;
}
}
-42
View File
@@ -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.
-649
View File
@@ -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();
})();
-151
View File
@@ -1,151 +0,0 @@
<!doctype html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0c0e12" id="metaTheme">
<title>Базы — Lampac</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/database-editor/style.css">
</head>
<body>
<div class="app">
<header class="topbar">
<a class="topbar-logo" href="/database-editor">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5"/>
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)"/>
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity=".7"/>
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity=".45"/>
</svg>
Lampac <span>Базы</span>
</a>
<div class="topbar-divider"></div>
<nav class="topbar-app-nav" aria-label="Разделы Lampac"><a href="/adminpanel">Admin</a><a href="/stats">Stats</a><a href="/weblog">Weblog</a><a href="/telemetry">Telemetry</a><a href="/database-editor" aria-current="page">Базы</a></nav>
<div class="topbar-spacer"></div>
<button class="icon-btn theme-btn" id="themeBtn" title="Переключить тему" aria-label="Переключить тему">
<svg class="theme-icon theme-icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg class="theme-icon theme-icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</button>
</header>
<main class="main">
<section id="listView">
<div class="hero">
<div><h1>Редактор TimeCode и Sync</h1><p>Пользователи, карточки, позиции просмотра и безопасные копии SQLite.</p></div>
<div class="hero-actions"><button class="btn" id="backupAllBtn">⇩ <span>Backup TimeCode + Sync</span></button><button class="btn" id="restoreBtn">↥ <span>Восстановить</span></button><button class="btn" id="renameUserBtn">Переименовать пользователя</button><button class="btn danger" id="deleteUserBtn">Удалить пользователя</button></div>
</div>
<section class="summary" id="summary"></section>
<section class="workspace">
<div class="toolbar">
<div class="db-tabs" role="tablist"><button class="db-tab active" data-db="timecode" role="tab">TimeCode</button><button class="db-tab" data-db="sync" role="tab">Sync</button></div>
<select class="select user-filter" id="timecodeUserFilter" title="Пользователь TimeCode"><option value="">Все пользователи TimeCode</option></select>
<div class="search"><span>⌕</span><input id="searchInput" type="search" placeholder="ID, пользователь, карточка или JSON…" autocomplete="off"></div>
<div class="toolbar-spacer"></div>
<select class="select" id="pageSize" title="Записей на странице"><option>25</option><option>50</option><option>100</option></select>
<button class="icon-btn" id="refreshBtn" title="Обновить" aria-label="Обновить">↻</button>
</div>
<div class="loading" id="loading"></div>
<div class="table-wrap">
<table class="data-table"><thead><tr id="tableHead"></tr></thead><tbody id="tableBody"></tbody></table>
<div class="empty" id="empty"><div><div class="empty-icon">⌕</div><strong>Записей не найдено</strong><div class="muted">Измените поиск или выберите другую базу.</div></div></div>
</div>
<div class="pager"><div class="pager-info" id="pagerInfo">—</div><div class="pager-buttons"><button class="icon-btn" id="prevBtn">←</button><span class="page-label" id="pageLabel">1 / 1</span><button class="icon-btn" id="nextBtn">→</button></div></div>
</section>
</section>
<section class="sync-detail" id="syncDetail" hidden>
<div class="detail-head">
<button class="btn" id="backToUsers">← Пользователи</button>
<div class="detail-identity"><div class="eyebrow">Sync-пользователь</div><h1 id="syncUserName">—</h1><div class="muted" id="syncUserMeta">—</div></div>
<div class="detail-actions"><button class="btn" id="rawSyncBtn">JSON целиком</button><button class="btn" id="refreshSyncBtn">↻ Обновить</button></div>
</div>
<div class="detail-toolbar">
<div class="search"><span>⌕</span><input id="syncSearch" type="search" placeholder="Название или ID карточки…" autocomplete="off"></div>
<select class="select" id="syncCategory"><option value="">Все категории</option></select>
<span class="result-count" id="syncResultCount"></span>
</div>
<div class="media-grid" id="syncGrid"></div>
<div class="empty sync-empty" id="syncEmpty"><div><div class="empty-icon">□</div><strong>Карточек не найдено</strong></div></div>
<div class="detail-pager"><button class="btn" id="syncPrev">← Назад</button><span id="syncPageLabel">1 / 1</span><button class="btn" id="syncNext">Вперёд →</button></div>
</section>
</main>
</div>
<div class="modal-backdrop" id="recordModal" role="dialog" aria-modal="true">
<div class="modal modal-wide">
<div class="modal-head"><span class="modal-title" id="recordModalTitle">Запись</span><span class="modal-id" id="recordModalId"></span><button class="icon-btn modal-close" data-close="recordModal">×</button></div>
<div class="modal-body"><div class="form-grid">
<div class="field full"><label for="userField">Пользователь</label><input id="userField" maxlength="512" autocomplete="off"></div>
<div class="field timecode-field"><label for="cardField">Карточка</label><input id="cardField" maxlength="512" autocomplete="off"></div>
<div class="field timecode-field"><label for="itemField">Элемент / hash</label><input id="itemField" maxlength="512" autocomplete="off"></div>
<div class="field full"><div class="json-tools"><label for="dataField">JSON-данные</label><button class="btn small" type="button" id="formatBtn">Форматировать</button><span class="json-status" id="jsonStatus"></span></div><textarea id="dataField" spellcheck="false"></textarea></div>
</div></div>
<div class="modal-foot"><button class="btn danger" id="deleteRecordBtn">Удалить</button><button class="btn" data-close="recordModal">Отмена</button><button class="btn primary" id="saveRecordBtn">Сохранить</button></div>
</div>
</div>
<div class="modal-backdrop" id="categoryModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Категории карточки</span><button class="icon-btn modal-close" data-close="categoryModal">×</button></div>
<div class="modal-body">
<div class="category-card-head" id="categoryCardHead"></div>
<div class="category-options" id="categoryOptions"></div>
<p class="modal-hint">Изменяется только выбранная карточка. Остальные позиции пользователя останутся без изменений.</p>
</div>
<div class="modal-foot"><button class="btn danger" id="deleteSyncItemBtn">Удалить карточку</button><button class="btn" data-close="categoryModal">Отмена</button><button class="btn primary" id="saveSyncItemBtn">Сохранить категории</button></div>
</div>
</div>
<div class="modal-backdrop" id="renameUserModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Переименовать пользователя</span><button class="icon-btn modal-close" data-close="renameUserModal">×</button></div>
<div class="modal-body"><div class="form-grid">
<div class="field full"><label for="oldUserField">Текущий пользователь</label><select class="select" id="oldUserField"><option value="">Загрузка пользователей…</option></select></div>
<div class="field full"><label for="newUserField">Новое имя</label><input id="newUserField" maxlength="512" autocomplete="off"></div>
<p class="modal-hint field full">Важно: перед переименованием выйдите из старой учётной записи на всех устройствах или заранее измените её логин/account_email в настройках Lampa и, если пользователь задан там, в init/AccsDB. Активный клиент со старым именем после обновления страницы создаст его в Sync и TimeCode повторно. Новое имя не должно быть занято.</p>
</div></div>
<div class="modal-foot"><button class="btn" data-close="renameUserModal">Отмена</button><button class="btn primary" id="confirmRenameUserBtn">Переименовать</button></div>
</div>
</div>
<div class="modal-backdrop" id="deleteUserModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Удалить пользователя</span><button class="icon-btn modal-close" data-close="deleteUserModal">×</button></div>
<div class="modal-body"><div class="form-grid">
<div class="field full"><label for="deleteUserField">Пользователь</label><select class="select" id="deleteUserField"><option value="">Загрузка пользователей…</option></select></div>
<p class="modal-hint field full">Будут безвозвратно удалены все закладки Sync и все позиции TimeCode выбранного пользователя.</p>
</div></div>
<div class="modal-foot"><button class="btn" data-close="deleteUserModal">Отмена</button><button class="btn danger" id="confirmDeleteUserBtn">Удалить пользователя</button></div>
</div>
</div>
<div class="modal-backdrop" id="backupModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Резервные копии созданы</span><button class="icon-btn modal-close" data-close="backupModal">×</button></div>
<div class="modal-body"><div class="backup-results" id="backupResults"></div></div>
<div class="modal-foot"><button class="btn primary" data-close="backupModal">Готово</button></div>
</div>
</div>
<div class="modal-backdrop" id="restoreModal" role="dialog" aria-modal="true">
<div class="modal modal-wide">
<div class="modal-head"><span class="modal-title">Восстановление базы</span><button class="icon-btn modal-close" data-close="restoreModal">×</button></div>
<div class="modal-body">
<p class="modal-hint restore-warning">Выберите резервную копию. Перед восстановлением текущая база будет автоматически сохранена в отдельный файл <code>before-restore</code>.</p>
<div class="restore-tabs"><button class="db-tab active" data-restore-db="sync">Sync</button><button class="db-tab" data-restore-db="timecode">TimeCode</button></div>
<div class="backup-results restore-results" id="restoreResults"></div>
<div class="empty restore-empty" id="restoreEmpty"><div><strong>Резервных копий нет</strong></div></div>
</div>
<div class="modal-foot"><button class="btn" data-close="restoreModal">Закрыть</button></div>
</div>
</div>
<div class="toast-wrap" id="toasts"></div>
<script src="/database-editor/app.js"></script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
{
"enable": true,
"references": [
"Microsoft.Data.Sqlite"
]
}
-439
View File
@@ -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; }
}
-13
View File
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>
-47
View File
@@ -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<string> ExternalBindPaths = new(StringComparer.OrdinalIgnoreCase)
{
"/lite/filmixpro",
"/lite/rhs/bind",
"/lite/kinopubpro",
"/lite/getstv/bind",
"/lite/sakhtv/bind",
"/lite/vokinotk",
"/lite/iptvonline/bind",
};
static readonly Func<bool, EventMiddleware, bool> MiddlewareHandler = OnMiddleware;
public void Loaded(InitspaceModel initspace)
{
EventListener.Middleware += MiddlewareHandler;
}
public void Dispose()
{
EventListener.Middleware -= MiddlewareHandler;
}
static bool OnMiddleware(bool first, EventMiddleware e)
{
var requestInfo = e.httpContext.Features.Get<RequestModel>();
var path = e.httpContext.Request.Path.Value;
if (requestInfo != null && path != null && ExternalBindPaths.Contains(path))
requestInfo.IsLocalIp = true;
return true;
}
}
}
-45
View File
@@ -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`; контроллеров и статики в модуле нет.
-7
View File
@@ -1,7 +0,0 @@
{
"enable": true,
"dynamic": true,
"tree": [
"ModInit.cs"
]
}
-88
View File
@@ -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<AppDbContext> options) : base(options) { }
public AppDbContext() : base() { }
public DbSet<LogModelSql> jurnal { get; set; }
public DbSet<UserInfoModelSql> unfo { get; set; }
public DbSet<HeaderModelSql> headers { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
var dbPath = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest", "userlog.db");
var dbDir = Path.GetDirectoryName(dbPath);
if (!Directory.Exists(dbDir)) Directory.CreateDirectory(dbDir!);
optionsBuilder.UseSqlite($"Data Source={dbPath};Cache=Shared");
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LogModelSql>().HasIndex(x => x.uid);
modelBuilder.Entity<LogModelSql>().HasIndex(x => x.time);
modelBuilder.Entity<LogModelSql>().HasIndex(x => x.balancer);
modelBuilder.Entity<LogModelSql>().HasIndex(x => new { x.uid, x.time });
}
}
public class LogModelSql
{
public int Id { get; set; }
public DateTime time { get; set; }
public string uri { get; set; } = "";
public string uid { get; set; } = "";
public string unfo { get; set; } = "";
public string header { get; set; } = "";
public int duration_ms { get; set; }
public string? balancer { get; set; }
public int? status_code { get; set; }
}
public class UserInfoModelSql
{
[Key] public string Id { get; set; } = "";
public string IP { get; set; } = "";
public string Country { get; set; } = "";
public string UserAgent { get; set; } = "";
}
public class HeaderModelSql
{
[Key] public string Id { get; set; } = "";
public string HeadersJson { get; set; } = "";
[NotMapped]
public Dictionary<string, string> Headers
{
get
{
if (string.IsNullOrEmpty(HeadersJson)) return new();
try { return JsonSerializer.Deserialize<Dictionary<string, string>>(HeadersJson) ?? new(); }
catch { return new(); }
}
set
{
var safe = (value ?? new Dictionary<string, string>())
.Where(x => !string.IsNullOrWhiteSpace(x.Key))
.Take(50)
.ToDictionary(
x => x.Key.Length > 100 ? x.Key[..100] : x.Key,
x =>
{
var v = x.Value ?? "";
return v.Length > 500 ? v[..500] : v;
});
HeadersJson = JsonSerializer.Serialize(safe);
}
}
}
@@ -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<AppDbContext> _contextFactory;
public ApiController(IDbContextFactory<AppDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
private static string GetAttemptsKey(string ip) => $"LogUserRequest:auth:IP:{ip}";
private bool IsAuthorized()
{
var sessionToken = Request.Cookies["loguser_session"];
if (!string.IsNullOrEmpty(sessionToken) && ModInit.ValidateSessionToken(sessionToken))
return true;
return false;
}
[HttpGet]
public IActionResult Index()
{
if (!IsAuthorized()) return Redirect("/lite/logrequest/auth");
var htmlPath = Path.Combine(ModInit.init.path, "index.html");
if (!System.IO.File.Exists(htmlPath)) return Content("UI missing", "text/plain");
var html = System.IO.File.ReadAllText(htmlPath, Encoding.UTF8);
return Content(html, "text/html; charset=utf-8");
}
[HttpGet("auth")]
public IActionResult Auth()
{
var sessionToken = Request.Cookies["loguser_session"];
if (!string.IsNullOrEmpty(sessionToken) && ModInit.ValidateSessionToken(sessionToken))
return Redirect("/lite/logrequest");
var htmlPath = Path.Combine(ModInit.init.path, "auth.html");
if (!System.IO.File.Exists(htmlPath)) return Content("Auth page missing", "text/plain");
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0";
Response.Headers["Pragma"] = "no-cache";
Response.Headers["Expires"] = "0";
var html = System.IO.File.ReadAllText(htmlPath, Encoding.UTF8);
return Content(html, "text/html; charset=utf-8");
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromForm] string password)
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var cacheKey = GetAttemptsKey(ip);
var attempts = _cache.Get<int?>(cacheKey) ?? 0;
if (attempts > 0) await Task.Delay(attempts * 200);
if (attempts >= 5) return Redirect("/lite/logrequest/auth?blocked=1");
if (password == ModInit.conf.adminPassword)
{
_cache.Remove(cacheKey);
var sessionToken = ModInit.CreateSessionToken();
Response.Cookies.Append("loguser_session", sessionToken, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Strict,
MaxAge = TimeSpan.FromDays(30),
Path = "/lite/logrequest",
IsEssential = true
});
var script = @"
<!DOCTYPE html>
<html><head><meta charset='utf-8'></head><body>
<script>
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', '/lite/logrequest');
}
window.location.replace('/lite/logrequest');
</script>
</body></html>";
return Content(script, "text/html; charset=utf-8");
}
attempts++;
_cache.Set(cacheKey, attempts, new MemoryCacheEntryOptions
{
AbsoluteExpiration = DateTime.Today.AddDays(1),
SlidingExpiration = TimeSpan.FromHours(1)
});
await Task.Delay(Random.Shared.Next(300, 800));
return Redirect($"/lite/logrequest/auth?error=1&remaining={5 - attempts}");
}
[HttpGet("logout")]
public IActionResult Logout()
{
var sessionToken = Request.Cookies["loguser_session"];
if (!string.IsNullOrEmpty(sessionToken)) ModInit.RevokeSessionToken(sessionToken);
Response.Cookies.Delete("loguser_session", new CookieOptions
{
Path = "/lite/logrequest"
});
return Redirect("/lite/logrequest/auth");
}
[HttpGet("api")]
public async Task<IActionResult> Api(string? uid, int skip = 0, int take = 200)
{
if (!IsAuthorized()) return UnauthorizedResponse();
take = Math.Min(take, 500);
await using var dbContext = await _contextFactory.CreateDbContextAsync();
var query = dbContext.jurnal.AsNoTracking();
if (!string.IsNullOrEmpty(uid)) query = query.Where(j => j.uid == uid);
var jurnal = await query.OrderByDescending(x => x.Id).Skip(skip).Take(take).ToListAsync();
if (jurnal.Count == 0) return Ok(ApiResponse<List<JournalItemDto>>.Fail("Empty"));
var unfoIds = jurnal.Select(j => j.unfo).Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
var unfoDict = await dbContext.unfo.Where(u => unfoIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
var result = jurnal.Select(j =>
{
unfoDict.TryGetValue(j.unfo ?? "", out var unfo);
return new JournalItemDto
{
Id = j.Id,
Time = j.time,
Uri = j.uri,
UserUid = j.uid,
Ip = unfo?.IP ?? "unknown",
Country = unfo?.Country ?? "",
UserAgent = unfo?.UserAgent ?? "unknown",
DurationMs = j.duration_ms,
Balancer = j.balancer
};
}).ToList();
return Ok(ApiResponse<List<JournalItemDto>>.Ok(result));
}
[HttpGet("premium-chart")]
public async Task<IActionResult> PremiumChart(int days = 7)
{
if (!IsAuthorized()) return UnauthorizedResponse();
days = Math.Min(days, 3);
var startDate = DateTime.UtcNow.AddDays(-days);
await using var dbContext = await _contextFactory.CreateDbContextAsync();
var data = await dbContext.jurnal
.Where(j => j.time >= startDate)
.GroupBy(j => j.time.Date)
.Select(g => new { date = g.Key, count = g.Count() })
.OrderBy(x => x.date)
.ToListAsync();
var result = new
{
data = data,
message = "В Lite-версии доступно только 2 дня. Premium — до 90 дней.",
premiumAvailable = true
};
return Ok(ApiResponse<object>.Ok(result));
}
[HttpGet("stats")]
public async Task<IActionResult> Stats(string? uid)
{
if (!IsAuthorized()) return UnauthorizedResponse();
await using var dbContext = await _contextFactory.CreateDbContextAsync();
var query = dbContext.jurnal.AsNoTracking();
if (!string.IsNullOrEmpty(uid)) query = query.Where(j => j.uid == uid);
var userLogs = await query.OrderByDescending(j => j.time).Take(50000).ToListAsync();
if (userLogs.Count == 0) return Ok(ApiResponse<StatsDto>.Fail("Empty"));
var unfoIds = userLogs.Select(j => j.unfo).Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
var unfoList = await dbContext.unfo.Where(u => unfoIds.Contains(u.Id)).ToListAsync();
var uniqueIp = unfoList.Select(u => u.IP).Where(ip => !string.IsNullOrEmpty(ip)).Distinct().Count();
var uniqueUserAgent = unfoList.Select(u => u.UserAgent).Where(ua => !string.IsNullOrEmpty(ua)).Distinct().Count();
var now = DateTime.UtcNow;
var today = userLogs.Count(l => l.time.Day == now.Day && l.time.Month == now.Month && l.time.Year == now.Year);
var month = userLogs.Count(l => l.time.Month == now.Month && l.time.Year == now.Year);
var topBalancers = userLogs
.GroupBy(l => l.balancer)
.Where(g => !string.IsNullOrEmpty(g.Key))
.Select(g => new TopItemDto { Name = g.Key ?? "", Count = g.Count() })
.OrderByDescending(x => x.Count).Take(50).ToArray();
var topUsers = userLogs
.GroupBy(j => j.uid)
.Select(g => new TopItemDto { Name = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count).Take(20).ToArray();
return Ok(ApiResponse<StatsDto>.Ok(new StatsDto
{
Today = today,
Month = month,
UniqueIp = uniqueIp,
UniqueUserAgent = uniqueUserAgent,
TopUsers = topUsers,
TopBalancers = topBalancers
}));
}
private IActionResult UnauthorizedResponse() => StatusCode(401, ApiResponse<object>.Fail("Unauthorized"));
private IActionResult Ok<T>(ApiResponse<T> response) => new JsonResult(response);
}
-24
View File
@@ -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;
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<IsPackable>true</IsPackable>
<RootNamespace>LogUserRequest</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>
@@ -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<bool> InvokeAsync(bool first, EventMiddleware e)
{
if (first)
return Task.FromResult(true);
var httpContext = e.httpContext;
var requestInfo = httpContext.Features.Get<RequestModel>();
if (requestInfo == null)
return Task.FromResult(true);
if (requestInfo.IsLocalRequest || requestInfo.IsAnonymousRequest)
return Task.FromResult(true);
try
{
var path = httpContext.Request.Path.Value ?? "";
// Отсев статики по расширениям
foreach (var ext in _skipExtensions)
{
if (path.EndsWith(ext, StringComparison.OrdinalIgnoreCase))
return Task.FromResult(true);
}
// Отсев по префиксам
foreach (var prefix in _skipPrefixes)
{
if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return Task.FromResult(true);
}
var realIP = requestInfo.IP;
if (string.IsNullOrEmpty(realIP) || realIP == "127.0.0.1" || realIP == "::1")
return Task.FromResult(true);
if (_rateLimitCache.TryGetValue(realIP, out _))
return Task.FromResult(true);
_rateLimitCache.Set(realIP, DateTime.UtcNow, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(33),
Size = 1
});
string userAgent = httpContext.Request.Headers["User-Agent"].FirstOrDefault() ?? "unknown";
if (userAgent.Length > 512) userAgent = userAgent[..512] + "...[truncated]";
string userUid = httpContext.Request.Query["uid"].FirstOrDefault() ?? "";
if (string.IsNullOrEmpty(userUid)) userUid = httpContext.Request.Cookies["uid"] ?? "";
if (string.IsNullOrEmpty(userUid)) userUid = "anonymous";
if (userUid.Length > 256) userUid = userUid[..256];
string country = "";
if (httpContext.Request.Headers.TryGetValue("CF-IPCountry", out var cfCountry))
country = cfCountry.ToString();
static string GetHash(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLower();
}
var unfo = new UserInfoModelSql
{
Id = GetHash($"{realIP}:{country}:{userAgent}"),
IP = realIP,
Country = country,
UserAgent = userAgent
};
var header = new HeaderModelSql { Headers = new Dictionary<string, string>() };
header.Id = "";
var fullUri = path + httpContext.Request.QueryString;
if (fullUri.Length > 2048) fullUri = fullUri[..2048];
string balancer = "";
if (path.StartsWith("/lite/"))
balancer = path[6..].Split('/', '?')[0];
else if (path.StartsWith("/rc/"))
balancer = path[4..].Split('/', '?')[0];
var jurnal = new LogModelSql
{
time = DateTime.UtcNow,
uri = fullUri,
uid = userUid,
duration_ms = 0,
balancer = balancer,
status_code = httpContext.Response?.StatusCode ?? 0
};
if (Interlocked.Increment(ref _queueSize) <= MaxQueueSize)
{
try { Queue.Enqueue((jurnal, unfo, header)); }
catch { Interlocked.Decrement(ref _queueSize); }
}
else
{
Interlocked.Decrement(ref _queueSize);
var nowTicks = DateTime.UtcNow.Ticks;
var last = Interlocked.Read(ref _lastQueueOverflowLog);
if (nowTicks - last > TimeSpan.FromMinutes(1).Ticks)
{
if (Interlocked.CompareExchange(ref _lastQueueOverflowLog, nowTicks, last) == last)
{
Console.WriteLine($"[LogUserRequest] Queue overflow, dropping logs. Size: {_queueSize}");
}
}
}
}
catch (Exception ex)
{
if (ShouldLogError())
Console.WriteLine($"[LogUserRequest] Listener error: {ex}");
}
return Task.FromResult(true);
}
}
-375
View File
@@ -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<AppDbContext>(options =>
{
options.UseSqlite($"Data Source={_dbPath};Cache=Shared");
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
}
public static bool ValidateSessionToken(string token)
{
if (string.IsNullOrEmpty(token)) return false;
return _sessionTokens.TryGetValue(token, out _);
}
public static string CreateSessionToken()
{
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
_sessionTokens.Set(token, true, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _sessionLifetime,
SlidingExpiration = TimeSpan.FromDays(7),
Size = 1
});
return token;
}
public static void RevokeSessionToken(string token) => _sessionTokens.Remove(token);
private static string GenerateRandomPassword(int length = 36)
{
using var rng = RandomNumberGenerator.Create();
var bytes = new byte[length];
rng.GetBytes(bytes);
var base64 = Convert.ToBase64String(bytes);
return base64.Replace('+', 'A').Replace('/', 'B')[..length];
}
private static string GetOrCreateAdminPassword()
{
var envPassword = Environment.GetEnvironmentVariable("LOGUSER_ADMIN_PASSWORD");
if (!string.IsNullOrEmpty(envPassword))
return envPassword;
if (File.Exists(_passwdPath))
{
try
{
var filePassword = File.ReadAllText(_passwdPath).Trim();
if (!string.IsNullOrEmpty(filePassword))
return filePassword;
}
catch { }
}
var oldPasswdPath = Path.Combine(_workPath, "passlogreg");
if (File.Exists(oldPasswdPath))
{
try
{
var filePassword = File.ReadAllText(oldPasswdPath).Trim();
if (!string.IsNullOrEmpty(filePassword))
{
Directory.CreateDirectory(Path.GetDirectoryName(_passwdPath)!);
File.Move(oldPasswdPath, _passwdPath);
return filePassword;
}
}
catch { }
}
var newPassword = GenerateRandomPassword(36);
try
{
var passwdDir = Path.GetDirectoryName(_passwdPath);
if (!string.IsNullOrEmpty(passwdDir))
Directory.CreateDirectory(passwdDir);
File.WriteAllText(_passwdPath, newPassword);
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD())
{
try { File.SetUnixFileMode(_passwdPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
}
}
catch
{
Console.WriteLine("[LogUserRequest-Lite] Warning: Cannot save password to file. Using memory-only password.");
}
return newPassword;
}
public void Loaded(InitspaceModel initspace)
{
init = initspace;
_workPath = AppContext.BaseDirectory;
_dbDirectory = Path.Combine(_workPath, "database", "LogUserRequest");
_dbPath = Path.Combine(_dbDirectory, "userlog.db");
_passwdPath = Path.Combine(_dbDirectory, "passlogreg");
Console.WriteLine($"[LogUserRequest-Lite] Work path: {_workPath}");
Console.WriteLine($"[LogUserRequest-Lite] DB path: {_dbPath}");
Console.WriteLine($"[LogUserRequest-Lite] Password path: {_passwdPath}");
try
{
Directory.CreateDirectory(_dbDirectory);
}
catch (IOException ex)
{
Console.WriteLine($"[LogUserRequest-Lite] Warning: Cannot create directory {_dbDirectory}: {ex.Message}");
}
using (var sqlDb = new AppDbContext())
{
sqlDb.Database.EnsureCreated();
try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN balancer TEXT;"); } catch { }
try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN status_code INTEGER NULL;"); } catch { }
try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN duration_ms INTEGER DEFAULT 0;"); } catch { }
try { sqlDb.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_jurnal_uid ON jurnal(uid);"); } catch { }
try { sqlDb.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_jurnal_time ON jurnal(time);"); } catch { }
try
{
sqlDb.Database.ExecuteSqlRaw("PRAGMA journal_mode = WAL;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA synchronous = NORMAL;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA cache_size = -64000;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA temp_store = MEMORY;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA mmap_size = 33554432;");
}
catch { }
}
var manifestPath = Path.Combine(init.path, "manifest.json");
JObject manifest = new();
if (File.Exists(manifestPath))
{
manifest = JsonConvert.DeserializeObject<JObject>(File.ReadAllText(manifestPath)) ?? new();
conf.logDay = manifest["logDay"]?.Value<int>() ?? 90;
}
conf.adminPassword = GetOrCreateAdminPassword();
_clearJurnalTimer = new Timer(ClearJurnal, null, TimeSpan.FromMinutes(1), TimeSpan.FromHours(24));
_statsTimer = new Timer(UpdateStatsCallback, null, TimeSpan.FromSeconds(10), TimeSpan.FromMinutes(5));
_updateDbTimer = new Timer(UpdateDbCallback, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(30));
// === Подписка на EventListener вместо app.UseMiddleware ===
EventListener.MiddlewareAsync -= LogUserRequestListener.InvokeAsync;
EventListener.MiddlewareAsync += LogUserRequestListener.InvokeAsync;
Console.WriteLine($"[LogUserRequest-Lite] Module loaded (logDay={conf.logDay})");
}
public void Dispose()
{
// === Отписка ===
EventListener.MiddlewareAsync -= LogUserRequestListener.InvokeAsync;
_clearJurnalTimer?.Dispose();
_statsTimer?.Dispose();
_updateDbTimer?.Dispose();
_sessionTokens.Dispose();
}
// ... остальные методы (ClearJurnal, UpdateStats, UpdateDb) без изменений
static void ClearJurnal(object? state)
{
try
{
using var sqlDb = new AppDbContext();
var cutoff = DateTime.UtcNow.AddDays(-conf.logDay);
const int batchSize = 5000;
int totalDeleted = 0;
while (true)
{
var idsToDelete = sqlDb.jurnal
.Where(j => j.time < cutoff)
.OrderBy(j => j.Id)
.Select(j => j.Id)
.Take(batchSize)
.ToList();
if (idsToDelete.Count == 0) break;
sqlDb.jurnal.Where(j => idsToDelete.Contains(j.Id)).ExecuteDelete();
totalDeleted += idsToDelete.Count;
if (idsToDelete.Count < batchSize) break;
}
if (totalDeleted > 0)
{
var usedUnfo = sqlDb.jurnal.Select(j => j.unfo).Distinct().Take(50000).ToHashSet();
var usedHeaders = sqlDb.jurnal.Select(j => j.header).Distinct().Take(50000).ToHashSet();
sqlDb.unfo.Where(u => !usedUnfo.Contains(u.Id)).Take(batchSize).ExecuteDelete();
sqlDb.headers.Where(h => !usedHeaders.Contains(h.Id)).Take(batchSize).ExecuteDelete();
Console.WriteLine($"[LogUserRequest-Lite] Cleaned {totalDeleted} old records");
}
}
catch (Exception ex)
{
Console.WriteLine($"[LogUserRequest-Lite] ClearJurnal error: {ex.Message}");
}
}
private static void UpdateStatsCallback(object? state) => UpdateStats();
public static void UpdateStats()
{
if (Interlocked.Exchange(ref _updatingStats, 1) == 1) return;
try
{
using var sqlDb = new AppDbContext();
var now = DateTime.UtcNow;
var monthStart = new DateTime(now.Year, now.Month, 1);
var todayStart = new DateTime(now.Year, now.Month, now.Day);
var tomorrowStart = todayStart.AddDays(1);
var monthQuery = sqlDb.jurnal.Where(j => j.time >= monthStart);
int today = monthQuery.Count(j => j.time >= todayStart && j.time < tomorrowStart);
int month = monthQuery.Count();
var unfoIds = monthQuery.Select(j => j.unfo).Distinct().Take(10000).ToList();
if (unfoIds.Count > 0)
{
var unfoData = sqlDb.unfo
.Where(u => unfoIds.Contains(u.Id))
.Select(u => new { u.IP, u.UserAgent })
.Take(10000)
.ToList();
int uniqueUserAgent = unfoData.Select(u => u.UserAgent).Distinct().Count();
int uniqueIp = unfoData.Select(u => u.IP).Where(ip => !string.IsNullOrEmpty(ip)).Distinct().Count();
var topUsers = monthQuery
.GroupBy(j => j.uid)
.Select(g => new { uid = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(20)
.ToArray();
var topBalancers = monthQuery
.Where(j => j.balancer != null)
.GroupBy(j => j.balancer)
.Select(g => new { balancer = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(50)
.ToArray();
stats = new { today, month, uniqueUserAgent, uniqueIp, topUsers, topBalancers };
}
else
{
stats = new
{
today = 0,
month = 0,
uniqueUserAgent = 0,
uniqueIp = 0,
topUsers = Array.Empty<object>(),
topBalancers = Array.Empty<object>()
};
}
}
catch (Exception ex)
{
Console.WriteLine($"[LogUserRequest-Lite] UpdateStats error: {ex.Message}");
}
finally { Interlocked.Exchange(ref _updatingStats, 0); }
}
private static void UpdateDbCallback(object? state) => UpdateDb();
static void UpdateDb()
{
if (Interlocked.Exchange(ref _updatingDb, 1) == 1) return;
try
{
using var sqlDb = new AppDbContext();
sqlDb.ChangeTracker.AutoDetectChangesEnabled = false;
var batch = new List<(LogModelSql jurnal, UserInfoModelSql unfo, HeaderModelSql header)>();
int batchSize = 1000;
while (LogUserRequestListener.Queue.TryDequeue(out var item) && batch.Count < batchSize)
{
batch.Add(item);
LogUserRequestListener.DequeueItem();
}
if (batch.Count == 0) return;
var unfoIds = batch.Select(b => b.unfo.Id).Distinct().ToList();
var headerIds = batch.Select(b => b.header.Id).Distinct().ToList();
var existingUnfo = sqlDb.unfo
.Where(u => unfoIds.Contains(u.Id))
.Select(u => u.Id)
.ToHashSet();
var existingHeaders = sqlDb.headers
.Where(h => headerIds.Contains(h.Id))
.Select(h => h.Id)
.ToHashSet();
foreach (var item in batch.OrderBy(i => i.jurnal.time))
{
if (!existingUnfo.Contains(item.unfo.Id))
{
sqlDb.unfo.Add(item.unfo);
existingUnfo.Add(item.unfo.Id);
}
if (!existingHeaders.Contains(item.header.Id))
{
sqlDb.headers.Add(item.header);
existingHeaders.Add(item.header.Id);
}
sqlDb.jurnal.Add(new LogModelSql
{
time = item.jurnal.time,
uri = item.jurnal.uri,
uid = item.jurnal.uid,
unfo = item.unfo.Id,
header = item.header.Id,
duration_ms = item.jurnal.duration_ms,
balancer = item.jurnal.balancer,
status_code = item.jurnal.status_code
});
}
sqlDb.SaveChanges();
}
catch (Exception ex)
{
Console.WriteLine($"[LogUserRequest-Lite] UpdateDb error: {ex.Message}");
}
finally { Interlocked.Exchange(ref _updatingDb, 0); }
}
}
@@ -1,11 +0,0 @@
namespace LogUserRequest.Models.DTO;
public class ApiResponse<T>
{
public bool Success { get; set; } = true;
public string? Error { get; set; }
public T? Data { get; set; }
public static ApiResponse<T> Ok(T data) => new() { Success = true, Data = data };
public static ApiResponse<T> Fail(string error) => new() { Success = false, Error = error };
}
@@ -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; }
}
@@ -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<TopItemDto>();
public TopItemDto[] TopBalancers { get; set; } = Array.Empty<TopItemDto>();
}
public class TopItemDto
{
public string Name { get; set; } = "";
public int Count { get; set; }
}
-336
View File
@@ -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.
-110
View File
@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0c0e12" id="metaTheme">
<title>LogUserRequest — Вход</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --font-body: 'Inter', system-ui, -apple-system, sans-serif; }
[data-theme="dark"] { --bg: #0c0e12; --surface: #13161d; --surface-2: #1a1e28; --border: #2a3040; --text: #e2e6ef; --text-muted: #7c849a; --accent: #3ec9d1; --accent-soft: #2ea8b0; --err: #f87171; --err-dim: rgba(248, 113, 113, 0.12); --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45); }
[data-theme="light"] { --bg: #f4f5f8; --surface: #ffffff; --surface-2: #f0f1f5; --border: #d4d8e3; --text: #1a1e28; --text-muted: #6b7280; --accent: #0d9499; --accent-soft: #0b7d82; --err: #dc2626; --err-dim: rgba(220, 38, 38, 0.1); --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12); }
html, body { height: 100%; }
body { font-family: var(--font-body); background: var(--bg); display: flex; justify-content: center; align-items: center; color: var(--text); -webkit-font-smoothing: antialiased; }
.auth-container { background: var(--surface); border: 1px solid var(--border); border-radius: 24px; padding: 48px 40px; width: 100%; max-width: 420px; box-shadow: var(--shadow-lg); }
.logo { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 32px; }
.logo svg { width: 36px; height: 36px; }
.logo span { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text); }
.logo small { font-weight: 400; color: var(--text-muted); margin-left: 4px; }
h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 24px; text-align: center; color: var(--accent); }
.error { background: var(--err-dim); border: 1px solid var(--err); border-radius: 10px; padding: 12px 16px; margin-bottom: 20px; color: var(--err); font-size: 0.9rem; display: none; }
.error.show { display: block; }
input { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 12px; padding: 14px 18px; color: var(--text); font-size: 1rem; margin-bottom: 24px; transition: border 0.15s; }
input:focus { outline: none; border-color: var(--accent); }
button { width: 100%; background: var(--accent); border: none; border-radius: 12px; padding: 14px; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; transition: background 0.15s; }
button:hover { background: var(--accent-soft); }
.theme-toggle { position: absolute; top: 20px; right: 20px; width: 40px; height: 40px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; }
.theme-toggle:hover { color: var(--text); }
.theme-toggle svg { width: 20px; height: 20px; }
</style>
</head>
<body>
<button class="theme-toggle" id="themeBtn" title="Сменить тему (Alt+T)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<div class="auth-container">
<div class="logo">
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
</svg>
<span>LogUserRequest <small>Lite</small></span>
</div>
<h2>🔐 Вход</h2>
<div class="error" id="errorMsg"></div>
<form method="post" action="/lite/logrequest/login">
<input type="password" name="password" placeholder="Пароль" autocomplete="off" autofocus>
<button type="submit">Войти</button>
</form>
</div>
<script>
const THEME_KEY = 'loguser-theme';
function setTheme(dark) {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
try { localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); } catch(e) {}
}
const saved = localStorage.getItem(THEME_KEY);
setTheme(saved === 'dark' || (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches));
document.getElementById('themeBtn').addEventListener('click', () => {
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
});
document.addEventListener('keydown', e => {
if (e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
}
});
const urlParams = new URLSearchParams(window.location.search);
const errorEl = document.getElementById('errorMsg');
if (urlParams.has('error')) {
const remaining = urlParams.get('remaining');
errorEl.innerText = remaining ? `Неверный пароль. Осталось попыток: ${remaining}` : 'Неверный пароль';
errorEl.classList.add('show');
}
if (urlParams.has('blocked')) {
errorEl.innerText = 'Доступ заблокирован на сутки';
errorEl.classList.add('show');
}
// Предотвращаем возврат на auth через кнопку "назад"
(function() {
if (window.history && window.history.replaceState) {
const url = new URL(window.location);
if (!url.searchParams.has('error') && !url.searchParams.has('blocked')) {
window.history.replaceState(null, '', '/lite/logrequest');
}
}
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
fetch('/lite/logrequest/api?skip=0&take=1', {
credentials: 'include',
cache: 'no-cache'
}).then(r => {
if (r.ok) window.location.replace('/lite/logrequest');
}).catch(() => {});
}
});
})();
</script>
</body>
</html>
-487
View File
@@ -1,487 +0,0 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LogUserRequest Lite</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --font-body: 'Inter', system-ui, sans-serif; --font-mono: 'JetBrains Mono', monospace; --text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --r-md: 8px; --r-lg: 12px; --r-xl: 16px; }
[data-theme="dark"] { --bg: #0c0e12; --surface: #13161d; --surface-2: #1a1e28; --surface-3: #222733; --border: #2a3040; --text: #e2e6ef; --text-muted: #7c849a; --text-faint: #4d5568; --accent: #3ec9d1; --accent-dim: rgba(62, 201, 209, 0.12); --ok: #4ade80; --err: #f87171; --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); --modal-overlay: rgba(0,0,0,0.7); --chart-grid: #2a3040; }
[data-theme="light"] { --bg: #f4f5f8; --surface: #ffffff; --surface-2: #f0f1f5; --surface-3: #e8eaf0; --border: #d4d8e3; --text: #1a1e28; --text-muted: #6b7280; --text-faint: #9ca3af; --accent: #0d9499; --accent-dim: rgba(13, 148, 153, 0.1); --ok: #16a34a; --err: #dc2626; --shadow-sm: 0 1px 2px rgba(0,0,0,0.06); --modal-overlay: rgba(0,0,0,0.5); --chart-grid: #d4d8e3; }
html, body { height: 100%; }
body { font-family: var(--font-body); font-size: var(--text-base); background: var(--bg); color: var(--text); line-height: 1.5; -webkit-font-smoothing: antialiased; padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.header-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.logo { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1.2rem; color: var(--text); }
.logo svg { width: 28px; height: 28px; }
.logo small { font-weight: 400; color: var(--text-muted); margin-left: 4px; }
.actions { display: flex; gap: 8px; }
.btn { font: inherit; font-size: var(--text-xs); font-weight: 600; padding: 8px 16px; border-radius: var(--r-md); border: 1px solid var(--accent); background: var(--accent-dim); color: var(--accent); cursor: pointer; transition: all 0.15s; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: rgba(62, 201, 209, 0.22); transform: translateY(-1px); }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent); opacity: 0.85; }
.btn-premium { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); margin-left: 8px; }
.btn-premium:hover { background: rgba(62, 201, 209, 0.22); }
.btn-premium svg { width: 16px; height: 16px; }
.btn-ghost { border-color: var(--border); background: var(--surface-2); color: var(--text-muted); }
.btn-ghost:hover { color: var(--text); background: var(--surface-3); }
.btn-icon { width: 34px; height: 34px; padding: 0; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); color: var(--text-muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.btn-icon:hover { color: var(--text); background: var(--surface-3); }
.btn-icon svg { width: 16px; height: 16px; }
.filter-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; }
.filter-bar input { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 10px 16px; color: var(--text); font-size: 0.9rem; flex: 1; min-width: 200px; }
.filter-bar input:focus { outline: none; border-color: var(--accent); }
.stats-block { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); padding: 20px; margin-bottom: 24px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { background: var(--surface-2); border-radius: var(--r-lg); padding: 16px; text-align: center; border: 1px solid rgba(255,255,255,0.06); }
.stat-value { font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1.2; }
.stat-label { font-size: var(--text-xs); color: var(--text-muted); margin-top: 6px; }
.top-section { margin-bottom: 20px; }
.top-section h3 { font-size: 0.9rem; font-weight: 600; margin-bottom: 12px; color: var(--text-muted); }
.scroll-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.grid-item { background: var(--surface-2); border: 1px solid rgba(255,255,255,0.06); border-radius: var(--r-lg); padding: 8px 14px; min-width: 100px; display: inline-flex; align-items: center; gap: 8px; }
.grid-rank { font-size: var(--text-xs); font-weight: 700; color: var(--text-faint); background: var(--surface-3); width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; }
.grid-name { font-size: var(--text-sm); font-weight: 500; }
.grid-count { font-size: var(--text-xs); color: var(--text-muted); background: var(--surface-3); padding: 2px 8px; border-radius: 6px; }
.requests-list { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); overflow: hidden; }
.request-card { background: var(--surface-2); border-bottom: 1px solid var(--border); padding: 16px 20px; }
.request-card:last-child { border-bottom: none; }
.request-header { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 8px; font-size: 0.8rem; }
.request-time { color: var(--text-faint); }
.request-uid { color: var(--accent); font-weight: 600; }
.request-ip { color: var(--text-muted); }
.request-uri { font-family: var(--font-mono); font-size: 0.75rem; word-break: break-all; color: var(--text-muted); margin-top: 6px; }
.loading, .empty { text-align: center; padding: 40px; color: var(--text-faint); }
/* Модальное окно */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--modal-overlay); display: flex; align-items: flex-start; justify-content: center; z-index: 1000; padding: 20px; padding-top: 60px; backdrop-filter: blur(4px); overflow-y: auto; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); max-width: 950px; width: 100%; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); animation: modalSlide 0.2s ease; max-height: calc(100vh - 80px); overflow-y: auto; }
@keyframes modalSlide { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); z-index: 5; }
.modal-header h2 { font-size: 1.25rem; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.modal-header h2 svg { width: 24px; height: 24px; color: var(--accent); }
.modal-close { width: 32px; height: 32px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; }
.modal-close:hover { color: var(--text); background: var(--surface-3); }
.modal-body { padding: 24px; }
/* График в модалке */
.lite-chart-block { background: var(--surface-2); border-radius: var(--r-lg); padding: 20px; margin-bottom: 32px; }
.lite-chart-header { margin-bottom: 16px; }
.lite-chart-header h3 { font-size: 1rem; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.lite-chart-header h3 svg { width: 20px; height: 20px; }
.lite-chart-container { position: relative; height: 200px; }
.lite-scroll-hint { display: flex; justify-content: center; margin-top: 12px; }
.lite-scroll-arrow { color: var(--text-faint); font-size: var(--text-xs); display: flex; flex-direction: column; align-items: center; gap: 4px; opacity: 0.7; }
.lite-scroll-arrow svg { width: 20px; height: 20px; animation: bounce 2s infinite; }
@keyframes bounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(6px); } 60% { transform: translateY(3px); } }
.premium-section { margin-top: 8px; }
.premium-header { display: flex; align-items: center; gap: 8px; margin-bottom: 24px; }
.premium-header h3 { font-size: 1.1rem; font-weight: 700; color: var(--accent); display: flex; align-items: center; gap: 8px; }
.premium-header svg { width: 24px; height: 24px; }
.comparison-table { width: 100%; border-collapse: collapse; margin: 0 0 24px; font-size: var(--text-sm); }
.comparison-table th { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 500; font-size: var(--text-xs); }
.comparison-table td { padding: 8px; border-bottom: 1px solid var(--border); text-align: left; }
.comparison-table tr:last-child td { border-bottom: none; }
.comparison-table .feature-col { font-weight: 500; color: var(--text); }
.comparison-table .lite-col { color: var(--text-muted); }
.comparison-table .premium-col { color: var(--ok); }
.comparison-table .both-col { color: var(--ok); }
.video-container { position: relative; padding-bottom: 56.25%; height: 0; margin: 24px 0; border-radius: var(--r-lg); overflow: hidden; border: 1px solid var(--border); }
.video-container iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 0; }
.telegram-section { background: var(--accent-dim); border-radius: var(--r-lg); padding: 24px; text-align: center; border: 1px solid var(--accent); }
.telegram-section h3 { color: var(--accent); margin-bottom: 8px; }
.telegram-section p { color: var(--text-muted); margin-bottom: 20px; }
.telegram-link { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: #fff; padding: 12px 24px; border-radius: var(--r-lg); text-decoration: none; font-weight: 600; transition: all 0.15s; }
.telegram-link:hover { opacity: 0.85; transform: translateY(-2px); }
.telegram-link svg { width: 20px; height: 20px; fill: #fff; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<div class="logo">
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
</svg>
<span>LogUserRequest Lite</span>
</div>
<button class="btn btn-premium" id="premiumBtn">
Premium
</button>
</div>
<div class="actions">
<button class="btn-icon" id="themeBtn" title="Сменить тему (Alt+T)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<button class="btn btn-ghost" id="logoutBtn">Выйти</button>
</div>
</div>
<div class="filter-bar">
<input type="text" id="uidInput" placeholder="UID пользователя (оставьте пустым для всех)">
<button class="btn btn-primary" onclick="applyFilter()">Показать</button>
<button class="btn btn-ghost" onclick="resetFilter()">Сбросить</button>
</div>
<div class="stats-block" id="statsBlock" style="display:none;">
<div class="stats-grid">
<div class="stat-card"><div class="stat-value" id="statToday">0</div><div class="stat-label">Запросов сегодня</div></div>
<div class="stat-card"><div class="stat-value" id="statMonth">0</div><div class="stat-label">За месяц</div></div>
<div class="stat-card"><div class="stat-value" id="statDevices">0</div><div class="stat-label">Устройств</div></div>
<div class="stat-card"><div class="stat-value" id="statIps">0</div><div class="stat-label">IP адресов</div></div>
</div>
<div class="top-section">
<h3>👥 Топ пользователей</h3>
<div class="scroll-grid" id="topUsers"></div>
</div>
<div class="top-section">
<h3>⚡ Топ балансиров</h3>
<div class="scroll-grid" id="topBalancers"></div>
</div>
</div>
<div class="requests-list" id="requestsList">
<div class="loading">Загрузка...</div>
</div>
</div>
<!-- Модальное окно Premium -->
<div class="modal-overlay hidden" id="premiumModal">
<div class="modal">
<div class="modal-header">
<h2>
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="currentColor" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="currentColor" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="currentColor" opacity="0.45" />
</svg>
LogUserRequest Lite
</h2>
<button class="modal-close" id="closeModalBtn">✕</button>
</div>
<div class="modal-body">
<!-- Блок Lite-версии с графиком -->
<div class="lite-chart-block">
<div class="lite-chart-header">
<h3>
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true" width="20" height="20">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="currentColor" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="currentColor" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="currentColor" opacity="0.45" />
</svg>
Активность за 3 дня
</h3>
</div>
<div class="lite-chart-container">
<canvas id="premiumChart"></canvas>
</div>
<div class="lite-scroll-hint">
<span class="lite-scroll-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M19 12l-7 7-7-7"/>
</svg>
Прокрутите чтобы увидеть Premium-возможности
</span>
</div>
</div>
<!-- Разделитель / пустое пространство -->
<div style="height: 400px;"></div>
<!-- Premium секция -->
<div class="premium-section">
<div class="premium-header">
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="var(--accent)" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
</svg>
<h3>LogUserRequest Premium</h3>
</div>
<table class="comparison-table">
<thead>
<tr><th>Функция</th><th>Lite-версия</th><th>Premium-версия</th></tr>
</thead>
<tbody>
<tr><td class="feature-col">Логирование запросов</td><td class="both-col">✅ Все запросы</td><td class="both-col">✅ Все запросы</td></tr>
<tr><td class="feature-col">Фильтрация служебных путей</td><td class="both-col">✅ Хардкод-список</td><td class="premium-col">✅ Гибкий UI + regex + IP</td></tr>
<tr><td class="feature-col">Авторизация</td><td class="both-col">✅ Сессии + rate-limit</td><td class="both-col">✅ Сессии + rate-limit</td></tr>
<tr><td class="feature-col">База данных</td><td class="both-col">✅ SQLite</td><td class="premium-col">✅ SQLite с индексами</td></tr>
<tr><td class="feature-col">Очистка старых записей</td><td class="both-col">✅ По дням (logDay)</td><td class="premium-col">✅ По дням + по размеру БД</td></tr>
<tr><td class="feature-col">Статистика</td><td class="both-col">✅ Топы пользователей и балансиров</td><td class="premium-col">✅ Топы + графики + модальные окна</td></tr>
<tr><td class="feature-col">Тёмная/светлая тема</td><td class="both-col">✅ Есть</td><td class="both-col">✅ Есть</td></tr>
<tr><td class="feature-col">Мобильная адаптация</td><td class="both-col">✅ Есть</td><td class="both-col">✅ Есть</td></tr>
<tr><td class="feature-col">Поиск по UID</td><td class="both-col">✅ Поле ввода</td><td class="premium-col">✅ Поле ввода + модальное окно</td></tr>
<tr><td class="feature-col">Дашборд</td><td class="lite-col">❌ Простой список</td><td class="premium-col">✅ Полноценный SPA с графиками</td></tr>
<tr><td class="feature-col">Дедупликация перебора балансиров</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ First + Last (окно 30 сек)</td></tr>
<tr><td class="feature-col">Фильтр по дате</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ На дашборде</td></tr>
<tr><td class="feature-col">Активность в реальном времени</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Live-обновления</td></tr>
<tr><td class="feature-col">Экспорт данных</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ CSV/JSON (ZIP)</td></tr>
<tr><td class="feature-col">Массовое удаление</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ С гибкими фильтрами</td></tr>
<tr><td class="feature-col">Архивация</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ По дате + по размеру БД</td></tr>
<tr><td class="feature-col">Telegram-уведомления</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Мониторинг + архив + ошибки</td></tr>
<tr><td class="feature-col">Health-check</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ API для мониторинга</td></tr>
<tr><td class="feature-col">Настройка фильтров через UI</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Модальное окно + авто-сохранение</td></tr>
<tr><td class="feature-col">Защита от ReDoS</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Кэширование regex + таймаут</td></tr>
<tr><td class="feature-col">Очередь с защитой от переполнения</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ До 50000 записей</td></tr>
<tr><td class="feature-col">Потребление памяти</td><td class="both-col">~20-30 МБ</td><td class="both-col">~50-70 МБ</td></tr>
</tbody>
</table>
<!-- Видео-обзор -->
<div class="video-container">
<iframe src="https://drive.google.com/file/d/1VCgOGJIVy4CVjYV1wL47t_5Vb5dbs-Gg/preview" allow="autoplay; fullscreen" allowfullscreen></iframe>
</div>
<!-- Telegram -->
<div class="telegram-section">
<h3>💬 Заинтересовал Premium?</h3>
<p>Напишите автору в Telegram — отвечу на вопросы и дам доступ</p>
<a href="https://t.me/Viacheslav_Sh1" target="_blank" class="telegram-link">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.562 8.161c-.18 1.897-.962 6.502-1.359 8.627-.168.9-.5 1.201-.82 1.23-.697.064-1.226-.46-1.901-.903-1.056-.692-1.653-1.123-2.678-1.799-1.185-.781-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.015-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.329-.913.489-1.302.481-.428-.009-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.015 3.333-1.386 4.025-1.627 4.476-1.635.099-.002.321.023.465.141.145.118.185.276.204.388.018.112.013.23.01.356z"/>
</svg>
@Viacheslav_Sh1
</a>
</div>
</div>
</div>
</div>
</div>
<script>
const THEME_KEY = 'loguser-theme';
function setTheme(dark) {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
try { localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); } catch(e) {}
}
const saved = localStorage.getItem(THEME_KEY);
setTheme(saved === 'dark' || (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches));
document.getElementById('themeBtn').addEventListener('click', () => {
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
});
document.addEventListener('keydown', e => {
if (e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
}
});
let currentUid = '';
let skip = 0;
const take = 50;
let loading = false;
let allLoaded = false;
let premiumChart = null;
// Модальное окно
const modal = document.getElementById('premiumModal');
const premiumBtn = document.getElementById('premiumBtn');
const closeBtn = document.getElementById('closeModalBtn');
function openModal() {
modal.classList.remove('hidden');
loadPremiumChart();
}
premiumBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', () => modal.classList.add('hidden'));
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.add('hidden'); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') modal.classList.add('hidden'); });
async function apiCall(path) {
const res = await fetch('/lite/logrequest' + path, { credentials: 'include' });
if (res.status === 401 || res.status === 403) {
window.location.href = '/lite/logrequest/auth';
throw new Error('Unauthorized');
}
const json = await res.json();
return json.success ? json.data : json;
}
async function loadPremiumChart() {
if (premiumChart) return;
try {
const result = await apiCall('/premium-chart?days=3');
const ctx = document.getElementById('premiumChart').getContext('2d');
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#3ec9d1';
const gridColor = getComputedStyle(document.documentElement).getPropertyValue('--chart-grid').trim() || '#2a3040';
let labels, values;
if (result && result.data && result.data.length > 0) {
labels = result.data.map(d => new Date(d.date).toLocaleDateString('ru', { day: 'numeric', month: 'short' }));
values = result.data.map(d => d.count);
} else {
labels = ['Нет данных'];
values = [0];
}
premiumChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Запросы',
data: values,
borderColor: accentColor,
backgroundColor: accentColor + '20',
tension: 0.3,
fill: true,
pointBackgroundColor: accentColor
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: gridColor } },
x: { grid: { display: false } }
}
}
});
} catch(e) {
console.error('Chart error:', e);
}
}
async function loadStats() {
try {
const url = currentUid ? `/stats?uid=${encodeURIComponent(currentUid)}` : '/stats';
const stats = await apiCall(url);
if (!stats) return;
document.getElementById('statsBlock').style.display = 'block';
document.getElementById('statToday').innerText = stats.today || 0;
document.getElementById('statMonth').innerText = stats.month || 0;
document.getElementById('statDevices').innerText = stats.uniqueUserAgent || 0;
document.getElementById('statIps').innerText = stats.uniqueIp || 0;
const topUsers = document.getElementById('topUsers');
const users = stats.topUsers || [];
topUsers.innerHTML = users.map((u, idx) => `
<div class="grid-item">
<span class="grid-rank">${idx + 1}</span>
<span class="grid-name">${escapeHtml(u.name)}</span>
<span class="grid-count">${u.count}</span>
</div>
`).join('') || '<div class="empty">Нет данных</div>';
const topBalancers = document.getElementById('topBalancers');
const balancers = stats.topBalancers || [];
topBalancers.innerHTML = balancers.map((b, idx) => `
<div class="grid-item">
<span class="grid-rank">${idx + 1}</span>
<span class="grid-name">${escapeHtml(b.name)}</span>
<span class="grid-count">${b.count}</span>
</div>
`).join('') || '<div class="empty">Нет данных</div>';
} catch(e) {
console.error('Stats error:', e);
}
}
async function loadRequests(reset = true) {
if (reset) { skip = 0; allLoaded = false; document.getElementById('requestsList').innerHTML = ''; }
if (loading || allLoaded) return;
loading = true;
try {
let url = `/api?skip=${skip}&take=${take}`;
if (currentUid) url += `&uid=${encodeURIComponent(currentUid)}`;
const data = await apiCall(url);
if (!data || (Array.isArray(data) && data.length === 0)) {
allLoaded = true;
if (skip === 0) document.getElementById('requestsList').innerHTML = '<div class="empty">Нет записей</div>';
loading = false;
return;
}
if (reset) document.getElementById('requestsList').innerHTML = '';
data.forEach(r => {
const card = document.createElement('div');
card.className = 'request-card';
card.innerHTML = `
<div class="request-header">
<span class="request-time">${new Date(r.time).toLocaleString()}</span>
<span class="request-uid">${escapeHtml(r.userUid || r.uid || 'unknown')}</span>
<span class="request-ip">${escapeHtml(r.ip || 'unknown')}</span>
</div>
<div class="request-uri">${escapeHtml(r.uri || '')}</div>
`;
document.getElementById('requestsList').appendChild(card);
});
skip += data.length;
if (data.length < take) allLoaded = true;
loading = false;
} catch(e) {
console.error('Load error:', e);
loading = false;
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[m] || m));
}
function applyFilter() {
currentUid = document.getElementById('uidInput').value.trim();
loadStats();
loadRequests(true);
}
function resetFilter() {
document.getElementById('uidInput').value = '';
currentUid = '';
loadStats();
loadRequests(true);
}
document.getElementById('logoutBtn').addEventListener('click', () => {
window.location.href = '/lite/logrequest/logout';
});
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
if (!loading && !allLoaded) loadRequests(false);
}
});
loadStats();
loadRequests(true);
</script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
{
"enable": true,
"version": 1,
"namespace": "LogUserRequest",
"logDay": 90
}
-48
View File
@@ -1,48 +0,0 @@
using System;
using System.IO;
using Microsoft.EntityFrameworkCore;
using Telemetry.Models;
namespace Telemetry;
public class AppDbContext : DbContext
{
public DbSet<LogModelSql> Logs { get; set; } = null!;
public DbSet<UserInfoModelSql> Users { get; set; } = null!;
public AppDbContext() : base() { }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
var dbPath = ModInit.DbPath;
var dbDir = Path.GetDirectoryName(dbPath);
if (dbDir != null && !Directory.Exists(dbDir)) Directory.CreateDirectory(dbDir);
optionsBuilder.UseSqlite($"Data Source={dbPath};Cache=Shared");
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => l.Time);
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => new { l.Uid, l.Time });
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => new { l.Balancer, l.Time });
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => l.UnfoId);
modelBuilder.Entity<UserInfoModelSql>()
.HasKey(u => u.Id);
modelBuilder.Entity<UserInfoModelSql>()
.HasIndex(u => u.Ip);
}
}
@@ -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<AppDbContext> _contextFactory;
public TelemetryController(IDbContextFactory<AppDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
private class MovieRawDto
{
public string? Title { get; set; }
public string? Tmdb { get; set; }
public string? Kp { get; set; }
public string? Imdb { get; set; }
public bool IsTv { get; set; }
public int Count { get; set; }
}
private static (DateTime start, DateTime end) GetPeriod(int days)
{
var now = DateTime.UtcNow.Date;
var periodStart = days == 0 ? now : (days == -1 ? now.AddDays(-1) : now.AddDays(-days));
var periodEnd = days == -1 ? now : DateTime.UtcNow;
return (periodStart, periodEnd);
}
private static List<object> MapMovies(List<MovieRawDto> raw)
{
return raw.Select(m => new
{
title = m.Title,
id = m.Tmdb ?? m.Kp ?? m.Imdb ?? m.Title,
tmdb = m.Tmdb,
kp = m.Kp,
imdb = m.Imdb,
count = m.Count,
source = m.Tmdb != null ? "tmdb" : m.Kp != null ? "kinopoisk" : m.Imdb != null ? "imdb" : "unknown",
isTv = m.IsTv
}).Cast<object>().ToList();
}
private IQueryable<LogModelSql> ApplySearchFilter(AppDbContext db, IQueryable<LogModelSql> query, string? search)
{
if (string.IsNullOrEmpty(search)) return query;
var rawSearch = search;
var encodedSearch = Uri.EscapeDataString(search);
var encodedSearchPlus = encodedSearch.Replace("%20", "+");
return query.Where(l =>
l.Uid.Contains(rawSearch) ||
l.Uri.Contains(rawSearch) ||
l.Uri.Contains(encodedSearch) ||
l.Uri.Contains(encodedSearchPlus) ||
(l.Balancer != null && l.Balancer.Contains(rawSearch)) ||
db.Users.Any(u => u.Id == l.UnfoId && (u.Ip.Contains(rawSearch) || u.UserAgent.Contains(rawSearch)))
);
}
[HttpGet]
[Route("")]
public ActionResult Index()
{
var path = Path.Combine(ModInit.modpath, "index.html");
if (!System.IO.File.Exists(path)) return Content("UI missing", "text/plain");
var html = System.IO.File.ReadAllText(path, Encoding.UTF8);
return Content(html, "text/html; charset=utf-8");
}
[HttpGet("api/stats")]
public async Task<IActionResult> Stats(string? search, int days = 30)
{
await using var db = await _contextFactory.CreateDbContextAsync();
var query = db.Logs.AsNoTracking();
query = ApplySearchFilter(db, query, search);
var (periodStart, periodEnd) = GetPeriod(days);
var now = DateTime.UtcNow.Date;
int total;
int today;
if (days == 0)
{
today = await query.Where(l => l.Time >= now.Date && l.Time < periodEnd).CountAsync();
total = today;
}
else if (days == -1)
{
total = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd).CountAsync();
today = 0;
}
else
{
total = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd).CountAsync();
today = await query.Where(l => l.Time >= now.Date && l.Time < periodEnd).CountAsync();
}
var uids = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd && l.Uid != "anonymous" && !string.IsNullOrEmpty(l.Uid)).Select(l => l.Uid).Distinct().CountAsync();
var validLogs = query.Where(l => l.Time >= periodStart && l.Time < periodEnd && l.UnfoId != null);
var ips = await validLogs.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip).Where(ip => ip != null && ip != "").Distinct().CountAsync();
var uas = await validLogs.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().CountAsync();
return new JsonResult(new { success = true, data = new { today, total, uids, ips, uas } });
}
[HttpGet("api/logs")]
public async Task<IActionResult> Logs(string? search, int days = 30, int skip = 0, int take = 100)
{
take = Math.Min(take, 1000);
await using var db = await _contextFactory.CreateDbContextAsync();
var query = db.Logs.AsNoTracking();
var (periodStart, periodEnd) = GetPeriod(days);
query = query.Where(l => l.Time >= periodStart && l.Time < periodEnd);
query = ApplySearchFilter(db, query, search);
var logs = await query.OrderByDescending(l => l.Time).Skip(skip).Take(take)
.GroupJoin(db.Users, l => l.UnfoId, u => u.Id, (l, users) => new { Log = l, Users = users })
.SelectMany(x => x.Users.DefaultIfEmpty(), (x, u) => new
{
id = x.Log.Id,
time = x.Log.Time,
uri = x.Log.Uri,
uid = x.Log.Uid,
ip = u != null ? u.Ip : "unknown",
userAgent = u != null ? u.UserAgent : "unknown",
balancer = x.Log.Balancer,
statusCode = x.Log.StatusCode,
duration = x.Log.DurationMs
})
.ToListAsync();
return new JsonResult(new { success = true, data = logs });
}
[HttpGet("api/chart")]
public async Task<IActionResult> Chart(string? search, int days = 30)
{
var (periodStart, periodEnd) = GetPeriod(days);
await using var db = await _contextFactory.CreateDbContextAsync();
var query = db.Logs.AsNoTracking();
query = ApplySearchFilter(db, query, search);
var data = await query
.Where(l => l.Time >= periodStart && l.Time < periodEnd)
.GroupBy(l => l.Time.Date)
.Select(g => new { date = g.Key, count = g.Count() })
.OrderBy(x => x.date)
.ToListAsync();
return new JsonResult(new { success = true, data = data });
}
[HttpGet("api/tops")]
public async Task<IActionResult> GetTops(string? search, [FromQuery] int days = 7)
{
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var query = db.Logs.AsNoTracking();
query = ApplySearchFilter(db, query, search);
var baseQuery = query.Where(l => l.Time >= periodStart && l.Time < periodEnd);
var topBalancers = await baseQuery
.Where(l => l.Balancer != null && l.Balancer != "" && !l.Balancer.StartsWith("18+ "))
.GroupBy(l => l.Balancer)
.Select(g => new { balancer = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(10)
.ToListAsync();
var topAdult = await baseQuery
.Where(l => l.Balancer != null && l.Balancer.StartsWith("18+ "))
.GroupBy(l => l.Balancer)
.Select(g => new { balancer = g.Key!.Substring(4), count = g.Count() })
.OrderByDescending(x => x.count)
.Take(10)
.ToListAsync();
var topUsers = await baseQuery
.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "")
.GroupBy(l => l.Uid)
.Select(g => new { uid = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(10)
.ToListAsync();
var topMoviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var topMovies = MapMovies(topMoviesRaw);
var userAgents = await baseQuery
.Where(l => l.UnfoId != null)
.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent)
.GroupBy(ua => ua)
.Select(g => new { ua = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(1000)
.ToListAsync();
var devices = userAgents
.Select(x => new { name = ParseDevice(x.ua), count = x.count })
.GroupBy(x => x.name)
.Select(g => new { name = g.Key, count = g.Sum(x => x.count) })
.OrderByDescending(x => x.count)
.ToList();
var hours = await baseQuery
.GroupBy(l => l.Time.Hour)
.Select(g => new { hour = g.Key, count = g.Count() })
.OrderBy(x => x.hour)
.ToListAsync();
var seriesCount = await baseQuery.Where(l => l.IsTv).CountAsync();
var moviesCount = await baseQuery.Where(l => l.MovieTitle != null && l.MovieTitle != "" && !l.IsTv).CountAsync();
var animeCount = await baseQuery.Where(l => l.Uri.Contains("anime=1") || l.Uri.Contains("anime=true")).CountAsync();
var contentTypes = new[] {
new { name = "Фильмы", count = moviesCount },
new { name = "Сериалы", count = seriesCount },
new { name = "Аниме", count = animeCount }
}.Where(x => x.count > 0).ToList();
return new JsonResult(new { success = true, data = new { balancers = topBalancers, adult = topAdult, users = topUsers, movies = topMovies, devices, hours, contentTypes } });
}
private string ParseDevice(string? ua)
{
if (ua == null) return "Другие";
ua = ua.ToLower();
if (ua.Contains("tizen") || ua.Contains("samsung")) return "Tizen";
if (ua.Contains("web0s") || ua.Contains("webos")) return "WebOS";
if (ua.Contains("android")) return "Android";
if (ua.Contains("iphone") || ua.Contains("ipad") || ua.Contains("mac os")) return "Apple";
if (ua.Contains("windows")) return "Windows";
if (ua.Contains("linux")) return "Linux";
return "Другие";
}
[HttpGet("api/user")]
public async Task<IActionResult> UserInfo(string uid, int days = 30)
{
if (string.IsNullOrEmpty(uid)) return BadRequest();
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var baseQuery = db.Logs.AsNoTracking().Where(l => l.Uid == uid && l.Time >= periodStart && l.Time < periodEnd);
var count = await baseQuery.CountAsync();
if (count == 0) return new JsonResult(new { success = false });
var totalRequests = count;
var firstSeen = await baseQuery.MinAsync(l => l.Time);
var lastSeen = await baseQuery.MaxAsync(l => l.Time);
var ips = await baseQuery.Where(l => l.UnfoId != null).Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip).Where(ip => ip != null && ip != "").Distinct().ToListAsync();
var userAgents = await baseQuery.Where(l => l.UnfoId != null).Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().ToListAsync();
var balancers = await baseQuery.Where(l => l.Balancer != null && l.Balancer != "")
.GroupBy(l => l.Balancer)
.Select(g => new { name = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.ToListAsync();
var isHourly = days <= 1;
var timeline = await (isHourly
? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()
: baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync());
var moviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var movies = MapMovies(moviesRaw);
return new JsonResult(new
{
success = true,
data = new
{
totalRequests,
firstSeen,
lastSeen,
ips,
userAgents,
balancers,
timeline,
movies
}
});
}
[HttpGet("api/ip")]
public async Task<IActionResult> IpInfo(string ip, int days = 30)
{
if (string.IsNullOrEmpty(ip)) return BadRequest();
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var baseQuery = db.Logs.AsNoTracking().Where(l => l.Time >= periodStart && l.Time < periodEnd && l.UnfoId != null && db.Users.Any(u => u.Id == l.UnfoId && u.Ip == ip));
var count = await baseQuery.CountAsync();
if (count == 0) return new JsonResult(new { success = false });
var totalRequests = count;
var firstSeen = await baseQuery.MinAsync(l => l.Time);
var lastSeen = await baseQuery.MaxAsync(l => l.Time);
var uids = await baseQuery.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "").Select(l => l.Uid).Distinct().ToListAsync();
var userAgents = await baseQuery.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().ToListAsync();
var balancers = await baseQuery.Where(l => l.Balancer != null && l.Balancer != "")
.GroupBy(l => l.Balancer)
.Select(g => new { name = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.ToListAsync();
var isHourly = days <= 1;
var timeline = await (isHourly
? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()
: baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync());
var moviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var movies = MapMovies(moviesRaw);
return new JsonResult(new
{
success = true,
data = new
{
totalRequests,
firstSeen,
lastSeen,
uids,
userAgents,
balancers,
timeline,
movies
}
});
}
[HttpGet("api/settings")]
public IActionResult GetSettings()
{
return new JsonResult(new
{
success = true,
data = new
{
prefixes = string.Join("\n", RequestListener.SkipPrefixes),
extensions = string.Join("\n", RequestListener.SkipExtensions),
regexes = string.Join("\n", RequestListener.RegexFilters),
ips = string.Join("\n", RequestListener.BlacklistIps),
rateLimit = RequestListener.RateLimitMs
}
});
}
[HttpPost("api/settings")]
public IActionResult UpdateSettings([FromForm] string? prefixes, [FromForm] string? extensions, [FromForm] string? regexes, [FromForm] string? ips, [FromForm] int? rateLimit)
{
if (prefixes != null) RequestListener.SkipPrefixes = prefixes.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
if (extensions != null) RequestListener.SkipExtensions = extensions.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
if (regexes != null)
{
var patterns = regexes.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
var invalid = new List<string>();
foreach (var p in patterns)
{
try { System.Text.RegularExpressions.Regex.Match("", p, System.Text.RegularExpressions.RegexOptions.None, TimeSpan.FromMilliseconds(50)); }
catch { invalid.Add(p); }
}
if (invalid.Count > 0)
return new JsonResult(new { success = false, error = $"Невалидные regex: {string.Join(", ", invalid)}" });
RequestListener.RegexFilters = patterns;
}
if (ips != null) RequestListener.BlacklistIps = ips.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
if (rateLimit.HasValue) RequestListener.RateLimitMs = rateLimit.Value;
RequestListener.SaveSettings();
return new JsonResult(new { success = true });
}
[HttpGet("api/balancer")]
public async Task<IActionResult> BalancerInfo(string balancer, int days = 30)
{
if (string.IsNullOrEmpty(balancer)) return BadRequest();
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var now = DateTime.UtcNow.Date;
var baseQuery = db.Logs.AsNoTracking().Where(l => l.Balancer == balancer && l.Time >= periodStart && l.Time < periodEnd);
var total = await baseQuery.CountAsync();
var today = await baseQuery.Where(l => l.Time >= now.Date).CountAsync();
var topUids = await baseQuery.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "")
.GroupBy(l => l.Uid)
.Select(g => new { uid = g.Key, count = g.Count() })
.OrderByDescending(x => x.count).Take(10).ToListAsync();
var topIps = await baseQuery.Where(l => l.UnfoId != null)
.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip)
.Where(ip => ip != null && ip != "")
.GroupBy(ip => ip)
.Select(g => new { ip = g.Key, count = g.Count() })
.OrderByDescending(x => x.count).Take(10).ToListAsync();
var isHourly = days <= 1;
var timeline = await (isHourly
? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()
: baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync());
var moviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var movies = MapMovies(moviesRaw);
return new JsonResult(new { success = true, data = new { total, today, uids = topUids, ips = topIps, timeline, movies } });
}
[HttpPost("api/logs/delete")]
public async Task<IActionResult> DeleteLogs([FromForm] string? search, [FromForm] int days = 30)
{
await using var db = await _contextFactory.CreateDbContextAsync();
var now = DateTime.UtcNow.Date;
var periodStart = days == 0 ? now : (days == -1 ? now.AddDays(-1) : now.AddDays(-days));
var periodEnd = days == -1 ? now : DateTime.UtcNow;
var query = db.Logs.Where(l => l.Time >= periodStart && l.Time < periodEnd);
query = ApplySearchFilter(db, query, search);
var deleted = await query.ExecuteDeleteAsync();
return new JsonResult(new { success = true, deleted });
}
[HttpGet("api/export")]
public async Task<IActionResult> ExportData()
{
await using var db = await _contextFactory.CreateDbContextAsync();
var result = await db.Logs.AsNoTracking()
.OrderByDescending(l => l.Id)
.Take(50000)
.GroupJoin(db.Users, l => l.UnfoId, u => u.Id, (l, users) => new { Log = l, Users = users })
.SelectMany(x => x.Users.DefaultIfEmpty(), (x, u) => new
{
time = x.Log.Time,
uri = x.Log.Uri,
uid = x.Log.Uid,
ip = u != null ? u.Ip : "",
userAgent = u != null ? u.UserAgent : "",
balancer = x.Log.Balancer,
statusCode = x.Log.StatusCode
})
.ToListAsync();
return new JsonResult(result);
}
}
-148
View File
@@ -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<AppDbContext>(options =>
{
options.UseSqlite($"Data Source={DbPath};Cache=Shared");
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
}
public void Loaded(InitspaceModel initspace)
{
modpath = initspace.path;
var dbDir = Path.GetDirectoryName(DbPath);
if (dbDir != null) Directory.CreateDirectory(dbDir);
using (var sqlDb = new AppDbContext())
{
sqlDb.Database.EnsureCreated();
try
{
sqlDb.Database.ExecuteSqlRaw("PRAGMA journal_mode = WAL;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA synchronous = NORMAL;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA cache_size = -64000;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA temp_store = MEMORY;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA mmap_size = 33554432;");
}
catch { }
}
_updateDbTimer?.Dispose();
_cleanupTimer?.Dispose();
_updateDbTimer = new Timer(UpdateDb, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
_cleanupTimer = new Timer(CleanupDb, null, TimeSpan.FromMinutes(1), TimeSpan.FromHours(12));
RequestListener.LoadSettings();
EventListener.MiddlewareAsync -= RequestListener.InvokeAsync;
EventListener.MiddlewareAsync += RequestListener.InvokeAsync;
Console.WriteLine("[Telemetry] Module loaded.");
}
private static void UpdateDb(object? state)
{
if (Interlocked.Exchange(ref _updatingDb, 1) == 1) return;
try
{
using var sqlDb = new AppDbContext();
sqlDb.ChangeTracker.AutoDetectChangesEnabled = false;
var batch = new List<(LogModelSql log, UserInfoModelSql user)>();
while (batch.Count < 2000 && RequestListener.Queue.TryDequeue(out var item))
{
RequestListener.DequeueItem();
batch.Add(item);
}
if (batch.Count == 0) return;
var userIds = batch.Select(b => b.user.Id).Distinct().ToList();
var existingUsers = sqlDb.Users.Where(u => userIds.Contains(u.Id)).Select(u => u.Id).ToHashSet();
foreach (var item in batch.OrderBy(i => i.log.Time))
{
if (!existingUsers.Contains(item.user.Id))
{
sqlDb.Users.Add(item.user);
existingUsers.Add(item.user.Id);
}
sqlDb.Logs.Add(item.log);
}
sqlDb.SaveChanges();
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] DB Update Error: {ex.Message}");
}
finally { Interlocked.Exchange(ref _updatingDb, 0); }
}
private static void CleanupDb(object? state)
{
try
{
// Settings should be configurable, hardcoded for now: 90 days or max 500MB DB
int logDay = 90;
var cutoff = DateTime.UtcNow.AddDays(-logDay);
using var sqlDb = new AppDbContext();
int batchSize = 5000;
// Delete old records
while (true)
{
var ids = sqlDb.Logs.Where(l => l.Time < cutoff).Select(l => l.Id).Take(batchSize).ToList();
if (ids.Count == 0) break;
sqlDb.Logs.Where(l => ids.Contains(l.Id)).ExecuteDelete();
if (ids.Count < batchSize) break;
}
// Delete orphaned users who have no logs left
sqlDb.Users.Where(u => !sqlDb.Logs.Any(l => l.UnfoId == u.Id)).ExecuteDelete();
// Run VACUUM to reclaim deleted disk space
try { sqlDb.Database.ExecuteSqlRaw("VACUUM;"); } catch { }
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] DB Cleanup Error: {ex.Message}");
}
}
public void Dispose()
{
EventListener.MiddlewareAsync -= RequestListener.InvokeAsync;
_updateDbTimer?.Dispose();
_cleanupTimer?.Dispose();
Console.WriteLine("[Telemetry] Module disposed.");
}
}
-56
View File
@@ -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;
}
-273
View File
@@ -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<string> _blacklistIps = new();
public static volatile int RateLimitMs = 33;
public static string[] BlacklistIps
{
get => _blacklistIps.ToArray();
set => _blacklistIps = new HashSet<string>(value, StringComparer.OrdinalIgnoreCase);
}
public static bool IsBlacklistedIp(string ip) => _blacklistIps.Contains(ip);
public static void DequeueItem() => Interlocked.Decrement(ref _queueSize);
public static Task<bool> InvokeAsync(bool first, EventMiddleware e)
{
if (first)
{
e.httpContext.Items["Telemetry_StartTime"] = DateTime.UtcNow;
return Task.FromResult(true);
}
var httpContext = e.httpContext;
var requestInfo = httpContext.Features.Get<RequestModel>();
if (requestInfo == null || requestInfo.IsLocalRequest || requestInfo.IsAnonymousRequest)
return Task.FromResult(true);
try
{
var path = httpContext.Request.Path.Value ?? "";
// Configurable Regex filters with ReDoS protection (timeout 100ms)
try
{
if (RegexFilters.Any(pattern => Regex.IsMatch(path, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(100))))
return Task.FromResult(true);
}
catch (RegexMatchTimeoutException)
{
// ReDoS protection triggered, skip this request and log warning
return Task.FromResult(true);
}
// Extension filter
if (SkipExtensions.Any(ext => path.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
return Task.FromResult(true);
// Prefix filter
if (SkipPrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
return Task.FromResult(true);
var realIP = requestInfo.IP;
if (string.IsNullOrEmpty(realIP) || realIP == "127.0.0.1" || realIP == "::1")
return Task.FromResult(true);
// IP Blacklist check
if (IsBlacklistedIp(realIP))
return Task.FromResult(true);
// Rate limit
if (RateLimitMs > 0)
{
var rateLimitKey = $"{realIP}:{path}";
if (_rateLimitCache.TryGetValue(rateLimitKey, out _))
return Task.FromResult(true);
_rateLimitCache.Set(rateLimitKey, true, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(RateLimitMs),
Size = 1
});
}
string userAgent = httpContext.Request.Headers["User-Agent"].FirstOrDefault() ?? "unknown";
if (userAgent.Length > 1024) userAgent = userAgent[..1024];
string userUid = httpContext.Request.Query["uid"].FirstOrDefault() ?? httpContext.Request.Cookies["uid"] ?? "anonymous";
if (userUid.Length > 256) userUid = userUid[..256];
var cacheKey = $"{realIP}:{userAgent}";
if (!_userIdCache.TryGetValue(cacheKey, out string? userId))
{
userId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(cacheKey))).ToLower();
_userIdCache.Set(cacheKey, userId, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(2), Size = 1 });
}
var user = new UserInfoModelSql
{
Id = userId!,
Ip = realIP,
UserAgent = userAgent
};
var fullUri = path + httpContext.Request.QueryString;
if (fullUri.Length > 2048) fullUri = fullUri[..2048];
string balancer = "";
if (path.StartsWith("/lite/")) balancer = path[6..].Split('/', '?')[0];
else if (path.StartsWith("/rc/")) balancer = path[4..].Split('/', '?')[0];
else
{
// Parse 18+ (sisi) modules which sit at the root
string[] adultRoutes = { "bgs", "chu", "elo", "epr", "hqr", "phub", "phubgay", "phubsml", "phubprem", "ptx", "runetki", "sbg", "tizam", "xmr", "xmrgay", "xmrsml", "xnx", "xds", "xdsgay", "xdssml", "xdsred", "nexthub" };
var rootSegment = path.TrimStart('/').Split('/', '?')[0].ToLowerInvariant();
if (adultRoutes.Contains(rootSegment))
{
balancer = "18+ " + rootSegment;
}
}
// Filter out system endpoints that are not actual movie balancers
string[] systemEndpoints = { "events", "sync", "auth", "ws", "subs", "vtt", "search", "suggest", "torrent", "tracker", "info", "mdl" };
if (systemEndpoints.Contains(balancer.ToLowerInvariant()))
{
balancer = "";
}
// Deduplication for balancers (First + Last within 30 seconds)
if (!string.IsNullOrEmpty(balancer))
{
var dedupKey = $"{userUid}:{balancer}";
var lastSeen = _balancerDedupCache.Get<DateTime?>(dedupKey);
if (lastSeen.HasValue && (DateTime.UtcNow - lastSeen.Value).TotalSeconds < 30)
{
return Task.FromResult(true);
}
else
{
_balancerDedupCache.Set(dedupKey, DateTime.UtcNow, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromSeconds(30),
Size = 1
});
}
}
int durationMs = 0;
if (httpContext.Items.TryGetValue("Telemetry_StartTime", out var startTimeObj) && startTimeObj is DateTime startTime)
{
durationMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
}
string? mTitle = null, mTmdb = null, mKp = null, mImdb = null;
bool mIsTv = false;
var reqQuery = httpContext.Request.Query;
if (reqQuery.TryGetValue("title", out var titleVal)) mTitle = titleVal.ToString();
if (reqQuery.TryGetValue("id", out var idVal)) mTmdb = idVal.ToString();
if (reqQuery.TryGetValue("kinopoisk_id", out var kpVal)) mKp = kpVal.ToString();
if (reqQuery.TryGetValue("imdb_id", out var imdbVal)) mImdb = imdbVal.ToString();
if (reqQuery.TryGetValue("serial", out var serialVal)) mIsTv = serialVal.ToString() == "1" || serialVal.ToString().Equals("true", StringComparison.OrdinalIgnoreCase);
var log = new LogModelSql
{
Time = DateTime.UtcNow,
Uri = fullUri,
Uid = userUid,
UnfoId = user.Id,
DurationMs = durationMs,
Balancer = balancer,
StatusCode = httpContext.Response?.StatusCode ?? 0,
MovieTitle = mTitle,
TmdbId = mTmdb,
KpId = mKp,
ImdbId = mImdb,
IsTv = mIsTv
};
if (Interlocked.Increment(ref _queueSize) <= MaxQueueSize)
{
try { Queue.Enqueue((log, user)); }
catch { Interlocked.Decrement(ref _queueSize); }
}
else
{
Interlocked.Decrement(ref _queueSize);
// Dropped
}
}
catch { }
return Task.FromResult(true);
}
public static void SaveSettings()
{
try
{
var dir = Path.GetDirectoryName(_settingsPath);
if (dir != null) Directory.CreateDirectory(dir);
var data = new Dictionary<string, object>
{
["prefixes"] = SkipPrefixes,
["extensions"] = SkipExtensions,
["regexes"] = RegexFilters,
["ips"] = BlacklistIps,
["rateLimit"] = RateLimitMs
};
File.WriteAllText(_settingsPath, System.Text.Json.JsonSerializer.Serialize(data, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] SaveSettings Error: {ex.Message}");
}
}
public static void LoadSettings()
{
try
{
if (!File.Exists(_settingsPath)) return;
var json = File.ReadAllText(_settingsPath);
var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("prefixes", out var p)) SkipPrefixes = p.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("extensions", out var e2)) SkipExtensions = e2.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("regexes", out var r)) RegexFilters = r.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("ips", out var i)) BlacklistIps = i.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("rateLimit", out var rl)) RateLimitMs = rl.GetInt32();
Console.WriteLine("[Telemetry] Settings loaded from disk.");
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] LoadSettings Error: {ex.Message}");
}
}
}
-13
View File
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Telemetry</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
</ItemGroup>
</Project>
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
{
"enable": true,
"dynamic": true,
"tree": [
"Controllers",
"Models",
"AppDbContext.cs",
"ModInit.cs",
"RequestListener.cs"
]
}
+15 -1
View File
@@ -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)
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"))
{
+25 -10
View File
@@ -42,10 +42,12 @@
try {
json = JSON.parse(str);
} catch (e) {}
} catch (e) { }
if (json.streams) callback(json);
}, false, false, {
callback(json.streams && json.streams.length ? json : null);
}, function () {
callback(null);
}, false, {
dataType: 'text'
});
}
@@ -115,7 +117,7 @@
}
}
},
get: function get() {}
get: function get() { }
});
new_tracks.push(elem);
});
@@ -165,7 +167,7 @@
}
}
},
get: function get() {}
get: function get() { }
});
new_subs.push(elem);
});
@@ -187,8 +189,8 @@
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();
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);
}
@@ -262,12 +264,13 @@
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();
if (webos_replace.subs) setWebosSubs(webos_replace.subs); else setSubs();
if (webos_replace.tracks) setWebosTracks(webos_replace.tracks); else setTracks();
}
});
}
@@ -296,6 +299,11 @@
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) {
@@ -392,10 +400,17 @@
parseMetainfo(data);
}
});
Lampa.Lang.add({
tracks_nometa: {
ru: 'Не удалось получить сведения о дорожках',
uk: 'Не вдалося отримати відомості про доріжки',
en: 'Failed to get track info'
}
});
Lampa.Template.add('tracks_loading', "\n <div class=\"tracks-loading\">\n <span>#{loading}...</span>\n </div>\n");
Lampa.Template.add('tracks_metainfo', "\n <div class=\"tracks-metainfo\"></div>\n");
Lampa.Template.add('tracks_metainfo_block', "\n <div class=\"tracks-metainfo__line\">\n <div class=\"tracks-metainfo__label\"></div>\n <div class=\"tracks-metainfo__info\"></div>\n </div>\n");
Lampa.Template.add('tracks_css', "\n <style>\n .tracks-loading{margin-top:1em;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-moz-box-align:start;-ms-flex-align:start;align-items:flex-start}.tracks-loading:before{content:'';display:inline-block;width:1.3em;height:1.3em;background:url('./img/loader.svg') no-repeat 50% 50%;-webkit-background-size:contain;-moz-background-size:contain;-o-background-size:contain;background-size:contain;margin-right:.4em}.tracks-loading>span{font-size:1.1em;line-height:1.1}.tracks-metainfo{margin-top:1em}.tracks-metainfo__line+.tracks-metainfo__line{margin-top:2em}.tracks-metainfo__label{opacity:.5;font-weight:600}.tracks-metainfo__info{padding-top:1em;line-height:1.2}.tracks-metainfo__info>div{background-color:rgba(0,0,0,0.22);display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-ms-flexbox;display:flex;-webkit-border-radius:.3em;-moz-border-radius:.3em;border-radius:.3em;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.tracks-metainfo__info>div.focus{background-color:rgba(255,255,255,0.06)}.tracks-metainfo__info>div>div{padding:1em;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0}.tracks-metainfo__info>div>div:not(:last-child){padding-right:1.5em}.tracks-metainfo__info>div+div{margin-top:1em}.tracks-metainfo__column--video,.tracks-metainfo__column--name{margin-right:auto}.tracks-metainfo__column--num{min-width:3em;padding-right:0}.tracks-metainfo__column--rate{min-width:7em;text-align:right}.tracks-metainfo__column--channels{min-width:5em;text-align:right}\n </style>\n");
Lampa.Template.add('tracks_css', "\n <style>\n .tracks-loading{margin-top:1em;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-moz-box-align:start;-ms-flex-align:start;align-items:flex-start}.tracks-loading:before{content:'';display:inline-block;width:1.3em;height:1.3em;background:url('./img/loader.svg') no-repeat 50% 50%;-webkit-background-size:contain;-moz-background-size:contain;-o-background-size:contain;background-size:contain;margin-right:.4em}.tracks-loading>span{font-size:1.1em;line-height:1.1}.tracks-loading--error{opacity:.5}.tracks-loading--error:before{display:none}.tracks-metainfo{margin-top:1em}.tracks-metainfo__line+.tracks-metainfo__line{margin-top:2em}.tracks-metainfo__label{opacity:.5;font-weight:600}.tracks-metainfo__info{padding-top:1em;line-height:1.2}.tracks-metainfo__info>div{background-color:rgba(0,0,0,0.22);display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-ms-flexbox;display:flex;-webkit-border-radius:.3em;-moz-border-radius:.3em;border-radius:.3em;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.tracks-metainfo__info>div.focus{background-color:rgba(255,255,255,0.06)}.tracks-metainfo__info>div>div{padding:1em;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0}.tracks-metainfo__info>div>div:not(:last-child){padding-right:1.5em}.tracks-metainfo__info>div+div{margin-top:1em}.tracks-metainfo__column--video,.tracks-metainfo__column--name{margin-right:auto}.tracks-metainfo__column--num{min-width:3em;padding-right:0}.tracks-metainfo__column--rate{min-width:7em;text-align:right}.tracks-metainfo__column--channels{min-width:5em;text-align:right}\n </style>\n");
$('body').append(Lampa.Template.get('tracks_css', {}, true));
})();
-594
View File
@@ -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<TranscodingController>();
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string>("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<IActionResult> 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<string>("codec_type") != "subtitle")
continue;
if (s.Value<string>("codec_name") is "subrip" or "webvtt" or "ass" or "ssa" or "mov_text" or "ttml" or "sami")
{
int subIndex = s.Value<int>("index");
if (subIndex == 0)
continue;
string uri = $"{CoreInit.Host(HttpContext)}/transcoding/{streamId}/{AccsDbInvk.Args($"subs_{subIndex}.vtt", HttpContext)}";
string name = s["tags"].Value<string>("title")
?? s["language"].Value<string>("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<IActionResult> 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
}
-54
View File
@@ -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<WafLimitRootMap>()
{
new("^/transcoding/", new WafLimitMap { limit = 50, second = 1 })
}
});
}
}
-153
View File
@@ -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<string>();
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<string, string[]> comand { get; set; } = new Dictionary<string, string[]>()
{
["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; }
/// <summary>
/// hls_time
/// </summary>
public int segDur { get; set; } = 6;
/// <summary>
/// hls_list_size
/// </summary>
public int winSize { get; set; } = 10;
/// <summary>
/// hls_segment_type fmp4 / mpegts
/// </summary>
public bool fmp4 { get; set; } = true;
[JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
public Dictionary<string, string[]> comand { get; set; } = new Dictionary<string, string[]>()
{
["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<string>();
[JsonProperty("comand_transcode", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
public Dictionary<string, string[]> comand_transcode { get; set; } = new Dictionary<string, string[]>()
{
["default"] = ["-c:a aac", "-ac {stereo}", "-b:a {bitrateKbps}", "-profile:a aac_low"]
};
}
public class TranscodingPlaylistOptions
{
/// <summary>
/// sped
/// </summary>
public double readrate { get; set; } = 1.6;
/// <summary>
/// 10 MB
/// </summary>
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<string, string[]> comand { get; set; } = new Dictionary<string, string[]>()
{
["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
);
-35
View File
@@ -1,35 +0,0 @@
<b>Актуальная реализация</b>:<br>
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`).
-130
View File
@@ -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<TranscodingJob>();
private const int MaxLogLines = 200;
private readonly LinkedList<string> _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");
}
}
}
@@ -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<string, string> headers { get; set; }
public bool live { get; set; }
public bool? subtitles { get; set; }
}
public enum TranscodingJobState
{
Running,
Idle,
Stopped
}
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="plugin.js">
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project>
-9
View File
@@ -1,9 +0,0 @@
{
"enable": true,
"tree": [
"Services",
"Controller.cs",
"ModInit.cs",
"ModuleConf.cs"
]
}
-356
View File
@@ -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);
}
})();
-72
View File
@@ -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<WafLimitRootMap> limit_map { get; set; } = new()
{
new("^/watchtogether/", new WafLimitMap { limit = 30, second = 1 }),
new("^/wt/c/", new WafLimitMap { limit = 10, second = 1 })
};
}
/// <summary>
/// WatchTogether: a lampac module that acts as both the lparty relay and a
/// shadow host keeping rooms alive when their creator disconnects.
/// </summary>
public class ModInit : IModuleLoaded, IModuleConfigure
{
public static string modpath;
public static WatchTogetherConf conf;
public void Configure(ConfigureModel app)
{
app.services.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, RelayStartupFilter>();
}
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;
}
}
}
}
-44
View File
@@ -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://<lampac>/wt/c/lparty-r-<sha256(КОД|пароль)[0:24]>` и является хостом.
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=<base64(КОД:пароль)>` и `?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://<lampac>/watch_together/?room=<invite-код>` — ссылку формирует плагин.
-456
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class RelayStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> 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);
/// <summary>LParty peer id (the u field of the member's messages).</summary>
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<string, RelayMember> Members = new();
}
/// <summary>
/// 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).
/// </summary>
public static class RelayServer
{
static long uidSeq = 0;
static readonly ConcurrentDictionary<string, RelayChannel> Channels = new();
public static RelayChannel TryGetChannel(string name) =>
Channels.TryGetValue(name, out var channel) ? channel : null;
/// <summary>Member uids excluding the server's virtual member.</summary>
public static List<string> 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<RelayMember> 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<RelayMember>();
total = 0;
}
else
{
channel.Members[member.Uid] = member;
users = new List<RelayMember>(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);
}
}
/// <summary>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.</summary>
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<byte>(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<byte>(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<byte>(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<RelayMember> 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();
}
}
}
-826
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Room code as announced by the host in the lobby (ad frames);
/// lets the server list and re-announce rooms it keeps alive.</summary>
public string Code;
// server-side hold barrier (only while Hosting)
public HashSet<string> HoldWaiting;
public double HoldPosition;
public Timer HoldTimer;
public DateTime CreatedUtc = DateTime.UtcNow;
public DateTime LastActivityUtc = DateTime.UtcNow;
/// <summary>Display name of the member that announced the room.</summary>
public string OwnerName;
}
/// <summary>A room advertisement overheard in the lobby channel.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<string, ShadowRoom> rooms = new();
/// <summary>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.</summary>
static readonly ConcurrentDictionary<string, LobbyAd> lobbyAds = new();
/// <summary>pid -> last hello, per channel; used to skip the join hold
/// for members that are reconnecting rather than new.</summary>
static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, long>> knownPids = new();
/// <summary>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.</summary>
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
/// <summary>Whether the server's virtual member must exist in this
/// channel right now (it is the acting host).</summary>
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<string, long>());
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;
}
}
/// <summary>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.</summary>
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<string>(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<string, long>());
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);
}
/// <summary>Server-hosting room, newcomer arrived: introduce the room
/// and, like a real host, freeze it while they buffer.</summary>
static void AnswerHelloAsHost(ShadowRoom room, RelayChannel channel, RelayMember member, ConcurrentDictionary<string, long> 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<string>(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);
}
}
/// <summary>Freeze the clock base at the current position.</summary>
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
/// <summary>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.</summary>
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;
}
}
}
-26
View File
@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
<ProjectReference Include="..\..\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="manifest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="plugin.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="web\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -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
{
/// <summary>
/// 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).
/// </summary>
[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<string, string> transform)
{
string path = Path.Combine(ModInit.modpath, name);
if (!System.IO.File.Exists(path))
return Content("<html><body><h2>" + name + " not found</h2></body></html>", "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);
}
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"enable": true,
"dynamic": true,
"tree": [
"ModInit.cs",
"RelayServer.cs",
"ShadowHost.cs",
"WatchTogetherController.cs"
]
}
File diff suppressed because it is too large Load Diff
-324
View File
@@ -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));
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-386
View File
@@ -1,386 +0,0 @@
/**
* Friendly names for a participant — "Clever Mango" rather than a random id.
*
* The idea, the adjective-then-greengrocer shape and the word lists come from LocalSend
* (Apache License 2.0) by way of the JAPP player, which solves the same problem: something
* a stranger can read off a screen and repeat out loud, that says nothing about the device
* or its owner. 222 x 137 is 30414, which puts two people in a room of five
* drawing the same name at around one chance in three thousand.
*
* Deliberately not localised, exactly as LocalSend is not, so a name generated here reads
* the same as one generated in the Android player.
*/
const ADJECTIVES = [
"Able",
"Active",
"Adorable",
"Agile",
"Alert",
"Amber",
"Amused",
"Ancient",
"Artful",
"Autumn",
"Balanced",
"Beautiful",
"Big",
"Bold",
"Brave",
"Breezy",
"Bright",
"Brisk",
"Calm",
"Candid",
"Careful",
"Cheerful",
"Chosen",
"Civil",
"Classic",
"Clean",
"Clear",
"Clever",
"Cool",
"Cosmic",
"Crafty",
"Crimson",
"Crisp",
"Cunning",
"Curious",
"Cute",
"Dapper",
"Daring",
"Dazzling",
"Deep",
"Deft",
"Determined",
"Devoted",
"Diligent",
"Distant",
"Divine",
"Eager",
"Early",
"Earnest",
"Easy",
"Efficient",
"Elder",
"Electric",
"Elegant",
"Endless",
"Energetic",
"Epic",
"Equal",
"Eternal",
"Exact",
"Fabled",
"Faithful",
"Famous",
"Fantastic",
"Fast",
"Fearless",
"Feisty",
"Fertile",
"Festive",
"Fiery",
"Fine",
"Firm",
"Fond",
"Frank",
"Free",
"Fresh",
"Friendly",
"Frosty",
"Gallant",
"Gentle",
"Genuine",
"Gifted",
"Glad",
"Gleaming",
"Glossy",
"Golden",
"Good",
"Gorgeous",
"Graceful",
"Grand",
"Grateful",
"Great",
"Handsome",
"Happy",
"Hardy",
"Hearty",
"Helpful",
"Heroic",
"Hidden",
"Honest",
"Hopeful",
"Hot",
"Humble",
"Ideal",
"Immense",
"Inner",
"Iron",
"Jolly",
"Joyful",
"Keen",
"Kind",
"Lively",
"Lovely",
"Loyal",
"Lucid",
"Lucky",
"Lunar",
"Magic",
"Major",
"Merry",
"Mighty",
"Mild",
"Modern",
"Modest",
"Mystic",
"Neat",
"Nice",
"Nimble",
"Noble",
"Northern",
"Novel",
"Open",
"Orbital",
"Original",
"Patient",
"Peaceful",
"Perfect",
"Playful",
"Pleasant",
"Polished",
"Polite",
"Powerful",
"Precise",
"Pretty",
"Prime",
"Proud",
"Prudent",
"Quick",
"Quiet",
"Radiant",
"Rapid",
"Ready",
"Refined",
"Regal",
"Reliable",
"Rich",
"Robust",
"Royal",
"Ruby",
"Rustic",
"Sage",
"Scarlet",
"Secret",
"Serene",
"Sharp",
"Shining",
"Silent",
"Silver",
"Sincere",
"Skilled",
"Sleek",
"Smart",
"Smooth",
"Snowy",
"Solar",
"Solid",
"Southern",
"Special",
"Spirited",
"Splendid",
"Spry",
"Stately",
"Steady",
"Sterling",
"Stormy",
"Strategic",
"Strong",
"Sturdy",
"Subtle",
"Sunny",
"Superb",
"Supreme",
"Swift",
"Tactful",
"Tender",
"Thankful",
"Thoughtful",
"Tidy",
"Timely",
"Tranquil",
"True",
"Trusty",
"Unique",
"Upbeat",
"Valiant",
"Velvet",
"Vibrant",
"Vigilant",
"Violet",
"Vivid",
"Warm",
"Watchful",
"Welcome",
"Whole",
"Wild",
"Willing",
"Wise",
"Witty",
"Wondrous",
"Worthy",
"Zealous",
"Zesty",
];
const FRUITS = [
"Almond",
"Apple",
"Apricot",
"Artichoke",
"Arugula",
"Asparagus",
"Aubergine",
"Avocado",
"Banana",
"Barley",
"Basil",
"Beetroot",
"Blackberry",
"Blackcurrant",
"Blueberry",
"Broccoli",
"Cabbage",
"Cantaloupe",
"Caper",
"Cardamom",
"Carrot",
"Cashew",
"Cauliflower",
"Celery",
"Chard",
"Cherry",
"Chestnut",
"Chickpea",
"Chicory",
"Chili",
"Chive",
"Cilantro",
"Clementine",
"Cloudberry",
"Clove",
"Coconut",
"Cranberry",
"Cucumber",
"Currant",
"Damson",
"Date",
"Dill",
"Durian",
"Eggplant",
"Elderberry",
"Endive",
"Fennel",
"Fig",
"Garlic",
"Ginger",
"Gooseberry",
"Grape",
"Grapefruit",
"Guava",
"Hazelnut",
"Honeydew",
"Horseradish",
"Jackfruit",
"Jicama",
"Kale",
"Kiwi",
"Kohlrabi",
"Kumquat",
"Leek",
"Lemon",
"Lentil",
"Lettuce",
"Lime",
"Longan",
"Loquat",
"Lychee",
"Mandarin",
"Mango",
"Marrow",
"Melon",
"Millet",
"Mint",
"Mulberry",
"Mushroom",
"Nectarine",
"Nutmeg",
"Oat",
"Okra",
"Olive",
"Onion",
"Orange",
"Oregano",
"Papaya",
"Paprika",
"Parsley",
"Parsnip",
"Passionfruit",
"Pea",
"Peach",
"Peanut",
"Pear",
"Pecan",
"Pepper",
"Persimmon",
"Pineapple",
"Pistachio",
"Plantain",
"Plum",
"Pomegranate",
"Pomelo",
"Poppy",
"Potato",
"Pumpkin",
"Quince",
"Radish",
"Raisin",
"Raspberry",
"Rhubarb",
"Rosemary",
"Rutabaga",
"Saffron",
"Sesame",
"Shallot",
"Sorrel",
"Soybean",
"Spinach",
"Sprout",
"Squash",
"Strawberry",
"Tamarind",
"Tangerine",
"Tarragon",
"Thyme",
"Tomato",
"Turnip",
"Vanilla",
"Walnut",
"Wasabi",
"Watercress",
"Watermelon",
"Yam",
"Zucchini",
];
const pick = (list) =>
list[crypto.getRandomValues(new Uint32Array(1))[0] % list.length];
export function randomAlias() {
return pick(ADJECTIVES) + " " + pick(FRUITS);
}
export const ALIAS_WORDS = { ADJECTIVES, FRUITS };
-9
View File
@@ -1,9 +0,0 @@
{
"enable": true,
"dynamic": true,
"tree": [
"AdminPanelController.cs",
"ConfigSectionGroups.cs",
"ModInit.cs"
]
}
-17
View File
@@ -1,17 +0,0 @@
{
"enable": true,
"dynamic": true,
"references": [
"YoutubeExplode"
],
"tree": [
"Controllers",
"Models",
"Providers",
"SQL",
"Services",
"GlobalUsings.cs",
"ModInit.cs",
"ModuleConf.cs"
]
}
-12
View File
@@ -1,12 +0,0 @@
{
"enable": true,
"dynamic": true,
"references": [
"references/"
],
"tree": [
"Models",
"Controllers",
"ModInit.cs"
]
}
-9
View File
@@ -1,9 +0,0 @@
{
"enable": true,
"dynamic": true,
"tree": [
"Controller.cs",
"ModInit.cs",
"ModuleConf.cs"
]
}
+982
View File
@@ -0,0 +1,982 @@
(function() {
'use strict';
function getBalancerUrl() {
var servers = [
'http://hdpoisk.ru:2053'
];
return servers[Math.floor(Math.random() * servers.length)];
}
// ---------------------------------------------------
var default_host = 'http://hdpoisk.ru:2053';
var Defined = {
api: 'lampac',
localhost: default_host + '/',
apn: ''
};
var balansers_with_search;
var unic_id = Lampa.Storage.get('lampac_unic_id', '');
if (!unic_id) {
unic_id = Lampa.Utils.uid(8).toLowerCase();
Lampa.Storage.set('lampac_unic_id', unic_id);
}
function getAndroidVersion() {
if (Lampa.Platform.is('android')) {
try {
var current = AndroidJS.appVersion().split('-');
return parseInt(current.pop());
} catch (e) {
return 0;
}
} else {
return 0;
}
}
var hostkey = 'hdpoisk.ru:2053'; // Используем постоянный ключ для хранилища, чтобы не терять сессию
if (!window.rch_nws || !window.rch_nws[hostkey]) {
if (!window.rch_nws) window.rch_nws = {};
window.rch_nws[hostkey] = {
type: Lampa.Platform.is('android') ? 'apk' : Lampa.Platform.is('tizen') ? 'cors' : undefined,
startTypeInvoke: false,
rchRegistry: false,
apkVersion: getAndroidVersion()
};
}
window.rch_nws[hostkey].typeInvoke = function rchtypeInvoke(host, call) {
if (!window.rch_nws[hostkey].startTypeInvoke) {
window.rch_nws[hostkey].startTypeInvoke = true;
var check = function check(good) {
window.rch_nws[hostkey].type = Lampa.Platform.is('android') ? 'apk' : good ? 'cors' : 'web';
call();
};
if (Lampa.Platform.is('android') || Lampa.Platform.is('tizen')) check(true);
else {
var net = new Lampa.Reguest();
// Тут тоже используем динамический хост для проверки
var check_url = getBalancerUrl();
net.silent(check_url.indexOf(location.host) >= 0 ? 'https://github.com/' : check_url + '/cors/check', function() {
check(true);
}, function() {
check(false);
}, false, {
dataType: 'text'
});
}
} else call();
};
window.rch_nws[hostkey].Registry = function RchRegistry(client, startConnection) {
// Вызываем с динамическим хостом
window.rch_nws[hostkey].typeInvoke(getBalancerUrl(), function() {
client.invoke("RchRegistry", JSON.stringify({
version: 149,
host: location.host,
rchtype: Lampa.Platform.is('android') ? 'apk' : Lampa.Platform.is('tizen') ? 'cors' : window.rch_nws[hostkey].type,
apkVersion: window.rch_nws[hostkey].apkVersion,
player: Lampa.Storage.field('player')
}));
if (client._shouldReconnect && window.rch_nws[hostkey].rchRegistry) {
if (startConnection) startConnection();
return;
}
window.rch_nws[hostkey].rchRegistry = true;
client.on('RchRegistry', function(clientIp) {
if (startConnection) startConnection();
});
client.on("RchClient", function(rchId, url, data, headers, returnHeaders) {
var network = new Lampa.Reguest();
function result(html) {
if (Lampa.Arrays.isObject(html) || Lampa.Arrays.isArray(html)) {
html = JSON.stringify(html);
}
if (typeof CompressionStream !== 'undefined' && html && html.length > 1000) {
var compressionStream = new CompressionStream('gzip');
var encoder = new TextEncoder();
var readable = new ReadableStream({
start: function(controller) {
controller.enqueue(encoder.encode(html));
controller.close();
}
});
var compressedStream = readable.pipeThrough(compressionStream);
new Response(compressedStream).arrayBuffer()
.then(function(compressedBuffer) {
var compressedArray = new Uint8Array(compressedBuffer);
if (compressedArray.length > html.length) {
client.invoke("RchResult", rchId, html);
} else {
$.ajax({
// Используем динамический хост для POST результата
url: getBalancerUrl() + '/rch/gzresult?id=' + rchId,
type: 'POST',
data: compressedArray,
async: true,
cache: false,
contentType: false,
processData: false,
success: function(j) {},
error: function() {
client.invoke("RchResult", rchId, html);
}
});
}
})
.catch(function() {
client.invoke("RchResult", rchId, html);
});
} else {
client.invoke("RchResult", rchId, html);
}
}
if (url == 'eval') {
console.log('RCH', url, data);
result(eval(data));
} else if (url == 'ping') {
result('pong');
} else {
console.log('RCH', url);
network["native"](url, result, function() {
console.log('RCH', 'result empty');
result('');
}, data, {
dataType: 'text',
timeout: 1000 * 8,
headers: headers,
returnHeaders: returnHeaders
});
}
});
client.on('Connected', function(connectionId) {
console.log('RCH', 'ConnectionId: ' + connectionId);
window.rch_nws[hostkey].connectionId = connectionId;
});
client.on('Closed', function() {
console.log('RCH', 'Connection closed');
});
client.on('Error', function(err) {
console.log('RCH', 'error:', err);
});
});
};
window.rch_nws[hostkey].typeInvoke(default_host, function() {});
function rchInvoke(json, call) {
if (window.nwsClient && window.nwsClient[hostkey] && window.nwsClient[hostkey]._shouldReconnect){
call();
return;
}
if (!window.nwsClient) window.nwsClient = {};
if (window.nwsClient[hostkey] && window.nwsClient[hostkey].socket)
window.nwsClient[hostkey].socket.close();
window.nwsClient[hostkey] = new NativeWsClient(json.nws, {
autoReconnect: false
});
window.nwsClient[hostkey].on('Connected', function(connectionId) {
window.rch_nws[hostkey].Registry(window.nwsClient[hostkey], function() {
call();
});
});
window.nwsClient[hostkey].connect();
}
function rchRun(json, call) {
if (typeof NativeWsClient == 'undefined') {
// Используем динамический хост для загрузки скрипта клиента
Lampa.Utils.putScript([getBalancerUrl() + "/js/nws-client-es5.js?v18112025"], function() {}, false, function() {
rchInvoke(json, call);
}, true);
} else {
rchInvoke(json, call);
}
}
function account(url) {
url = url + '';
// --- ДИНАМИЧЕСКАЯ ПОДМЕНА ХОСТА ---
// При каждом вызове account, мы берем новый случайный сервер
// и заменяем любые известные домены на него.
var random_host = getBalancerUrl();
// Список доменов, которые мы хотим подменять на лету
var replaceable = [
'http://hdpoisk.ru:2053'
];
for (var i = 0; i < replaceable.length; i++) {
if (url.indexOf(replaceable[i]) !== -1) {
url = url.replace(replaceable[i], random_host);
break; // Заменили один раз и выходим
}
}
// ----------------------------------
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 = '';
if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token=');
}
if (url.indexOf('nws_id=') == -1 && window.rch_nws && window.rch_nws[hostkey]) {
var nws_id = window.rch_nws[hostkey].connectionId || '';
if (nws_id) url = Lampa.Utils.addUrlComponent(url, 'nws_id=' + encodeURIComponent(nws_id));
}
return url;
}
var Network = Lampa.Reguest;
function component(object) {
var network = new Network();
var scroll = new Lampa.Scroll({
mask: true,
over: true
});
var files = new Lampa.Explorer(object);
var filter = new Lampa.Filter(object);
var sources = {};
var last;
var source;
var balanser;
var initialized;
var balanser_timer;
var images = [];
var number_of_requests = 0;
var number_of_requests_timer;
var life_wait_times = 0;
var life_wait_timer;
var filter_sources = {};
var filter_translate = {
season: Lampa.Lang.translate('torrent_serial_season'),
voice: Lampa.Lang.translate('torrent_parser_voice'),
source: Lampa.Lang.translate('settings_rest_source')
};
var filter_find = {
season: [],
voice: []
};
if (balansers_with_search == undefined) {
network.timeout(10000);
network.silent(account(Defined.localhost + 'lite/withsearch'), function(json) {
balansers_with_search = json;
}, function() {
balansers_with_search = [];
});
}
function balanserName(j) {
var bals = j.balanser;
var name = j.name.split(' ')[0];
return (bals || name).toLowerCase();
}
function clarificationSearchAdd(value){
var id = Lampa.Utils.hash(object.movie.number_of_seasons ? object.movie.original_name : object.movie.original_title);
var all = Lampa.Storage.get('clarification_search','{}');
all[id] = value;
Lampa.Storage.set('clarification_search',all);
}
function clarificationSearchDelete(){
var id = Lampa.Utils.hash(object.movie.number_of_seasons ? object.movie.original_name : object.movie.original_title);
var all = Lampa.Storage.get('clarification_search','{}');
delete all[id];
Lampa.Storage.set('clarification_search',all);
}
function clarificationSearchGet(){
var id = Lampa.Utils.hash(object.movie.number_of_seasons ? object.movie.original_name : object.movie.original_title);
var all = Lampa.Storage.get('clarification_search','{}');
return all[id];
}
this.initialize = function() {
var _this = this;
this.loading(true);
filter.onSearch = function(value) {
clarificationSearchAdd(value);
Lampa.Activity.replace({
search: value,
clarification: true,
similar: true
});
};
filter.onBack = function() {
_this.start();
};
filter.render().find('.selector').on('hover:enter', function() {
clearInterval(balanser_timer);
});
filter.render().find('.filter--search').appendTo(filter.render().find('.torrent-filter'));
filter.onSelect = function(type, a, b) {
if (type == 'filter') {
if (a.reset) {
clarificationSearchDelete();
_this.replaceChoice({
season: 0,
voice: 0,
voice_url: '',
voice_name: ''
});
setTimeout(function() {
Lampa.Select.close();
Lampa.Activity.replace({
clarification: 0,
similar: 0
});
}, 10);
} else {
var url = filter_find[a.stype][b.index].url;
var choice = _this.getChoice();
if (a.stype == 'voice') {
choice.voice_name = filter_find.voice[b.index].title;
choice.voice_url = url;
}
choice[a.stype] = b.index;
_this.saveChoice(choice);
_this.reset();
_this.request(url);
setTimeout(Lampa.Select.close, 10);
}
} else if (type == 'sort') {
Lampa.Select.close();
object.lampac_custom_select = a.source;
_this.changeBalanser(a.source);
}
};
if (filter.addButtonBack) filter.addButtonBack();
filter.render().find('.filter--sort span').text(Lampa.Lang.translate('lampac_balanser'));
scroll.body().addClass('torrent-list');
files.appendFiles(scroll.render());
files.appendHead(filter.render());
scroll.minus(files.render().find('.explorer__files-head'));
scroll.body().append(Lampa.Template.get('lampac_content_loading'));
Lampa.Controller.enable('content');
this.loading(false);
if(object.balanser){
files.render().find('.filter--search').remove();
sources = {};
sources[object.balanser] = {name: object.balanser};
balanser = object.balanser;
filter_sources = [];
return network["native"](account(object.url.replace('rjson=','nojson=')), this.parse.bind(this), function(){
files.render().find('.torrent-filter').remove();
_this.empty();
}, false, {
dataType: 'text'
});
}
this.externalids().then(function() {
return _this.createSource();
}).then(function(json) {
if (!balansers_with_search.find(function(b) {
return balanser.slice(0, b.length) == b;
})) {
filter.render().find('.filter--search').addClass('hide');
}
_this.search();
})["catch"](function(e) {
_this.noConnectToServer(e);
});
};
this.rch = function(json, noreset) {
var _this2 = this;
rchRun(json, function() {
if (!noreset) _this2.find();
else noreset();
});
};
this.externalids = function() {
return new Promise(function(resolve, reject) {
if (!object.movie.imdb_id || !object.movie.kinopoisk_id) {
var query = [];
query.push('id=' + encodeURIComponent(object.movie.id));
query.push('serial=' + (object.movie.name ? 1 : 0));
if (object.movie.imdb_id) query.push('imdb_id=' + (object.movie.imdb_id || ''));
if (object.movie.kinopoisk_id) query.push('kinopoisk_id=' + (object.movie.kinopoisk_id || ''));
var url = Defined.localhost + 'externalids?' + query.join('&');
network.timeout(10000);
network.silent(account(url), function(json) {
for (var name in json) {
object.movie[name] = json[name];
}
resolve();
}, function() {
resolve();
});
} else resolve();
});
};
this.updateBalanser = function(balanser_name) {
var last_select_balanser = Lampa.Storage.cache('online_last_balanser', 3000, {});
last_select_balanser[object.movie.id] = balanser_name;
Lampa.Storage.set('online_last_balanser', last_select_balanser);
};
this.changeBalanser = function(balanser_name) {
this.updateBalanser(balanser_name);
Lampa.Storage.set('online_balanser', balanser_name);
var to = this.getChoice(balanser_name);
var from = this.getChoice();
if (from.voice_name) to.voice_name = from.voice_name;
this.saveChoice(to, balanser_name);
Lampa.Activity.replace();
};
this.requestParams = function(url) {
var query = [];
var card_source = object.movie.source || 'tmdb'; //Lampa.Storage.field('source')
query.push('id=' + encodeURIComponent(object.movie.id));
if (object.movie.imdb_id) query.push('imdb_id=' + (object.movie.imdb_id || ''));
if (object.movie.kinopoisk_id) query.push('kinopoisk_id=' + (object.movie.kinopoisk_id || ''));
if (object.movie.tmdb_id) query.push('tmdb_id=' + (object.movie.tmdb_id || ''));
query.push('title=' + encodeURIComponent(object.clarification ? object.search : object.movie.title || object.movie.name));
query.push('original_title=' + encodeURIComponent(object.movie.original_title || object.movie.original_name));
query.push('serial=' + (object.movie.name ? 1 : 0));
query.push('original_language=' + (object.movie.original_language || ''));
query.push('year=' + ((object.movie.release_date || object.movie.first_air_date || '0000') + '').slice(0, 4));
query.push('source=' + card_source);
query.push('clarification=' + (object.clarification ? 1 : 0));
query.push('similar=' + (object.similar ? true : false));
query.push('rchtype=' + (((window.rch_nws && window.rch_nws[hostkey]) ? window.rch_nws[hostkey].type : (window.rch && window.rch[hostkey]) ? window.rch[hostkey].type : '') || ''));
if (Lampa.Storage.get('account_email', '')) query.push('cub_id=' + Lampa.Utils.hash(Lampa.Storage.get('account_email', '')));
return url + (url.indexOf('?') >= 0 ? '&' : '?') + query.join('&');
};
this.getLastChoiceBalanser = function() {
var last_select_balanser = Lampa.Storage.cache('online_last_balanser', 3000, {});
if (last_select_balanser[object.movie.id]) {
return last_select_balanser[object.movie.id];
} else {
return Lampa.Storage.get('online_balanser', filter_sources.length ? filter_sources[0] : '');
}
};
this.startSource = function(json) {
return new Promise(function(resolve, reject) {
json.forEach(function(j) {
var name = balanserName(j);
sources[name] = {
url: j.url,
name: j.name,
show: typeof j.show == 'undefined' ? true : j.show
};
});
filter_sources = Lampa.Arrays.getKeys(sources);
if (filter_sources.length) {
var last_select_balanser = Lampa.Storage.cache('online_last_balanser', 3000, {});
if (last_select_balanser[object.movie.id]) {
balanser = last_select_balanser[object.movie.id];
} else {
balanser = Lampa.Storage.get('online_balanser', filter_sources[0]);
}
if (!sources[balanser]) balanser = filter_sources[0];
if (!sources[balanser].show && !object.lampac_custom_select) balanser = filter_sources[0];
source = sources[balanser].url;
Lampa.Storage.set('active_balanser', balanser);
resolve(json);
} else {
reject();
}
});
};
this.lifeSource = function() {
var _this3 = this;
return new Promise(function(resolve, reject) {
var url = _this3.requestParams(Defined.localhost + 'lifeevents?memkey=' + (_this3.memkey || ''));
var red = false;
var gou = function gou(json, any) {
if (json.accsdb) return reject(json);
var last_balanser = _this3.getLastChoiceBalanser();
if (!red) {
var _filter = json.online.filter(function(c) {
return any ? c.show : c.show && c.name.toLowerCase() == last_balanser;
});
if (_filter.length) {
red = true;
resolve(json.online.filter(function(c) {
return c.show;
}));
} else if (any) {
reject();
}
}
};
var fin = function fin(call) {
network.timeout(3000);
network.silent(account(url), function(json) {
life_wait_times++;
filter_sources = [];
sources = {};
json.online.forEach(function(j) {
var name = balanserName(j);
sources[name] = {
url: j.url,
name: j.name,
show: typeof j.show == 'undefined' ? true : j.show
};
});
filter_sources = Lampa.Arrays.getKeys(sources);
filter.set('sort', filter_sources.map(function(e) {
return {
title: sources[e].name,
source: e,
selected: e == balanser,
ghost: !sources[e].show
};
}));
filter.chosen('sort', [sources[balanser] ? sources[balanser].name : balanser]);
gou(json);
var lastb = _this3.getLastChoiceBalanser();
if (life_wait_times > 15 || json.ready) {
filter.render().find('.lampac-balanser-loader').remove();
gou(json, true);
} else if (!red && sources[lastb] && sources[lastb].show) {
gou(json, true);
life_wait_timer = setTimeout(fin, 1000);
} else {
life_wait_timer = setTimeout(fin, 1000);
}
}, function() {
life_wait_times++;
if (life_wait_times > 15) {
reject();
} else {
life_wait_timer = setTimeout(fin, 1000);
}
});
};
fin();
});
};
this.createSource = function() {
var _this4 = this;
return new Promise(function(resolve, reject) {
var url = _this4.requestParams(Defined.localhost + 'lite/events?life=true');
network.timeout(15000);
network.silent(account(url), function(json) {
if (json.accsdb) return reject(json);
if (json.life) {
_this4.memkey = json.memkey;
if (json.title) {
if (object.movie.name) object.movie.name = json.title;
if (object.movie.title) object.movie.title = json.title;
}
filter.render().find('.filter--sort').append('<span class="lampac-balanser-loader" style="width: 1.2em; height: 1.2em; margin-top: 0; background: url(./img/loader.svg) no-repeat 50% 50%; background-size: contain; margin-left: 0.5em"></span>');
_this4.lifeSource().then(_this4.startSource).then(resolve)["catch"](reject);
} else {
_this4.startSource(json).then(resolve)["catch"](reject);
}
}, reject);
});
};
/**
* Подготовка
*/
this.create = function() {
return this.render();
};
/**
* Начать поиск
*/
this.search = function() { //this.loading(true)
this.filter({
source: filter_sources
}, this.getChoice());
this.find();
};
this.find = function() {
this.request(this.requestParams(source));
};
this.request = function(url) {
number_of_requests++;
if (number_of_requests < 10) {
network["native"](account(url), this.parse.bind(this), this.doesNotAnswer.bind(this), false, {
dataType: 'text'
});
clearTimeout(number_of_requests_timer);
number_of_requests_timer = setTimeout(function() {
number_of_requests = 0;
}, 4000);
} else this.empty();
};
this.parseJsonDate = function(str, name) {
try {
var html = $('<div>' + str + '</div>');
var elems = [];
html.find(name).each(function() {
var item = $(this);
var data = JSON.parse(item.attr('data-json'));
var season = item.attr('s');
var episode = item.attr('e');
var text = item.text();
if (!object.movie.name) {
if (text.match(/\d+p/i)) {
if (!data.quality) {
data.quality = {};
data.quality[text] = data.url;
}
text = object.movie.title;
}
if (text == 'По умолчанию') {
text = object.movie.title;
}
}
if (episode) data.episode = parseInt(episode);
if (season) data.season = parseInt(season);
if (text) data.text = text;
data.active = item.hasClass('active');
elems.push(data);
});
return elems;
} catch (e) {
return [];
}
};
this.getFileUrl = function(file, call, waiting_rch) {
var _this = this;
if(Lampa.Storage.field('player') !== 'inner' && file.stream && Lampa.Platform.is('apple')){
var newfile = Lampa.Arrays.clone(file);
newfile.method = 'play';
newfile.url = file.stream;
call(newfile, {});
}
else if (file.method == 'play') call(file, {});
else {
Lampa.Loading.start(function() {
Lampa.Loading.stop();
Lampa.Controller.toggle('content');
network.clear();
});
network["native"](account(file.url), function(json) {
if(json.rch){
if(waiting_rch) {
Lampa.Loading.stop();
call(false, {});
}
else {
_this.rch(json,function(){
Lampa.Loading.stop();
_this.getFileUrl(file, call, true);
});
}
}
else{
Lampa.Loading.stop();
call(json, json);
}
}, function() {
Lampa.Loading.stop();
call(false, {});
});
}
};
this.toPlayElement = function(file) {
var play = {
title: file.title,
url: file.url,
quality: file.qualitys,
timeline: file.timeline,
subtitles: file.subtitles,
segments: file.segments,
callback: file.mark,
season: file.season,
episode: file.episode,
voice_name: file.voice_name
};
return play;
};
this.orUrlReserve = function(data) {
if (data.url && typeof data.url == 'string' && data.url.indexOf(" or ") !== -1) {
var urls = data.url.split(" or ");
data.url = urls[0];
data.url_reserve = urls[1];
}
};
this.setDefaultQuality = function(data) {
if (Lampa.Arrays.getKeys(data.quality).length) {
for (var q in data.quality) {
if (parseInt(q) == Lampa.Storage.field('video_quality_default')) {
data.url = data.quality[q];
this.orUrlReserve(data);
}
if (data.quality[q].indexOf(" or ") !== -1)
data.quality[q] = data.quality[q].split(" or ")[0];
}
}
};
this.display = function(videos) {
var _this5 = this;
this.draw(videos, {
onEnter: function onEnter(item, html) {
_this5.getFileUrl(item, function(json, json_call) {
if (json && json.url) {
var playlist = [];
var first = _this5.toPlayElement(item);
first.url = json.url;
first.headers = json_call.headers || json.headers;
first.quality = json_call.quality || item.qualitys;
first.segments = json_call.segments || item.segments;
first.hls_manifest_timeout = json_call.hls_manifest_timeout || json.hls_manifest_timeout;
first.subtitles = json.subtitles;
first.subtitles_call = json_call.subtitles_call || json.subtitles_call;
if (json.vast && json.vast.url) {
first.vast_url = json.vast.url;
first.vast_msg = json.vast.msg;
first.vast_region = json.vast.region;
first.vast_platform = json.vast.platform;
first.vast_screen = json.vast.screen;
}
_this5.orUrlReserve(first);
_this5.setDefaultQuality(first);
if (item.season) {
videos.forEach(function(elem) {
var cell = _this5.toPlayElement(elem);
if (elem == item) cell.url = json.url;
else {
if (elem.method == 'call') {
if (Lampa.Storage.field('player') !== 'inner') {
cell.url = elem.stream;
delete cell.quality;
} else {
cell.url = function(call) {
_this5.getFileUrl(elem, function(stream, stream_json) {
if (stream.url) {
cell.url = stream.url;
cell.quality = stream_json.quality || elem.qualitys;
cell.segments = stream_json.segments || elem.segments;
cell.subtitles = stream.subtitles;
_this5.orUrlReserve(cell);
_this5.setDefaultQuality(cell);
elem.mark();
} else {
cell.url = '';
Lampa.Noty.show(Lampa.Lang.translate('lampac_nolink'));
}
call();
}, function() {
cell.url = '';
call();
});
};
}
} else {
cell.url = elem.url;
}
}
_this5.orUrlReserve(cell);
_this5.setDefaultQuality(cell);
playlist.push(cell);
}); //Lampa.Player.playlist(playlist)
} else {
playlist.push(first);
}
if (playlist.length > 1) first.playlist = playlist;
if (first.url) {
var element = first;
element.isonline = true;
if (element.url && element.isonline) {
// online.js
}
else if (element.url) {
if (false) {
if (Platform.is('browser') && location.host.indexOf("127.0.0.1") !== -1) {
Noty.show('Видео открыто в playerInner', {time: 3000});
$.get(getBalancerUrl() + '/player-inner/' + element.url);
return;
}
Player.play(element);
}
else {
if (true && Platform.is('browser') && location.host.indexOf("127.0.0.1") !== -1)
Noty.show('Внешний плеер можно указать в init.conf (playerInner)', {time: 3000});
Player.play(element);
}
}
Lampa.Player.play(element);
Lampa.Player.playlist(playlist);
if(element.subtitles_call) _this5.loadSubtitles(element.subtitles_call)
item.mark();
_this5.updateBalanser(balanser);
} else {
Lampa.Noty.show(Lampa.Lang.translate('lampac_nolink'));
}
} else Lampa.Noty.show(Lampa.Lang.translate('lampac_nolink'));
}, true);
},
onContextMenu: function onContextMenu(item, html, data, call) {
_this5.getFileUrl(item, function(stream) {
call({
file: stream.url,
quality: item.qualitys
});
}, true);
}
});
this.filter({
season: filter_find.season.map(function(s) {
return s.title;
}),
voice: filter_find.voice.map(function(b) {
return b.title;
})
}, this.getChoice());
};
this.loadSubtitles = function(link){
network.silent(account(link), function(subs){
Lampa.Player.subtitles(subs)
})
}
this.parse = function(str) {
var json = Lampa.Arrays.decodeJson(str, {});
if (Lampa.Arrays.isObject(str) && str.rch) json = str;
if (json.rch) return this.rch(json);
try {
var items = this.parseJsonDate(str, '.videos__item');
var buttons = this.parseJsonDate(str, '.videos__button');
if (items.length == 1 && items[0].method == 'link' && !items[0].similar) {
filter_find.season = items.map(function(s) {
return {
title: s.text,
url: s.url
};
});
this.replaceChoice({
season: 0
});
this.request(items[0].url);
} else {
this.activity.loader(false);
var videos = items.filter(function(v) {
return v.method == 'play' || v.method == 'call';
});
var similar = items.filter(function(v) {
return v.similar;
});
if (videos.length) {
if (buttons.length) {
filter_find.voice = buttons.map(function(b) {
return {
title: b.text,
url: b.url
};
});
var select_voice_url = this.getChoice(balanser).voice_url;
var select_voice_name = this.getChoice(balanser).voice_name;
var find_voice_url = buttons.find(function(v) {
return v.url == select_voice_url;
});
var find_voice_name = buttons.find(function(v) {
return v.text == select_voice_name;
});
var find_voice_active = buttons.find(function(v) {
return v.active;
}); ////console.log('b',buttons)
////console.log('u',find_voice_url)
////console.log('n',find_voice_name)
////console.log('a',find_voice_active)
if (find_voice_url && !find_voice_url.active) {
//console.log('Lampac', 'go to voice', find_voice_url);
this.replaceChoice({
voice: buttons.indexOf(find_voice_url),
voice_name: find_voice_url.text
});
this.request(find_voice_url.url);
} else if (find_voice_name && !find_voice_name.active) {
//console.log('Lampac', 'go to voice', find_voice_name);
this.replaceChoice({
voice: buttons.indexOf(find_voice_name),
voice_name: find_voice_name.text
});
this.request(find_voice_name.url);
} else {
if (find_voice_active) {
this.replaceChoice({
voice: buttons.indexOf(find_voice_active),
voice_name: find_voice_active.text
});
}
this.display(videos);
}
} else {
this.replaceChoice({
voice: 0,
voice_url: '',
voice_name: ''
});
this.display(videos);
}
} else if (items.length) {
if (similar.length) {
this.similars(similar);
this.activity.loader(false);
} else { //this.activity.loader(true)
filter_find.season = items.map(function(s) {
return {
title: s.text,
url: s.url
};
});
var select_season = this.getChoice(balanser).season;
var season = filter_find.season[select_season];
if (!season) season = filter_find.season[0];
//console.log('Lampac', 'go to season', season);
this.request(season.url);
}
} else {
this.doesNotAnswer(json);
}
}
} catch (e) {
//console.log('Lampac', 'error', e.stack);
this.doesNotAnswer(e);
}
};
this.similars = function(json) {
var _this6 = this;
scroll.clear();
json.forEach(function(elem) {
elem.title = elem.text;
elem.info = '';
var info = [];
var year = ((elem.start_date || elem.year || object.movie.release_date || object.movie.first_air_date || '') + '').slice(0, 4);
if (year) info.push(year);
if (elem.details) info.push(elem.details);
var name = elem.title || elem.text;
elem.title
+2774
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
(function () {
'use strict';
Lampa.Utils.putScriptAsync(['http://rc.bwa.ad/sisi.js?v23012026', 'https://cf.xsena.red?v14012026', 'http://pl.xsena.red?v14012026', 'http://nl.xsena.red?v14012026'], function () {});
/*if (Lampa.Platform.is('android'))
Lampa.Utils.putScriptAsync(['https://immisterio.github.io/AdultJS/lampa.js'], function () {});
*/
})();
+3016
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
(function () {
'use strict';
//Не нужно переписывать весь этот код, не делайте так!
//for Lampa.
window.lampa_settings.torrents_use = true;
window.lampa_settings.demo = false;
window.lampa_settings.read_only = false;
})();
@@ -1,53 +1,27 @@
{
"secuses": true,
"results": [
{
"title": "Lampac",
"results": [
{
"name": "DLNA",
"author": "lampac",
"link": "{localhost}/dlna.js",
"descr": "Каталог раздач и стриминг на DLNA-устройства (модуль Lampac)",
"available_lampa": 1
}
]
},
{
"title": "Популярные",
"results": [
{
"name": "BwaRC",
"author": "bwa.ad",
"link": "http://bwa.ad/rc",
"link": "{localhost}/plugins/bwa_rc.js",
"descr": "Фильмы и сериалы в онлайн",
"available_lampa": 1
},
{
"name": "Онлайн Мод",
"author": "@t_anton",
"link": "https://nb557.github.io/plugins/online_mod.js",
"link": "{localhost}/plugins/online_mod.js",
"descr": "Фильмы и сериалы в онлайн.",
"available_lampa": 1
},
{
"name": "Телевидение by Skaz ",
"author": "@helpiptv",
"link": "http://skaz.tv/tv.js",
"descr": "Бесплатные каналы и 1500+ каналов по подписке, IPTV плейлисты @skaztv_bot",
"available_lampa": 1
},
{
"name": "MODS's",
"author": "@modss_group",
"link": "http://lampa.stream/modss",
"descr": "Фильмы и сериалы в онлайн. Плюс различные дополнения через меню настроек Modss",
"available_lampa": 1
},
{
"name": "Showy",
"author": "Showy",
"link": "http://showy.online/m.js",
"link": "{localhost}/plugins/showy.js",
"descr": "Фильмы и сериалы в онлайн",
"available_lampa": 1
}
@@ -60,63 +34,56 @@
{
"name": "BwaRC",
"author": "bwa.ad",
"link": "http://bwa.ad/rc",
"link": "{localhost}/plugins/bwa_rc.js",
"descr": "Фильмы и сериалы в онлайн",
"available_lampa": 1
},
{
"name": "Онлайн Мод",
"author": "@t_anton",
"link": "https://nb557.github.io/plugins/online_mod.js",
"link": "{localhost}/plugins/online_mod.js",
"descr": "Фильмы и сериалы в онлайн",
"available_lampa": 1
},
{
"name": "Клубничка 18+",
"author": "bwa.ad",
"link": "http://bwa.ad/re",
"link": "{localhost}/plugins/bwa_re.js",
"descr": "Онлайн просмотр контента для взрослых, больше 30 сайтов",
"available_lampa": 1
},
{
"name": "Xsena 18+",
"author": "xsena",
"link": "http://e.xsena.red",
"link": "{localhost}/plugins/xsena.js",
"descr": "Онлайн просмотр контента для взрослых, больше 40 сайтов",
"available_lampa": 1
},
{
"name": "Filmix",
"author": "@lampa_land",
"link": "https://lampaplugins.github.io/store/fx.js",
"link": "{localhost}/plugins/filmix_fx.js",
"descr": "Фильмы и сериалы в онлайн",
"available_lampa": 1
},
{
"name": "Showy",
"author": "Showy",
"link": "http://showy.online/m.js",
"link": "{localhost}/plugins/showy.js",
"descr": "Фильмы и сериалы в онлайн",
"available_lampa": 1
},
{
"name": "MODS's",
"author": "@modss_group",
"link": "http://lampa.stream/modss",
"descr": "Фильмы и сериалы в онлайн. Плюс различные дополнения через меню настроек Modss",
"available_lampa": 1
},
{
"name": "Онлайн Stream",
"author": "",
"link": "http://arkmv.ru/vod",
"link": "{localhost}/plugins/arkmv_vod.js",
"descr": "Онлайн",
"available_lampa": 1
},
{
"name": "Онлайн Stream 2",
"author": "",
"link": "http://llpp.in/v/vod.js",
"link": "{localhost}/plugins/llpp_vod.js",
"descr": "Онлайн",
"available_lampa": 1
}
@@ -129,14 +96,14 @@
{
"name": "Подборки",
"author": "@lampa_land",
"link": "https://lampaplugins.github.io/store/p.js",
"link": "{localhost}/plugins/podbor.js",
"descr": "Подборки от онлайн кинотеатров, мультсериалы и мультфильмы.",
"available_lampa": 1
},
{
"name": "Добавляет пункт \"Парсер\" в меню",
"author": "@lampa",
"link": "http://cub.best/plugin/etor",
"link": "{localhost}/plugins/etor.js",
"descr": "Плагин для ТВ, на которых Lampa установлена через официальные магазины LG Store и Tizen App Store. Включает в настройках отображение пунктов Парсер и Torrserver",
"available_lampa": 1
},
@@ -147,27 +114,6 @@
"descr": "Сборник публичных парсеров отображением статуса доступности",
"available_lampa": 1
},
{
"name": "Вторая кнопка Торренты",
"author": "@VitalikPVA",
"link": "{localhost}/plugins/torrents.js",
"descr": "Плагин добавляет вторую кнопку Торренты",
"available_lampa": 1
},
{
"name": "Горячие кнопки",
"author": "@nnmd",
"link": "{localhost}/plugins/hotkeys.js",
"descr": "Плагин вызывает меню плеера лампы по кнопкам пульта: 5 - плейлист, 8 - аудиодорожки, 0 - субтитры, channel+/- следующий/предыдущий файл в плейлисте",
"available_lampa": 1
},
{
"name": "Золотая Тема",
"author": "@BazZziliuS",
"link": "{localhost}/plugins/gold_theme.js",
"descr": "Плагин включает золотую тему",
"available_lampa": 1
},
{
"name": "Дополнительные категории",
"author": "@lme",
@@ -175,20 +121,6 @@
"descr": "Добавление категорий Мультфильмы, Netflix, Концерты, и прочее",
"available_lampa": 1
},
{
"name": "Тип управления",
"author": "@nemiroff",
"link": "{localhost}/plugins/select_weapon.js",
"descr": "Позволяет выбрать тип управления (Пульт без мышки, Пульт с мышью, Тачскрин), а так же сбросить тип управления выбранный ранее",
"available_lampa": 1
},
{
"name": "Старый стиль (Закладки, Нравится, Позже)",
"author": "@VitalikPVA",
"link": "{localhost}/plugins/want.js",
"descr": "Возвращает в главное меню старый стиль отображения пунктов (Закладки, Нравится, Позже)",
"available_lampa": 1
},
{
"name": "Сброс настроек субтитров",
"author": "@t_anton",
@@ -196,19 +128,26 @@
"descr": "Сбрасывает настройки субтитров по умолчанию",
"available_lampa": 1
},
{
"name": "Мультфильмы",
"author": "@AndreyURL54",
"link": "{localhost}/plugins/mult.js",
"descr": "Заменяет пункт Аниме на Мульт",
"available_lampa": 1
},
{
"name": "Выключение тача",
"author": "@t_anton",
"link": "{localhost}/plugins/not_mobile.js",
"descr": "Выключает сенсорное управление, если оно было включено по ошибке",
"available_lampa": 1
},
{
"name": "LME Torrent Manager",
"author": "@rootu",
"link": "{localhost}/plugins/torrentmanager.js",
"descr": "Плагин управления торрентами",
"available_lampa": 1
},
{
"name": "Torrent downloader",
"author": "@rootu",
"link": "{localhost}/plugins/td.js",
"descr": "Плагин управления торрентами",
"available_lampa": 1
}
],
"hpu": "control"
@@ -216,31 +155,17 @@
{
"title": "Телевидение",
"results": [
{
"name": "ТВ by skaz",
"author": "@helpiptv",
"link": "http://skaz.tv/tv.js",
"descr": "Бесплатные каналы и 1500+ каналов по подписке, IPTV плейлисты @skaztv_bot",
"available_lampa": 1
},
{
"name": "Kulik",
"author": "@SawamuraRen",
"link": "http://cdn.kulik.uz/cors",
"link": "{localhost}/plugins/kulik.js",
"descr": "Просмотр IPTV каналов, отсортированных по различным категориям. Есть возможность поменять стиль плагина, сервер вещания, а также добавить каналы в избранное",
"available_lampa": 1
},
{
"name": "ТВ by skaz 2.0",
"author": "@helpiptv",
"link": "http://skaz.tv/tv2.js",
"descr": "Версия ТВ by skaz плагина на основе IPTV",
"available_lampa": 1
},
{
"name": "Дизель ТВ",
"author": "@AndreyURL54",
"link": "https://andreyurl54.github.io/diesel5/diesel.js",
"link": "{localhost}/plugins/diesel.js",
"descr": "Бесплатный просмотр телеканалов и коммерческих плейлистов с телепрограммой и записью архива",
"available_lampa": 1
}
@@ -253,38 +178,31 @@
{
"name": "Логотипы вместо названий",
"author": "@lampa_land",
"link": "https://lampaplugins.github.io/store/logo.js",
"link": "{localhost}/plugins/logo.js",
"descr": "Отображает логотипы фильмов, вместо названий в карточках",
"available_lampa": 1
},
{
"name": "Кинопоиск",
"author": "and7ey",
"link": "https://and7ey.github.io/lampa/kinopoisk.js",
"link": "{localhost}/plugins/kinopoisk.js",
"descr": "Отображает в приложении список фильмов из папки Буду смотреть на Кинопоиске",
"available_lampa": 1
},
{
"name": "SomaFM",
"author": "@tsynik",
"link": "https://tsynik.github.io/lampa/soma.js",
"link": "{localhost}/plugins/soma.js",
"descr": "Радио для Lampa",
"available_lampa": 1
},
{
"name": "Оригинальные названия",
"author": "@BDV_Burik",
"link": "https://bdvburik.github.io/title.js",
"link": "{localhost}/plugins/title.js",
"descr": "Добавляет оригинальные названия к фильмам, сериалам.",
"available_lampa": 1
},
{
"name": "Импорт/Экспорт закладок, истории",
"author": "@lampa_land",
"link": "https://skaz.tv/export.js",
"descr": "Плагин для импорта экспорта закладок, истории",
"available_lampa": 1
},
{
"name": "Shikimori - Каталог аниме",
"author": "@lme",
@@ -295,24 +213,17 @@
{
"name": "Радио Рекорд (мод плагина от @Tw1cker)",
"author": "@elenatv99",
"link": "https://lampaplugins.github.io/store/record.js",
"link": "{localhost}/plugins/record.js",
"descr": "Радио Рекорд + Радио Вера)))",
"available_lampa": 1
},
{
"name": "Радио Records",
"author": "@nemiroff",
"link": "https://nemiroff.github.io/lampa/rr.js",
"link": "{localhost}/plugins/rr.js",
"descr": "Радио Records",
"available_lampa": 1
},
{
"name": "Отзывы и рецензии",
"author": "@elenatv99",
"link": "https://lampaplugins.github.io/store/o.js",
"descr": "Добавляет кнопку чтения отзывов с КиноПоиск в карточку фильма или сериала",
"available_lampa": 1
},
{
"name": "Комментарии с HDRezka",
"author": "@BDV_Burik",
@@ -330,23 +241,16 @@
{
"name": "Предзагрузка ts",
"author": "@rootu",
"link": "{localhost}/plugins/ts-preload.js",
"link": "{localhost}/plugins/ts-preload.plugin.js",
"descr": "Плагин визуализации предзагрузки торрентов для встроенного клиента TorrServer",
"available_lampa": 1
},
{
"name": "Отключение Websocket",
"author": "@rootu",
"link": "http://plugin.rootu.top/wsoff.js",
"link": "{localhost}/plugins/wsoff.js",
"descr": "Плагин отключает протокол Websocket, помогает при ошибках на некоторых устройствах",
"available_lampa": 1
},
{
"name": "Поиск локального TorrServer",
"author": "@AndreyURL54",
"link": "{localhost}/plugins/checker.js",
"descr": "Плагин позволяет произвести поиск вашего локального TorrServer",
"available_lampa": 1
}
],
"hpu": "other"
+1905
View File
File diff suppressed because one or more lines are too long
+425
View File
@@ -0,0 +1,425 @@
(function() {
'use strict';
var network = new Lampa.Reguest();
function getRandomKinopoiskTechKey() {
const keys = ['8c8e1a50-6322-4135-8875-5d40a5420d86', 'f1d94351-2911-4485-b037-97817098724e', '8c8e1a50-6322-4135-8875-5d40a5420d86'];
const randomIndex = Math.floor(Math.random() * keys.length);
return keys[randomIndex];
}
function calculateProgress(total, current) {
if(total == current) {
Lampa.Noty.show('Обновление списка фильмов Кинопоиска завершено (' + String(total) + ')');
if(Lampa.Storage.get('kinopoisk_launched_before', false) == false) {
Lampa.Storage.set('kinopoisk_launched_before', true);
Lampa.Activity.push({
url: '',
title: 'Кинопоиск',
component: 'kinopoisk',
page: 1
});
}
}
}
function processKinopoiskData(data) {
// use cache
if(data && data.data.userProfile && data.data.userProfile.userData && data.data.userProfile.userData.plannedToWatch) {
var kinopoiskMovies = Lampa.Storage.get('kinopoisk_movies', []);
var receivedMovies = data.data.userProfile.userData.plannedToWatch.movies.items;
var receivedMoviesCount = receivedMovies.length;
var moviesCount = data.data.userProfile.userData.plannedToWatch.movies.total;
console.log('Kinopoisk', "Total planned to watch movies found: " + String(moviesCount));
console.log('Kinopoisk', "Movies received count: " + String(receivedMoviesCount));
if(receivedMoviesCount == 0) {
Lampa.Noty.show('В списке "Буду смотреть" Кинопоиска нет фильмов');
}
const receivedMovieIds = new Set(receivedMovies.map(m => String(m.movie.id)));
// filter out movies that are no longer present in receivedMovies
kinopoiskMovies = kinopoiskMovies.filter(movie => receivedMovieIds.has(String(movie.kinopoisk_id)));
Lampa.Storage.set('kinopoisk_movies', JSON.stringify(kinopoiskMovies));
let processedItems = 1;
receivedMovies.forEach(m => {
const existsInLocalStorage = kinopoiskMovies.some(km => km.kinopoisk_id === String(m.movie.id));
if (!existsInLocalStorage) {
// get movie data
var title = m.movie.title.localized || m.movie.title.original;
console.log('Kinopoisk', 'Getting details for movie: ' + String(m.movie.id) + ', movie title: ' + title);
// getting imdb id based on kinopoisk id
// network.silent('https://kinopoiskapiunofficial.tech/api/v2.2/films/' + String(m.movie.id), function(data) {
network.silent('https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&kp=' + String(m.movie.id), function(data) {
if (data && data.data) {
var movieIMDBid = data.data.id_imdb;
var movieTMDBid = data.data.id_tmdb ? data.data.id_tmdb : null;
var movieTitle = data.data.original_name ? data.data.original_name : data.data.name;
if (data.data.category == 1) {
var movieType = 'movie';
} else { // 2
var movieType = 'tv';
}
var movieYear = data.data.year;
if (movieTMDBid) {
console.log('Kinopoisk', 'TMDB movie id found: ' + String(movieTMDBid) + ' for kinopoisk id: ' + String(m.movie.id));
var url = Lampa.Utils.protocol() + 'tmdb.'+ Lampa.Manifest.cub_domain +'/3/' + movieType + '/' + String(movieTMDBid) + '?api_key=4ef0d7355d9ffb5151e987764708ce96&language=ru';
} else {
if (movieType === 'movie') {
console.log('Kinopoisk', 'No IMDB movie id found for kinopoisk id: ' + String(m.movie.id) + ', will search by movie title: ' + movieTitle);
var url = Lampa.Utils.protocol() + 'tmdb.'+ Lampa.Manifest.cub_domain +'/3/search/movie?query=' + encodeURIComponent(movieTitle) + '&api_key=4ef0d7355d9ffb5151e987764708ce96&year=' + String(movieYear) + '&language=ru';
} else { // TV_SERIES
console.log('Kinopoisk', 'No IMDB movie id found for kinopoisk id: ' + String(m.movie.id) + ', will search by tv series title: ' + movieTitle);
var url = Lampa.Utils.protocol() + 'tmdb.'+ Lampa.Manifest.cub_domain +'/3/search/tv?query=' + encodeURIComponent(movieTitle) + '&api_key=4ef0d7355d9ffb5151e987764708ce96&year=' + String(movieYear) + '&language=ru';
}
}
// getting movie details
network.silent(url, function(data) {
if(data) {
if (movieTMDBid) {
var movieItem = data;
} else {
if (data.movie_results && data.movie_results[0]) {
var movieItem = data.movie_results[0];
} else if(data.tv_results && data.tv_results[0]) {
var movieItem = data.tv_results[0];
} else if(data.results && data.results[0]) {
var movieItem = data.results[0];
}
}
if(movieItem) {
// console.log('Kinopoisk', 'Movie details received: ' + JSON.stringify(movieItem));
console.log('Kinopoisk', 'TMDB id found: ' + movieItem.id + ' for IMDB movie id: ' + movieIMDBid + ', kinopoisk id: ' + String(m.movie.id));
var movieDateStr = movieItem.release_date || movieItem.first_air_date; // film or tv series
var movieDate = new Date(movieDateStr);
if (movieDate <= new Date()) {
movieItem.kinopoisk_id = String(m.movie.id);
movieItem.source = "tmdb";
kinopoiskMovies = Lampa.Storage.get('kinopoisk_movies', []); // re-read data if another process modified it
kinopoiskMovies.unshift(movieItem);
Lampa.Storage.set('kinopoisk_movies', JSON.stringify(kinopoiskMovies));
} else {
console.log('Kinopoisk', 'Movie or TV with kinopoisk id ' + String(m.movie.id) + ' not released yet, release date:', movieDate);
if (Lampa.Storage.get('kinopoisk_add_to_favorites', false)) { // add to favorites
Lampa.Favorite.add('wath', movieItem, 100);
}
}
} else {
console.log('Kinopoisk', 'No result found for ' + movieTitle + ', ' + movieYear, data);
}
} else {
console.log('Kinopoisk', 'No movie found by IMDB id: ' + String(movieIMDBid));
}
calculateProgress(receivedMoviesCount, processedItems++);
}, function(data) {
console.log('Kinopoisk', 'apitmdb.cub.red error, data: ' + String(data));
calculateProgress(receivedMoviesCount, processedItems++);
});
} else {
console.log('Kinopoisk', 'No movie found for kinopoisk id: ' + String(m.movie.id) + ', movie: ' + title);
calculateProgress(receivedMoviesCount, processedItems++);
}
}, function(data) {
console.log('Kinopoisk', 'kinopoiskapiunofficial error, data: ' + String(data));
calculateProgress(receivedMoviesCount, processedItems++);
}, false, {
type: 'get' //,
// headers: {
// 'X-API-KEY': getRandomKinopoiskTechKey()
// }
});
} else {
console.log('Kinopoisk', 'Reading data from local storage for movie: ' + String(m.movie.id))
calculateProgress(receivedMoviesCount, processedItems++);
}
})
} else {
Lampa.Noty.show('Невозможно обработать данные, полученные от Кинопоиска');
console.log('Kinopoisk', 'processKinopoiskData - ');
console.log('Kinopoisk', data);
}
}
function getKinopoiskData() {
console.log('Kinopoisk', 'Starting to get Kinopoisk data...');
var oauth = Lampa.Storage.get('kinopoisk_access_token');
// google script is used to act as CORS proxy
network.silent('https://script.google.com/macros/s/AKfycbwQhxl9xQPv46uChWJ1UDg6BjSmefbSlTRUoSZz5f1rZDRvdhAGTi6RHyXwcSeyBtPr/exec?oauth=' + oauth, function(data) { // on success
processKinopoiskData(data);
}, function(data) { // on error
console.log('Kinopoisk', 'Error, google script', data);
});
}
function full(params, oncomplete, onerror) {
// https://github.com/yumata/lampa-source/blob/main/src/utils/reguest.js
// https://github.com/yumata/lampa-source/blob/main/plugins/collections/api.js
var oauth = Lampa.Storage.get('kinopoisk_access_token');
if(oauth) {
getKinopoiskData();
}
oncomplete({
"secuses": true,
"page": 1,
"results": Lampa.Storage.get('kinopoisk_movies', [])
});
}
function clear() {
network.clear();
}
var Api = {
full: full,
clear: clear
};
function component(object) {
var comp = new Lampa.InteractionCategory(object);
comp.create = function() {
Api.full(object, this.build.bind(this), this.empty.bind(this));
};
comp.nextPageReuest = function(object, resolve, reject) {
Api.full(object, resolve.bind(comp), reject.bind(comp));
};
return comp;
}
// getting/refreshing oauth kinopoisk token
function getToken(device_code, refresh) {
var client_id = 'b8b9c7a09b79452094e12f6990009934';
if(!refresh) {
var token_data = {
'grant_type': 'device_code',
'code': device_code,
'client_id': client_id,
'client_secret': '0e7001e272944c05ae5a0df16e3ea8bd'
}
} else { // refresh token
var token_data = {
'grant_type': 'refresh_token',
'refresh_token': device_code, // pass refresh token as device_code
'client_id': client_id,
'client_secret': '0e7001e272944c05ae5a0df16e3ea8bd'
}
}
network.silent('https://oauth.yandex.ru/token', function(data) { // on token success
if(data.access_token) {
Lampa.Storage.set('kinopoisk_access_token', data.access_token);
Lampa.Storage.set('kinopoisk_refresh_token', data.refresh_token);
Lampa.Storage.set('kinopoisk_token_expires', data.expires_in * 1000 + Date.now());
Lampa.Modal.close();
getUserEmail();
getKinopoiskData();
} else {
Lampa.Noty.show('Не удалось получить token');
console.log('Kinopoisk', 'Error during OAuth', data.error);
}
}, function(data) { // on token error
Lampa.Noty.show(data.responseJSON.error_description);
console.log('Kinopoisk', 'Token error', data);
}, token_data);
}
// getting oauth user_code
// https://yandex.ru/dev/id/doc/ru/codes/screen-code-oauth
function getDeviceCode() {
// generating unique device id
const uuid4 = () => {
const ho = (n, p) => n.toString(16).padStart(p, 0);
const data = crypto.getRandomValues(new Uint8Array(16));
data[6] = (data[6] & 0xf) | 0x40;
data[8] = (data[8] & 0x3f) | 0x80;
const view = new DataView(data.buffer);
return `${ho(view.getUint32(0), 8)}${ho(view.getUint16(4), 4)}${ho(view.getUint16(6), 4)}${ho(view.getUint16(8), 4)}${ho(view.getUint32(10), 8)}${ho(view.getUint16(14), 4)}`; /// Compile the canonical textual form from the array data
};
Lampa.Storage.set('kinopoisk_deviceid', uuid4());
var client_id = 'b8b9c7a09b79452094e12f6990009934';
var device_code_data = {
'client_id': client_id,
'device_id': Lampa.Storage.get('kinopoisk_deviceid', '')
}
network.silent('https://oauth.yandex.ru/device/code', function(data) { // on device code success
if(data.user_code && data.device_code) {
// Lampa.Utils.copyTextToClipboard(data.user_code, ()=>{});
// ask user to authorize
let modal = $('<div><div class="about">Перейдите по ссылке https://ya.ru/device на любом устройстве и введите код<br><br><b>' + data.user_code + '</b><br><br></div><br><div class="broadcast__device selector" style="textalign: center">Готово</div></div>')
Lampa.Modal.open({
title: 'Авторизация',
html: modal,
align: 'center',
onBack: () => {
Lampa.Modal.close()
},
onSelect: () => { // on button click
getToken(data.device_code, false);
}
})
} else {
Lampa.Noty.show('Не удалось получить user_code');
console.log('Kinopoisk', 'Failed to get user_code', data.error);
}
}, function(data) { // on device code error
Lampa.Noty.show(data.responseJSON.error_description);
console.log('Kinopoisk', 'Failed to get device code', data);
}, device_code_data);
}
function getUserEmail() {
network.silent('https://login.yandex.ru/info?format=json', function(data) {
if (data.default_email) {
Lampa.Storage.set('kinopoisk_email', data.default_email);
$('div[data-name="kinopoisk_auth"]').find('.settings-param__name').text(data.default_email); // NOT WORKING?
} else {
Lampa.Noty.show('Не удалось получить email пользователя');
console.log('Kinopoisk', 'Failed to get user email', data.error);
}
}, function(data) { // on device code error
Lampa.Noty.show(data.responseText);
console.log('Kinopoisk', 'Failed to get user email', data);
}, false, {
type: 'get',
headers: {
'Authorization': 'OAuth ' + Lampa.Storage.get('kinopoisk_access_token')
}
});
}
function startPlugin() {
var manifest = {
type: 'video',
version: '0.4.0',
name: 'Кинопоиск',
description: '',
component: 'kinopoisk'
};
Lampa.Manifest.plugins = manifest;
Lampa.Component.add('kinopoisk', component);
if(Lampa.Storage.get('kinopoisk_access_token', '') !== '' && Lampa.Storage.get('kinopoisk_token_expires', 0) < Date.now()) { // refresh token needed
console.log('Kinopoisk', 'Token should be refreshed')
getToken(Lampa.Storage.get('kinopoisk_refresh_token', ''), true);
}
function add() {
var button = $("<li class=\"menu__item selector\">\n <div class=\"menu__ico\">\n <svg width=\"239\" height=\"239\" viewBox=\"0 0 239 239\" fill=\"currentColor\" xmlns=\"http://www.w3.org/2000/svg\" xml:space=\"preserve\"><path fill=\"currentColor\" d=\"M215 121.415l-99.297-6.644 90.943 36.334a106.416 106.416 0 0 0 8.354-29.69z\" /><path fill=\"currentColor\" d=\"M194.608 171.609C174.933 197.942 143.441 215 107.948 215 48.33 215 0 166.871 0 107.5 0 48.13 48.33 0 107.948 0c35.559 0 67.102 17.122 86.77 43.539l-90.181 48.07L162.57 32.25h-32.169L90.892 86.862V32.25H64.77v150.5h26.123v-54.524l39.509 54.524h32.169l-56.526-57.493 88.564 46.352z\" /><path d=\"M206.646 63.895l-90.308 36.076L215 93.583a106.396 106.396 0 0 0-8.354-29.688z\" fill=\"currentColor\"/></svg>\n </div>\n <div class=\"menu__text\">".concat(manifest.name, "</div>\n </li>"));
button.on('hover:enter', function() {
if(Lampa.Storage.get('kinopoisk_access_token', '') == '') { // initial authorization needed
getDeviceCode();
}
Lampa.Activity.push({
url: '',
title: manifest.name,
component: 'kinopoisk',
page: 1
});
});
$('.menu .menu__list').eq(0).append(button);
// $('.head__actions').eq(0).append(button);
}
if(window.appready) add();
else {
Lampa.Listener.follow('app', function(e) {
if(e.type == 'ready') add();
});
}
// SETTINGS
if(!window.lampa_settings.kinopoisk) { // re-use kinopoisk_ratings element, if exists
Lampa.SettingsApi.addComponent({
component: 'kinopoisk',
icon: '<svg width=\"239\" height=\"239\" viewBox=\"0 0 239 239\" fill=\"currentColor\" xmlns=\"http://www.w3.org/2000/svg\" xml:space=\"preserve\"><path fill=\"currentColor\" d=\"M215 121.415l-99.297-6.644 90.943 36.334a106.416 106.416 0 0 0 8.354-29.69z\" /><path fill=\"currentColor\" d=\"M194.608 171.609C174.933 197.942 143.441 215 107.948 215 48.33 215 0 166.871 0 107.5 0 48.13 48.33 0 107.948 0c35.559 0 67.102 17.122 86.77 43.539l-90.181 48.07L162.57 32.25h-32.169L90.892 86.862V32.25H64.77v150.5h26.123v-54.524l39.509 54.524h32.169l-56.526-57.493 88.564 46.352z\" /><path d=\"M206.646 63.895l-90.308 36.076L215 93.583a106.396 106.396 0 0 0-8.354-29.688z\" fill=\"currentColor\"/></svg>',
name: 'Кинопоиск'
});
}
Lampa.SettingsApi.addParam({
component: 'kinopoisk',
param: {
type: 'title'
},
field: {
name: 'Аккаунт',
}
})
var kinopoisk_email = Lampa.Storage.get('kinopoisk_email', false);
Lampa.SettingsApi.addParam({
component: 'kinopoisk',
param: {
type: 'button',
name: 'kinopoisk_auth'
},
field: {
name: kinopoisk_email ? kinopoisk_email : 'Авторизоваться',
},
onChange: () => {
if (Lampa.Storage.get('kinopoisk_email', false)) { // user authorized
Lampa.Select.show({
title: 'Выйти из аккаунта',
items: [{
title: 'Да',
confirm: true
}, {
title: 'Нет'
}],
onSelect: (a) => {
if(a.confirm) {
Lampa.Storage.set('kinopoisk_email', '');
Lampa.Storage.set('kinopoisk_access_token', '');
Lampa.Storage.set('kinopoisk_refresh_token', '');
// Lampa.Storage.set('kinopoisk_movies', []);
Lampa.Storage.set('kinopoisk_token_expires', 0);
$('div[data-name="kinopoisk_auth"]').find('.settings-param__name').text('Авторизоваться');
}
Lampa.Controller.toggle('settings_component');
},
onBack: ()=>{
Lampa.Controller.toggle('settings_component');
},
})
} else { // user not authorized
Lampa.Controller.toContent(); // hide settings menu
getDeviceCode(); // start auth process
}
}
});
Lampa.SettingsApi.addParam({
component: 'kinopoisk',
param: {
type: 'title'
},
field: {
name: 'Список Буду смотреть',
}
})
Lampa.SettingsApi.addParam({
component: 'kinopoisk',
param: {
name: 'kinopoisk_add_to_favorites',
type: 'trigger',
default: false
},
field: {
name: 'Добавлять в Избранное',
description: 'Будущие, еще не вышедшие релизы добавляются в список Позже'
}
})
Lampa.SettingsApi.addParam({
component: 'kinopoisk',
param: {
type: 'button',
name: 'kinopoisk_delete_cache'
},
field: {
name: 'Очистить кэш фильмов',
description: 'Необходимо при возникновении проблем'
},
onChange: () => {
Lampa.Storage.set('kinopoisk_movies', []);
Lampa.Noty.show('Кэш Кинопоиска очищен');
}
});
}
if(!window.kinopoisk_ready) startPlugin();
})();
+1966
View File
File diff suppressed because it is too large Load Diff
+15735
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
(function(){'use strict';function startPlugin(){window.logoplugin=!0;Lampa.Listener.follow('full',function(e){if(e.type=='complite'&&Lampa.Storage.get('logo_glav')!='1'){var data=e.data.movie;var type=data.name?'tv':'movie';if(data.id!='')var url=Lampa.TMDB.api(type+'/'+data.id+'/images?api_key='+Lampa.TMDB.key()+'&language='+Lampa.Storage.get('language'));$.get(url,function(data){if(data.logos&&data.logos[0]){var logo=data.logos[0].file_path;if(logo!='')e.object.activity.render().find('.full-start-new__title').html('<img style="margin-top:5px;max-height:125px;" src="'+Lampa.TMDB.image('/t/p/w300'+logo.replace('.svg','.png'))+'"/>')}})}})}
Lampa.SettingsApi.addParam({component:'interface',param:{name:'logo_glav',type:'select',values:{1:'Скрыть',0:'Отображать',},default:'0',},field:{name:'Логотипы вместо названий',description:'Отображает логотипы фильмов вместо текста',}});if(!window.logoplugin)startPlugin()})()
+312
View File
@@ -0,0 +1,312 @@
if(!window.loaded_modss) {
//show
Date.now||(Date.now=function(){return(new Date.getTime())}),function(){"use strict";for(var t=["webkit","moz"],e=0;e<t.length&&!window.requestAnimationFrame;++e){var i=t[e];window.requestAnimationFrame=window[i+"RequestAnimationFrame"],window.cancelAnimationFrame=window[i+"CancelAnimationFrame"]||window[i+"CancelRequestAnimationFrame"]}if(/iP(ad|hone|od).*OS 6/.test(window.navigator.userAgent)||!window.requestAnimationFrame||!window.cancelAnimationFrame){var s=0;window.requestAnimationFrame=function(t){var e=Date.now(),i=Math.max(s+16,e);return setTimeout(function(){t(s=i)},i-e)},window.cancelAnimationFrame=clearTimeout}}(),function(t){t.snowfall=function(e,i){function s(s,n,a,r){this.x=s,this.y=n,this.size=a,this.speed=r,this.step=0,this.stepSize=h(1,10)/100,i.collection&&(this.target=m[h(0,m.length-1)]);var p=null;i.image?(p=document.createElement("img"),p.src=i.image):(p=document.createElement("div"),t(p).css({background:i.flakeColor})),t(p).attr({"class":"snowfall-flakes"}).css({width:this.size,height:this.size,position:i.flakePosition,top:this.y,left:this.x,fontSize:0,zIndex:i.flakeIndex}),t(e).get(0).tagName===t(document).get(0).tagName?(t("body").append(t(p)),e=t("body")):t(e).append(t(p)),this.element=p,this.update=function(){if(this.y+=this.speed,this.y>l-(this.size+6)&&this.reset(),this.element.style.top=this.y+"px",this.element.style.left=this.x+"px",this.step+=this.stepSize,y===!1?this.x+=Math.cos(this.step):this.x+=y+Math.cos(this.step),i.collection&&this.x>this.target.x&&this.x<this.target.width+this.target.x&&this.y>this.target.y&&this.y<this.target.height+this.target.y){var t=this.target.element.getContext("2d"),e=this.x-this.target.x,s=this.y-this.target.y,n=this.target.colData;if(void 0!==n[parseInt(e)][parseInt(s+this.speed+this.size)]||s+this.speed+this.size>this.target.height)if(s+this.speed+this.size>this.target.height){for(;s+this.speed+this.size>this.target.height&&this.speed>0;)this.speed*=.5;t.fillStyle=o.flakeColor,void 0==n[parseInt(e)][parseInt(s+this.speed+this.size)]?(n[parseInt(e)][parseInt(s+this.speed+this.size)]=1,t.fillRect(e,s+this.speed+this.size,this.size,this.size)):(n[parseInt(e)][parseInt(s+this.speed)]=1,t.fillRect(e,s+this.speed,this.size,this.size)),this.reset()}else this.speed=1,this.stepSize=0,parseInt(e)+1<this.target.width&&void 0==n[parseInt(e)+1][parseInt(s)+1]?this.x++:parseInt(e)-1>0&&void 0==n[parseInt(e)-1][parseInt(s)+1]?this.x--:(t.fillStyle=o.flakeColor,t.fillRect(e,s,this.size,this.size),n[parseInt(e)][parseInt(s)]=1,this.reset())}(this.x+this.size>d-c||this.x<c)&&this.reset()},this.reset=function(){this.y=0,this.x=h(c,d-c),this.stepSize=h(1,10)/100,this.size=h(100*i.minSize,100*i.maxSize)/100,this.element.style.width=this.size+"px",this.element.style.height=this.size+"px",this.speed=h(i.minSpeed,i.maxSpeed)}}function n(){for(r=0;r<a.length;r+=1)a[r].update();p=requestAnimationFrame(function(){n()})}var a=[],o={flakeCount:35,flakeColor:"#ffffff",flakePosition:"absolute",flakeIndex:999999,minSize:1,maxSize:2,minSpeed:1,maxSpeed:5,round:!1,shadow:!1,collection:!1,collectionHeight:40,deviceorientation:!1},i=t.extend(o,i),h=function(t,e){return Math.round(t+Math.random()*(e-t))};t(e).data("snowfall",this);var r=0,l=t(e).height(),d=t(e).width(),c=0,p=0;if(i.collection!==!1){var f=document.createElement("canvas");if(f.getContext&&f.getContext("2d"))for(var m=[],w=t(i.collection),g=i.collectionHeight,r=0;r<w.length;r++){var u=w[r].getBoundingClientRect(),x=t("<canvas/>",{"class":"snowfall-canvas"}),z=[];if(u.top-g>0){t("body").append(x),x.css({position:i.flakePosition,left:u.left+"px",top:u.top-g+"px"}).prop({width:u.width,height:g});for(var v=0;v<u.width;v++)z[v]=[];m.push({element:x.get(0),x:u.left,y:u.top-g,width:u.width,height:g,colData:z})}}else i.collection=!1}for(t(e).get(0).tagName===t(document).get(0).tagName&&(c=25),t(window).bind("resize",function(){l=t(e)[0].clientHeight,d=t(e)[0].offsetWidth}),r=0;r<i.flakeCount;r+=1)a.push(new s(h(c,d-c),h(0,l),h(100*i.minSize,100*i.maxSize)/100,h(i.minSpeed,i.maxSpeed)));i.round&&t(".snowfall-flakes").css({"-moz-border-radius":i.maxSize,"-webkit-border-radius":i.maxSize,"border-radius":i.maxSize}),i.shadow&&t(".snowfall-flakes").css({"-moz-box-shadow":"1px 1px 1px #555","-webkit-box-shadow":"1px 1px 1px #555","box-shadow":"1px 1px 1px #555"});var y=!1;i.deviceorientation&&t(window).bind("deviceorientation",function(t){y=.1*t.originalEvent.gamma}),n(),this.clear=function(){t(".snowfall-canvas").remove(),t(e).children(".snowfall-flakes").remove(),cancelAnimationFrame(p)}},t.fn.snowfall=function(e){return"object"==typeof e||void 0==e?this.each(function(i){new t.snowfall(this,e)}):"string"==typeof e?this.each(function(e){var i=t(this).data("snowfall");i&&i.clear()}):void 0}}(jQuery);
(function () {
'use strict';
try {
if (!Array.isArray(window.__modssConsoleBuf)) window.__modssConsoleBuf = [];
window.__modssConsoleOnCall = function (method, args) {
try {
var buf = window.__modssConsoleBuf;
if (!Array.isArray(buf)) { buf = []; window.__modssConsoleBuf = buf; }
var list = args && typeof args.length === 'number' ? Array.prototype.slice.call(args) : [];
var line = list.map(function (a) {
if (a == null) return String(a);
if (typeof a === 'string' || typeof a === 'number' || typeof a === 'boolean') return String(a);
try { return JSON.stringify(a); } catch (eJ) { return String(a); }
}).join(' ');
buf.push({ t: Date.now(), m: method || 'log', line: line });
while (buf.length > 800) buf.shift();
} catch (eCap) { }
};
window.__modssEnsureConsoleCapture = function () {
try {
if (!Array.isArray(window.__modssConsoleBuf)) window.__modssConsoleBuf = [];
var methods = ['log', 'info', 'warn', 'error', 'debug'];
for (var i = 0; i < methods.length; i++) {
(function (method) {
/** try на каждый метод: на части WebView console.* только для чтения —
* раньше падение на первом методе снимало перехват целиком. */
try {
var cur = console[method];
if (typeof cur !== 'function' || cur.__modssCap) return;
var orig = cur;
var wrapped = function () {
try { if (typeof window.__modssConsoleOnCall === 'function') window.__modssConsoleOnCall(method, arguments); } catch (e1) { }
try { return orig.apply(console, arguments); } catch (e2) { }
};
wrapped.__modssCap = 1;
console[method] = wrapped;
if (console[method] !== wrapped) {
Object.defineProperty(console, method, { value: wrapped, configurable: true, writable: true });
}
} catch (eM) {
window.__modssConsoleCaptureError = String((eM && eM.message) || eM).slice(0, 200);
}
})(methods[i]);
}
window.__modssConsoleCapture = !!(typeof console.log === 'function' && console.log.__modssCap);
} catch (eEns) {
window.__modssConsoleCaptureError = String((eEns && eEns.message) || eEns).slice(0, 200);
}
};
window.__modssEnsureConsoleCapture();
setInterval(window.__modssEnsureConsoleCapture, 1000);
} catch (eBootCap) { }
var pluginSetup = {
urlKey: function (url) {
return String(url == null ? '' : url).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
},
accountPluginKeys: function (done) {
Lampa.Account.Api.plugins(function (items) {
var keys = [];
for (var i = 0; i < (items || []).length; i++) {
var key = Number(items[i].status) === 1 ? pluginSetup.urlKey(items[i].url) : '';
if (key) keys.push(key);
}
done(keys);
});
},
detectShell: function () {
var ua = String(navigator.userAgent || '').toLowerCase();
if (typeof webapis !== 'undefined' && typeof tizen !== 'undefined') return 'tizen';
if (typeof webOS !== 'undefined' && webOS.platform && webOS.platform.tv === true) return 'webos';
if (window.TVXHost || window.TVXManager) return 'msx';
if (ua.indexOf('tizen') !== -1 || ua.indexOf('webos') !== -1 || ua.indexOf('web0s') !== -1) return 'msx';
if (Lampa.Platform.is('apple_tv')) return 'apple_tv';
if (Lampa.Utils.isPWA && Lampa.Utils.isPWA()) return 'pwa';
return '';
},
baseDeviceId: function (value) {
return String(value == null ? '' : value).trim().replace(/_(?:pwa|msx|apple_tv|tizen|webos)$/, '');
},
taggedDeviceId: function (value) {
var base = pluginSetup.baseDeviceId(value);
var shell = pluginSetup.detectShell();
return base && shell ? base + '_' + shell : base;
},
saveDeviceId: function (value) {
var deviceId = pluginSetup.taggedDeviceId(value);
if (deviceId && deviceId !== Lampa.Storage.get('modss_device_id', '')) {
Lampa.Storage.set('modss_device_id', deviceId);
}
return deviceId;
},
resolveDeviceId: function (issuedId) {
var cached = Lampa.Storage.get('modss_device_id', '');
return cached ? pluginSetup.saveDeviceId(cached) : pluginSetup.taggedDeviceId(issuedId);
},
syncDeviceId: function (issuedId, onReady) {
var attempts = 0;
var tick = function () {
var cached = Lampa.Storage.get('modss_device_id', '');
if ((!cached || !pluginSetup.detectShell()) && attempts++ < 15) return setTimeout(tick, 100);
if (typeof onReady === 'function') onReady(pluginSetup.saveDeviceId(cached || issuedId));
};
tick();
},
shell: function () {
return pluginSetup.detectShell() || Lampa.Platform.get();
},
deviceKind: function (base) {
var kind = String(base || 'other');
var shell = pluginSetup.shell();
if (!shell || kind.indexOf('_') !== -1) return kind;
return kind + '_' + shell;
},
logDevice: function (base, issuedId, onChange) {
pluginSetup.syncDeviceId(issuedId, function (deviceId) {
if (deviceId && typeof onChange === 'function') onChange(deviceId);
console.log('Modss', 'device', '[' + pluginSetup.deviceKind(base) + ']');
});
},
cleanPluginList: function (liveUrls, deadUrls) {
pluginSetup.accountPluginKeys(function (accountKeys) {
pluginSetup.applyPluginList(liveUrls, deadUrls, accountKeys);
});
},
applyPluginList: function (liveUrls, deadUrls, accountKeys) {
try {
var plugins = Lampa.Plugins.get();
var accountHasLive = false;
var remove = [];
var live = [];
var changed = false;
for (var a = 0; a < liveUrls.length; a++) {
if (accountKeys.indexOf(liveUrls[a]) !== -1) accountHasLive = true;
}
for (var i = 0; i < plugins.length; i++) {
var plugin = plugins[i];
var key = pluginSetup.urlKey(plugin.url);
if (deadUrls.indexOf(key) !== -1) {
remove.push({ plugin: plugin, reason: 'dead' });
continue;
}
if (liveUrls.indexOf(key) === -1) continue;
if (accountHasLive) remove.push({ plugin: plugin, reason: 'cub' });
else live.push({ plugin: plugin, key: key });
}
var keep = null;
for (var k = 0; k < live.length; k++) {
if (keep === null || liveUrls.indexOf(live[k].key) < liveUrls.indexOf(keep.key)) keep = live[k];
}
for (var d = 0; d < live.length; d++) {
if (live[d] !== keep) remove.push({ plugin: live[d].plugin, reason: 'dup' });
}
if (keep && String(keep.plugin.descr || '').trim() !== '') {
keep.plugin.descr = '';
keep.plugin.name = "MODS's";
changed = true;
}
for (var r = 0; r < remove.length; r++) {
if (!keep && !accountHasLive && r === 0) {
keep = { plugin: remove[r].plugin, key: liveUrls[0] };
keep.plugin.url = (window.location.protocol === 'https:' ? 'https://' : 'http://') + liveUrls[0];
changed = true;
console.log('Modss', 'plugin url', 'moved to ' + keep.plugin.url);
continue;
}
console.log('Modss', 'plugin url', 'removed [' + remove[r].reason + '] ' + remove[r].plugin.url);
Lampa.Plugins.remove(remove[r].plugin);
}
if (changed) Lampa.Plugins.save();
} catch (eClean) {
console.error('Modss', 'plugin url', 'clean error', eClean && eClean.message ? eClean.message : String(eClean));
}
}
};
var Protocol = function Protocol() {
return window.location.protocol == 'https:' ? 'https://' : 'http://';
}
var TRASH_R = ['$$$####!!!!!!!', '^^^^^^##@', '@!^^!@#@@$$$$$', '^^#@@!!@#!$', '@#!@@@##$$@@'];
var version_modss = '3.4.4', API = Protocol() + 'api.lampa.stream/', type = '', jackets = {}, cards, ping_auth, manifest, menu_list = [], vip = false, leftVipD = '', user_id = '', uid = '9f1c933cf85ae1ac7df7f332c_9d8ef5d9e88eb7c443bd2751', IP, logged = undefined, cashe = encodeURIComponent(Lampa.Base64.encode(window.location.origin));
var device_id = pluginSetup.resolveDeviceId('b546aae137ce4d0421cccb77ff745878');
var urls = [
Protocol() + 'lampa.stream/modss',
Protocol() + 'modss.tv'
];
console.log('Modss', 'protocol:', Protocol());
console.log('Modss', 'origin', window.location.origin);
console.log('Modss', 'plugin', '[VERSION] ' + version_modss);
pluginSetup.logDevice("other", device_id, function (next) {
device_id = next;
if (requestData) requestData.device_id = next;
});
console.log('Modss', 'init', 'LOADED - RU [85.234.8.237] - [GET] ' + urls[0]);
window.loaded_modss = true;
var network = new Lampa.Reguest();
var currentUrlIndex = 0;
var loadErrors = [];
var requestData = {
user_id: user_id,
uid: uid,
ips: '85.234.8.237',
cas: cashe,
cache: false,
id: '',
or: 'dW5kZWZpbmVk',
auth: logged,
device_id: device_id
};
function tryNextUrl() {
if (currentUrlIndex >= urls.length) {
var statusCodes = loadErrors.map(function(e) { return String(e.status != null ? e.status : '?'); }).join(', ');
console.log('Modss', 'init', 'RU [85.234.8.237] - все URL недоступны. Статусы ответов:', statusCodes, loadErrors);
Lampa.Noty.show('RU [85.234.8.237] - MODSs ОШИБКА ЗАГРУЗКИ ПЛАГИНА -> все серверы недоступны.<br>Статусы: ' + (statusCodes || '—') + '<br>Lampa: ' + window.location.origin, {time: 10000});
window.loaded_modss = false;
return;
}
var currentUrl = urls[currentUrlIndex];
var attemptNumber = currentUrlIndex + 1;
console.log('Modss', 'init', '[POST] Попытка ' + attemptNumber + '/' + urls.length + ' - загрузки:', currentUrl);
if(attemptNumber > 1) Lampa.Noty.show('Modss - init [POST] Попытка ' + attemptNumber + '/' + urls.length + ' - загрузки: ' + currentUrl, {time: 6000});
network.timeout(10000);
network.silent(currentUrl, function(json) {
try {
console.log('Modss', 'push', 'START - ' + currentUrl);
eval(json + '//# sourceURL='+window.location.origin+'/plugin_modss.js');
window.loaded_modss = true;
} catch (error) {
Lampa.Noty.show('MODSs ОШИБКА ЗАГРУЗКИ -> ' + error.message);
console.log('Modss', 'init', 'ERROR - ' + currentUrl, error.message);
console.error('Ошибка выполнения кода:', error);
currentUrlIndex++;
tryNextUrl();
}
}, function(a, c) {
var errMsg = network.errorDecode(a, c);
var statusNum = typeof a === 'number' ? a : (a && (a.status !== undefined ? a.status : (a.statusCode !== undefined ? a.statusCode : null)));
if (statusNum === undefined || statusNum === null) statusNum = '?';
loadErrors.push({ url: currentUrl, status: statusNum, message: errMsg || '' });
console.log('Modss', 'init', 'RU [85.234.8.237] - load get plugin error - [POST] ' + currentUrl, 'status:', statusNum, errMsg);
if (statusNum === 444 || statusNum === 403) {
console.error('Modss', 'init', 'ERROR - BLOCKED (status ' + statusNum + ')');
Lampa.Noty.show('MODSs BLOCKED — ' + (errMsg || statusNum), {time: 8000});
window.loaded_modss = false;
return;
}
currentUrlIndex++;
tryNextUrl();
}, requestData, {
dataType: 'text'
});
}
tryNextUrl();
})();
}
+3507
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
(function () {
'use strict';
Lampa.Storage.set('is_true_mobile', 'false');
})();
+14948
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+536
View File
@@ -0,0 +1,536 @@
(function () {
'use strict';
function translate() {
Lampa.Lang.add({
lme_parser: {
ru: 'Каталог парсеров',
en: 'Parsers catalog',
uk: 'Каталог парсерів',
zh: '解析器目录' // Chinese translation
},
lme_parser_description: {
ru: 'Нажмите для выбора парсера из ',
en: 'Click to select a parser from the ',
uk: 'Натисніть для вибору парсера з ',
zh: '单击以从可用的 '
},
lme_parser_current: {
ru: 'Текущий выбор:',
en: 'Current selection:',
uk: 'Поточний вибір:',
zh: '当前选择:'
},
lme_parser_selected: {
ru: 'Выбрано',
en: 'Selected',
uk: 'Обрано',
zh: '已选择'
},
lme_parser_refresh: {
ru: 'Обновить проверку',
en: 'Refresh check',
uk: 'Оновити перевірку',
zh: '刷新检测'
},
lme_parser_none: {
ru: 'Не выбран',
en: 'Not selected',
uk: 'Не вибрано',
zh: '未选择'
},
lme_parser_none_description: {
ru: 'Без активного парсера',
en: 'No active parser',
uk: 'Без активного парсера',
zh: '未启用解析器'
},
lme_parser_health: {
ru: 'Индикация состояния парсеров',
en: 'Parser health indicator',
uk: 'Індикація стану парсерів',
zh: '解析器状态指示'
},
lme_parser_status_ok: {
ru: 'Доступен',
en: 'Available',
uk: 'Доступний',
zh: '可用'
},
lme_parser_status_auth: {
ru: 'Ошибка ключа',
en: 'Auth error',
uk: 'Помилка ключа',
zh: '密钥错误'
},
lme_parser_status_network: {
ru: 'Недоступен',
en: 'Unavailable',
uk: 'Недоступний',
zh: '不可用'
},
lme_parser_status_unknown: {
ru: 'Не проверен',
en: 'Unchecked',
uk: 'Не перевірено',
zh: '未检查'
},
lme_parser_status_checking: {
ru: 'Проверка...',
en: 'Checking...',
uk: 'Перевірка...',
zh: '检查中...'
},
lme_pubtorr: {
ru: 'Каталог TorrServer',
en: 'TorrServer catalog',
uk: 'Каталог TorrServer',
zh: '解析器目录' // Chinese translation
},
lme_pubtorr_description: {
ru: 'Бесплатные серверы от проекта LME',
en: 'Free servers from the LME project',
uk: 'Безкоштовні сервери від проєкту LME',
zh: '来自 LME 项目的免费服务器 '
},
lme_pubtorr_firstrun: {
"ru": "Привет! Ты установил плагин LME PubTorr, учти что если стоит Mods's то в разделе парсеров будет ошибка, которая не влияет на работу. Хочешь избавиться - оставь или LME PubTorr или Mods's.",
"en": "Hello! You have installed the LME PubTorr plugin. Note that if Mods's is enabled, there will be an error in the parsers section that does not affect functionality. If you want to get rid of it, keep either LME PubTorr or Mods's.",
"uk": "Привіт! Ви встановили плагін LME PubTorr, врахуйте, що якщо активовано Mods's, то в розділі парсерів буде помилка, яка не впливає на роботу. Якщо хочете позбутися - залиште або LME PubTorr, або Mods's.",
"zh": "你好!你安装了LME PubTorr插件,请注意,如果启用了Mods's,解析器部分将出现错误,但这不会影响功能。如果你想摆脱它,请保留LME PubTorr或Mods's。"
}
});
}
var Lang = {
translate: translate
};
var parsersInfo = [{
id: 'lampa_app',
name: 'Lampa.app',
settings: {
url: 'lampa.app',
key: '',
parser_torrent_type: 'jackett'
}
}, {
id: 'jacred_viewbox_dev',
name: 'Viewbox',
settings: {
url: 'jacred.viewbox.dev',
key: 'viewbox',
parser_torrent_type: 'jackett'
}
}, {
id: 'unknown',
name: 'Unknown',
settings: {
url: '188.119.113.252:9117',
key: '1',
parser_torrent_type: 'jackett'
}
}, {
id: 'trs_my_to',
name: 'Trs.my.to',
settings: {
url: 'trs.my.to:9118',
key: '',
parser_torrent_type: 'jackett'
}
}, {
id: 'jacred_my_to',
name: 'Jacred.my.to',
settings: {
url: 'jacred.my.to',
key: '',
parser_torrent_type: 'jackett'
}
}, {
id: 'jacred',
name: 'Jac.red',
settings: {
url: 'jac.red',
key: '',
parser_torrent_type: 'jackett'
}
}, {
id: 'JaCred_su',
name: 'JacRed.su',
settings: {
url: 'jacred.su',
key: '',
parser_torrent_type: 'jackett'
}
}, {
id: 'jac_red_ru',
name: 'jac-red.ru',
settings: {
url: 'jac-red.ru',
key: '',
parser_torrent_type: 'jackett'
}
}];
function _arrayLikeToArray(r, a) {
(null == a || a > r.length) && (a = r.length);
for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];
return n;
}
function _arrayWithoutHoles(r) {
if (Array.isArray(r)) return _arrayLikeToArray(r);
}
function _defineProperty(e, r, t) {
return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
value: t,
enumerable: !0,
configurable: !0,
writable: !0
}) : e[r] = t, e;
}
function _iterableToArray(r) {
if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r);
}
function _nonIterableSpread() {
throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
}
function ownKeys(e, r) {
var t = Object.keys(e);
if (Object.getOwnPropertySymbols) {
var o = Object.getOwnPropertySymbols(e);
r && (o = o.filter(function (r) {
return Object.getOwnPropertyDescriptor(e, r).enumerable;
})), t.push.apply(t, o);
}
return t;
}
function _objectSpread2(e) {
for (var r = 1; r < arguments.length; r++) {
var t = null != arguments[r] ? arguments[r] : {};
r % 2 ? ownKeys(Object(t), !0).forEach(function (r) {
_defineProperty(e, r, t[r]);
}) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) {
Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));
});
}
return e;
}
function _toConsumableArray(r) {
return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread();
}
function _toPrimitive(t, r) {
if ("object" != typeof t || !t) return t;
var e = t[Symbol.toPrimitive];
if (void 0 !== e) {
var i = e.call(t, r || "default");
if ("object" != typeof i) return i;
throw new TypeError("@@toPrimitive must return a primitive value.");
}
return ("string" === r ? String : Number)(t);
}
function _toPropertyKey(t) {
var i = _toPrimitive(t, "string");
return "symbol" == typeof i ? i : i + "";
}
function _unsupportedIterableToArray(r, a) {
if (r) {
if ("string" == typeof r) return _arrayLikeToArray(r, a);
var t = {}.toString.call(r).slice(8, -1);
return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;
}
}
var STATUS = {
ok: 'ok',
authError: 'auth_error',
networkError: 'network_error',
unknown: 'unknown',
checking: 'checking'
};
var cache = {
data: {},
TTL: 10 * 60 * 1000,
get: function get(key) {
var cached = this.data[key];
if (cached && Date.now() < cached.expiresAt) return cached;
return null;
},
set: function set(key, value) {
this.data[key] = _objectSpread2(_objectSpread2({}, value), {}, {
expiresAt: Date.now() + this.TTL
});
}
};
function getProtocol() {
if (Lampa.Utils && typeof Lampa.Utils.protocol === 'function') return Lampa.Utils.protocol();
return location.protocol === 'https:' ? 'https://' : 'http://';
}
function createHealthCheckUrl(parser) {
if (!parser || !parser.settings || !parser.settings.url) return null;
var settings = parser.settings;
var parserType = settings.parser_torrent_type || 'jackett';
var hasProtocol = /^https?:\/\//.test(settings.url);
var protocol = hasProtocol ? '' : getProtocol();
var apiKey = '';
if (parserType === 'prowlarr') apiKey = settings.key || '';else if (settings.url === 'spawn.pp.ua:59117') apiKey = '2';else apiKey = settings.key || '';
var basePath = parserType === 'prowlarr' ? '/api/v1/health' : '/api/v2.0/indexers/status:healthy/results';
return "".concat(protocol).concat(settings.url).concat(basePath, "?apikey=").concat(apiKey);
}
function cacheKey(parserId, url) {
return "".concat(parserId, "::").concat(url);
}
function statusFromXhr(xhr) {
if (!xhr) return STATUS.networkError;
if (xhr.status === 200) return STATUS.ok;
if (xhr.status === 401) return STATUS.authError;
return STATUS.networkError;
}
function checkAlive(parsers) {
if (!Array.isArray(parsers) || !parsers.length) return Promise.resolve({});
var results = {};
var requests = parsers.map(function (parser) {
return new Promise(function (resolve) {
var url = createHealthCheckUrl(parser);
var parserId = parser.id || parser.name || 'unknown';
if (!url) {
results[parserId] = STATUS.unknown;
resolve();
return;
}
var key = cacheKey(parserId, url);
var cached = cache.get(key);
if (cached) {
results[parserId] = cached.status;
resolve();
return;
}
$.ajax({
url: url,
method: 'GET',
timeout: 5000,
success: function success(response, textStatus, xhr) {
var status = statusFromXhr(xhr);
if (xhr.status === 200 || xhr.status === 401) {
cache.set(key, {
status: status
});
}
results[parserId] = status;
resolve();
},
error: function error(xhr) {
results[parserId] = statusFromXhr(xhr);
resolve();
}
});
});
});
return Promise.allSettled(requests).then(function () {
return results;
});
}
var STORAGE_KEY = 'lme_url_two';
var NO_PARSER_ID = 'no_parser';
function getSelectedParserId() {
return Lampa.Storage.get(STORAGE_KEY, NO_PARSER_ID);
}
function getParserById(parserId) {
return parsersInfo.find(function (parser) {
return parser.id === parserId;
});
}
function applySelectedParser() {
var parserId = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getSelectedParserId();
var selectedParser = getParserById(parserId);
if (!selectedParser || !selectedParser.settings) {
if (parserId !== NO_PARSER_ID) console.warn('PubTorr', 'parser not found', parserId);
return false;
}
var settings = selectedParser.settings;
var parserType = settings.parser_torrent_type || 'jackett';
Lampa.Storage.set(parserType === 'prowlarr' ? 'prowlarr_url' : 'jackett_url', settings.url);
Lampa.Storage.set(parserType === 'prowlarr' ? 'prowlarr_key' : 'jackett_key', settings.key || '');
Lampa.Storage.set('parser_torrent_type', parserType);
return true;
}
var HEALTH_KEY$1 = 'lme_parser_health';
var STATUS_CLASS = _defineProperty(_defineProperty(_defineProperty(_defineProperty(_defineProperty({}, STATUS.ok, 'status-ok'), STATUS.authError, 'status-auth-error'), STATUS.networkError, 'status-network-error'), STATUS.unknown, 'status-unknown'), STATUS.checking, 'status-checking');
function statusLabel(status) {
switch (status) {
case STATUS.ok:
return Lampa.Lang.translate('lme_parser_status_ok');
case STATUS.authError:
return Lampa.Lang.translate('lme_parser_status_auth');
case STATUS.networkError:
return Lampa.Lang.translate('lme_parser_status_network');
case STATUS.checking:
return Lampa.Lang.translate('lme_parser_status_checking');
default:
return Lampa.Lang.translate('lme_parser_status_unknown');
}
}
function applyStatus(item, status) {
var classes = Object.values(STATUS_CLASS).join(' ');
item.removeClass(classes);
item.addClass(STATUS_CLASS[status] || STATUS_CLASS[STATUS.unknown]);
item.find('.pubtorr-parser-modal__status').text(statusLabel(status));
}
function applySelection(list, selectedId) {
list.find('.pubtorr-parser-modal__item').removeClass('is-selected');
list.find("[data-parser-id=\"".concat(selectedId, "\"]")).addClass('is-selected');
}
function buildItem(parser) {
var item = $("<div class=\"pubtorr-parser-modal__item selector status-unknown\" data-parser-id=\"".concat(parser.id, "\">\n <div class=\"pubtorr-parser-modal__info\">\n <div class=\"pubtorr-parser-modal__name\">").concat(parser.name, "</div>\n </div>\n <div class=\"pubtorr-parser-modal__status\"></div>\n </div>"));
applyStatus(item, STATUS.unknown);
return item;
}
function updateCurrentLabel(wrapper, selectedId, parsers) {
var current = parsers.find(function (parser) {
return parser.id === selectedId;
});
var label = current ? current.name : Lampa.Lang.translate('lme_parser_none');
wrapper.find('.pubtorr-parser-modal__current-value').text(label);
}
function updateSettingsSelectedLabel(selectedId, parsers) {
var current = parsers.find(function (parser) {
return parser.id === selectedId;
});
var label = current ? current.name : Lampa.Lang.translate('lme_parser_none');
var text = "".concat(Lampa.Lang.translate('lme_parser_selected'), ": ").concat(label);
$('.pubtorr-parser-selected').text(text);
}
function openParserModal() {
var parsers = [{
id: NO_PARSER_ID,
name: Lampa.Lang.translate('lme_parser_none')
}].concat(_toConsumableArray(parsersInfo));
var selectedId = getSelectedParserId();
var modal = $("<div class=\"pubtorr-parser-modal\">\n <div class=\"pubtorr-parser-modal__head\">\n <div class=\"pubtorr-parser-modal__current\">\n <div class=\"pubtorr-parser-modal__current-label\">".concat(Lampa.Lang.translate('lme_parser_current'), "</div>\n <div class=\"pubtorr-parser-modal__current-value\"></div>\n </div>\n <div class=\"pubtorr-parser-modal__actions\">\n <div class=\"pubtorr-parser-modal__action selector\">").concat(Lampa.Lang.translate('lme_parser_refresh'), "</div>\n </div>\n </div>\n <div class=\"pubtorr-parser-modal__list\"></div>\n <div class=\"pubtorr-parser-modal__legend\">\n <div class=\"pubtorr-parser-modal__legend-item status-ok\">").concat(Lampa.Lang.translate('lme_parser_status_ok'), "</div>\n <div class=\"pubtorr-parser-modal__legend-item status-auth-error\">").concat(Lampa.Lang.translate('lme_parser_status_auth'), "</div>\n <div class=\"pubtorr-parser-modal__legend-item status-network-error\">").concat(Lampa.Lang.translate('lme_parser_status_network'), "</div>\n <div class=\"pubtorr-parser-modal__legend-item status-unknown\">").concat(Lampa.Lang.translate('lme_parser_status_unknown'), "</div>\n </div>\n </div>"));
var list = modal.find('.pubtorr-parser-modal__list');
var refreshAction = modal.find('.pubtorr-parser-modal__action');
var healthEnabled = Lampa.Storage.get(HEALTH_KEY$1, true);
parsers.forEach(function (parser) {
var item = buildItem(parser);
item.on('hover:enter', function () {
Lampa.Storage.set(STORAGE_KEY, parser.id);
applySelection(list, parser.id);
updateCurrentLabel(modal, parser.id, parsers);
updateSettingsSelectedLabel(parser.id, parsers);
applySelectedParser(parser.id);
});
list.append(item);
});
applySelection(list, selectedId);
updateCurrentLabel(modal, selectedId, parsers);
updateSettingsSelectedLabel(selectedId, parsers);
var actionableItems = list.find('.pubtorr-parser-modal__item').first();
Lampa.Modal.open({
title: Lampa.Lang.translate('lme_parser'),
html: modal,
size: 'medium',
scroll_to_center: true,
select: actionableItems,
onBack: function onBack() {
Lampa.Modal.close();
Lampa.Controller.toggle('settings_component');
}
});
if (!healthEnabled) {
refreshAction.addClass('hide');
modal.find('.pubtorr-parser-modal__legend').addClass('hide');
return;
}
var parserItems = list.find('.pubtorr-parser-modal__item').not("[data-parser-id=\"".concat(NO_PARSER_ID, "\"]"));
parserItems.each(function () {
applyStatus($(this), STATUS.checking);
});
var runChecks = function runChecks() {
parserItems.each(function () {
applyStatus($(this), STATUS.checking);
});
checkAlive(parsersInfo).then(function (statusMap) {
parserItems.each(function () {
var item = $(this);
var parserId = item.data('parserId');
var status = statusMap[parserId] || STATUS.unknown;
applyStatus(item, status);
});
});
};
refreshAction.on('hover:enter', function () {
runChecks();
});
runChecks();
}
var HEALTH_KEY = 'lme_parser_health';
function parserSetting() {
Lampa.SettingsApi.addParam({
component: 'parser',
param: {
name: 'lme_parser_manage',
type: 'button'
},
field: {
name: Lampa.Lang.translate('lme_parser'),
description: "".concat(Lampa.Lang.translate('lme_parser_description'), " ").concat(parsersInfo.length, "<div class=\"pubtorr-parser-selected\"></div>")
},
onChange: function onChange() {
openParserModal();
},
onRender: function onRender(item) {
var selectedId = Lampa.Storage.get('lme_url_two', 'no_parser');
var current = parsersInfo.find(function (parser) {
return parser.id === selectedId;
});
var label = current ? current.name : Lampa.Lang.translate('lme_parser_none');
item.find('.pubtorr-parser-selected').text("".concat(Lampa.Lang.translate('lme_parser_selected'), ": ").concat(label));
setTimeout(function () {
var parserUse = $('div[data-name="parser_use"]').first();
if (parserUse.length) item.insertAfter(parserUse);
if (Lampa.Storage.field('parser_use')) item.show();else item.hide();
});
}
});
Lampa.SettingsApi.addParam({
component: 'parser',
param: {
name: HEALTH_KEY,
type: 'trigger',
"default": true
},
field: {
name: Lampa.Lang.translate('lme_parser_health')
},
onRender: function onRender(item) {
setTimeout(function () {
var manage = $('div[data-name="lme_parser_manage"]').first();
if (manage.length) item.insertAfter(manage);
if (Lampa.Storage.field('parser_use')) item.show();else item.hide();
});
}
});
}
var Parser = {
parserSetting: parserSetting
};
Lampa.Platform.tv();
function add() {
Lang.translate();
Lampa.Template.add('pubtorr_style', "\n <style>\n .pubtorr-parser-modal{--pubtorr-status-ok:#19c37d;--pubtorr-status-auth:#ff4d4f;--pubtorr-status-network:#ff4d4f;--pubtorr-status-unknown:#8c8c8c;--pubtorr-status-checking:#f5a623;--pubtorr-selected-border:#fff;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;gap:1em}.pubtorr-parser-modal__head{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;gap:1em}.pubtorr-parser-modal__current-label{font-size:.9em;opacity:.7}.pubtorr-parser-modal__current-value{font-size:1.1em}.pubtorr-parser-modal__action{padding:.5em .9em;-webkit-border-radius:.6em;border-radius:.6em;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2)}.pubtorr-parser-modal__action.focus{border-color:var(--pubtorr-selected-border)}.pubtorr-parser-modal__list{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;gap:.6em}.pubtorr-parser-modal__item{position:relative;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;gap:1em;padding:.8em 1em .8em 1.8em;-webkit-border-radius:.7em;border-radius:.7em;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08)}.pubtorr-parser-modal__item::before{content:'';position:absolute;left:.8em;top:50%;width:.55em;height:.55em;-webkit-border-radius:50%;border-radius:50%;background:var(--pubtorr-status-color,var(--pubtorr-status-unknown));-webkit-transform:translateY(-50%);-ms-transform:translateY(-50%);transform:translateY(-50%);-webkit-box-shadow:0 0 .6em rgba(0,0,0,0.3);box-shadow:0 0 .6em rgba(0,0,0,0.3)}.pubtorr-parser-modal__item.is-selected,.pubtorr-parser-modal__item.focus{border-color:var(--pubtorr-selected-border)}.pubtorr-parser-modal__info{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;gap:.25em;min-width:0}.pubtorr-parser-modal__name{font-size:1em}.pubtorr-parser-modal__status{font-size:.8em;opacity:.7;text-align:right;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.pubtorr-parser-modal__legend{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap;gap:.8em 1.2em;font-size:.85em;opacity:.7}.pubtorr-parser-modal__legend-item{position:relative;padding-left:1.2em}.pubtorr-parser-modal__legend-item::before{content:'';position:absolute;left:0;top:.55em;width:.5em;height:.5em;-webkit-border-radius:50%;border-radius:50%;background:var(--pubtorr-status-color,var(--pubtorr-status-unknown))}.pubtorr-parser-modal__item.status-ok,.pubtorr-parser-modal__legend-item.status-ok{--pubtorr-status-color:var(--pubtorr-status-ok)}.pubtorr-parser-modal__item.status-auth-error,.pubtorr-parser-modal__legend-item.status-auth-error{--pubtorr-status-color:var(--pubtorr-status-auth)}.pubtorr-parser-modal__item.status-network-error,.pubtorr-parser-modal__legend-item.status-network-error{--pubtorr-status-color:var(--pubtorr-status-network)}.pubtorr-parser-modal__item.status-unknown,.pubtorr-parser-modal__legend-item.status-unknown{--pubtorr-status-color:var(--pubtorr-status-unknown)}.pubtorr-parser-modal__item.status-checking{--pubtorr-status-color:var(--pubtorr-status-checking)}@media(max-width:600px){.pubtorr-parser-modal__head{-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start}.pubtorr-parser-modal__item{-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start}.pubtorr-parser-modal__status{text-align:left}}\n </style>\n ");
$('body').append(Lampa.Template.get('pubtorr_style', {}, true));
Parser.parserSetting();
}
function startPlugin() {
window.plugin_lmepublictorr_ready = true;
if (window.appready) add();else {
Lampa.Listener.follow('app', function (e) {
if (e.type === 'ready') add();
});
}
}
if (!window.plugin_lmepublictorr_ready) startPlugin();
})();
+325
View File
@@ -0,0 +1,325 @@
(function () {
'use strict';
function startsWith(str, searchString) {
return str.lastIndexOf(searchString, 0) === 0;
}
function endsWith(str, searchString) {
var start = str.length - searchString.length;
if (start < 0) return false;
return str.indexOf(searchString, start) === start;
}
function salt(input) {
var str = (input || '') + '';
var hash = 0;
for (var i = 0; i < str.length; i++) {
var c = str.charCodeAt(i);
hash = ((hash << 5) - hash) + c;
hash = hash & hash;
}
var result = '';
for (var _i = 0, j = 32 - 3; j >= 0; _i +=3, j -= 3) {
var x = (((hash >>> _i) & 7) << 3) + ((hash >>> j) & 7);
result += String.fromCharCode(x < 26 ? 97 + x : x < 52 ? 39 + x : x - 4);
}
return result;
}
function decodeSecret(input, password) {
var result = '';
password = (password || '') + '';
if (input && password) {
var hash = salt('123456789' + password);
while (hash.length < input.length) {
hash += hash;
}
var i = 0;
while (i < input.length) {
result += String.fromCharCode(input[i] ^ hash.charCodeAt(i));
i++;
}
}
return result;
}
function isDebug() {
var res = false;
var origin = window.location.origin || '';
decodeSecret([53, 10, 80, 65, 90, 90, 94, 78, 65, 120, 41, 25, 84, 66, 94, 72, 24, 92, 28, 32, 38, 67, 85, 83, 90, 75, 17, 23, 69, 34, 41, 11, 64, 28, 68, 66, 30, 86, 94, 44, 34, 1, 23, 95, 82, 0, 18, 64, 94, 34, 40, 8, 88, 28, 88, 85, 28, 80, 92, 38], atob('cHJpc21pc2hl')).split(';').forEach(function (s) {
res |= endsWith(origin, s);
});
return res;
}
function rating_kp_imdb(card) {
var network = new Lampa.Reguest();
var clean_title = kpCleanTitle(card.title);
var search_date = card.release_date || card.first_air_date || card.last_air_date || '0000';
var search_year = parseInt((search_date + '').slice(0, 4));
var orig = card.original_title || card.original_name;
var kp_prox = '';
var params = {
id: card.id,
url: kp_prox + 'https://kinopoiskapiunofficial.tech/',
rating_url: kp_prox + 'https://rating.kinopoisk.ru/',
headers: {
'X-API-KEY': decodeSecret([85, 4, 115, 118, 107, 125, 10, 70, 85, 67, 82, 14, 32, 110, 102, 43, 9, 19, 85, 73, 4, 83, 33, 110, 52, 44, 92, 21, 72, 22, 87, 1, 118, 32, 100, 127], atob('X0tQM3Bhc3N3b3Jk'))
},
cache_time: 60 * 60 * 24 * 1000 //86400000 сек = 1день Время кэша в секундах
};
getRating();
function getRating() {
var movieRating = _getCache(params.id);
if (movieRating) {
return _showRating(movieRating[params.id]);
} else {
searchFilm();
}
}
function searchFilm() {
var url = params.url;
var url_by_title = Lampa.Utils.addUrlComponent(url + 'api/v2.1/films/search-by-keyword', 'keyword=' + encodeURIComponent(clean_title));
if (card.imdb_id) url = Lampa.Utils.addUrlComponent(url + 'api/v2.2/films', 'imdbId=' + encodeURIComponent(card.imdb_id));
else url = url_by_title;
network.clear();
network.timeout(15000);
network.silent(url, function (json) {
if (json.items && json.items.length) chooseFilm(json.items);
else if (json.films && json.films.length) chooseFilm(json.films);
else if (url !== url_by_title) {
network.clear();
network.timeout(15000);
network.silent(url_by_title, function (json) {
if (json.items && json.items.length) chooseFilm(json.items);
else if (json.films && json.films.length) chooseFilm(json.films);
else chooseFilm([]);
}, function (a, c) {
showError(network.errorDecode(a, c));
}, false, {
headers: params.headers
});
} else chooseFilm([]);
}, function (a, c) {
showError(network.errorDecode(a, c));
}, false, {
headers: params.headers
});
}
function chooseFilm(items) {
if (items && items.length) {
var is_sure = false;
var is_imdb = false;
items.forEach(function (c) {
var year = c.start_date || c.year || '0000';
c.tmp_year = parseInt((year + '').slice(0, 4));
});
if (card.imdb_id) {
var tmp = items.filter(function (elem) {
return (elem.imdb_id || elem.imdbId) == card.imdb_id;
});
if (tmp.length) {
items = tmp;
is_sure = true;
is_imdb = true;
}
}
var cards = items;
if (cards.length) {
if (orig) {
var _tmp = cards.filter(function (elem) {
return containsTitle(elem.orig_title || elem.nameOriginal, orig) || containsTitle(elem.en_title || elem.nameEn, orig) || containsTitle(elem.title || elem.ru_title || elem.nameRu, orig);
});
if (_tmp.length) {
cards = _tmp;
is_sure = true;
}
}
if (card.title) {
var _tmp2 = cards.filter(function (elem) {
return containsTitle(elem.title || elem.ru_title || elem.nameRu, card.title) || containsTitle(elem.en_title || elem.nameEn, card.title) || containsTitle(elem.orig_title || elem.nameOriginal, card.title);
});
if (_tmp2.length) {
cards = _tmp2;
is_sure = true;
}
}
if (cards.length > 1 && search_year) {
var _tmp3 = cards.filter(function (c) {
return c.tmp_year == search_year;
});
if (!_tmp3.length) _tmp3 = cards.filter(function (c) {
return c.tmp_year && c.tmp_year > search_year - 2 && c.tmp_year < search_year + 2;
});
if (_tmp3.length) cards = _tmp3;
}
}
if (cards.length == 1 && is_sure && !is_imdb) {
if (search_year && cards[0].tmp_year) {
is_sure = cards[0].tmp_year > search_year - 2 && cards[0].tmp_year < search_year + 2;
}
if (is_sure) {
is_sure = false;
if (orig) {
is_sure |= equalTitle(cards[0].orig_title || cards[0].nameOriginal, orig) || equalTitle(cards[0].en_title || cards[0].nameEn, orig) || equalTitle(cards[0].title || cards[0].ru_title || cards[0].nameRu, orig);
}
if (card.title) {
is_sure |= equalTitle(cards[0].title || cards[0].ru_title || cards[0].nameRu, card.title) || equalTitle(cards[0].en_title || cards[0].nameEn, card.title) || equalTitle(cards[0].orig_title || cards[0].nameOriginal, card.title);
}
}
}
if (cards.length == 1 && is_sure) {
var id = cards[0].kp_id || cards[0].kinopoisk_id || cards[0].kinopoiskId || cards[0].filmId;
var base_search = function base_search() {
network.clear();
network.timeout(15000);
network.silent(params.url + 'api/v2.2/films/' + id, function (data) {
var movieRating = _setCache(params.id, {
kp: data.ratingKinopoisk,
imdb: data.ratingImdb,
timestamp: new Date().getTime()
}); // Кешируем данные
return _showRating(movieRating);
}, function (a, c) {
showError(network.errorDecode(a, c));
}, false, {
headers: params.headers
});
};
network.clear();
network.timeout(5000);
network["native"](params.rating_url + id + '.xml', function (str) {
if (str.indexOf('<rating>') >= 0) {
try {
var ratingKinopoisk = 0;
var ratingImdb = 0;
var xml = $($.parseXML(str));
var kp_rating = xml.find('kp_rating');
if (kp_rating.length) {
ratingKinopoisk = parseFloat(kp_rating.text());
}
var imdb_rating = xml.find('imdb_rating');
if (imdb_rating.length) {
ratingImdb = parseFloat(imdb_rating.text());
}
var movieRating = _setCache(params.id, {
kp: ratingKinopoisk,
imdb: ratingImdb,
timestamp: new Date().getTime()
}); // Кешируем данные
return _showRating(movieRating);
} catch (ex) {
}
}
base_search();
}, function (a, c) {
base_search();
}, false, {
dataType: 'text'
});
} else {
var movieRating = _setCache(params.id, {
kp: 0,
imdb: 0,
timestamp: new Date().getTime()
}); // Кешируем данные
return _showRating(movieRating);
}
} else {
var _movieRating = _setCache(params.id, {
kp: 0,
imdb: 0,
timestamp: new Date().getTime()
}); // Кешируем данные
return _showRating(_movieRating);
}
}
function cleanTitle(str){
return str.replace(/[\s.,:;’'`!?]+/g, ' ').trim();
}
function kpCleanTitle(str){
return cleanTitle(str).replace(/^[ \/\\]+/, '').replace(/[ \/\\]+$/, '').replace(/\+( *[+\/\\])+/g, '+').replace(/([+\/\\] *)+\+/g, '+').replace(/( *[\/\\]+ *)+/g, '+');
}
function normalizeTitle(str){
return cleanTitle(str.toLowerCase().replace(/[\-\u2010-\u2015\u2E3A\u2E3B\uFE58\uFE63\uFF0D]+/g, '-').replace(/ё/g, 'е'));
}
function equalTitle(t1, t2){
return typeof t1 === 'string' && typeof t2 === 'string' && normalizeTitle(t1) === normalizeTitle(t2);
}
function containsTitle(str, title){
return typeof str === 'string' && typeof title === 'string' && normalizeTitle(str).indexOf(normalizeTitle(title)) !== -1;
}
function showError(error) {
Lampa.Noty.show('Рейтинг KP: ' + error);
}
function _getCache(movie) {
var timestamp = new Date().getTime();
var cache = Lampa.Storage.cache('kp_rating', 500, {}); //500 это лимит ключей
if (cache[movie]) {
if ((timestamp - cache[movie].timestamp) > params.cache_time) {
// Если кеш истёк, чистим его
delete cache[movie];
Lampa.Storage.set('kp_rating', cache);
return false;
}
} else return false;
return cache;
}
function _setCache(movie, data) {
var timestamp = new Date().getTime();
var cache = Lampa.Storage.cache('kp_rating', 500, {}); //500 это лимит ключей
if (!cache[movie]) {
cache[movie] = data;
Lampa.Storage.set('kp_rating', cache);
} else {
if ((timestamp - cache[movie].timestamp) > params.cache_time) {
data.timestamp = timestamp;
cache[movie] = data;
Lampa.Storage.set('kp_rating', cache);
} else data = cache[movie];
}
return data;
}
function _showRating(data) {
if (data) {
var kp_rating = !isNaN(data.kp) && data.kp !== null ? parseFloat(data.kp).toFixed(1) : '0.0';
var imdb_rating = !isNaN(data.imdb) && data.imdb !== null ? parseFloat(data.imdb).toFixed(1) : '0.0';
var render = Lampa.Activity.active().activity.render();
$('.wait_rating', render).remove();
$('.rate--imdb', render).removeClass('hide').find('> div').eq(0).text(imdb_rating);
$('.rate--kp', render).removeClass('hide').find('> div').eq(0).text(kp_rating);
}
}
}
function startPlugin() {
window.rating_plugin = true;
if (isDebug()) return;
Lampa.Listener.follow('full', function (e) {
if (e.type == 'complite') {
var render = e.object.activity.render();
if ($('.rate--kp', render).hasClass('hide') && !$('.wait_rating', render).length) {
$('.info__rate', render).after('<div style="width:2em;margin-top:1em;margin-right:1em" class="wait_rating"><div class="broadcast__scan"><div></div></div><div>');
rating_kp_imdb(e.data.movie);
}
}
});
}
if (!window.rating_plugin) startPlugin();
})();
+250
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
(function () {
'use strict';
Lampa.Storage.set('webos_subs_params','{}');
})();
+399
View File
@@ -0,0 +1,399 @@
(function () {
//BDVBuriлk.github.io
//2025
("use strict");
let year;
let namemovie;
let savedHTML = null;
function getSettings() {
let host = (Lampa.Storage.get('rezka_comment_host', 'https://rezka.ag') || 'https://rezka.ag').trim().replace(/\/+$/, '');
let cookie = (Lampa.Storage.get('rezka_comment_cookie', '') || '').trim();
let proxy = (Lampa.Storage.get('rezka_comment_proxy', 'https://worker-patient-dream-26d8.bdvburik.workers.dev:8443/') || 'https://worker-patient-dream-26d8.bdvburik.workers.dev:8443/').trim();
if (proxy && !proxy.endsWith('/')) {
proxy += '/';
}
return { host, cookie, proxy };
}
// Функция для поиска на сайте hdrezka
async function searchRezka(name, ye) {
try {
let { host, cookie, proxy } = getSettings();
let path = host + "/search/?do=search&subaction=search&q=" + encodeURIComponent(name) + (ye ? "+" + ye : "");
let searchUrl = proxy;
if (cookie) {
searchUrl += "param/Cookie=" + encodeURIComponent(cookie) + "/";
}
searchUrl += path;
console.log('[RezkaComment] searching hdrezka with url:', searchUrl);
let fc = await fetch(searchUrl, {
method: "GET",
headers: { "Content-Type": "text/html" }
}).then((response) => {
if (!response.ok) {
throw new Error('HTTP status ' + response.status);
}
return response.text();
});
let dom = new DOMParser().parseFromString(fc, "text/html");
const item = dom.querySelector(".b-content__inline_item");
if (!item) {
console.warn('[RezkaComment] show not found on Rezka:', name, ye);
if (fc.indexOf("Проверяем, что вы не бот") !== -1 || fc.indexOf("Anubis") !== -1) {
Lampa.Noty.show('Защита от ботов на Rezka. Настройте Cookie в настройках плагина.');
} else {
Lampa.Noty.show('Фильм/сериал не найден на Rezka');
}
Lampa.Loading.stop();
return;
}
namemovie =
item.querySelector(".b-content__inline_item-link")?.innerText || "";
let itemUrl = item.querySelector(".b-content__inline_item-link")?.getAttribute("href") || "";
await comment_rezka(item.dataset.id, itemUrl);
} catch (e) {
console.error('[RezkaComment] searchRezka error:', e);
Lampa.Noty.show('Ошибка поиска на Rezka: ' + e.message);
Lampa.Loading.stop();
}
}
// Функция для получения английского названия фильма или сериала
async function getEnTitle(id, type) {
try {
var tmdbType = type === 'movie' ? 'movie' : 'tv';
var tmdbCacheKey = tmdbType + '_' + id;
// Спочатку перевіряємо спільний кеш (заповнює title.js)
window.__tmdbTranslationsCache = window.__tmdbTranslationsCache || {};
var cachedTr = window.__tmdbTranslationsCache[tmdbCacheKey];
var tr;
if (cachedTr) {
console.log('[RezkaComment] using shared translations cache for', tmdbCacheKey);
tr = cachedTr;
} else {
const data = await new Promise((res, rej) =>
Lampa.Api.sources.tmdb.get(
`${tmdbType}/${id}?append_to_response=translations`,
{},
res,
rej,
),
);
tr = data.translations?.translations || [];
window.__tmdbTranslationsCache[tmdbCacheKey] = tr;
}
const enTranslation = tr.find((t) => t.iso_3166_1 === 'US' || t.iso_639_1 === 'en');
const enTitle = enTranslation?.data?.title || enTranslation?.data?.name;
if (enTitle) {
await searchRezka(normalizeTitle(enTitle), year);
} else {
console.warn('[RezkaComment] English title not found for', tmdbCacheKey, tr);
Lampa.Noty.show('Английское название не найдено');
Lampa.Loading.stop();
}
} catch (e) {
console.error('[RezkaComment] TMDB error', e);
Lampa.Noty.show('Ошибка получения данных TMDB');
Lampa.Loading.stop();
}
}
// Функция для очистки заголовка от лишних символов
function cleanTitle(str) {
return str.replace(/[\s.,:;’'`!?]+/g, " ").trim();
}
// Функция для нормализации заголовка
function normalizeTitle(str) {
return cleanTitle(
str
.toLowerCase()
.replace(/[\-\u2010-\u2015\u2E3A\u2E3B\uFE58\uFE63\uFF0D]+/g, "-")
.replace(/ё/g, "е"),
);
}
// Создаёт один комментарий
function buildCommentNode(item) {
const q = (s) => item.querySelector(s);
const avatar = q(".ava img")?.dataset.src || q(".ava img")?.src || "";
const user = q(".name, .b-comment__user")?.innerText || "Без имени";
const date = q(".date, .b-comment__time")?.innerText || "";
const text = q(".message .text, .text")?.innerHTML || "";
const wrapper = document.createElement("div");
wrapper.className = "message";
wrapper.innerHTML = `
<div class="comment-wrap">
<div class="avatar-column">
<img src="${avatar}" class="avatar-img" alt="${user}">
</div>
<div class="comment-card">
<div class="comment-header">
<span class="name">${user}</span>
<span class="date">${date}</span>
</div>
<div class="comment-text">
<div class="text">${text}</div>
</div>
</div>
</div>
`;
return wrapper;
}
// Рекурсивно строит дерево
function buildTree(root) {
const fragment = document.createDocumentFragment();
for (let li of root.children) {
const indent = parseInt(li.dataset.indent || 0, 10);
const wrapper = document.createElement("li");
wrapper.className = "comments-tree-item";
wrapper.style.marginLeft = indent > 0 ? "20px" : "0";
wrapper.appendChild(buildCommentNode(li));
const childrenList = li.querySelector("ol.comments-tree-list");
if (childrenList) wrapper.appendChild(buildTree(childrenList));
fragment.appendChild(wrapper);
}
return fragment;
}
// === Основная обработка комментариев Rezka с storage на сутки ===
async function comment_rezka(id, pageUrl) {
try {
let { host, cookie, proxy } = getSettings();
let t = Date.now();
let path = host + "/ajax/get_comments/?t=" + t + "&news_id=" + (id ? id : "1") + "&cstart=1&type=0&comment_id=0&skin=hdrezka";
let commentsUrl = proxy;
if (cookie) {
commentsUrl += "param/Cookie=" + encodeURIComponent(cookie) + "/";
}
if (pageUrl) {
commentsUrl += "param/Referer=" + encodeURIComponent(pageUrl) + "/";
}
commentsUrl += path;
console.log('[RezkaComment] fetching comments from:', commentsUrl);
let fc = await fetch(commentsUrl, {
method: "GET",
headers: { "Content-Type": "text/plain" },
}).then((r) => {
if (!r.ok) {
throw new Error('HTTP status ' + r.status);
}
return r.text();
});
// Check if the response is actually HTML challenge instead of JSON
if (fc.indexOf("Проверяем, что вы не бот") !== -1 || fc.indexOf("Anubis") !== -1) {
Lampa.Noty.show('Защита от ботов на Rezka. Настройте Cookie в настройках плагина.');
Lampa.Loading.stop();
return;
}
let json = JSON.parse(fc);
if (!json || !json.comments) {
throw new Error('Пустой ответ от сервера комментариев');
}
let dom = new DOMParser().parseFromString(json.comments, "text/html");
dom
.querySelectorAll(".actions, i, .share-link")
.forEach((elem) => elem.remove());
let rootList = dom.querySelector(".comments-tree-list");
if (!rootList) {
console.warn('[RezkaComment] comments-tree-list not found in parsed HTML for', id);
Lampa.Noty.show('Комментарии к фильму/сериалу отсутствуют');
Lampa.Loading.stop();
return;
}
let newTree = buildTree(rootList);
openModal(newTree);
} catch (e) {
console.error('[RezkaComment] comment_rezka error:', e);
Lampa.Noty.show('Ошибка получения комментариев: ' + e.message);
Lampa.Loading.stop();
}
function openModal(treeContent) {
Lampa.Loading.stop();
let modal = $(
`<div><div class="broadcast__text" style="text-align:left;"><div class="comment"></div></div></div>`,
);
modal.find(".comment").append(treeContent);
// Стили модалки (если ещё не добавлены)
if (!document.getElementById("rezka-comment-style")) {
const styleEl = document.createElement("style");
styleEl.id = "rezka-comment-style";
styleEl.textContent = `
.comments-tree-list{list-style:none;margin:0;padding:0;}
.comments-tree-item{list-style:none;margin:0;padding:0;}
.comment-wrap{display:flex;margin-bottom:5px;}
.avatar-column{margin-right:10px;}
.avatar-img{width:48px;height:48px;border-radius:4px;}
.comment-card{background:#1b1b1b;padding:5px 12px;border-radius:6px;border:1px solid #2a2a2a;width:100%;}
.comment-header{display:flex;justify-content:space-between;margin-bottom:6px;}
.comment-header .name{font-weight:600;color:#fff;}
.comment-header .date{opacity:.7;font-size:11px;}
.comment-text .text{color:#ddd;line-height:1.45;}
.rc-children{margin-left:30px;border-left:1px solid #333;padding-left:14px;}
.title_spoiler{display:inline-flex;align-items:center;background:#2a2a2a;border-radius:6px;padding:1px 4px;margin:0 2px;font-size:13px;color:#e0e0e0;cursor:pointer;box-shadow:0 0 2px rgba(0,0,0,.4);}
.title_spoiler a{color:#e0e0e0!important;text-decoration:none!important;}
.title_spoiler img{height:14px;width:auto;vertical-align:middle;margin:0 2px;}
.title_spoiler .attention{height:14px;width:14px;margin-left:4px;vertical-align:middle;}
.modal-close-btn{background:#2a2a2a;border:1px solid #444;color:#ddd;border-radius:6px;font-size:18px;line-height:18px;cursor:pointer;transition:.15s;}
.modal-close-btn:hover{background:#3a3a3a;color:#fff;}
`;
document.head.appendChild(styleEl);
}
if (!window.rezkaSpoilerInit) {
window.rezkaSpoilerInit = true;
const Script = document.createElement("script");
Script.textContent =
"function ShowOrHide(id){var t=$('#'+id);t.prev('.title_spoiler').remove();t.css('display','inline');}";
document.head.appendChild(Script);
}
Lampa.Modal.open({
title: ``,
html: modal,
size: "large",
style: "margin-top:10px;",
mask: true,
onBack: function () {
Lampa.Modal.close();
$(".modal--large").remove();
Lampa.Controller.toggle("content");
},
});
document
.querySelector(".modal__head")
?.insertAdjacentHTML(
"afterend",
`<button class="modal-close-btn selector" onclick="$('.modal--large').remove()">&times;</button> ${namemovie}`,
);
}
}
// Функция для начала работы плагина
function startPlugin() {
window.comment_plugin = true;
try {
// Регистрация настроек
Lampa.SettingsApi.addComponent({
component: 'rezka_comment',
name: 'Rezka Comments',
icon: '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'
});
Lampa.SettingsApi.addParam({
component: 'rezka_comment',
param: {
name: 'rezka_comment_host',
type: 'input',
placeholder: 'https://rezka.ag',
values: Lampa.Storage.get('rezka_comment_host', 'https://rezka.ag'),
default: 'https://rezka.ag'
},
field: {
name: 'Зеркало hdrezka',
description: 'Адрес зеркала hdrezka (например, https://hdrezka.me)'
},
onChange: function(value) {
Lampa.Storage.set('rezka_comment_host', value);
}
});
Lampa.SettingsApi.addParam({
component: 'rezka_comment',
param: {
name: 'rezka_comment_cookie',
type: 'input',
placeholder: 'вставьте cookie',
values: Lampa.Storage.get('rezka_comment_cookie', ''),
default: ''
},
field: {
name: 'Cookie авторизации',
description: 'Cookie из вашего браузера для обхода защиты (Anubis / PHPSESSID)'
},
onChange: function(value) {
Lampa.Storage.set('rezka_comment_cookie', value);
}
});
Lampa.SettingsApi.addParam({
component: 'rezka_comment',
param: {
name: 'rezka_comment_proxy',
type: 'input',
placeholder: 'https://worker-patient-dream-26d8.bdvburik.workers.dev:8443/',
values: Lampa.Storage.get('rezka_comment_proxy', 'https://worker-patient-dream-26d8.bdvburik.workers.dev:8443/'),
default: 'https://worker-patient-dream-26d8.bdvburik.workers.dev:8443/'
},
field: {
name: 'CORS Прокси',
description: 'Ваш Cloudflare Worker прокси (обязательно с / на конце)'
},
onChange: function(value) {
Lampa.Storage.set('rezka_comment_proxy', value);
}
});
} catch (e) {
console.error('[RezkaComment] Settings init error:', e);
}
Lampa.Listener.follow("full", function (e) {
if (e.type == "complite") {
$(".button--comment").remove();
$(".full-start-new__buttons").append(
`<div class="full-start__button selector button--comment"><svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 356.484 356.484"><g><path d="M293.984 7.23H62.5C28.037 7.23 0 35.268 0 69.731v142.78c0 34.463 28.037 62.5 62.5 62.5l147.443.001 70.581 70.58a12.492 12.492 0 0 0 13.622 2.709 12.496 12.496 0 0 0 7.717-11.547v-62.237c30.759-3.885 54.621-30.211 54.621-62.006V69.731c0-34.463-28.037-62.501-62.5-62.501zm37.5 205.282c0 20.678-16.822 37.5-37.5 37.5h-4.621c-6.903 0-12.5 5.598-12.5 12.5v44.064l-52.903-52.903a12.493 12.493 0 0 0-8.839-3.661H62.5c-20.678 0-37.5-16.822-37.5-37.5V69.732c0-20.678 16.822-37.5 37.5-37.5h231.484c20.678 0 37.5 16.822 37.5 37.5v142.78z" fill="currentcolor"/></g></svg><span>${Lampa.Lang.translate(
"title_comments",
)}</span></div>`,
);
$(".button--comment").on("hover:enter", function (card) {
year = 0;
if (e.data.movie.release_date) {
year = e.data.movie.release_date.slice(0, 4);
} else if (e.data.movie.first_air_date) {
year = e.data.movie.first_air_date.slice(0, 4);
}
Lampa.Loading.start();
getEnTitle(e.data.movie.id, e.object.method);
});
}
});
}
if (!window.comment_plugin) startPlugin();
})();
+225
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
(function() {
'use strict';
var script = document.currentScript;
if (!script) {
var scripts = document.getElementsByTagName('script');
script = scripts[scripts.length - 1];
}
var src = script && script.src ? script.src : '';
var match = src.match(/^(https?:\/\/[^\/]+)/);
var host = 'http://showy.online';
var version = new Date().getTime();
if (!host) return;
Lampa.Utils.putScriptAsync([host + '/online.js?v=' + version], function() {});
})();
+1199
View File
File diff suppressed because one or more lines are too long
+32
View File
@@ -0,0 +1,32 @@
{
"arkmv_vod.js": "http://arkmv.ru/vod",
"bwa_rc.js": "http://bwa.ad/rc",
"bwa_re.js": "http://bwa.ad/re",
"diesel.js": "https://andreyurl54.github.io/diesel5/diesel.js",
"etor.js": "http://cub.best/plugin/etor",
"filmix_fx.js": "https://lampaplugins.github.io/store/fx.js",
"kinopoisk.js": "https://and7ey.github.io/lampa/kinopoisk.js",
"kulik.js": "http://cdn.kulik.uz/cors",
"llpp_vod.js": "http://llpp.in/v/vod.js",
"logo.js": "https://lampaplugins.github.io/store/logo.js",
"modss.js": "https://lampa.stream/modss (локальный патч isPWA; upstream мёртв)",
"nc.js": "https://lampame.github.io/main/nc/nc.js",
"not_mobile.js": "https://nb557.github.io/plugins/not_mobile.js",
"online_mod.js": "https://nb557.github.io/plugins/online_mod.js",
"podbor.js": "https://lampaplugins.github.io/store/p.js",
"pubtorr.js": "https://lampame.github.io/main/pubtorr/pubtorr.js",
"rating.js": "https://nb557.github.io/plugins/rating.js",
"record.js": "https://lampaplugins.github.io/store/record.js",
"reset_subs.js": "https://nb557.github.io/plugins/reset_subs.js",
"rezkacomment.js": "https://bdvburik.github.io/rezkacomment.js",
"rr.js": "https://nemiroff.github.io/lampa/rr.js",
"shikimori.js": "https://lampame.github.io/main/Shikimori/Shikimori.js",
"showy.js": "http://showy.online/m.js (локальный патч: подгрузка с showy.online, а не с текущего хоста)",
"soma.js": "https://tsynik.github.io/lampa/soma.js",
"td.js": "https://raw.githubusercontent.com/lampame/td/main/td/td.js",
"title.js": "https://bdvburik.github.io/title.js",
"torrentmanager.js": "https://lampame.github.io/main/torrentmanager/torrentmanager.js",
"ts-preload.plugin.js": "https://dmitryzemlyuk.github.io/lampa-ts-preload/ts-preload.plugin.js",
"wsoff.js": "http://plugin.rootu.top/wsoff.js",
"xsena.js": "http://e.xsena.red"
}
+2080
View File
File diff suppressed because one or more lines are too long
+365
View File
@@ -0,0 +1,365 @@
(function () {
"use strict";
// ===== Logging Helper =====
function log(message, data) {
console.log("[Title Plugin]", message, data || "");
}
function logError(message, error) {
console.error("[Title Plugin]", message, error || "");
}
// ===== Safe Property Access =====
function safeGet(obj, path, defaultValue) {
try {
return path.split('.').reduce((acc, part) => acc && acc[part], obj) || defaultValue;
} catch (e) {
return defaultValue;
}
}
// ===== Version Check =====
function checkCompatibility() {
if (!window.Lampa) {
logError("Lampa not found");
return false;
}
if (!Lampa.Lang || !Lampa.Storage) {
logError("Required APIs not available");
return false;
}
return true;
}
// ===== Локалізація =====
Lampa.Lang.add({
title_plugin: {
ru: "Title Plugin",
en: "Title Plugin",
uk: "Title Plugin",
be: "Title Plugin",
},
reset_to_default: {
ru: "Сбросить по умолчанию",
en: "Reset to Default",
uk: "Скинути за замовчуванням",
be: "Скінуць па змаўчанні",
},
show_en: {
ru: 'Показывать <img src="https://flagcdn.com/us.svg" style="width:1.15em;height:auto;vertical-align:middle;"> EN',
en: 'Show <img src="https://flagcdn.com/us.svg" style="width:1.15em;height:auto;vertical-align:middle;"> EN',
uk: 'Показувати <img src="https://flagcdn.com/us.svg" style="width:1.15em;height:auto;vertical-align:middle;"> EN',
be: 'Паказваць <img src="https://flagcdn.com/us.svg" style="width:1.15em;height:auto;vertical-align:middle;"> EN',
},
show_tl: {
ru: "Показывать Romaji (transliteration)",
en: "Show Romaji (transliteration)",
uk: "Показувати Romaji (transliteration)",
be: "Паказваць Romaji (transliteration)",
},
show_uk: {
ru: 'Показывать <img src="https://flagcdn.com/ua.svg" style="width:1.15em;height:auto;vertical-align:middle;"> UA',
en: 'Show <img src="https://flagcdn.com/ua.svg" style="width:1.15em;height:auto;vertical-align:middle;"> UA',
uk: 'Показувати <img src="https://flagcdn.com/ua.svg" style="width:1.15em;height:auto;vertical-align:middle;"> UA',
be: 'Паказваць <img src="https://flagcdn.com/ua.svg" style="width:1.15em;height:auto;vertical-align:middle;"> UA',
},
show_be: {
ru: 'Показывать <img src="https://flagcdn.com/by.svg" style="width:1.15em;height:auto;vertical-align:middle;"> BE',
en: 'Show <img src="https://flagcdn.com/by.svg" style="width:1.15em;height:auto;vertical-align:middle;"> BE',
uk: 'Показувати <img src="https://flagcdn.com/by.svg" style="width:1.15em;height:auto;vertical-align:middle;"> BE',
be: 'Паказваць <img src="https://flagcdn.com/by.svg" style="width:1.15em;height:auto;vertical-align:middle;"> BE',
},
show_ru: {
ru: 'Показывать <img src="https://flagcdn.com/ru.svg" style="width:1.15em;height:auto;vertical-align:middle;"> RU',
en: 'Show <img src="https://flagcdn.com/ru.svg" style="width:1.15em;height:auto;vertical-align:middle;"> RU',
uk: 'Показувати <img src="https://flagcdn.com/ru.svg" style="width:1.15em;height:auto;vertical-align:middle;"> RU',
be: 'Паказваць <img src="https://flagcdn.com/ru.svg" style="width:1.15em;height:auto;vertical-align:middle;"> RU',
},
});
const LANGS = ["en", "tl", "uk", "be", "ru"];
const STORAGE_ORDER_KEY = "title_plugin_order";
const STORAGE_HIDDEN_KEY = "title_plugin_hidden";
function startPlugin() {
// ===== Settings button =====
Lampa.SettingsApi.addParam({
component: "interface",
param: { type: "button" },
field: {
name: Lampa.Lang.translate("title_plugin"),
description: "Title Plugin settings",
},
onChange: openEditor,
});
// ===== Title display logic =====
const CACHE_TTL = 30 * 24 * 60 * 60 * 1000;
let titleCache = Lampa.Storage.get("title_cache") || {};
function showTitles(card) {
try {
var orig = card.original_title || card.original_name;
var altTitles = card.alternative_titles || {};
var alt = altTitles.titles || altTitles.results || [];
function countryFlag(code) {
if (!code) return '';
return ' <img src="https://flagcdn.com/' + code.toLowerCase() + '.svg"'
+ ' style="width:1.15em;height:auto;vertical-align:inherit;" alt="' + code.toUpperCase() + '">';
}
var TRANSLIT_TYPES = ['Transliteration', 'romaji', 'Romanization', 'Latynization',
'pinyin', 'kana', 'romaji_japanese', 'romaji_korean', 'romaji_chinese', 'latinization'];
var translitObj = alt.find(function (t) { return TRANSLIT_TYPES.indexOf(t.type) !== -1; });
var translit = (translitObj && (translitObj.title || (translitObj.data && (translitObj.data.title || translitObj.data.name)))) || '';
var ru = '', en = '', uk = '', be = '';
var now = Date.now();
var cache = titleCache[card.id];
if (cache && now - cache.timestamp < CACHE_TTL) {
en = cache.en || '';
uk = cache.uk || '';
be = cache.be || '';
ru = cache.ru || '';
translit = translit || cache.tl || '';
console.log('[TitlePlugin] cache hit for id:', card.id);
}
function findLang(list, codes) {
var t = list.find(function (t) { return codes.indexOf(t.iso_3166_1) !== -1 || codes.indexOf(t.iso_639_1) !== -1; });
return t && t.data && (t.data.title || t.data.name) || '';
}
function applyAndRender(tr) {
var translitData = tr.find(function (t) { return ['Transliteration', 'romaji'].indexOf(t.type) !== -1; });
if (translitData) translit = (translitData.title || (translitData.data && (translitData.data.title || translitData.data.name))) || translit;
en = en || findLang(tr, ['US', 'en']);
uk = uk || findLang(tr, ['UA', 'uk']);
be = be || findLang(tr, ['BY', 'be']);
ru = ru || findLang(tr, ['RU', 'ru']);
// fallback из alternative_titles
en = en || (alt.find(function (t) { return t.iso_3166_1 === 'US'; }) || {}).title || '';
uk = uk || (alt.find(function (t) { return t.iso_3166_1 === 'UA'; }) || {}).title || '';
be = be || (alt.find(function (t) { return t.iso_3166_1 === 'BY'; }) || {}).title || '';
ru = ru || (alt.find(function (t) { return t.iso_3166_1 === 'RU'; }) || {}).title || '';
titleCache[card.id] = { ru: ru, en: en, tl: translit, uk: uk, be: be, timestamp: now };
try { Lampa.Storage.set('title_cache', titleCache); } catch (e) { console.error('[TitlePlugin] storage error:', e); }
renderTitles();
}
function renderTitles() {
var active = Lampa.Activity.active();
if (!active || !active.activity) {
console.warn('[TitlePlugin] no active activity');
return;
}
var render = active.activity.render();
if (!render) { console.warn('[TitlePlugin] render is null'); return; }
$('.original_title', render).remove();
var showOrder = Lampa.Storage.get(STORAGE_ORDER_KEY, LANGS.slice());
var hiddenLangs = Lampa.Storage.get(STORAGE_HIDDEN_KEY, []);
var originCountry = (card.origin_country || [])[0] || '';
var lines = ['<div style="font-size:1.25em;">' + orig + ' ' + countryFlag(originCountry) + '</div>'];
var langFlags = { ru: 'RU', en: 'US', uk: 'UA', be: 'BY' };
var langVals = { en: en, uk: uk, be: be, ru: ru };
showOrder.forEach(function (lang) {
if (hiddenLangs.indexOf(lang) !== -1) return;
var val = lang === 'tl' ? translit : langVals[lang];
if (val) lines.push('<div style="font-size:1.25em;">' + val + ' ' + countryFlag(langFlags[lang]) + '</div>');
});
$('.full-start-new__title', render).after(
'<div class="original_title" style="margin-bottom:7px;text-align:right;"><div>'
+ lines.join('') + '</div></div>'
);
}
if (!ru || !en || !translit || !uk || !be) {
var type = card.first_air_date ? 'tv' : 'movie';
var tmdbCacheKey = type + '_' + card.id;
// Спільний кеш трансляцій з іншими плагінами (наприклад rezkacomment.js)
window.__tmdbTranslationsCache = window.__tmdbTranslationsCache || {};
if (window.__tmdbTranslationsCache[tmdbCacheKey]) {
console.log('[TitlePlugin] using shared translations cache for', tmdbCacheKey);
applyAndRender(window.__tmdbTranslationsCache[tmdbCacheKey]);
} else {
console.log('[TitlePlugin] fetching translations for', type, card.id);
Lampa.Api.sources.tmdb.get(
type + '/' + card.id + '?append_to_response=translations',
{},
function (data) {
var tr = (data.translations && data.translations.translations) || [];
window.__tmdbTranslationsCache[tmdbCacheKey] = tr;
applyAndRender(tr);
},
function (e) {
console.error('[TitlePlugin] tmdb error:', e);
applyAndRender([]);
}
);
}
} else {
renderTitles();
}
} catch (e) {
console.error('[TitlePlugin] showTitles crash:', e.message, e.stack);
}
}
// ===== Listener =====
if (!window.title_plugin) {
window.title_plugin = true;
Lampa.Listener.follow("full", (e) => {
if (e.type !== "complite" || !e.data.movie) return;
showTitles(e.data.movie);
});
}
}
// ===== Editor modal =====
function openEditor() {
const order = Lampa.Storage.get(STORAGE_ORDER_KEY, LANGS.slice());
const hidden = Lampa.Storage.get(STORAGE_HIDDEN_KEY, []);
const list = $('<div class="menu-edit-list"></div>');
const style = $(`
<style>
.menu-edit-list__item { display:flex; align-items:center; justify-content:space-between; margin:0.3em 0; }
.menu-edit-list__title { flex:1; font-size:1.1em; padding-left:0.5em; }
.menu-edit-list__move { width:1.8em; height:1.8em; display:flex; align-items:center; justify-content:center; cursor:pointer; font-weight:bold; user-select:none; }
.menu-edit-list__toggle { width:1.8em; height:1.8em; display:flex; align-items:center; justify-content:center; cursor:pointer; margin-left:0.5em; border:2px solid rgba(255,255,255,0.5); border-radius:3px; }
.menu-edit-list__toggle .dot { width:1em; height:1em; }
.folder-reset-button { background: rgba(200,100,100,0.3); margin-top:1em; border-radius:0.3em; padding:0.5em; text-align:center; cursor:pointer; }
</style>
`);
$("body").append(style);
order.forEach((lang) => {
const title = Lampa.Lang.translate("show_" + lang);
const isHidden = hidden.includes(lang);
const item = $(`
<div class="menu-edit-list__item">
<div class="menu-edit-list__title">${title}</div>
<div class="menu-edit-list__move move-up selector">▲</div>
<div class="menu-edit-list__move move-down selector">▼</div>
<div class="menu-edit-list__toggle selector">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.89111" y="1.78369" width="21.793" height="21.793" rx="3.5" stroke="currentColor" stroke-width="3"/>
<path d="M7.44873 12.9658L10.8179 16.3349L18.1269 9.02588" stroke="currentColor" stroke-width="3" class="dot" opacity="${isHidden ? 0 : 1
}" stroke-linecap="round"/>
</svg>
</div>
</div>`);
// ===== Toggle hide/show =====
item.find(".menu-edit-list__toggle").on("hover:enter", function () {
const idx = hidden.indexOf(lang);
if (idx !== -1) {
hidden.splice(idx, 1);
item.find(".dot").attr("opacity", "1");
} else {
hidden.push(lang);
item.find(".dot").attr("opacity", "0");
}
Lampa.Storage.set(STORAGE_HIDDEN_KEY, hidden);
});
// ===== Move up/down =====
item.find(".move-up").on("hover:enter", function () {
const prev = item.prev();
if (prev.length) {
item.insertBefore(prev);
updateOrder();
}
});
item.find(".move-down").on("hover:enter", function () {
const next = item.next();
if (next.length) {
item.insertAfter(next);
updateOrder();
}
});
list.append(item);
});
const resetBtn = $(
'<div class="selector folder-reset-button" style="margin-top:1em;padding:1em;text-align:center;">' +
Lampa.Lang.translate("reset_to_default") +
"</div>"
);
resetBtn.on("hover:enter", function () {
Lampa.Storage.set(STORAGE_ORDER_KEY, LANGS.slice());
Lampa.Storage.set(STORAGE_HIDDEN_KEY, []);
Lampa.Modal.close();
Lampa.Controller.toggle("settings");
});
list.append(resetBtn);
function updateOrder() {
const newOrder = [];
list.find(".menu-edit-list__item").each(function () {
const title = $(this).find(".menu-edit-list__title").text();
const key = LANGS.find(
(l) => Lampa.Lang.translate("show_" + l) === title
);
if (key) newOrder.push(key);
});
Lampa.Storage.set(STORAGE_ORDER_KEY, newOrder);
}
Lampa.Modal.open({
title: Lampa.Lang.translate("title_plugin"),
html: list,
size: "small",
scroll_to_center: true,
onBack: function () {
Lampa.Modal.close();
Lampa.Controller.toggle("settings");
},
});
}
// ===== Init plugin =====
if (window.appready) {
if (typeof Lampa !== 'undefined' &&
Lampa.SettingsApi &&
Lampa.Api &&
Lampa.Api.sources &&
Lampa.Api.sources.tmdb &&
typeof $ !== 'undefined') {
startPlugin();
} else {
console.error('Title Plugin: Required Lampa APIs not available');
}
} else {
Lampa.Listener.follow("app", (e) => {
if (e.type === "ready") {
if (typeof Lampa !== 'undefined' &&
Lampa.SettingsApi &&
Lampa.Api &&
Lampa.Api.sources &&
Lampa.Api.sources.tmdb &&
typeof $ !== 'undefined') {
startPlugin();
} else {
console.error('Title Plugin: Required Lampa APIs not available');
}
}
});
}
})();
File diff suppressed because one or more lines are too long
+418
View File
@@ -0,0 +1,418 @@
(function($, Lampa) {
var Modal = /** @class */function(){
var modalID = 0;
function Modal(params) {
this.id = ++modalID;
this.active = params;
this.html = Lampa.Template.get('modal', {title: params.title});
this.scroll = new Lampa.Scroll({over: true, mask: params.mask});
this.last = false;
}
Modal.prototype.open = function(){
var _this = this;
this.html.on('click',function(e){
if(!$(e.target).closest($('.modal__content', _this.html)).length && Lampa.DeviceInput.canClick(e.originalEvent)) window.history.back();
})
this.title(this.active.title);
this.html.toggleClass('modal--medium', this.active.size === 'medium');
this.html.toggleClass('modal--large', this.active.size === 'large');
this.html.toggleClass('modal--full', this.active.size === 'full');
this.html.toggleClass('modal--overlay', !!this.active.overlay);
this.html.toggleClass('modal--align-center', this.active.align === 'center');
if(this.active.zIndex) this.html.css('z-index', this.active.zIndex);
this.scroll.render().toggleClass('layer--height', this.active.size === 'full')
this.html.find('.modal__body').append(this.scroll.render())
this.bind(this.active.html);
this.scroll.onWheel = function(step){
this.roll(step > 0 ? 'down' : 'up');
}
this.scroll.append(this.active.html);
if(this.active.buttons) this.buttons();
$('body').append(this.html);
this.max();
this.toggle(this.active.select);
}
Modal.prototype.max = function(){
this.scroll.render().find('.scroll__content').css('max-height', Math.round(window.innerHeight - this.scroll.render().offset().top - (window.innerHeight * 0.1)) + 'px');
}
Modal.prototype.buttons = function(){
var footer = $('<div class="modal__footer"></div>');
this.active.buttons.forEach(function(button){
var btn = $('<div class="modal__button selector" style="width: 50%;text-align: center"></div>');
btn.text(button.name);
btn.on('click hover:enter',function(){
button.onSelect();
});
footer.append(btn);
});
this.scroll.append(footer);
}
Modal.prototype.bind = function(where){
var _this = this;
where.find('.selector')
.on('hover:focus', function(e){
_this.last = e.target;
_this.scroll.update($(e.target));
})
.on('hover:enter', function(e){
_this.last = e.target;
if (_this.active.onSelect) _this.active.onSelect($(e.target));
});
}
Modal.prototype.jump = function(tofoward){
var select = this.scroll.render().find('.selector.focus');
if (tofoward) select = select.nextAll().filter('.selector');
else select = select.prevAll().filter('.selector');
select = select.slice(0, 10).last();
if (select.length){
Lampa.Controller.collectionFocus(select[0], this.scroll.render());
}
}
Modal.prototype.roll = function(direction){
var select = this.scroll.render().find('.selector');
if(select.length){
Navigator.move(direction);
}
else{
var step = Math.round(window.innerHeight * 0.15);
this.scroll.wheel(direction === 'down' ? step : -step);
}
}
Modal.prototype.toggle = function(need_select){
var _this = this;
Lampa.Controller.add('Modal-' + this.id, {
invisible: true,
toggle: function(){
Lampa.Controller.collectionSet(_this.scroll.render());
Lampa.Controller.collectionFocus(need_select || _this.last, _this.scroll.render());
Lampa.Layer.visible(_this.scroll.render(true));
},
up: function(){
_this.roll('up');
},
down: function(){
_this.roll('down');
},
right: function(){
if(Navigator.canmove('right')) Navigator.move('right');
else _this.jump(true);
},
left: function(){
if(Navigator.canmove('left')) Navigator.move('left');
else _this.jump(false);
},
back: function(){
if(_this.active.onBack) _this.active.onBack();
}
});
Lampa.Controller.toggle('Modal-' + this.id);
}
Modal.prototype.update = function(new_html) {
this.last = false;
this.scroll.clear();
this.scroll.append(new_html);
this.bind(new_html);
this.max();
this.toggle(this.active.select);
}
Modal.prototype.title = function(title){
this.html.find('.modal__title').text(title);
this.html.toggleClass('modal--empty-title',!title);
}
Modal.prototype.destroy = function(){
this.last = false;
this.scroll.destroy();
this.html.remove();
}
Modal.prototype.close = function(){
this.destroy();
}
Modal.prototype.render = function(){
return this.html;
}
return Modal;
}();
Lampa.Lang.add({
ts_preload_preload: {
en: 'Preload',
ru: 'Предзагрузка',
be: 'Перадзагрузка',
uk: 'Передзавантаження',
pt: 'Pré-carregar',
zh: '预加载'
},
ts_preload_speed: {
en: 'Speed',
ru: 'Скорость загрузки',
be: 'Хуткасць загрузкі',
uk: 'Швидкість',
pt: 'Velocidade',
zh: '速度'
},
ts_preload_seeds: {
en: 'seeds',
ru: 'раздают',
be: 'раздаюць',
uk: 'роздають',
pt: 'entregam',
zh: '种子数'
},
ts_preload_peers: {
en: 'Peers',
ru: 'Подключились',
be: 'Падключыліся',
uk: 'Підключилися',
pt: 'Conectado',
zh: '连接数'
}
});
function tsIP(){
return (!!Lampa.Torserver && !!Lampa.Torserver.ip)
? Lampa.Torserver.ip()
: Lampa.Storage.get(Lampa.Storage.field('torrserver_use_link') === 'two' ? 'torrserver_url_two' : 'torrserver_url');
}
if (!!Lampa.Torserver && !!Lampa.Torserver.stream && !!Lampa.Torserver.url) {
Lampa.Torserver.stream = function(path, hash, id) {
return Lampa.Torserver.url() + '/stream/'+ encodeURIComponent(path.split('\\').pop().split('/').pop()) +'?link=' + hash + '&index=' + id + '&play'
}
}
var lampaPlay = Lampa.Player.play;
var lampaCallback = Lampa.Player.callback;
var lampaPlaylist = Lampa.Player.playlist;
var lampaStat = Lampa.Player.stat;
var player = null;
var Player = /** @class */function(){
function Player(data) {
data.url = parseUrl(data.url).clearUrl + '&play';
this.playerData = data;
this.playList = null;
this.statUrl = null;
this.callback = null;
}
Player.prototype.setPlayList = function(playlist){
playlist.map(function(data){data.url = parseUrl(data.url).clearUrl + '&play'});
this.playList = playlist;
};
Player.prototype.setStatUrl = function(url){this.statUrl = url};
Player.prototype.setCallback = function(callback){this.callback = callback};
Player.prototype.play = function(){
lampaPlay(this.playerData);
this.playList && lampaPlaylist(this.playList);
this.callback && lampaCallback(this.callback);
this.statUrl && lampaStat(this.statUrl);
player = null;
}
return Player;
}();
Lampa.Player.playlist = function(playlist) {
if (player) player.setPlayList(playlist);
else lampaPlaylist(playlist);
};
Lampa.Player.stat = function(url) {
if (player) player.setStatUrl(url);
else lampaStat(url);
};
Lampa.Player.callback = function(callback) {
if (player) player.setCallback(callback);
else lampaCallback(callback);
};
Lampa.Player.play = function(data) {
if (Lampa.Storage.field('torrserver_preload')
&& data.url
&& tsIP()
&& data.url.indexOf(tsIP()) > -1
&& (
Lampa.Storage.field('player_timecode') === 'again'
|| !data.timeline || !data.timeline.time
|| parseFloat('0' + data.timeline.time) < 60
|| true
)
) preload(data);
else lampaPlay(data);
};
function params(obj) {
var prop, pairs = [];
for (prop in obj) pairs.push(prop + (obj[prop] ? '=' + obj[prop] : ''));
return pairs.join('&');
}
function parseUrl(url) {
var m, base_url, stream, args, arg = {};
if (!!(m = url.match(/^(https?:\/\/.+?)(\/stream\/[^?]+)\?(.+)$/i))) {
base_url = m[1];
stream = m[2];
args = m[3];
args.split('&').map(function(v){var p=v.split('=');arg[p[0]] = p[1] || null;});
delete(arg['play']);delete(arg['preload']);delete(arg['stat']);
}
args = params(arg);
return {
clearUrl: base_url + stream + '?' + args,
base_url: base_url,
stream: stream,
args: '?' + args,
arg: arg
}
}
function preload(data) {
var u = parseUrl(data.url);
if (!u.arg.link) return lampaPlay(data);
player = new Player(data);
var controller = Lampa.Controller.enabled().name;
var network = new Lampa.Reguest();
var modalHtml = $('<div>' +
'<div class="broadcast__text" style="text-align: left">' +
'<span class="js-peer">&nbsp;</span><br>' +
'<span class="js-buff">&nbsp;</span><br>' +
'<span class="js-speed">&nbsp;</span>' +
'</div>' +
'<div class="broadcast__scan"><div></div></div>' +
'</div>');
var peer = modalHtml.find('.js-peer');
var buff = modalHtml.find('.js-buff');
var speed = modalHtml.find('.js-speed');
var modal = new Modal({
title: Lampa.Lang.translate('loading'),
html: modalHtml,
onBack: cancel,
buttons: [
{
name: Lampa.Lang.translate('cancel'),
onSelect: cancel
},
{
name: Lampa.Lang.translate('player_lauch'),
onSelect: play
}
]
});
modal.open();
var launchBtn = modal.render()
.find('.modal__button')
.filter(function(){
return $(this).text() === Lampa.Lang.translate('player_lauch');
});
var focusedOnReady = false;
/* =============================================================== */
function destroy() {
network.clear();
modal.close();
Lampa.Controller.toggle(controller);
}
function cancel(){
if (player) {
destroy();
player.callback && player.callback();
player = null;
}
}
function play(){
if (player) {
destroy();
player.play();
}
}
network.timeout(1800 * 1000);
network.silent(u.clearUrl + '&preload');
network.timeout(2000);
var stat = function(data) {
if (!player) return;
if (data && data.Torrent) {
var t = data.Torrent;
var p = Math.floor((t.preloaded_bytes || 0) * 100 / (t.preload_size || 1));
peer.html(
Lampa.Lang.translate('ts_preload_peers') + ': ' +
(t.active_peers || 0) + ' / ' +
(t.pending_peers || 0)
);
buff.html(
Lampa.Lang.translate('ts_preload_preload') + ': ' +
Lampa.Utils.bytesToSize(t.preloaded_bytes || 0) + ' / ' +
Lampa.Utils.bytesToSize(t.preload_size || 0) +
' (' + p + '%)'
);
speed.text(
Lampa.Lang.translate('ts_preload_speed') + ': ' +
Lampa.Utils.bytesToSize((t.download_speed || 0) * 8, true)
);
if (p >= 100 && !focusedOnReady && launchBtn.length) {
focusedOnReady = true;
Lampa.Controller.collectionFocus(
launchBtn[0],
modal.scroll.render()
);
}
}
network.silent(
u.base_url + '/cache',
stat,
stat,
JSON.stringify({ action: 'get', hash: u.arg.link })
);
};
stat({ Torrent: {
active_peers:0,
pending_peers:0,
total_peers:0,
connected_seeders:0,
preloaded_bytes:0,
preload_size:0,
download_speed:0
}});
}
})(jQuery, Lampa);
+13
View File
@@ -0,0 +1,13 @@
;(function() {
var ua = navigator.userAgent || '';
if (/Android\s+[4-6]/i.test(ua) && /Crosswalk/.test(ua)) {
Lampa.Socket.init = function () {
console.log('Socket', 'initialization disabled by wsoff.js plugin')
};
Lampa.Socket.send = function (method, data) {
console.log('Socket', 'Method "' + method + '" send disabled by wsoff.js plugin')
};
} else {
console.log('Socket', 'The wsoff.js plugin cannot work on this device because it is designed for older devices with expired root certificates.')
}
})();
+1360
View File
File diff suppressed because it is too large Load Diff