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

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
@@ -0,0 +1,131 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using Shared;
using System.IO;
using System.Threading.Tasks;
namespace TelegramBot.Controllers
{
public class TgNotifyController : BaseController
{
[HttpGet]
[AllowAnonymous]
[Route("tg-notify.js")]
public ActionResult TgNotifyPlugin()
{
// Ищем плагин в возможных расположениях модуля (NextGen: module/ или mods/)
string file = null;
foreach (var p in new[]
{
"module/Tg-notify.bot/tg-notify.js",
"module/Community/Tg-notify.bot/tg-notify.js",
"mods/TelegramBot/tg-notify.js",
"module/TelegramBot/tg-notify.js",
"plugins/tg-notify.js",
"wwwroot/plugins/tg-notify.js"
})
{
if (System.IO.File.Exists(p)) { file = p; break; }
}
if (file == null)
return Content(string.Empty, "application/javascript; charset=utf-8");
var js = System.IO.File.ReadAllText(file);
js = js.Replace("{localhost}", host);
return Content(js, "application/javascript; charset=utf-8");
}
[HttpPost]
[Route("/api/tg/subscribe")]
public async Task<ActionResult> Subscribe()
{
if (!ModInit.IsRunning)
return Json(new { success = false, msg = "bot_not_running" });
var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString());
if (string.IsNullOrEmpty(uid))
return Json(new { success = false, msg = "no_uid" });
using var reader = new StreamReader(HttpContext.Request.Body);
var json = JObject.Parse(await reader.ReadToEndAsync());
var result = await ModInit.SubscribeApi(
uid,
json.Value<int>("tmdb_id"),
json.Value<string>("title") ?? "",
json.Value<string>("voice") ?? "",
json.Value<int>("season"),
json.Value<int>("episode"),
json.Value<string>("mirage_orid") ?? "",
json.Value<int>("mirage_voice_id"),
json.Value<int>("voice_episode"),
json.Value<string>("collaps_orid") ?? "",
json.Value<string>("voice_source") ?? "",
json.Value<int>("kp_id")
);
return Json(result);
}
[HttpPost]
[Route("/api/tg/unsubscribe")]
public async Task<ActionResult> Unsubscribe()
{
if (!ModInit.IsRunning)
return Json(new { success = false, msg = "bot_not_running" });
var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString());
if (string.IsNullOrEmpty(uid))
return Json(new { success = false, msg = "no_uid" });
using var reader = new StreamReader(HttpContext.Request.Body);
var json = JObject.Parse(await reader.ReadToEndAsync());
return Json(ModInit.UnsubscribeApi(uid, json.Value<int>("tmdb_id"), json.Value<string>("voice")));
}
[HttpGet]
[Route("/api/tg/status")]
public ActionResult Status(int tmdb_id)
{
var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString());
if (string.IsNullOrEmpty(uid))
return Json(new { success = false, msg = "no_uid" });
return Json(ModInit.StatusApi(uid, tmdb_id));
}
[HttpGet]
[Route("/api/tg/voices")]
public async Task<ActionResult> Voices(string title, int year, int season, int tmdb_id)
{
return Json(await ModInit.GetVoicesApi(title ?? "", year, season, tmdb_id));
}
[HttpGet]
[Route("/api/tg/link")]
public async Task<ActionResult> GetLink()
{
var uid = requestInfo.user_uid;
if (string.IsNullOrEmpty(uid))
return Json(new { success = false, msg = "no_uid" });
return Json(await ModInit.LinkApi(uid));
}
/// <summary>
/// Список всех подписок пользователя с метаданными (для страницы подписок в Lampa)
/// </summary>
[HttpGet]
[Route("/api/tg/subscriptions")]
public ActionResult Subscriptions()
{
var uid = ModInit.ResolveUid(requestInfo.user_uid, Request.Query["uid"].ToString());
if (string.IsNullOrEmpty(uid))
return Json(new { success = false, msg = "no_uid" });
return Json(ModInit.SubscriptionsApi(uid));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
using Shared.Models.Module;
namespace TelegramBot.Models
{
public class TelegramBotConf : ModuleBaseConf
{
public bool enable { get; set; } = true;
public string bot_token { get; set; } = "YOUR_BOT_TOKEN";
public string tmdb_api_key { get; set; } = "";
public string trakt_client_id { get; set; } = "";
public string lampac_host { get; set; } = "http://127.0.0.1:9118";
public string lampac_token { get; set; } = "";
// Ключ kinopoiskapiunofficial.tech — резолвинг tmdb→kinopoisk_id по названию для источника VideoHub.
// Пусто → kp_id резолвится только через мост Lampac /externalids (покрытие ~40%).
public string kp_api_key { get; set; } = "";
public int check_interval_minutes { get; set; } = 60;
public string tmdb_lang { get; set; } = "ru-RU";
// Каталог для users.json / subscriptions.json. Пусто → database/tgnotify
public string data_dir { get; set; } = "database/tgnotify";
}
}
+163
View File
@@ -0,0 +1,163 @@
# Tg-notify.bot
Фоновый **Telegram-бот уведомлений о новых сериях и озвучках** (long polling). Пользователь подписывается на сериал прямо из карточки Lampa, а бот сам отслеживает выход новых эпизодов и появление выбранной озвучки и шлёт уведомление в Telegram с обложкой, номером серии и описанием из TMDB.
Проект: **`Modules/Tg-notify.bot/`**. C#-namespace модуля — `TelegramBot`.
Бот объединяет три подсистемы:
1. **Трекер новых серий** — Trakt API (приоритет) с фоллбэком на TMDB.
2. **Трекер озвучек** — параллельный опрос балансеров **Mirage**, **Collaps** и **VideoHub** (поиск по названию или kinopoisk_id, мультиозвучка, чистый JSON). Берётся максимальный эпизод из всех источников — кто первым выложил серию в нужной озвучке, от того и уведомление.
3. **Клиентский плагин Lampa** (`tg-notify.js`) — вкладка «TG Подписки», перехват кнопки 🔔 в карточке, меню выбора озвучки.
---
## Включение
1. В шаблоне [`config/base.conf`](../../../config/base.conf) при необходимости проверьте, что **`TelegramBot`** не попал в **`BaseModule.SkipModules`** (если попал — уберите имя из списка, иначе модуль не загрузится).
2. [`manifest.json`](manifest.json): выставьте **`"enable": true`** (в репозитории по умолчанию `false`).
3. `init.conf`: добавьте секцию **`TelegramBot`**. Пример — [`init.merge.example.json`](init.merge.example.json).
4. Перезапустите сервис: `systemctl restart lampac` (или имя вашего systemd-юнита).
При `enable: true` и пустом `bot_token` long polling **не стартует** — в лог пишется предупреждение.
---
## Конфигурация (секция `init.conf`)
```json
"TelegramBot": {
"enable": true,
"bot_token": "ТОКЕН_ОТ_BOTFATHER",
"tmdb_api_key": "ВАШ_КЛЮЧ_TMDB",
"trakt_client_id": "",
"lampac_host": "http://127.0.0.1:9118",
"lampac_token": "",
"kp_api_key": "",
"check_interval_minutes": 60,
"tmdb_lang": "ru-RU",
"data_dir": "database/tgnotify"
}
```
| Поле | Назначение |
|------|-----------|
| `bot_token` | Токен бота от [@BotFather](https://t.me/BotFather). **Обязателен.** |
| `tmdb_api_key` | Ключ TMDB v3 — обложки, описания эпизодов, определение актуального сезона. |
| `trakt_client_id` | Client ID приложения Trakt. Если задан — основной источник новых серий; если пуст — используется только TMDB. |
| `lampac_host` | Базовый URL самого Lampac для запросов к балансерам (`/lite/mirage`, `/lite/collaps`). Обычно локальный: `http://127.0.0.1:9118`. |
| `lampac_token` | Токен **accsdb**. Обязателен, если у вас включена защита `accsdb` — без него балансеры вернут `{"accsdb":true,"msg":"Войдите в аккаунт"}`. |
| `kp_api_key` | Ключ [kinopoiskapiunofficial.tech](https://kinopoiskapiunofficial.tech/) — резолвинг `tmdb_id → kinopoisk_id` по названию для источника **VideoHub**. Опционально: если пусто, `kinopoisk_id` берётся только из встроенного моста Lampac `/externalids` (покрытие ~40%); с ключом — фоллбэк через поиск по названию поднимает покрытие почти до полного. |
| `check_interval_minutes` | Период фоновой проверки подписок. По умолчанию 60. Первый прогон — через 2 минуты после старта. |
| `tmdb_lang` | Язык метаданных TMDB. По умолчанию `ru-RU` (с фоллбэком на `en-US` при пустом описании). |
| `data_dir` | Каталог хранения `users.json` / `subscriptions.json`. По умолчанию `database/tgnotify`. |
---
## Зависимости
- **`Telegram.Bot` 22.4.4** — кладётся в [`references/`](references/) (для рантайм-компиляции Roslyn) и подтягивается через `PackageReference` при локальной сборке.
- **TMDB API** — ключ v3 (`tmdb_api_key`).
- **Trakt API** (опционально) — `trakt_client_id` для более точного и быстрого трекинга выхода серий.
- Балансеры **Mirage**, **Collaps** и **VideoHub** (`/lite/cdnvideohub`) должны быть доступны по `lampac_host`.
- **kinopoiskapiunofficial.tech** (опционально) — `kp_api_key`. Нужен только для VideoHub: резолвит `kinopoisk_id` по названию, когда встроенный мост Lampac его не знает.
---
## Структура модуля
```
Tg-notify.bot/
├── manifest.json # dynamic:true, references+tree
├── Tg-notify.bot.csproj # только для локальной сборки; на сервере не используется
├── init.merge.example.json # пример секции init.conf
├── ModInit.cs # ядро: polling, трекинг серий, парсеры озвучек, HTTP API
├── Models/
│ └── TelegramBotConf.cs # конфиг (наследует ModuleBaseConf)
├── Controllers/
│ └── TgNotifyController.cs # HTTP API + раздача tg-notify.js
├── references/
│ └── Telegram.Bot.dll # 22.4.4
└── tg-notify.js # клиентский плагин Lampa
```
> На сервере модуль компилируется рантайм-движком **Roslyn** по `manifest.json` (`dynamic: true`) — `.csproj` нужен только для локальной разработки. Поля `references` и `tree` обязательны.
---
## Как работает
### Привязка аккаунта
UID пользователя определяется так же, как для закладок и тайм-кодов: плагин передаёт `token`, `account_email` и `uid` устройства, сервер берёт первое непустое (`RequestInfo.getuid`). Вошёл в аккаунт CUB — подписки общие для всех его устройств.
Из вкладки «TG Подписки» в Lampa пользователь получает deep-link на бота. Telegram пропускает в параметре только `A-Za-z0-9_-` (до 64 символов), поэтому UID вида email уходит как `link64_{base64url(uid)}`, остальные — как раньше, `link_{uid}`. Бот сохраняет связку `chat_id ↔ lampac_uid` (`users.json`), после чего подписки этого UID становятся «его». Привязка, сделанная раньше по `uid` устройства, продолжает работать: если под основным UID привязки нет, API ищет её по `uid` устройства.
### Подписка
В карточке сериала плагин перехватывает кнопку 🔔 и показывает меню озвучек (`/api/tg/voices` → Mirage + Collaps + VideoHub). Выбор отправляется в `/api/tg/subscribe` и сохраняется в `subscriptions.json`.
### Фоновый цикл `CheckAll`
Раз в `check_interval_minutes` (и вручную командой `/check`) по каждой подписке:
1. **Новые серии** — `CheckNewEpisodes` (Trakt → TMDB). Если у подписки не выбрана конкретная озвучка — уведомление шлётся о самом факте выхода серии.
2. **Озвучки** (если озвучка выбрана) — **параллельно** (`Task.WhenAll`) опрашиваются Mirage, Collaps и VideoHub; берётся максимальный доступный эпизод; кто первый нашёл новую серию — от того уведомление. На каждую новую серию шлётся отдельное сообщение с инфой из TMDB.
> **VideoHub и kinopoisk_id.** VideoHub ищет контент только по `kinopoisk_id`, тогда как бот работает по `tmdb_id`. Резолвинг выполняется лениво и кешируется в самой подписке: сначала через встроенный мост Lampac `/externalids`, при неудаче — поиском по названию через `kp_api_key`. Имя озвучки в запросе URL-кодируется (кириллица иначе даёт HTTP 400). Если `kinopoisk_id` не нашёлся, VideoHub для этой подписки молча пропускается — Mirage и Collaps продолжают работать.
Формат уведомления:
```
🎬 Название
🎙 Озвучка
📺 S05E08 — Название эпизода
Описание (до 300 символов)
```
С обложкой кадра (`SendPhoto`), если у эпизода есть `still_path`.
---
## Команды бота
| Команда / кнопка | Действие |
|------------------|----------|
| `/start` | Приветствие; `/start link_{uid}` или `/start link64_{base64url(uid)}` — привязка аккаунта |
| `/list` · «📋 Подписки» | Список активных подписок |
| `/check` · «🔍 Проверить» | Принудительная проверка (в фоне) |
| `/help` · «📖 Помощь» | Справка |
| `/unlink` · «🔓 Отвязать» | Отвязать аккаунт |
---
## HTTP API (`TgNotifyController : BaseController`)
| Метод | Маршрут | Назначение |
|-------|---------|-----------|
| `GET` | `/tg-notify.js` | Раздаёт клиентский плагин с автозаменой `{localhost}` → реальный хост. `[AllowAnonymous]` |
| `POST` | `/api/tg/subscribe` | Подписка на сериал/озвучку |
| `POST` | `/api/tg/unsubscribe` | Отписка |
| `GET` | `/api/tg/status?tmdb_id=` | Статус подписки текущего пользователя |
| `GET` | `/api/tg/voices?title=&year=&season=&tmdb_id=` | Список доступных озвучек |
| `GET` | `/api/tg/link` | Ссылка привязки бота |
| `GET` | `/api/tg/subscriptions` | Все подписки пользователя с метаданными |
Все маршруты, кроме `/tg-notify.js` и `/api/tg/voices`, требуют `requestInfo.user_uid`.
---
## Клиентский плагин (`tg-notify.js`)
Подключается через `customPlugins` в `init.conf` либо раздаётся самим модулем по `/tg-notify.js`:
```json
"LampaWeb": {
"customPlugins": [
{ "url": "{localhost}/tg-notify.js", "status": 1 }
]
}
```
Возможности: вкладка «TG Подписки» (сетка карточек с бейджем `S5 E4`), перехват кнопки 🔔 в полной карточке, меню выбора озвучки (`Lampa.Select.show`, метки `Mirage [—]` / `Collaps [C]` / `VideoHub [V]`), загрузка метаданных через `Lampa.TMDB` / `Lampa.Network.silent` с кешем. Хост подставляется через `{localhost}` — хардкода адресов нет.
+28
View File
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
<RootNamespace>TelegramBot</RootNamespace>
<AssemblyName>TelegramBot</AssemblyName>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Telegram.Bot" Version="22.4.4" />
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
<!-- Кладёт Telegram.Bot.dll в references/ для рантайм-компиляции Roslyn на сервере -->
<Target Name="CopyTelegramBotToModReferences" AfterTargets="ResolveReferences">
<ItemGroup>
<_TelegramBotRef Include="@(ReferencePath)" Condition="'%(Filename)' == 'Telegram.Bot'" />
</ItemGroup>
<MakeDir Directories="$(MSBuildProjectDirectory)\references" />
<Copy SourceFiles="@(_TelegramBotRef)" DestinationFolder="$(MSBuildProjectDirectory)\references"
SkipUnchangedFiles="true" Condition="@(_TelegramBotRef->Count()) != 0" />
</Target>
</Project>
@@ -0,0 +1,13 @@
{
"TelegramBot": {
"enable": true,
"bot_token": "ВАШ_ТОКЕН_ОТ_BOTFATHER",
"tmdb_api_key": "ВАШ_КЛЮЧ_TMDB",
"trakt_client_id": "",
"lampac_host": "http://127.0.0.1:9118",
"lampac_token": "",
"check_interval_minutes": 60,
"tmdb_lang": "ru-RU",
"data_dir": "database/tgnotify"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"enable": true,
"dynamic": true,
"references": [
"references/"
],
"tree": [
"Models",
"Controllers",
"ModInit.cs"
]
}
Binary file not shown.
+407
View File
@@ -0,0 +1,407 @@
(function () {
'use strict';
var lampac_host = '{localhost}';
var network = new Lampa.Reguest();
// Пользователя выбирает сервер, как в account() остальных плагинов Lampac: token,
// account_email и uid передаются отдельными параметрами, RequestInfo.getuid берёт первое
// непустое. Иначе подписки живут под номером устройства, а закладки и тайм-коды того же
// человека — под его аккаунтом.
function apiUrl(path) {
var url = lampac_host + path;
var ids = {
token: Lampa.Storage.get('token', ''),
account_email: Lampa.Storage.get('account_email', ''),
uid: Lampa.Storage.get('lampac_unic_id', '')
};
for (var name in ids) {
if (ids[name]) url = Lampa.Utils.addUrlComponent(url, name + '=' + encodeURIComponent(ids[name]));
}
return url;
}
function pad(n) { return n < 10 ? '0' + n : '' + n; }
var TMDB_KEY = '';
// TMDB image/api с поддержкой прокси — используем встроенные методы Lampa
function tmdbImage(url) {
try { return Lampa.TMDB.image(url); } catch(e) {}
return 'https://image.tmdb.org/' + url;
}
function tmdbApi(path) {
try { return Lampa.TMDB.api(path + '&api_key=' + (Lampa.TMDB.key ? Lampa.TMDB.key() : TMDB_KEY)); } catch(e) {}
return 'https://api.themoviedb.org/3/' + path + '&api_key=' + TMDB_KEY;
}
// =============================================
// Компонент подписок — построен по образцу
// src/interaction/items/category/base.js
// =============================================
function TgSubscribesComponent(object) {
var scroll = new Lampa.Scroll({ mask: true, over: true, step: 250, end_ratio: 2 });
var items = [];
var html = document.createElement('div');
var body = $(document.createElement('div')); // jQuery объект, как в Lampa
var last;
var active = 0;
this.create = function () {
var self = this;
// Как в base.js: сначала scroll.minus(), потом append body
scroll.minus();
scroll.append(body);
html.appendChild(scroll.render(true));
// Layer.visible при скролле — для ленивой загрузки изображений
scroll.onScroll = function () {
Lampa.Layer.visible(scroll.render(true));
};
self.activity.loader(true);
var net = new Lampa.Reguest();
net.timeout(15000);
net['native'](apiUrl('/api/tg/subscriptions'), function (result) {
if (!result.linked || !result.results || !result.results.length) {
self.empty();
return;
}
var subs = result.results;
var lang = Lampa.Storage.field('tmdb_lang') || 'ru-RU';
var total = subs.length;
var done = 0;
var tmdbData = {};
function buildCards() {
// Классы сетки из items.js: mapping--grid + cols--6
body.addClass('mapping--grid');
body.addClass('cols--6');
var frag = document.createDocumentFragment();
subs.forEach(function (sub) {
var t = tmdbData[sub.tmdb_id] || {};
var cardData = {
id: sub.tmdb_id,
tmdb_id: sub.tmdb_id,
tmdb: sub.tmdb_id,
name: t.name || sub.title,
title: t.name || sub.title,
original_name: t.original_name || sub.title,
poster_path: t.poster_path || '',
backdrop_path: t.backdrop_path || '',
vote_average: t.vote_average || 0,
first_air_date: t.first_air_date || '',
number_of_seasons: t.number_of_seasons || sub.last_season,
source: 'tmdb'
};
if (t.poster_path)
cardData.img = tmdbImage('t/p/w300' + t.poster_path);
var card = new Lampa.Card(cardData, { object: object, card_category: true });
card.create();
var season = parseInt(sub.last_season) || 0;
var episode = parseInt(sub.last_episode) || 0;
var voice = (sub.voice || '').trim();
// Бейдж подписки — без pad() как в стандартном Subscribe модуле Lampa
var badge = document.createElement('div');
badge.className = 'card__subscribe';
badge.innerHTML =
'<div class="card__subscribe-status on"></div>' +
'<div class="card__subscribe-position">S' + season + ' E' + episode + '</div>' +
'<div class="card__subscribe-voice">' + (voice || 'Любая') + '</div>';
var view = card.render(true).querySelector('.card__view');
if (view) view.after(badge);
card.onFocus = function (target, data) {
last = target;
active = items.indexOf(card);
scroll.update($(target), true);
// Используем стандартную функцию Lampa — сама выбирает постер/backdrop по настройкам
Lampa.Background.change(Lampa.Utils.cardImgBackground(data));
};
card.onEnter = function (target, data) {
Lampa.Activity.push({
url: '', component: 'full',
id: data.tmdb_id || data.id,
method: 'tv', card: data, source: 'tmdb'
});
};
frag.appendChild(card.render(true));
items.push(card);
});
body.append(frag);
// Устанавливаем фон на первую карточку при открытии
if (items.length > 0) {
Lampa.Background.change(Lampa.Utils.cardImgBackground(items[0].data));
}
// Layer.visible — запускает ленивую загрузку изображений в карточках
Lampa.Layer.visible(scroll.render(true));
self.activity.loader(false);
self.activity.toggle();
}
// Параллельная загрузка TMDB через Lampa.Network (с кешем и прокси)
subs.forEach(function (sub) {
var url = tmdbApi('tv/' + sub.tmdb_id + '?language=' + lang);
Lampa.Network.silent(url, function (t) {
if (t && !t.status_code) tmdbData[sub.tmdb_id] = t;
if (++done >= total) buildCards();
},
function () { if (++done >= total) buildCards(); },
false, { cache: { life: 60 * 24 } }
);
});
}, function () { self.empty(); });
};
this.empty = function () {
var empty = new Lampa.Empty();
html.appendChild(empty.render(true));
this.start = empty.start.bind(empty);
this.activity.loader(false);
this.activity.toggle();
};
// start() — точная копия base.js
this.start = function () {
var self = this;
Lampa.Controller.add('content', {
link: self,
invisible: true,
toggle: function () {
scroll.restorePosition ? scroll.restorePosition() : null;
Lampa.Controller.collectionSet(scroll.render(true));
Lampa.Controller.collectionFocus(last || false, scroll.render(true));
},
left: function () { if (Navigator.canmove('left')) Navigator.move('left'); else Lampa.Controller.toggle('menu'); },
right: function () { if (Navigator.canmove('right')) Navigator.move('right'); },
up: function () { if (Navigator.canmove('up')) Navigator.move('up'); else Lampa.Controller.toggle('head'); },
down: function () { if (Navigator.canmove('down')) Navigator.move('down'); },
back: function () { Lampa.Activity.backward(); }
});
Lampa.Controller.toggle('content');
};
this.pause = function () {};
this.stop = function () {};
this.render = function (js) { return js ? html : $(html); };
this.destroy = function () {
items.forEach(function (c) { try { c.destroy(); } catch(e) {} });
scroll.destroy();
html.remove();
items = [];
};
}
// =============================================
// Меню
// =============================================
var MENU_ICON = '<svg><use xlink:href="#sprite-subscribes"></use></svg>';
var MENU_TITLE = 'TG Подписки';
var MENU_ACTION = 'tg_subscribes';
function openSubscribesPage() {
Lampa.Activity.push({ url: '', title: MENU_TITLE, component: MENU_ACTION, page: 1 });
}
function addMenu() {
if ($('.menu__item[data-action="' + MENU_ACTION + '"]').length) return;
Lampa.Menu.addButton(MENU_ICON, MENU_TITLE, openSubscribesPage).attr('data-action', MENU_ACTION);
try {
var sort = Lampa.Storage.get('menu_sort', '[]');
if (typeof sort === 'string') sort = JSON.parse(sort);
if (Array.isArray(sort) && sort.indexOf(MENU_TITLE) === -1) {
sort.push(MENU_TITLE);
Lampa.Storage.set('menu_sort', JSON.stringify(sort));
}
} catch(e) {}
}
function registerComponent() {
Lampa.Component.add(MENU_ACTION, TgSubscribesComponent);
setTimeout(addMenu, 500);
}
// =============================================
// Перехват кнопки 🔔 в карточке сериала
// =============================================
function initSubscribeOverride() {
Lampa.Listener.follow('full', function (e) {
if (e.type === 'complite')
setTimeout(function () { overrideSubscribeButton(e.object); }, 300);
});
}
function overrideSubscribeButton(obj) {
if (!obj || !obj.card || !obj.card.number_of_seasons) return;
var card = obj.card;
var button;
try { button = obj.activity.render().find('.button--subscribe'); } catch(e) { return; }
if (!button || !button.length) return;
button.removeClass('hide');
button.off('hover:enter');
checkStatus(card.id, function (status) {
if (status.subscribed) button.addClass('active').find('path').attr('fill', 'currentColor');
button.on('hover:enter', function () {
if (!status.linked) showLinkDialog();
else showVoiceMenu(card, button, status);
});
});
}
function showVoiceMenu(card, button, currentStatus) {
var title = card.name || card.title || '';
var year = card.first_air_date ? parseInt(card.first_air_date) : (card.year || 0);
var tmdbId = card.id || 0;
Lampa.Loading.start(function () { network.clear(); Lampa.Loading.stop(); });
network.clear();
network.timeout(30000);
network['native'](
apiUrl('/api/tg/voices?title=' + encodeURIComponent(title) + '&year=' + year + '&season=0&tmdb_id=' + tmdbId),
function (result) {
Lampa.Loading.stop();
var season = result.season || card.number_of_seasons || 1;
var items = [];
if (currentStatus && currentStatus.subscribed && currentStatus.voices) {
currentStatus.voices.forEach(function (v) {
items.push({ title: '❌ Отписаться: ' + (v || 'Любая озвучка'), unsubscribe: true, voice: v });
});
if (items.length) items.push({ title: '', separator: true });
}
items.push({ title: '🔔 Любая озвучка (оригинал)', subtitle: 'При выходе серии', voice: '', orid: '', voice_id: 0, collaps_orid: '', voice_source: '' });
if (result.success && result.voices && result.voices.length) {
items.push({ title: 'Озвучки:', separator: true });
result.voices.forEach(function (v) {
var src = v.source || 'mirage';
var badge = src === 'collaps' ? ' [C]' : src === 'rhs' ? ' [R]' : src === 'videohub' ? ' [V]' : '';
items.push({ title: '🎙 ' + v.name + badge, voice: v.name, orid: result.orid || '', voice_id: v.id || 0, collaps_orid: result.collaps_orid || '', kp_id: result.kp_id || 0, voice_source: src });
});
}
Lampa.Select.show({
title: 'TG Уведомления', items: items,
onSelect: function (a) {
Lampa.Controller.toggle('content');
if (a.unsubscribe) doUnsubscribe(card, button, a.voice);
else if (typeof a.voice !== 'undefined') doSubscribe(card, a.voice, season, button, a.orid, a.voice_id, a.collaps_orid, a.voice_source, a.kp_id);
},
onBack: function () { Lampa.Controller.toggle('content'); }
});
},
function () {
Lampa.Loading.stop();
var items = [{ title: '🔔 Подписаться (без озвучки)', voice: '', orid: '', voice_id: 0 }];
if (currentStatus && currentStatus.subscribed) items.unshift({ title: '❌ Отписаться', unsubscribe: true, voice: '' });
Lampa.Select.show({
title: 'TG Уведомления', items: items,
onSelect: function (a) {
Lampa.Controller.toggle('content');
if (a.unsubscribe) doUnsubscribe(card, button, null);
else doSubscribe(card, '', card.number_of_seasons || 1, button, '', 0);
},
onBack: function () { Lampa.Controller.toggle('content'); }
});
}
);
}
function doSubscribe(card, voice, season, button, orid, voiceId, collapsOrid, voiceSource, kpId) {
network.clear();
network['native'](apiUrl('/api/tg/subscribe'), function (r) {
if (r.success) {
Lampa.Noty.show(voice ? '🔔 Подписка: ' + voice : '🔔 Подписка оформлена!');
button.addClass('active').find('path').attr('fill', 'currentColor');
} else if (r.msg === 'not_linked') {
showLinkDialog();
} else {
Lampa.Noty.show('Ошибка: ' + (r.msg || ''));
}
}, function () {
Lampa.Noty.show('Ошибка подписки');
}, JSON.stringify({ tmdb_id: card.id, title: card.name || card.title || '', voice: voice, season: season, episode: 0, mirage_orid: orid || '', mirage_voice_id: voiceId || 0, voice_episode: 0, collaps_orid: collapsOrid || '', voice_source: voiceSource || '', kp_id: (card.kinopoisk_id || card.kp_id || kpId || 0) }),
{ dataType: 'json', contentType: 'application/json' });
}
function doUnsubscribe(card, button, voice) {
network.clear();
network['native'](apiUrl('/api/tg/unsubscribe'), function (r) {
if (r.success) {
Lampa.Noty.show('Подписка отменена');
checkStatus(card.id, function (st) {
if (!st.subscribed) button.removeClass('active').find('path').attr('fill', 'transparent');
});
}
}, function () { Lampa.Noty.show('Ошибка'); },
JSON.stringify({ tmdb_id: card.id, voice: voice }),
{ dataType: 'json', contentType: 'application/json' });
}
function checkStatus(tmdb_id, callback) {
var n = new Lampa.Reguest();
n.timeout(5000);
n['native'](apiUrl('/api/tg/status?tmdb_id=' + tmdb_id),
function (r) { callback(r || { success: false, linked: false, subscribed: false, voices: [] }); },
function () { callback({ success: false, linked: false, subscribed: false, voices: [] }); }
);
}
function showLinkDialog() {
var n = new Lampa.Reguest();
n['native'](apiUrl('/api/tg/link'), function (r) {
if (!r.success || !r.link) { Lampa.Noty.show('Бот не запущен'); return; }
Lampa.Select.show({
title: 'Привязка Telegram',
items: [
{ title: 'Открыть ссылку', subtitle: r.link, link: r.link },
{ title: 'Скопировать ссылку', show_link: r.link }
],
onSelect: function (a) {
Lampa.Controller.toggle('content');
if (a.link) {
if (typeof Android !== 'undefined' && Android.openBrowser) Android.openBrowser(a.link);
else if (window.open) window.open(a.link, '_blank');
}
if (a.show_link) Lampa.Noty.show(a.show_link);
},
onBack: function () { Lampa.Controller.toggle('content'); }
});
}, function () { Lampa.Noty.show('Модуль TelegramBot недоступен'); });
}
// =============================================
// Init
// =============================================
function init() {
registerComponent();
initSubscribeOverride();
console.log('[TG-Notify] Plugin loaded v17');
}
if (window.appready) init();
else Lampa.Listener.follow('app', function (e) { if (e.type === 'ready') init(); });
})();