from pathlib import Path import httpx from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import ContextTypes from .config import DOWNLOAD_DIR, COOKIES_FILE, BROWSER, CONNECT_TIMEOUT, READ_TIMEOUT from .utils import is_instagram_story_url, VIDEO_SUFFIXES, IMAGE_SUFFIXES, extract_instagram_username from .ytdlp_client import build_opts, run from .telegram_send import send_video, send_audio, send_document, send_photo from .transcode import transcode_to_mobile_mp4 # Профили качества для видео QUALITY_MAP = { 'max': 'bestvideo*+bestaudio/best', '1080': 'bestvideo[height<=1080]*+bestaudio/best[height<=1080]', '720': 'bestvideo[height<=720]*+bestaudio/best[height<=720]', '480': 'bestvideo[height<=480]*+bestaudio/best[height<=480]', } class Handlers: def __init__(self): self.user_data = {} self.sending_active: dict[int, bool] = {} async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): text = "🎬 Отправьте ссылку (YouTube/TikTok/Instagram/VK и др.) или Stories Instagram.\n" \ "Доступно: видео (выбор качества), аудио (MP3), изображения, совместимый MP4, Stories (одна/все у автора)." if COOKIES_FILE and COOKIES_FILE.exists(): text += "\n🔐 Instagram: используется cookies.txt" elif BROWSER: text += f"\n🔐 Instagram: cookies из браузера ({BROWSER})" else: text += "\n⚠️ Для Instagram Stories обычно нужны cookies." await update.message.reply_text(text) async def on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): url = (update.message.text or "").strip() if not url.startswith(("http://", "https://")): await update.message.reply_text("⚠️ Отправьте корректную ссылку") return self.user_data[update.message.from_user.id] = {"url": url} kb = [ [InlineKeyboardButton("🌟 Stories (по ссылке)", callback_data="story")], [InlineKeyboardButton("📚 Все Stories автора", callback_data="story_all")], [InlineKeyboardButton("🎥 Видео (выбор качества)", callback_data="video")], [InlineKeyboardButton("🎧 Только аудио (MP3)", callback_data="audio")], [InlineKeyboardButton("📸 Картинка/Галерея", callback_data="image")], [InlineKeyboardButton("📁 Оригинал", callback_data="original")], ] await update.message.reply_text("Выберите действие:", reply_markup=InlineKeyboardMarkup(kb)) async def on_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query await query.answer() uid = query.from_user.id url = self.user_data.get(uid, {}).get("url", "") chat_id = query.message.chat_id if query.data == "story": if not is_instagram_story_url(url): await query.edit_message_text("⚠️ Нужен URL вида: https://www.instagram.com/stories/USERNAME/...") return msg = await query.edit_message_text("⏳ Загрузка Stories...") await self.download_story(url, msg, chat_id) elif query.data == "story_all": msg = await query.edit_message_text("⏳ Ищу все активные Stories автора...") await self.download_all_stories(url, msg, chat_id) elif query.data == "video": kb = [ [InlineKeyboardButton("🔥 Максимальное", callback_data="q_max")], [InlineKeyboardButton("🖥 1080p", callback_data="q_1080")], [InlineKeyboardButton("📺 720p", callback_data="q_720")], [InlineKeyboardButton("📱 480p", callback_data="q_480")], ] await query.edit_message_text("Выберите качество видео:", reply_markup=InlineKeyboardMarkup(kb)) elif query.data.startswith("q_"): qual = query.data.split("_", 1)[1] msg = await query.edit_message_text(f"⏳ Загрузка видео ({qual})...") await self.download_video(url, msg, qual, chat_id) elif query.data == "audio": msg = await query.edit_message_text("⏳ Загрузка аудио...") await self.download_audio(url, msg, chat_id) elif query.data == "image": msg = await query.edit_message_text("⏳ Загрузка изображений...") await self.download_images(url, msg, chat_id) elif query.data == "original": msg = await query.edit_message_text("⏳ Загрузка оригинала...") await self.download_original(url, msg, chat_id) # -------- Stories: одиночная -------- async def download_story(self, url: str, message, chat_id: int): try: opts = build_opts(for_video=True, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=True) # Для видео принудительно mp4, изображения не пострадают, т.к. merge только для видео opts['format'] = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]' filepath, info = await run(url, opts) count = await self._send_playlist_mixed_with_images(info, message, chat_id) # fallback одиночного файла if count == 0 and filepath and filepath.exists(): suffix = (filepath.suffix or '').lower() if suffix in IMAGE_SUFFIXES: await self._send_photo_safe(message, filepath, chat_id) count = 1 elif suffix in VIDEO_SUFFIXES: out_mp4 = await transcode_to_mobile_mp4(filepath) try: await self._send_video_safe(message, out_mp4, chat_id) count = 1 finally: out_mp4.unlink(missing_ok=True) else: await self._send_document_safe(message, filepath, chat_id) count = 1 filepath.unlink(missing_ok=True) if count > 0: await message.edit_text(f"✅ Stories отправлены! ({count} шт.)") else: await message.edit_text("ℹ️ Stories не найдены или доступ ограничен.") except Exception as e: await message.edit_text(f"⚠️ Ошибка Stories: {e}") # -------- Stories: все активные у автора -------- async def download_all_stories(self, url: str, message, chat_id: int): try: username = extract_instagram_username(url) if not username: await message.edit_text("⚠️ Не удалось определить username из ссылки.") return list_url = f"https://www.instagram.com/stories/{username}/" opts = build_opts(for_video=True, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=True) opts['format'] = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]' _filepath, info = await run(list_url, opts) count = await self._send_playlist_mixed_with_images(info, message, chat_id) if count: await message.edit_text(f"✅ Отправлено элементов: {count} (у @{username})") else: await message.edit_text(f"ℹ️ У @{username} нет активных сторис или доступ ограничен.") except Exception as e: await message.edit_text(f"⚠️ Ошибка при загрузке всех Stories автора: {e}") # -------- Видео (с выбором качества) -------- async def download_video(self, url: str, message, quality_key: str, chat_id: int): try: fmt = QUALITY_MAP.get(quality_key, QUALITY_MAP['max']) opts = build_opts(for_video=True, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=False) opts['format'] = fmt filepath, info = await run(url, opts) sent_any = False if filepath and filepath.exists(): out_mp4 = await transcode_to_mobile_mp4(filepath) try: await self._send_video_safe(message, out_mp4, chat_id) sent_any = True finally: out_mp4.unlink(missing_ok=True) filepath.unlink(missing_ok=True) sent_any = (await self._send_playlist_mixed_with_images(info, message, chat_id)) or sent_any if sent_any: await message.edit_text("✅ Видео отправлено!") else: await message.edit_text("❌ Файл не найден после загрузки.") except Exception as e: await message.edit_text(f"⚠️ Ошибка: {e}") # -------- Аудио -------- async def download_audio(self, url: str, message, chat_id: int): try: opts = build_opts(for_video=False, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=False) opts.update({ 'format': 'bestaudio/best', 'postprocessors': [{'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '192'}] }) filepath, info = await run(url, opts) if filepath and filepath.exists(): title = (info.get('title') if isinstance(info, dict) else None) await self._send_audio_safe(message, filepath, title, chat_id) await message.edit_text("✅ Аудио отправлено!") filepath.unlink(missing_ok=True) return await message.edit_text("❌ Аудиофайл не найден.") except Exception as e: await message.edit_text(f"⚠️ Ошибка: {e}") # -------- Изображения -------- async def download_images(self, url: str, message, chat_id: int): try: opts = build_opts(for_video=False, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=True) opts['format'] = 'best' filepath, info = await run(url, opts) sent = 0 if filepath and filepath.exists(): suffix = (filepath.suffix or '').lower() if suffix in IMAGE_SUFFIXES: await self._send_photo_safe(message, filepath, chat_id) sent += 1 else: await self._send_document_safe(message, filepath, chat_id) filepath.unlink(missing_ok=True) sent += await self._send_images_from_playlist_with_fallback(info, message, chat_id) if sent: await message.edit_text(f"✅ Отправлено изображений: {sent}") else: await message.edit_text("ℹ️ Изображения не найдены.") except Exception as e: await message.edit_text(f"⚠️ Ошибка при загрузке изображений: {e}") # -------- Оригинал -------- async def download_original(self, url: str, message, chat_id: int): try: opts = build_opts(for_video=False, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=False) opts['format'] = 'best' filepath, info = await run(url, opts) if filepath and filepath.exists(): await self._send_document_safe(message, filepath, chat_id) await message.edit_text("✅ Файл отправлен!") filepath.unlink(missing_ok=True) return sent_any = (await self._send_playlist_mixed_with_images(info, message, chat_id)) > 0 if sent_any: await message.edit_text("✅ Файлы отправлены!") else: await message.edit_text("❌ Файл не найден.") except Exception as e: await message.edit_text(f"⚠️ Ошибка: {e}") # -------- Вспомогательные -------- async def _send_video_safe(self, message, filepath: Path, chat_id: int): self.sending_active[chat_id] = True try: await send_video(message, filepath, CONNECT_TIMEOUT, READ_TIMEOUT) finally: self.sending_active[chat_id] = False async def _send_audio_safe(self, message, filepath: Path, title: str | None, chat_id: int): self.sending_active[chat_id] = True try: await send_audio(message, filepath, title, CONNECT_TIMEOUT, READ_TIMEOUT) finally: self.sending_active[chat_id] = False async def _send_document_safe(self, message, filepath: Path, chat_id: int): self.sending_active[chat_id] = True try: await send_document(message, filepath, CONNECT_TIMEOUT, READ_TIMEOUT) finally: self.sending_active[chat_id] = False async def _send_photo_safe(self, message, filepath: Path, chat_id: int): self.sending_active[chat_id] = True try: await send_photo(message, filepath, CONNECT_TIMEOUT, READ_TIMEOUT) finally: self.sending_active[chat_id] = False async def _download_file(self, url: str, into: Path) -> Path: into.parent.mkdir(parents=True, exist_ok=True) timeout = httpx.Timeout(30.0) async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: r = await client.get(url) r.raise_for_status() ctype = r.headers.get('content-type', '') ext = '' if 'image/jpeg' in ctype: ext = '.jpg' elif 'image/png' in ctype: ext = '.png' elif 'image/webp' in ctype: ext = '.webp' elif 'image/avif' in ctype: ext = '.avif' elif 'image/heic' in ctype or 'image/heif' in ctype: ext = '.heic' if not into.suffix: into = into.with_suffix(ext or '.jpg') into.write_bytes(r.content) return into def _pick_path(self, entry) -> Path | None: if isinstance(entry, dict): rd = entry.get('requested_downloads') or [] for it in rd[::-1]: fp = it.get('filepath') or it.get('filename') if fp: p = Path(fp) if p.exists(): return p fn = entry.get('_filename') if fn: p = Path(fn) if p.exists(): return p return None async def _send_images_from_playlist_with_fallback(self, info: dict | None, message, chat_id: int) -> int: if not isinstance(info, dict): return 0 entries = info.get('entries') or [] sent = 0 for idx, entry in enumerate(entries): fp = self._pick_path(entry) temp_file: Path | None = None try: if fp and fp.exists(): suffix = (fp.suffix or '').lower() if suffix in IMAGE_SUFFIXES: await self._send_photo_safe(message, fp, chat_id) sent += 1 else: await self._send_document_safe(message, fp, chat_id) else: image_url = None thumbs = entry.get('thumbnails') if isinstance(entry, dict) else None if isinstance(thumbs, list) and thumbs: urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')] if urls: image_url = max(urls, key=len) if not image_url and isinstance(entry, dict): cand = entry.get('url') or entry.get('thumbnail') if isinstance(cand, str) and cand.startswith(('http://', 'https://')): image_url = cand if image_url: temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"img_{idx}") await self._send_photo_safe(message, temp_file, chat_id) sent += 1 finally: if temp_file: temp_file.unlink(missing_ok=True) if fp: fp.unlink(missing_ok=True) return sent async def _send_playlist_mixed_with_images(self, info: dict | None, message, chat_id: int) -> int: if not isinstance(info, dict): return 0 entries = info.get('entries') or [] sent = 0 for idx, entry in enumerate(entries): fp = self._pick_path(entry) temp_file: Path | None = None try: if fp and fp.exists(): suffix = (fp.suffix or '').lower() if suffix in IMAGE_SUFFIXES: await self._send_photo_safe(message, fp, chat_id) sent += 1 elif suffix in VIDEO_SUFFIXES: out_mp4 = await transcode_to_mobile_mp4(fp) try: await self._send_video_safe(message, out_mp4, chat_id) sent += 1 finally: out_mp4.unlink(missing_ok=True) else: await self._send_document_safe(message, fp, chat_id) sent += 1 else: image_url = None thumbs = entry.get('thumbnails') if isinstance(entry, dict) else None if isinstance(thumbs, list) and thumbs: urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')] if urls: image_url = max(urls, key=len) if not image_url and isinstance(entry, dict): cand = entry.get('url') or entry.get('thumbnail') if isinstance(cand, str) and cand.startswith(('http://', 'https://')): image_url = cand if image_url: temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"story_img_{idx}") await self._send_photo_safe(message, temp_file, chat_id) sent += 1 finally: if temp_file: temp_file.unlink(missing_ok=True) if fp: fp.unlink(missing_ok=True) return sent