using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Shared; using Shared.Models.Events; using Shared.Models.Module; using Shared.Models.Module.Interfaces; using Shared.Services; using TelegramBot.Models; using Telegram.Bot; using Telegram.Bot.Types; using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.ReplyMarkups; namespace TelegramBot { public class ModInit : IModuleLoaded { #region Static data public static TelegramBotConf Config { get; private set; } = new(); public static ConcurrentDictionary Users { get; private set; } = new(); public static ConcurrentDictionary> Subs { get; private set; } = new(); public static TelegramBotClient Bot { get; private set; } public static bool IsRunning { get; private set; } public static string modpath { get; private set; } static string DataDir => string.IsNullOrWhiteSpace(Config?.data_dir) ? "database/tgnotify" : Config.data_dir; static string configPath => System.IO.Path.Combine(DataDir, "config.json"); static string subscriptionsPath => System.IO.Path.Combine(DataDir, "subscriptions.json"); static string usersPath => System.IO.Path.Combine(DataDir, "users.json"); static readonly HttpClient http = CreateHttpClient(); static Timer checkTimer; static HttpClient CreateHttpClient() { var client = new HttpClient(); client.DefaultRequestHeaders.Add("User-Agent", "LampaNotifications/1.0"); client.DefaultRequestHeaders.Add("Accept", "application/json"); client.Timeout = TimeSpan.FromSeconds(30); return client; } // Быстрый клиент для балансеров (Mirage/Collaps/RHS) — короткий таймаут static readonly HttpClient httpFast = CreateFastHttpClient(); static HttpClient CreateFastHttpClient() { var client = new HttpClient(); client.DefaultRequestHeaders.Add("User-Agent", "LampaNotifications/1.0"); client.DefaultRequestHeaders.Add("Accept", "application/json"); client.Timeout = TimeSpan.FromSeconds(8); return client; } // Кеш для озвучек и поиска (ключ → (результат, время кеширования)) static readonly ConcurrentDictionary _cache = new(); static readonly TimeSpan CacheTTL = TimeSpan.FromMinutes(30); static T GetCache(string key) where T : class { if (_cache.TryGetValue(key, out var entry) && DateTime.UtcNow - entry.cached_at < CacheTTL) return entry.result as T; return null; } static void SetCache(string key, object value) { _cache[key] = (value, DateTime.UtcNow); // Чистим старые записи (раз в 100 записей) if (_cache.Count > 200) foreach (var k in _cache.Where(kv => DateTime.UtcNow - kv.Value.cached_at > CacheTTL).Select(kv => kv.Key).ToList()) _cache.TryRemove(k, out _); } #endregion #region Models public class TgUser { public long chat_id { get; set; } public string username { get; set; } public string lampac_uid { get; set; } public DateTime linked_at { get; set; } } public class Subscription { public long chat_id { get; set; } public int tmdb_id { get; set; } public string media_type { get; set; } = "tv"; public string title { get; set; } public string voice { get; set; } // Озвучка: "LostFilm", "HDrezka Studio" и т.д. Пусто = любая public string voice_source { get; set; } // "mirage" или "collaps" public string mirage_orid { get; set; } // ID сериала в Mirage public int mirage_voice_id { get; set; } // ID озвучки (параметр t=) public string collaps_orid { get; set; } // ID сериала в Collaps public int kp_id { get; set; } // kinopoisk_id для VideoHub (резолвится один раз и кешируется) public int last_season { get; set; } public int last_episode { get; set; } public int last_voice_episode { get; set; } // Последняя серия в озвучке public DateTime subscribed_at { get; set; } } #endregion public void Loaded(InitspaceModel initspace) { modpath = initspace.path; UpdateConfFromInit(); EventListener.UpdateInitFile += UpdateConfFromInit; Directory.CreateDirectory(DataDir); LoadUsers(); LoadSubscriptions(); if (!Config.enable) { Console.WriteLine("\n\t[TelegramBot] Модуль отключён (TelegramBot.enable=false в init.conf)\n"); return; } if (string.IsNullOrWhiteSpace(Config.bot_token) || Config.bot_token == "YOUR_BOT_TOKEN") { Console.WriteLine("\n\t[TelegramBot] Установите bot_token в секции \"TelegramBot\" файла init.conf\n"); return; } if (IsRunning) return; _ = Task.Run(async () => { try { var cts = new CancellationTokenSource(); Bot = new TelegramBotClient(Config.bot_token, cancellationToken: cts.Token); var me = await Bot.GetMe(); IsRunning = true; Console.WriteLine($"\n\t[TelegramBot] @{me.Username} запущен!\n"); // Устанавливаем команды в меню бота try { await Bot.SetMyCommands(new[] { new BotCommand { Command = "list", Description = "📋 Мои подписки" }, new BotCommand { Command = "check", Description = "🔍 Проверить сейчас" }, new BotCommand { Command = "help", Description = "📖 Помощь" }, new BotCommand { Command = "unlink", Description = "🔓 Отвязать аккаунт" } }); Console.WriteLine("[TelegramBot] Bot commands set"); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] SetMyCommands error: {ex.Message}"); } checkTimer = new Timer(async _ => { try { await CheckAll(); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] Timer error: {ex.Message}"); } }, null, TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(Config.check_interval_minutes)); Console.WriteLine("[TelegramBot] Starting polling loop..."); await HandleUpdates(cts.Token); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] Fatal: {ex}\n"); IsRunning = false; } }); } void UpdateConfFromInit() { Config = ModuleInvoke.Init("TelegramBot", new TelegramBotConf { enable = true, bot_token = "YOUR_BOT_TOKEN", tmdb_api_key = "", 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" }); } #region Telegram polling static async Task HandleUpdates(CancellationToken ct) { int offset = 0; Console.WriteLine("[TelegramBot] HandleUpdates started"); while (!ct.IsCancellationRequested) { try { var updates = await Bot.GetUpdates(offset, timeout: 30, cancellationToken: ct); foreach (var update in updates) { offset = update.Id + 1; try { if (update.Message?.Text != null) { Console.WriteLine($"[TelegramBot] Msg: {update.Message.Text}"); await HandleMessage(update.Message); } else if (update.CallbackQuery != null) await HandleCallback(update.CallbackQuery); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] Handle error: {ex}"); } } } catch (TaskCanceledException) { break; } catch (Exception ex) { Console.WriteLine($"[TelegramBot] Poll error: {ex.Message}"); await Task.Delay(5000, ct); } } } static ReplyKeyboardMarkup MainKeyboard => new ReplyKeyboardMarkup(new[] { new KeyboardButton[] { "📋 Подписки", "🔍 Проверить" }, new KeyboardButton[] { "📖 Помощь", "🔓 Отвязать" } }) { ResizeKeyboard = true }; static async Task HandleMessage(Message msg) { var chatId = msg.Chat.Id; var text = msg.Text.Trim(); if (text.StartsWith("/start")) { var parts = text.Split(' '); if (parts.Length > 1 && TryParseLinkPayload(parts[1], out var uid)) { Users[chatId] = new TgUser { chat_id = chatId, username = msg.From?.Username ?? "", lampac_uid = uid, linked_at = DateTime.UtcNow }; SaveUsers(); await Bot.SendMessage(chatId, "✅ *Аккаунт привязан!*\n\nИспользуйте кнопки ниже для управления.", parseMode: ParseMode.Markdown, replyMarkup: MainKeyboard); return; } await Bot.SendMessage(chatId, "🎬 *Lampa Notifications Bot*\n\nУведомления о новых сериях и озвучках.", parseMode: ParseMode.Markdown, replyMarkup: MainKeyboard); } else if (text == "/list" || text == "📋 Подписки") await ShowSubscriptions(chatId); else if (text == "/check" || text == "🔍 Проверить") { Console.WriteLine($"[TelegramBot] /check from {chatId}"); // Запускаем в фоне чтобы не блокировать polling _ = Task.Run(async () => { try { await Bot.SendMessage(chatId, "🔍 Проверяю..."); await CheckAll(chatId); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] /check error: {ex}"); } }); } else if (text == "/help" || text == "📖 Помощь") { await Bot.SendMessage(chatId, "📖 *Помощь*\n\n" + "• Карточка сериала → 🔔 → выберите озвучку\n" + "• Бот пришлёт уведомление когда появится серия в этой озвучке\n" + "• Или подпишитесь на «Любая озвучка» — уведомление при выходе оригинала\n\n" + "Кнопки:\n" + "📋 — список подписок\n" + "🔍 — проверить новые серии\n" + "🔓 — отвязать аккаунт", parseMode: ParseMode.Markdown, replyMarkup: MainKeyboard); } else if (text == "/unlink" || text == "🔓 Отвязать") { if (Users.TryRemove(chatId, out _)) { foreach (var kvp in Subs) kvp.Value.RemoveAll(s => s.chat_id == chatId); SaveUsers(); SaveSubscriptions(); await Bot.SendMessage(chatId, "🔓 Аккаунт отвязан.", replyMarkup: new ReplyKeyboardRemove()); } else { await Bot.SendMessage(chatId, "Аккаунт не привязан."); } } } static async Task HandleCallback(CallbackQuery cb) { if (cb.Data.StartsWith("unsub_")) { var tmdbId = cb.Data.Substring(6); if (Subs.TryGetValue(tmdbId, out var list) && list.RemoveAll(s => s.chat_id == cb.Message.Chat.Id) > 0) { SaveSubscriptions(); await Bot.AnswerCallbackQuery(cb.Id, "Удалено ✅"); await ShowSubscriptions(cb.Message.Chat.Id); return; } await Bot.AnswerCallbackQuery(cb.Id, "Не найдено"); } } static async Task ShowSubscriptions(long chatId) { var userSubs = Subs.SelectMany(kvp => kvp.Value).Where(s => s.chat_id == chatId).OrderByDescending(s => s.subscribed_at).ToList(); if (userSubs.Count == 0) { await Bot.SendMessage(chatId, "📋 Нет подписок."); return; } var text = "📋 *Ваши подписки:*\n\n"; var buttons = new List>(); foreach (var sub in userSubs) { var v = string.IsNullOrEmpty(sub.voice) ? "Любая озвучка" : sub.voice; text += $"• *{EscapeMd(sub.title)}* — {v}\n S{sub.last_season:D2}E{sub.last_episode:D2}"; if (sub.last_voice_episode > 0) text += $" (озв: E{sub.last_voice_episode:D2})"; text += "\n"; buttons.Add(new List { InlineKeyboardButton.WithCallbackData($"❌ {sub.title} ({v})", $"unsub_{sub.tmdb_id}") }); } await Bot.SendMessage(chatId, text, parseMode: ParseMode.Markdown, replyMarkup: new InlineKeyboardMarkup(buttons)); } #endregion #region Проверка — главная public static async Task CheckAll(long? onlyChatId = null) { Console.WriteLine($"[TelegramBot] CheckAll started, onlyChatId={onlyChatId}"); var allSubs = Subs.SelectMany(kvp => kvp.Value).ToList(); if (onlyChatId.HasValue) allSubs = allSubs.Where(s => s.chat_id == onlyChatId.Value).ToList(); int notified = 0; foreach (var sub in allSubs.Where(s => s.media_type == "tv")) { try { bool hasVoice = !string.IsNullOrEmpty(sub.voice); bool hasMirage = !string.IsNullOrEmpty(sub.mirage_orid); bool hasCollaps = !string.IsNullOrEmpty(sub.collaps_orid); // 1. Проверить новые серии (Trakt/TMDB) var prevSeason = sub.last_season; var newEpisodes = await CheckNewEpisodes(sub); foreach (var ep in newEpisodes) { if (!hasVoice) { await SendEpisodeNotification(sub, ep); notified++; } sub.last_season = ep.season; sub.last_episode = ep.episode; } // Если сезон сменился — обнуляем счётчик озвучек if (sub.last_season > prevSeason && sub.last_voice_episode > 0) { Console.WriteLine($"[TelegramBot] Season changed {prevSeason}→{sub.last_season} for {sub.title}, resetting voice episode {sub.last_voice_episode}→0"); sub.last_voice_episode = 0; } // 2. Проверить озвучки — все источники параллельно if (hasVoice) { int voiceEps = sub.last_voice_episode; var voiceTasks = new List>(); if (hasMirage) voiceTasks.Add(CheckMirageVoice(sub)); voiceTasks.Add(CheckCollapsVoice(sub)); voiceTasks.Add(CheckRHSVoice(sub)); voiceTasks.Add(CheckVideoHubVoice(sub)); await Task.WhenAll(voiceTasks); foreach (var task in voiceTasks) if (task.Result > voiceEps) voiceEps = task.Result; if (voiceEps > sub.last_voice_episode) { int fromEp = sub.last_voice_episode + 1; int toEp = voiceEps; for (int e = fromEp; e <= toEp; e++) { var (epTitle, epOverview, imgUrl) = await GetTmdbEpisodeInfo(sub.tmdb_id, sub.last_season, e); var msg = $"🎬 *{EscapeMd(sub.title)}*\n🎙 {EscapeMd(sub.voice)}\n📺 S{sub.last_season:D2}E{e:D2}"; if (!string.IsNullOrEmpty(epTitle)) msg += $" — _{EscapeMd(epTitle)}_"; if (!string.IsNullOrEmpty(epOverview)) { var desc = epOverview.Length > 300 ? epOverview.Substring(0, 300) + "..." : epOverview; msg += $"\n\n{EscapeMd(desc)}"; } try { if (!string.IsNullOrEmpty(imgUrl)) await Bot.SendPhoto(sub.chat_id, InputFile.FromUri(imgUrl), caption: msg, parseMode: ParseMode.Markdown); else await Bot.SendMessage(sub.chat_id, msg, parseMode: ParseMode.Markdown); notified++; } catch (Exception ex) { Console.WriteLine($"[TelegramBot] Send voice error: {ex.Message}"); } } sub.last_voice_episode = voiceEps; Console.WriteLine($"[TelegramBot] Voice update: {sub.title} {sub.voice} now E{voiceEps}"); } } await Task.Delay(500); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] Check error {sub.tmdb_id}: {ex.Message}"); } } SaveSubscriptions(); Console.WriteLine($"[TelegramBot] CheckAll done, notified={notified}"); if (onlyChatId.HasValue && Bot != null) await Bot.SendMessage(onlyChatId.Value, notified > 0 ? $"✅ Уведомлений: {notified}" : "Новых серий/озвучек не найдено."); } static async Task SendEpisodeNotification(Subscription sub, EpisodeInfo ep) { var voice = string.IsNullOrEmpty(sub.voice) ? "" : $"\n🎙 {EscapeMd(sub.voice)}"; var message = $"🎬 *{EscapeMd(ep.show_name)}*\n📺 S{ep.season:D2}E{ep.episode:D2}"; if (!string.IsNullOrEmpty(ep.title)) message += $" — _{EscapeMd(ep.title)}_"; message += voice + $"\n📅 {ep.air_date}"; if (!string.IsNullOrEmpty(ep.overview)) { var desc = ep.overview.Length > 300 ? ep.overview.Substring(0, 300) + "..." : ep.overview; message += $"\n\n{EscapeMd(desc)}"; } try { if (!string.IsNullOrEmpty(ep.image_url)) await Bot.SendPhoto(sub.chat_id, InputFile.FromUri(ep.image_url), caption: message, parseMode: ParseMode.Markdown); else await Bot.SendMessage(sub.chat_id, message, parseMode: ParseMode.Markdown); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] Send error: {ex.Message}"); } } #endregion #region Проверка новых эпизодов (Trakt → TMDB) class EpisodeInfo { public string show_name { get; set; } public int season { get; set; } public int episode { get; set; } public string title { get; set; } public string air_date { get; set; } public string overview { get; set; } public string image_url { get; set; } } static async Task> CheckNewEpisodes(Subscription sub) { bool useTrakt = !string.IsNullOrEmpty(Config.trakt_client_id); if (useTrakt) { try { var (found, episodes) = await CheckViaTrakt(sub); if (found) return episodes; // Trakt нашёл шоу — верим только ему, даже если 0 новых серий // Trakt не нашёл шоу — fallback на TMDB Console.WriteLine($"[TelegramBot] Trakt: show {sub.tmdb_id} not found, using TMDB"); return await CheckViaTmdb(sub); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] Trakt error for {sub.tmdb_id}: {ex.Message}, using TMDB"); try { return await CheckViaTmdb(sub); } catch { } return new List(); } } try { return await CheckViaTmdb(sub); } catch { return new List(); } } static async Task<(bool found, List episodes)> CheckViaTrakt(Subscription sub) { var result = new List(); var searchReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.trakt.tv/search/tmdb/{sub.tmdb_id}?type=show"); searchReq.Headers.Add("trakt-api-key", Config.trakt_client_id); searchReq.Headers.Add("trakt-api-version", "2"); var searchResp = await http.SendAsync(searchReq); Console.WriteLine($"[TelegramBot] Trakt search tmdb/{sub.tmdb_id}: status={searchResp.StatusCode}"); if (!searchResp.IsSuccessStatusCode) { Console.WriteLine($"[TelegramBot] Trakt search failed: {await searchResp.Content.ReadAsStringAsync()}"); return (false, result); } var searchBody = await searchResp.Content.ReadAsStringAsync(); var searchData = JArray.Parse(searchBody); Console.WriteLine($"[TelegramBot] Trakt search results: {searchData.Count} items"); if (searchData.Count == 0) return (false, result); var showName = searchData[0]?["show"]?.Value("title") ?? sub.title; var traktSlug = searchData[0]?["show"]?["ids"]?.Value("slug"); var traktId = searchData[0]?["show"]?["ids"]?.Value("trakt"); var showId = traktSlug ?? traktId; Console.WriteLine($"[TelegramBot] Trakt found: {showName}, slug={traktSlug}, id={traktId}, using={showId}"); if (string.IsNullOrEmpty(showId)) return (false, result); // Шоу найдено — с этого момента верим только Trakt var seasonsReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.trakt.tv/shows/{showId}/seasons?extended=full"); seasonsReq.Headers.Add("trakt-api-key", Config.trakt_client_id); seasonsReq.Headers.Add("trakt-api-version", "2"); var seasonsResp = await http.SendAsync(seasonsReq); if (!seasonsResp.IsSuccessStatusCode) return (true, result); // шоу найдено, но сезоны недоступны — не fallback var seasons = JArray.Parse(await seasonsResp.Content.ReadAsStringAsync()); var seasonsToCheck = seasons .Where(s => s.Value("number") >= sub.last_season && s.Value("number") > 0) .Where(s => s.Value("aired_episodes") > 0 || s.Value("episode_count") > 0) .Select(s => s.Value("number")).OrderBy(n => n).ToList(); foreach (var seasonNum in seasonsToCheck) { var epsReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.trakt.tv/shows/{showId}/seasons/{seasonNum}?extended=full"); epsReq.Headers.Add("trakt-api-key", Config.trakt_client_id); epsReq.Headers.Add("trakt-api-version", "2"); var epsResp = await http.SendAsync(epsReq); if (!epsResp.IsSuccessStatusCode) continue; foreach (JObject ep in JArray.Parse(await epsResp.Content.ReadAsStringAsync())) { int sNum = ep.Value("season"), eNum = ep.Value("number"); var firstAired = ep.Value("first_aired"); if (sNum < sub.last_season || (sNum == sub.last_season && eNum <= sub.last_episode)) continue; if (string.IsNullOrEmpty(firstAired)) continue; if (!DateTime.TryParse(firstAired, null, System.Globalization.DateTimeStyles.RoundtripKind, out var airDt)) continue; if (airDt > DateTime.UtcNow) continue; // Получаем русское описание и картинку из TMDB var (tmdbTitle, tmdbOverview, tmdbImage) = await GetTmdbEpisodeInfo(sub.tmdb_id, sNum, eNum); result.Add(new EpisodeInfo { show_name = sub.title, // Русское название из подписки season = sNum, episode = eNum, title = !string.IsNullOrEmpty(tmdbTitle) ? tmdbTitle : (ep.Value("title") ?? ""), air_date = airDt.ToString("yyyy-MM-dd HH:mm UTC"), overview = !string.IsNullOrEmpty(tmdbOverview) ? tmdbOverview : (ep.Value("overview") ?? ""), image_url = tmdbImage }); } await Task.Delay(300); } return (true, result); } static async Task> CheckViaTmdb(Subscription sub) { var result = new List(); var show = JObject.Parse(await http.GetStringAsync($"https://api.themoviedb.org/3/tv/{sub.tmdb_id}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); var lastSeason = show.Value("number_of_seasons"); var showName = show.Value("name") ?? sub.title; var season = JObject.Parse(await http.GetStringAsync($"https://api.themoviedb.org/3/tv/{sub.tmdb_id}/season/{lastSeason}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); var episodes = season["episodes"] as JArray; if (episodes == null) return result; foreach (JObject ep in episodes) { int sNum = ep.Value("season_number"), eNum = ep.Value("episode_number"); var airDate = ep.Value("air_date"); if (sNum < sub.last_season || (sNum == sub.last_season && eNum <= sub.last_episode)) continue; if (string.IsNullOrEmpty(airDate) || !DateTime.TryParse(airDate, out var airDt) || airDt.Date > DateTime.UtcNow.Date) continue; var still = ep.Value("still_path"); result.Add(new EpisodeInfo { show_name = showName, season = sNum, episode = eNum, title = ep.Value("name") ?? "", air_date = airDate, image_url = !string.IsNullOrEmpty(still) ? $"https://image.tmdb.org/t/p/w500{still}" : null }); } return result; } static async Task GetTmdbStill(int tmdbId, int season, int episode) { try { var data = JObject.Parse(await http.GetStringAsync($"https://api.themoviedb.org/3/tv/{tmdbId}/season/{season}/episode/{episode}?api_key={Config.tmdb_api_key}")); var still = data.Value("still_path"); return !string.IsNullOrEmpty(still) ? $"https://image.tmdb.org/t/p/w500{still}" : null; } catch { return null; } } // Получить полную информацию об эпизоде (название, описание, картинка) static async Task<(string title, string overview, string imageUrl)> GetTmdbEpisodeInfo(int tmdbId, int season, int episode) { try { var data = JObject.Parse(await http.GetStringAsync( $"https://api.themoviedb.org/3/tv/{tmdbId}/season/{season}/episode/{episode}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); var still = data.Value("still_path"); var title = data.Value("name") ?? ""; var overview = data.Value("overview") ?? ""; var imageUrl = !string.IsNullOrEmpty(still) ? $"https://image.tmdb.org/t/p/w500{still}" : null; // Если описание пустое на русском, попробуем английский if (string.IsNullOrEmpty(overview)) { try { var dataEn = JObject.Parse(await http.GetStringAsync( $"https://api.themoviedb.org/3/tv/{tmdbId}/season/{season}/episode/{episode}?api_key={Config.tmdb_api_key}&language=en-US")); overview = dataEn.Value("overview") ?? ""; if (string.IsNullOrEmpty(title)) title = dataEn.Value("name") ?? ""; } catch { } } return (title, overview, imageUrl); } catch { return ("", "", null); } } #endregion #region Mirage — проверка озвучек static async Task CheckMirageVoice(Subscription sub) { if (string.IsNullOrEmpty(sub.mirage_orid) || string.IsNullOrEmpty(Config.lampac_host)) return sub.last_voice_episode; try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; var url = $"{Config.lampac_host}/lite/mirage?rjson=true&s={sub.last_season}&t={sub.mirage_voice_id}&orid={sub.mirage_orid}{token}"; Console.WriteLine($"[TelegramBot] Mirage check: {sub.title} voice={sub.voice} s={sub.last_season} t={sub.mirage_voice_id}"); var body = await httpFast.GetStringAsync(url); if (string.IsNullOrEmpty(body) || body == "null") return sub.last_voice_episode; int maxEp = 0; try { var json = JObject.Parse(body); var data = json["data"] as JArray; if (data != null) foreach (var ep in data) { var e = ep.Value("e") ?? 0; if (e > maxEp) maxEp = e; } } catch { // Fallback HTML парсинг var matches = Regex.Matches(body, @"e=""(\d+)"""); foreach (Match m in matches) if (int.TryParse(m.Groups[1].Value, out var ep) && ep > maxEp) maxEp = ep; } Console.WriteLine($"[TelegramBot] Mirage result: {sub.title} voice={sub.voice} episodes={maxEp} (was {sub.last_voice_episode})"); return maxEp; } catch (Exception ex) { Console.WriteLine($"[TelegramBot] Mirage error: {ex.Message}"); return sub.last_voice_episode; } } public static async Task> GetMirageVoices(string orid, int season) { var cacheKey = $"mirage_voices:{orid}:{season}"; var cached = GetCache>(cacheKey); if (cached != null) return cached; var result = new List(); if (string.IsNullOrEmpty(orid) || string.IsNullOrEmpty(Config.lampac_host)) return result; try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; var url = $"{Config.lampac_host}/lite/mirage?rjson=true&s={season}&orid={orid}{token}"; var body = await httpFast.GetStringAsync(url); if (string.IsNullOrEmpty(body) || body == "null") return result; try { var json = JObject.Parse(body); var voices = json["voice"] as JArray; if (voices != null) foreach (var v in voices) { var name = v.Value("name") ?? ""; if (!string.IsNullOrEmpty(name)) { int tid = 0; var vUrl = v.Value("url") ?? ""; var tm = Regex.Match(vUrl, @"t=(\d+)"); if (tm.Success) int.TryParse(tm.Groups[1].Value, out tid); result.Add(new VoiceInfo { id = tid, name = name, source = "mirage" }); } } } catch { // Fallback HTML var matches = Regex.Matches(body, @"t=(\d+)&[^""]*""[^>]*>([^<]+)"); foreach (Match m in matches) if (int.TryParse(m.Groups[1].Value, out var tid)) result.Add(new VoiceInfo { id = tid, name = m.Groups[2].Value.Trim(), source = "mirage" }); } } catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetMirageVoices error: {ex.Message}"); } if (result.Count > 0) SetCache(cacheKey, result); return result; } public static async Task> SearchMirage(string title, int year) { var cacheKey = $"mirage_search:{title}:{year}"; var cached = GetCache>(cacheKey); if (cached != null) return cached; var result = new List(); if (string.IsNullOrEmpty(Config.lampac_host)) return result; try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; // Помощник: запрос с проглатыванием 503/500/ошибок (возвращает null вместо исключения) async Task TryGet(string u) { try { return await httpFast.GetStringAsync(u); } catch { return null; } } var url = $"{Config.lampac_host}/lite/mirage-search?rjson=true&title={Uri.EscapeDataString(title)}&year={year}{token}"; var body = await TryGet(url); if (string.IsNullOrEmpty(body) || body == "null") { // Fallback 1: без rjson url = $"{Config.lampac_host}/lite/mirage-search?title={Uri.EscapeDataString(title)}&year={year}{token}"; body = await TryGet(url); } if (string.IsNullOrEmpty(body) || body == "null") { // Fallback 2: очищенное название. Mirage отдаёт 503 на цифрах/апострофах/дефисах в запросе. // "Люди Икс '97" -> "Люди Икс"; "Уидоус-Бэй" -> "Уидоус Бэй" var simple = Regex.Replace(title, @"[\u2018\u2019\u02BC'`]", ""); simple = Regex.Replace(simple, @"[-\u2013\u2014]", " "); simple = Regex.Replace(simple, @"\d+", ""); simple = Regex.Replace(simple, @"\s+", " ").Trim(); if (simple.Length >= 3 && !simple.Equals(title, StringComparison.OrdinalIgnoreCase)) { Console.WriteLine($"[TelegramBot] Mirage fallback simplified: '{title}' -> '{simple}'"); url = $"{Config.lampac_host}/lite/mirage-search?rjson=true&title={Uri.EscapeDataString(simple)}&year={year}{token}"; body = await TryGet(url); } } if (string.IsNullOrEmpty(body) || body == "null") return result; // Попробовать JSON try { var json = JObject.Parse(body); var data = json["data"] as JArray; if (data != null) foreach (var item in data) { var itemUrl = item.Value("url") ?? ""; var itemTitle = item.Value("title") ?? ""; var itemYear = item.Value("year") ?? 0; var oridMatch = Regex.Match(itemUrl, @"orid=([a-f0-9]+)"); if (oridMatch.Success) result.Add(new MirageSearchResult { orid = oridMatch.Groups[1].Value, title = itemTitle, year = itemYear }); } } catch { // Fallback HTML var matches = Regex.Matches(body, @"data-json='(\{[^']+\})'"); foreach (Match m in matches) try { var jj = JObject.Parse(m.Groups[1].Value.Replace("&", "&")); var itemUrl = jj.Value("url") ?? ""; var itemTitle = jj.Value("title") ?? ""; var itemYear = jj.Value("year") ?? 0; var oridMatch = Regex.Match(itemUrl, @"orid=([a-f0-9]+)"); if (oridMatch.Success) result.Add(new MirageSearchResult { orid = oridMatch.Groups[1].Value, title = itemTitle, year = itemYear }); } catch { } } // Сортировка по точности if (result.Count > 1) { var searchTitle = title.Trim().ToLowerInvariant(); result = result.OrderByDescending(r => { int score = 0; var it = (r.title ?? "").Trim().ToLowerInvariant(); if (it == searchTitle) score += 100; else if (it.Length > 0 && searchTitle.Length > 0 && (it.Contains(searchTitle) || searchTitle.Contains(it))) score += (int)(50.0 * Math.Min(it.Length, searchTitle.Length) / Math.Max(it.Length, searchTitle.Length)); if (year > 0 && r.year == year) score += 20; else if (year > 0 && Math.Abs(r.year - year) <= 1) score += 10; return score; }).ToList(); } Console.WriteLine($"[TelegramBot] SearchMirage: '{title}' year={year} -> {result.Count} results, best: {result.FirstOrDefault()?.title} ({result.FirstOrDefault()?.year})"); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] SearchMirage error: {ex.Message}"); } if (result.Count > 0) SetCache(cacheKey, result); return result; } public class VoiceInfo { public int id { get; set; } public string name { get; set; } public string source { get; set; } } public class MirageSearchResult { public string orid { get; set; } public string title { get; set; } public int year { get; set; } } #endregion #region Collaps — rjson public static async Task SearchCollaps(string title, int year) { var cacheKey = $"collaps_search:{title}:{year}"; var cached = GetCache(cacheKey); if (cached != null) return cached; if (string.IsNullOrEmpty(Config.lampac_host)) return null; try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; var url = $"{Config.lampac_host}/lite/collaps?rjson=true&title={Uri.EscapeDataString(title)}&year={year}{token}"; var body = await httpFast.GetStringAsync(url); if (string.IsNullOrEmpty(body) || body == "null") return null; var json = JObject.Parse(body); var data = json["data"] as JArray; if (data == null || data.Count == 0) return null; string bestOrid = null; int bestScore = -1; foreach (var item in data) { var itemUrl = item.Value("url") ?? ""; var itemTitle = item.Value("title") ?? ""; var itemYear = item.Value("year") ?? 0; var oridMatch = Regex.Match(itemUrl, @"orid=(\d+)"); if (!oridMatch.Success) continue; int score = 0; if (itemYear == year) score += 10; else if (Math.Abs(itemYear - year) <= 1) score += 5; if (itemTitle.Equals(title, StringComparison.OrdinalIgnoreCase)) score += 5; if (score > bestScore) { bestScore = score; bestOrid = oridMatch.Groups[1].Value; } } Console.WriteLine($"[TelegramBot] SearchCollaps: '{title}' year={year} -> orid={bestOrid}"); if (!string.IsNullOrEmpty(bestOrid)) SetCache(cacheKey, bestOrid); return bestOrid; } catch (Exception ex) { Console.WriteLine($"[TelegramBot] SearchCollaps error: {ex.Message}"); return null; } } public static async Task> GetCollapsVoices(string collapsOrid, int season) { var cacheKey = $"collaps_voices:{collapsOrid}:{season}"; var cached = GetCache>(cacheKey); if (cached != null) return cached; var result = new List(); if (string.IsNullOrEmpty(collapsOrid) || string.IsNullOrEmpty(Config.lampac_host)) return result; try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; var url = $"{Config.lampac_host}/lite/collaps?rjson=true&orid={collapsOrid}&s={season}{token}"; var body = await httpFast.GetStringAsync(url); if (string.IsNullOrEmpty(body) || body == "null") return result; var json = JObject.Parse(body); var data = json["data"] as JArray; if (data == null || data.Count == 0) return result; // Берём details из первого эпизода var details = data[0]?.Value("details") ?? data[0]?.Value("voice_name") ?? ""; if (!string.IsNullOrEmpty(details)) { int id = 1; foreach (var v in details.Split(',')) { var name = v.Trim().Trim('"'); if (!string.IsNullOrEmpty(name) && name.Length > 1) result.Add(new VoiceInfo { id = id++, name = name, source = "collaps" }); } } } catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetCollapsVoices error: {ex.Message}"); } if (result.Count > 0) SetCache(cacheKey, result); return result; } static async Task CheckCollapsVoice(Subscription sub) { if (string.IsNullOrEmpty(Config.lampac_host)) return sub.last_voice_episode; var orid = sub.collaps_orid; if (string.IsNullOrEmpty(orid)) { orid = await SearchCollaps(sub.title, 0); if (!string.IsNullOrEmpty(orid)) { sub.collaps_orid = orid; Console.WriteLine($"[TelegramBot] Collaps auto-found orid={orid} for {sub.title}"); } else return sub.last_voice_episode; } try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; var url = $"{Config.lampac_host}/lite/collaps?rjson=true&orid={orid}&s={sub.last_season}{token}"; Console.WriteLine($"[TelegramBot] Collaps check: {sub.title} voice={sub.voice} s={sub.last_season}"); var body = await httpFast.GetStringAsync(url); if (string.IsNullOrEmpty(body) || body == "null") return sub.last_voice_episode; var json = JObject.Parse(body); var data = json["data"] as JArray; if (data == null) return sub.last_voice_episode; int maxEp = 0; foreach (var ep in data) { var epNum = ep.Value("e") ?? 0; var details = ep.Value("details") ?? ep.Value("voice_name") ?? ""; if (details.Contains(sub.voice, StringComparison.OrdinalIgnoreCase) && epNum > maxEp) maxEp = epNum; } Console.WriteLine($"[TelegramBot] Collaps result: {sub.title} voice={sub.voice} episodes={maxEp} (was {sub.last_voice_episode})"); return maxEp; } catch (Exception ex) { Console.WriteLine($"[TelegramBot] Collaps check error: {ex.Message}"); return sub.last_voice_episode; } } #endregion #region VideoHub (CDNvideohub) — поиск по kinopoisk_id // Резолвер tmdb_id -> kinopoisk_id. Порядок: кеш в подписке -> мост Lampac /externalids -> поиск по названию в KP API. // Результат кешируется прямо в sub.kp_id, чтобы не дёргать KP API каждый прогон. -1 = резолв не удался (больше не пытаемся). public static async Task GetKpId(Subscription sub) { if (sub.kp_id > 0) return sub.kp_id; if (sub.kp_id == -1) return 0; // ранее не нашли — не долбим API повторно // 1) Мост Lampac /externalids (бесплатно, без квоты KP API) string imdbId = null; if (!string.IsNullOrEmpty(Config.lampac_host)) { try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; var url = $"{Config.lampac_host}/externalids?id={sub.tmdb_id}&ts=tv{token}"; var body = await httpFast.GetStringAsync(url); if (!string.IsNullOrEmpty(body) && body != "null") { var j = JObject.Parse(body); var kpStr = j.Value("kinopoisk_id"); imdbId = j.Value("imdb_id"); if (int.TryParse(kpStr, out var kp) && kp > 0) { sub.kp_id = kp; Console.WriteLine($"[TelegramBot] KP resolved via externalids: {sub.title} -> kp={kp}"); return kp; } } } catch (Exception ex) { Console.WriteLine($"[TelegramBot] externalids error: {ex.Message}"); } } // 2) Поиск по названию через kinopoiskapiunofficial.tech (нужен ключ kp_api_key) if (!string.IsNullOrEmpty(Config.kp_api_key)) { int year = 0; string originalTitle = null; try { var show = JObject.Parse(await http.GetStringAsync( $"https://api.themoviedb.org/3/tv/{sub.tmdb_id}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); originalTitle = show.Value("original_name"); var firstAir = show.Value("first_air_date") ?? ""; if (firstAir.Length >= 4) int.TryParse(firstAir.Substring(0, 4), out year); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetKpId TMDB info error: {ex.Message}"); } var kp = await SearchKpByTitle(sub.title, year, originalTitle); if (kp > 0) { sub.kp_id = kp; Console.WriteLine($"[TelegramBot] KP resolved via search: {sub.title} -> kp={kp}"); return kp; } } sub.kp_id = -1; // пометка «не нашли», чтобы не пытаться каждый раз Console.WriteLine($"[TelegramBot] KP NOT resolved: {sub.title} (tmdb={sub.tmdb_id})"); return 0; } // Поиск kinopoisk_id по названию. Точное совпадение nameRu/nameEn + год + type=TV_SERIES имеет приоритет. // Нормализация названия для сравнения: убирает апострофы (обычные и типографские), // приводит дефисы/двоеточия/точки к пробелам, схлопывает пробелы, lower-case. static string NormTitle(string s) { if (string.IsNullOrEmpty(s)) return ""; s = Regex.Replace(s, @"[\u2018\u2019\u02BC'`]", ""); s = Regex.Replace(s, @"[-\u2013\u2014:.,]", " "); s = Regex.Replace(s, @"\s+", " ").Trim().ToLowerInvariant(); return s; } // Поиск kinopoisk_id по названию. Сопоставление нормализованное ('97 vs '97, регистр). // Приоритет: имя+TV_SERIES+год -> имя+TV_SERIES -> 0. Мусорный fallback (первый попавшийся) убран: // лучше пропустить VideoHub, чем взять чужой фильм. static async Task SearchKpByTitle(string title, int year, string originalTitle = null) { if (string.IsNullOrEmpty(Config.kp_api_key) || string.IsNullOrEmpty(title)) return 0; var cacheKey = $"kp_search:{title}:{originalTitle}:{year}"; var cached = GetCache(cacheKey); if (cached != null) return int.TryParse(cached, out var c) ? c : 0; var queries = new List { title }; if (!string.IsNullOrEmpty(originalTitle) && !originalTitle.Equals(title, StringComparison.OrdinalIgnoreCase)) queries.Add(originalTitle); var wantRu = NormTitle(title); var wantEn = NormTitle(originalTitle); try { int exactSeriesYear = 0, exactSeries = 0, exactAny = 0; foreach (var q in queries) { var url = $"https://kinopoiskapiunofficial.tech/api/v2.1/films/search-by-keyword?keyword={Uri.EscapeDataString(q)}"; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Add("X-API-KEY", Config.kp_api_key); req.Headers.Add("accept", "application/json"); var resp = await httpFast.SendAsync(req); var body = await resp.Content.ReadAsStringAsync(); if (string.IsNullOrEmpty(body)) continue; var films = JObject.Parse(body)["films"] as JArray; if (films == null) continue; foreach (var f in films) { var id = f.Value("filmId") ?? 0; if (id <= 0) continue; var nRu = NormTitle(f.Value("nameRu")); var nEn = NormTitle(f.Value("nameEn")); int.TryParse(f.Value("year"), out var fy); bool isSeries = (f.Value("type") ?? "") == "TV_SERIES"; bool nameMatch = (wantRu.Length > 0 && (nRu == wantRu || (nRu.Length > 3 && wantRu.Length > 3 && (nRu.Contains(wantRu) || wantRu.Contains(nRu))))) || (wantEn.Length > 0 && (nEn == wantEn || (nEn.Length > 3 && wantEn.Length > 3 && (nEn.Contains(wantEn) || wantEn.Contains(nEn))))); if (!nameMatch) continue; if (isSeries && year > 0 && fy == year && exactSeriesYear == 0) exactSeriesYear = id; if (isSeries && exactSeries == 0) exactSeries = id; if (exactAny == 0) exactAny = id; } if (exactSeriesYear != 0) break; } int result = exactSeriesYear != 0 ? exactSeriesYear : exactSeries != 0 ? exactSeries : exactAny; // 0, если имя нигде не совпало → VideoHub пропустится Console.WriteLine($"[TelegramBot] SearchKpByTitle: '{title}' (orig='{originalTitle}') year={year} -> kp={result}"); SetCache(cacheKey, result.ToString()); return result; } catch (Exception ex) { Console.WriteLine($"[TelegramBot] SearchKpByTitle error: {ex.Message}"); return 0; } } // Список озвучек VideoHub для сезона (для меню плагина). Возвращает VoiceInfo с source="videohub". public static async Task> GetVideoHubVoices(int kpId, int season) { var result = new List(); if (kpId <= 0 || string.IsNullOrEmpty(Config.lampac_host)) return result; var cacheKey = $"vhub_voices:{kpId}:{season}"; var cached = GetCache>(cacheKey); if (cached != null) return cached; try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; var url = $"{Config.lampac_host}/lite/cdnvideohub?rjson=true&kinopoisk_id={kpId}&s={season}{token}"; var body = await httpFast.GetStringAsync(url); if (string.IsNullOrEmpty(body) || body == "null") return result; var json = JObject.Parse(body); var voices = json["voice"] as JArray; if (voices != null) foreach (var v in voices) { var name = v.Value("name"); if (!string.IsNullOrEmpty(name)) result.Add(new VoiceInfo { id = 0, name = name, source = "videohub" }); } SetCache(cacheKey, result); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetVideoHubVoices error: {ex.Message}"); } return result; } // Проверка максимального вышедшего эпизода в нужной озвучке через VideoHub. static async Task CheckVideoHubVoice(Subscription sub) { if (string.IsNullOrEmpty(Config.lampac_host)) return sub.last_voice_episode; int kpId = await GetKpId(sub); if (kpId <= 0) return sub.last_voice_episode; try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; // t = имя озвучки, обязательно URL-кодировать (кириллица иначе даёт HTTP 400) var tParam = !string.IsNullOrEmpty(sub.voice) ? $"&t={Uri.EscapeDataString(sub.voice)}" : ""; var url = $"{Config.lampac_host}/lite/cdnvideohub?rjson=true&kinopoisk_id={kpId}&s={sub.last_season}{tParam}{token}"; Console.WriteLine($"[TelegramBot] VideoHub check: {sub.title} voice={sub.voice} kp={kpId} s={sub.last_season}"); var body = await httpFast.GetStringAsync(url); if (string.IsNullOrEmpty(body) || body == "null") return sub.last_voice_episode; var json = JObject.Parse(body); var data = json["data"] as JArray; if (data == null) return sub.last_voice_episode; int maxEp = 0; foreach (var ep in data) { var epNum = ep.Value("e") ?? 0; if (epNum > maxEp) maxEp = epNum; } Console.WriteLine($"[TelegramBot] VideoHub result: {sub.title} voice={sub.voice} episodes={maxEp} (was {sub.last_voice_episode})"); return maxEp; } catch (Exception ex) { Console.WriteLine($"[TelegramBot] VideoHub check error: {ex.Message}"); return sub.last_voice_episode; } } #endregion #region RedHeadSound (RHS) — rjson public static async Task> GetRHSVoices(string title, int year, int season) { var cacheKey = $"rhs_voices:{title}:{year}:{season}"; var cached = GetCache>(cacheKey); if (cached != null) return cached; var result = new List(); if (string.IsNullOrEmpty(Config.lampac_host)) return result; try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; var url = $"{Config.lampac_host}/lite/redheadsound?rjson=true&title={Uri.EscapeDataString(title)}&year={year}&s={season}{token}"; var body = await httpFast.GetStringAsync(url); if (string.IsNullOrEmpty(body) || body == "null") return result; var json = JObject.Parse(body); var voices = json["voice"] as JArray; if (voices != null) foreach (var v in voices) { var name = v.Value("name") ?? ""; if (string.IsNullOrEmpty(name)) continue; int tid = 0; var vUrl = v.Value("url") ?? ""; var tm = Regex.Match(vUrl, @"t=(\d+)"); if (tm.Success) int.TryParse(tm.Groups[1].Value, out tid); result.Add(new VoiceInfo { id = tid, name = name, source = "rhs" }); } Console.WriteLine($"[TelegramBot] GetRHSVoices: '{title}' s{season} -> {result.Count} voices"); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetRHSVoices error: {ex.Message}"); } if (result.Count > 0) SetCache(cacheKey, result); return result; } static async Task CheckRHSVoice(Subscription sub) { if (string.IsNullOrEmpty(sub.voice) || string.IsNullOrEmpty(Config.lampac_host)) return sub.last_voice_episode; try { var token = !string.IsNullOrEmpty(Config.lampac_token) ? $"&token={Config.lampac_token}" : ""; var voiceUrl = $"{Config.lampac_host}/lite/redheadsound?rjson=true&title={Uri.EscapeDataString(sub.title)}&year=0&s={sub.last_season}{token}"; Console.WriteLine($"[TelegramBot] RHS check: {sub.title} voice={sub.voice} s={sub.last_season}"); var voiceBody = await httpFast.GetStringAsync(voiceUrl); if (string.IsNullOrEmpty(voiceBody) || voiceBody == "null") return sub.last_voice_episode; var voiceJson = JObject.Parse(voiceBody); // Ищем озвучку по имени → получаем правильный t= int rhsVoiceId = 0; var voices = voiceJson["voice"] as JArray; if (voices != null) foreach (var v in voices) { var vName = v.Value("name") ?? ""; if (vName.Equals(sub.voice, StringComparison.OrdinalIgnoreCase)) { var vUrl = v.Value("url") ?? ""; var tm = Regex.Match(vUrl, @"t=(\d+)"); if (tm.Success) int.TryParse(tm.Groups[1].Value, out rhsVoiceId); break; } } if (rhsVoiceId == 0) { // Озвучка не найдена по имени — пробуем по details в эпизодах var data0 = voiceJson["data"] as JArray; if (data0 != null) { int maxEp = 0; foreach (var ep in data0) { var details = ep.Value("details") ?? ep.Value("voice_name") ?? ""; var e = ep.Value("e") ?? 0; if (details.Contains(sub.voice, StringComparison.OrdinalIgnoreCase) && e > maxEp) maxEp = e; } if (maxEp > 0) { Console.WriteLine($"[TelegramBot] RHS result (details): {sub.title} voice={sub.voice} episodes={maxEp} (was {sub.last_voice_episode})"); return maxEp; } } return sub.last_voice_episode; } // Запрос с правильным t= var url = $"{Config.lampac_host}/lite/redheadsound?rjson=true&title={Uri.EscapeDataString(sub.title)}&year=0&s={sub.last_season}&t={rhsVoiceId}{token}"; var body = await httpFast.GetStringAsync(url); if (string.IsNullOrEmpty(body) || body == "null") return sub.last_voice_episode; var json = JObject.Parse(body); var data = json["data"] as JArray; if (data == null) return sub.last_voice_episode; int max = 0; foreach (var ep in data) { var e = ep.Value("e") ?? 0; if (e > max) max = e; } Console.WriteLine($"[TelegramBot] RHS result: {sub.title} voice={sub.voice} t={rhsVoiceId} episodes={max} (was {sub.last_voice_episode})"); return max; } catch (Exception ex) { Console.WriteLine($"[TelegramBot] RHS check error: {ex.Message}"); return sub.last_voice_episode; } } #endregion #region API для контроллера public static async Task SubscribeApi(string uid, int tmdbId, string title, string voice, int season, int episode, string mirageOrid = null, int mirageVoiceId = 0, int voiceEpisode = 0, string collapsOrid = null, string voiceSource = null, int kpId = 0) { var user = Users.Values.FirstOrDefault(u => u.lampac_uid == uid); if (user == null) return new { success = false, msg = "not_linked" }; var key = tmdbId.ToString(); if (!Subs.ContainsKey(key)) Subs[key] = new List(); // Удалить старую подписку с той же озвучкой Subs[key].RemoveAll(s => s.chat_id == user.chat_id && s.voice == (voice ?? "")); Subs[key].Add(new Subscription { chat_id = user.chat_id, tmdb_id = tmdbId, title = title, voice = voice ?? "", voice_source = voiceSource ?? (string.IsNullOrEmpty(mirageOrid) ? "collaps" : "mirage"), mirage_orid = mirageOrid ?? "", mirage_voice_id = mirageVoiceId, collaps_orid = collapsOrid ?? "", kp_id = kpId, last_season = season, last_episode = episode, last_voice_episode = voiceEpisode, subscribed_at = DateTime.UtcNow }); SaveSubscriptions(); if (Bot != null) { var v = string.IsNullOrEmpty(voice) ? "любая озвучка" : voice; try { await Bot.SendMessage(user.chat_id, $"🔔 Подписка!\n*{EscapeMd(title)}* — {v}", parseMode: ParseMode.Markdown); } catch { } } return new { success = true }; } public static object UnsubscribeApi(string uid, int tmdbId, string voice = null) { var user = Users.Values.FirstOrDefault(u => u.lampac_uid == uid); if (user == null) return new { success = false, msg = "not_linked" }; var key = tmdbId.ToString(); if (Subs.TryGetValue(key, out var list)) { if (voice != null) list.RemoveAll(s => s.chat_id == user.chat_id && s.voice == voice); else list.RemoveAll(s => s.chat_id == user.chat_id); SaveSubscriptions(); } return new { success = true }; } public static object StatusApi(string uid, int tmdbId) { var user = Users.Values.FirstOrDefault(u => u.lampac_uid == uid); if (user == null) return new { success = true, subscribed = false, linked = false, voices = new string[0] }; var userSubs = Subs.TryGetValue(tmdbId.ToString(), out var list) ? list.Where(s => s.chat_id == user.chat_id).ToList() : new List(); return new { success = true, linked = true, subscribed = userSubs.Count > 0, voices = userSubs.Select(s => s.voice).ToArray() }; } /// /// Возвращает список подписок пользователя для страницы подписок в Lampa. /// Каждая подписка содержит tmdb_id, title, last_season, last_episode, /// last_voice_episode, voice, voice_source — всё необходимое для карточки. /// public static object SubscriptionsApi(string uid) { var user = Users.Values.FirstOrDefault(u => u.lampac_uid == uid); if (user == null) return new { success = true, linked = false, results = new object[0] }; var userSubs = Subs .SelectMany(kvp => kvp.Value) .Where(s => s.chat_id == user.chat_id) .OrderByDescending(s => s.subscribed_at) .Select(s => new { tmdb_id = s.tmdb_id, title = s.title, media_type = s.media_type ?? "tv", last_season = s.last_season, last_episode = s.last_episode, last_voice_episode = s.last_voice_episode, voice = s.voice ?? "", voice_source = s.voice_source ?? "", subscribed_at = s.subscribed_at.ToString("o") }) .ToArray(); return new { success = true, linked = true, results = userSubs }; } public static async Task GetVoicesApi(string title, int year, int season, int tmdbId = 0) { // Проверяем кеш var cacheKey = $"voices:{tmdbId}:{title}:{year}:{season}"; var cached = GetCache(cacheKey); if (cached != null) { Console.WriteLine($"[TelegramBot] GetVoices cache hit: {title}"); return cached; } // Определить актуальный сезон + альтернативное название — параллельно int actualSeason = season > 0 ? season : 1; var allTitles = new List(); if (tmdbId > 0) { // Одним запросом к TMDB получаем и сезон, и альтернативные названия try { var show = JObject.Parse(await http.GetStringAsync( $"https://api.themoviedb.org/3/tv/{tmdbId}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); var tmdbName = show.Value("name") ?? ""; var tmdbOriginal = show.Value("original_name") ?? ""; // Определяем сезон из TMDB данных (быстро, без доп. запросов) var tmdbSeasons = show["seasons"] as JArray; if (tmdbSeasons != null) { int bestSeason = 1; foreach (var s in tmdbSeasons) { var sNum = s.Value("season_number"); var epCount = s.Value("episode_count"); var airDate = s.Value("air_date"); if (sNum <= 0 || epCount <= 0) continue; if (!string.IsNullOrEmpty(airDate) && DateTime.TryParse(airDate, out var dt) && dt.Date <= DateTime.UtcNow.Date) bestSeason = sNum; } if (bestSeason > 0) actualSeason = bestSeason; } else { var ns = show.Value("number_of_seasons"); if (ns > 0) actualSeason = ns; } Console.WriteLine($"[TelegramBot] TMDB info: {tmdbName} / {tmdbOriginal}, season={actualSeason}"); // Собираем все уникальные названия для поиска // Порядок: Lampa title, TMDB name (ru), TMDB original_name allTitles.Add(title); if (!string.IsNullOrEmpty(tmdbName) && !allTitles.Any(t => t.Equals(tmdbName, StringComparison.OrdinalIgnoreCase))) allTitles.Add(tmdbName); if (!string.IsNullOrEmpty(tmdbOriginal) && !allTitles.Any(t => t.Equals(tmdbOriginal, StringComparison.OrdinalIgnoreCase))) allTitles.Add(tmdbOriginal); } catch (Exception ex) { Console.WriteLine($"[TelegramBot] TMDB info error: {ex.Message}"); } } // Список названий для поиска var titles = allTitles.Count > 0 ? allTitles : new List { title }; Console.WriteLine($"[TelegramBot] GetVoices titles: [{string.Join(", ", titles)}] season={actualSeason}"); string mirageOrid = ""; string collapsOrid = ""; var allVoices = new List(); // === ПАРАЛЛЕЛЬНЫЙ поиск по всем источникам === var mirageTask = SearchMirageMulti(titles, year); var collapsTask = SearchCollapsMulti(titles, year); var rhsTask = SearchRHSMulti(titles, year, actualSeason); await Task.WhenAll(mirageTask, collapsTask, rhsTask); // Получаем озвучки из найденных источников — параллельно var mirageResult = mirageTask.Result; var cOrid = collapsTask.Result; var rhsVoices = rhsTask.Result; if (mirageResult.orid != null) mirageOrid = mirageResult.orid; if (!string.IsNullOrEmpty(cOrid)) collapsOrid = cOrid; // Запускаем получение озвучек параллельно var mirageVoicesTask = !string.IsNullOrEmpty(mirageOrid) ? GetMirageVoices(mirageOrid, actualSeason) : Task.FromResult(new List()); var collapsVoicesTask = !string.IsNullOrEmpty(collapsOrid) ? GetCollapsVoices(collapsOrid, actualSeason) : Task.FromResult(new List()); await Task.WhenAll(mirageVoicesTask, collapsVoicesTask); // Собираем результаты: Mirage → Collaps → RHS (без дубликатов) foreach (var v in mirageVoicesTask.Result) { v.source = "mirage"; allVoices.Add(v); } var existingNames = new HashSet(allVoices.Select(v => v.name), StringComparer.OrdinalIgnoreCase); foreach (var v in collapsVoicesTask.Result) if (!existingNames.Contains(v.name)) { allVoices.Add(v); existingNames.Add(v.name); } foreach (var v in rhsVoices) if (!existingNames.Contains(v.name)) { allVoices.Add(v); existingNames.Add(v.name); } // === VideoHub: резолвим kp_id по tmdb и добавляем озвучки === int vhubKp = 0; if (tmdbId > 0) { var probe = new Subscription { tmdb_id = tmdbId, title = title }; vhubKp = await GetKpId(probe); if (vhubKp > 0) { var vhubVoices = await GetVideoHubVoices(vhubKp, actualSeason); foreach (var v in vhubVoices) if (!existingNames.Contains(v.name)) { allVoices.Add(v); existingNames.Add(v.name); } Console.WriteLine($"[TelegramBot] GetVoices VideoHub: kp={vhubKp} voices={vhubVoices.Count}"); } } Console.WriteLine($"[TelegramBot] GetVoices: {title} ({year}) s{actualSeason} — mirage:{mirageOrid} collaps:{collapsOrid} rhs:{rhsVoices.Count} voices:{allVoices.Count}"); var result = (object)new { success = true, voices = allVoices, orid = mirageOrid, collaps_orid = collapsOrid, kp_id = vhubKp, season = actualSeason }; if (allVoices.Count > 0) SetCache(cacheKey, result); return result; } // Параллельный поиск по нескольким названиям — все сразу, берём лучший static async Task<(string orid, string title)> SearchMirageMulti(List titles, int year) { var tasks = titles.Select(t => SearchMirage(t, year)).ToArray(); await Task.WhenAll(tasks); // Собираем ВСЕХ кандидатов со всех названий (без дублей по orid) var all = new List(); var seen = new HashSet(); foreach (var task in tasks) foreach (var r in task.Result) if (!string.IsNullOrEmpty(r.orid) && seen.Add(r.orid)) all.Add(r); if (all.Count == 0) return (null, null); // Приоритет 1: точное совпадение года (главный критерий — локализованные названия часто расходятся) if (year > 0) { var byYear = all.Where(r => r.year == year).ToList(); if (byYear.Count > 0) { // среди совпавших по году — предпочесть совпадение названия с любым из искомых var best = byYear.OrderByDescending(r => TitleScore(r.title, titles)).First(); Console.WriteLine($"[TelegramBot] SearchMirageMulti: matched by year={year} -> {best.title} orid={best.orid}"); return (best.orid, best.title); } } // Приоритет 2: года нет или не совпал — берём лучшего по названию var fallback = all.OrderByDescending(r => TitleScore(r.title, titles)).First(); return (fallback.orid, fallback.title); } // Очки совпадения названия кандидата с любым из искомых названий static int TitleScore(string candidate, List titles) { var c = (candidate ?? "").Trim().ToLowerInvariant(); if (c.Length == 0) return 0; int best = 0; foreach (var t in titles) { var s = (t ?? "").Trim().ToLowerInvariant(); if (s.Length == 0) continue; if (c == s) best = Math.Max(best, 100); else if (c.Contains(s) || s.Contains(c)) best = Math.Max(best, (int)(50.0 * Math.Min(c.Length, s.Length) / Math.Max(c.Length, s.Length))); } return best; } static async Task SearchCollapsMulti(List titles, int year) { if (titles.Count == 1) return await SearchCollaps(titles[0], year); var tasks = titles.Select(t => SearchCollaps(t, year)).ToArray(); await Task.WhenAll(tasks); foreach (var task in tasks) if (!string.IsNullOrEmpty(task.Result)) return task.Result; return null; } static async Task> SearchRHSMulti(List titles, int year, int season) { if (titles.Count == 1) return await GetRHSVoices(titles[0], year, season); var tasks = titles.Select(t => GetRHSVoices(t, year, season)).ToArray(); await Task.WhenAll(tasks); foreach (var task in tasks) if (task.Result.Count > 0) return task.Result; return new List(); } // Определить последний вышедший сезон static async Task GetCurrentSeason(int tmdbId) { // Сначала Trakt if (!string.IsNullOrEmpty(Config.trakt_client_id)) { try { var searchReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.trakt.tv/search/tmdb/{tmdbId}?type=show"); searchReq.Headers.Add("trakt-api-key", Config.trakt_client_id); searchReq.Headers.Add("trakt-api-version", "2"); var searchResp = await http.SendAsync(searchReq); if (searchResp.IsSuccessStatusCode) { var searchData = JArray.Parse(await searchResp.Content.ReadAsStringAsync()); if (searchData.Count > 0) { var slug = searchData[0]?["show"]?["ids"]?.Value("slug"); if (!string.IsNullOrEmpty(slug)) { var sReq = new HttpRequestMessage(HttpMethod.Get, $"https://api.trakt.tv/shows/{slug}/seasons?extended=full"); sReq.Headers.Add("trakt-api-key", Config.trakt_client_id); sReq.Headers.Add("trakt-api-version", "2"); var sResp = await http.SendAsync(sReq); if (sResp.IsSuccessStatusCode) { var seasons = JArray.Parse(await sResp.Content.ReadAsStringAsync()); var lastAired = seasons .Where(s => s.Value("number") > 0 && s.Value("aired_episodes") > 0) .OrderByDescending(s => s.Value("number")) .FirstOrDefault(); if (lastAired != null) { var result = lastAired.Value("number"); Console.WriteLine($"[TelegramBot] Trakt current season for tmdb {tmdbId}: {result} (total seasons: {seasons.Count})"); return result; } else { Console.WriteLine($"[TelegramBot] Trakt: no aired seasons found for tmdb {tmdbId}"); } } } } } } catch (Exception ex) { Console.WriteLine($"[TelegramBot] GetCurrentSeason Trakt error: {ex.Message}"); } } // Fallback — TMDB: проверяем каждый сезон на наличие вышедших эпизодов try { Console.WriteLine($"[TelegramBot] GetCurrentSeason TMDB fallback for {tmdbId}"); var show = JObject.Parse(await http.GetStringAsync( $"https://api.themoviedb.org/3/tv/{tmdbId}?api_key={Config.tmdb_api_key}&language={Config.tmdb_lang}")); var tmdbSeasons = show["seasons"] as JArray; if (tmdbSeasons != null) { // Ищем последний сезон с episode_count > 0 и air_date <= сегодня int bestSeason = 1; foreach (var s in tmdbSeasons) { var sNum = s.Value("season_number"); var epCount = s.Value("episode_count"); var airDate = s.Value("air_date"); if (sNum <= 0 || epCount <= 0) continue; if (!string.IsNullOrEmpty(airDate) && DateTime.TryParse(airDate, out var dt) && dt.Date <= DateTime.UtcNow.Date) bestSeason = sNum; } Console.WriteLine($"[TelegramBot] TMDB current season for {tmdbId}: {bestSeason}"); return bestSeason; } return show.Value("number_of_seasons"); } catch { return 1; } } public static async Task LinkApi(string uid) { if (Bot == null) return new { success = false, msg = "bot_not_running" }; var me = await Bot.GetMe(); return new { success = true, link = $"https://t.me/{me.Username}?start={LinkPayload(uid)}" }; } // Telegram пропускает в параметре deep-link только A-Za-z0-9_- и не длиннее 64 символов, // а uid бывает email-ом: «@» и «.» обрывают параметр, и бот получает голый /start. // Такой uid уходит в base64url с префиксом link64_; безопасный — как раньше, link_{uid}. const string LinkPrefix = "link_"; const string LinkPrefix64 = "link64_"; const int DeepLinkMaxLength = 64; static string LinkPayload(string uid) { if (Regex.IsMatch(uid, "^[A-Za-z0-9_-]+$")) return LinkPrefix + uid; string encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(uid)) .TrimEnd('=').Replace('+', '-').Replace('/', '_'); // Длинный email не влезет и так; прежний формат хотя бы работает командой // «/start link_{uid}», набранной боту вручную. return LinkPrefix64.Length + encoded.Length <= DeepLinkMaxLength ? LinkPrefix64 + encoded : LinkPrefix + uid; } static bool TryParseLinkPayload(string payload, out string uid) { uid = null; if (payload.StartsWith(LinkPrefix64)) { string encoded = payload.Substring(LinkPrefix64.Length).Replace('-', '+').Replace('_', '/'); encoded = encoded.PadRight(encoded.Length + (4 - encoded.Length % 4) % 4, '='); try { uid = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); } catch (FormatException) { return false; } } else if (payload.StartsWith(LinkPrefix)) { uid = payload.Substring(LinkPrefix.Length); } return !string.IsNullOrEmpty(uid); } // Плагин передаёт account_email и uid устройства, сервер выбирает account_email. У кого бот // привязан по uid устройства (прежний плагин слал только его), подписки не должны пропасть: // если под основным id привязки нет, а под uid устройства есть — берём его. public static string ResolveUid(string uid, string deviceUid) { if (string.IsNullOrEmpty(deviceUid) || deviceUid == uid) return uid; if (Users.Values.Any(u => u.lampac_uid == uid)) return uid; return Users.Values.Any(u => u.lampac_uid == deviceUid) ? deviceUid : uid; } #endregion #region Storage static void LoadUsers() { try { if (System.IO.File.Exists(usersPath)) { var l = JsonConvert.DeserializeObject>(System.IO.File.ReadAllText(usersPath)); if (l != null) foreach (var u in l) Users[u.chat_id] = u; } } catch { } } public static void SaveUsers() { try { System.IO.File.WriteAllText(usersPath, JsonConvert.SerializeObject(Users.Values.ToList(), Formatting.Indented)); } catch { } } static void LoadSubscriptions() { try { if (System.IO.File.Exists(subscriptionsPath)) { var d = JsonConvert.DeserializeObject>>(System.IO.File.ReadAllText(subscriptionsPath)); if (d != null) foreach (var kvp in d) Subs[kvp.Key] = kvp.Value; } } catch { } } public static void SaveSubscriptions() { try { System.IO.File.WriteAllText(subscriptionsPath, JsonConvert.SerializeObject(Subs.ToDictionary(k => k.Key, k => k.Value), Formatting.Indented)); } catch { } } #endregion static string EscapeMd(string text) => string.IsNullOrEmpty(text) ? "" : text.Replace("_", "\\_").Replace("*", "\\*").Replace("[", "\\[").Replace("]", "\\]").Replace("`", "\\`"); public void Dispose() { EventListener.UpdateInitFile -= UpdateConfFromInit; try { checkTimer?.Dispose(); } catch { } IsRunning = false; } } }