diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..24adf06 Binary files /dev/null and b/.DS_Store differ diff --git a/.env b/.env new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..440e3a2 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Instagram DL Bot (yt-dlp) + +Бот скачивает видео (YouTube/видео-хостинги) и Instagram Stories через yt-dlp. +Требуется ffmpeg и актуальный yt-dlp. + +## Быстрый старт +1) Установить зависимости: + - apt install ffmpeg + - python3 -m venv .venv && source .venv/bin/activate + - pip install -r requirements.txt + +2) Заполнить .env (скопировать .env.example) + +3) Запустить: + - python -m bot.app + +## Авторизация Instagram +- Настройте COOKIES_FILE=/path/to/cookies.txt, или +- BROWSER=chrome|firefox|brave — тогда cookies будут взяты из профиля браузера. + +## Ограничения +- Instagram часто меняет схемы. Обновляйте yt-dlp регулярно. +- Для Stories почти всегда нужны cookies (без них Instagram отдаёт пустые ответы). diff --git a/bot/__pycache__/app.cpython-311.pyc b/bot/__pycache__/app.cpython-311.pyc new file mode 100644 index 0000000..8a7c78b Binary files /dev/null and b/bot/__pycache__/app.cpython-311.pyc differ diff --git a/bot/__pycache__/app.cpython-312.pyc b/bot/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..8f61a57 Binary files /dev/null and b/bot/__pycache__/app.cpython-312.pyc differ diff --git a/bot/__pycache__/config.cpython-311.pyc b/bot/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..c450cf4 Binary files /dev/null and b/bot/__pycache__/config.cpython-311.pyc differ diff --git a/bot/__pycache__/config.cpython-312.pyc b/bot/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..ae60ad6 Binary files /dev/null and b/bot/__pycache__/config.cpython-312.pyc differ diff --git a/bot/__pycache__/handlers.cpython-311.pyc b/bot/__pycache__/handlers.cpython-311.pyc new file mode 100644 index 0000000..54180e9 Binary files /dev/null and b/bot/__pycache__/handlers.cpython-311.pyc differ diff --git a/bot/__pycache__/handlers.cpython-312.pyc b/bot/__pycache__/handlers.cpython-312.pyc new file mode 100644 index 0000000..2ea1303 Binary files /dev/null and b/bot/__pycache__/handlers.cpython-312.pyc differ diff --git a/bot/__pycache__/logging_conf.cpython-311.pyc b/bot/__pycache__/logging_conf.cpython-311.pyc new file mode 100644 index 0000000..986aff8 Binary files /dev/null and b/bot/__pycache__/logging_conf.cpython-311.pyc differ diff --git a/bot/__pycache__/logging_conf.cpython-312.pyc b/bot/__pycache__/logging_conf.cpython-312.pyc new file mode 100644 index 0000000..8240cca Binary files /dev/null and b/bot/__pycache__/logging_conf.cpython-312.pyc differ diff --git a/bot/__pycache__/telegram_send.cpython-311.pyc b/bot/__pycache__/telegram_send.cpython-311.pyc new file mode 100644 index 0000000..5e575f0 Binary files /dev/null and b/bot/__pycache__/telegram_send.cpython-311.pyc differ diff --git a/bot/__pycache__/telegram_send.cpython-312.pyc b/bot/__pycache__/telegram_send.cpython-312.pyc new file mode 100644 index 0000000..bfbc680 Binary files /dev/null and b/bot/__pycache__/telegram_send.cpython-312.pyc differ diff --git a/bot/__pycache__/transcode.cpython-311.pyc b/bot/__pycache__/transcode.cpython-311.pyc new file mode 100644 index 0000000..611ea6c Binary files /dev/null and b/bot/__pycache__/transcode.cpython-311.pyc differ diff --git a/bot/__pycache__/transcode.cpython-312.pyc b/bot/__pycache__/transcode.cpython-312.pyc new file mode 100644 index 0000000..59d7200 Binary files /dev/null and b/bot/__pycache__/transcode.cpython-312.pyc differ diff --git a/bot/__pycache__/utils.cpython-311.pyc b/bot/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000..edf4c48 Binary files /dev/null and b/bot/__pycache__/utils.cpython-311.pyc differ diff --git a/bot/__pycache__/utils.cpython-312.pyc b/bot/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000..a64c948 Binary files /dev/null and b/bot/__pycache__/utils.cpython-312.pyc differ diff --git a/bot/__pycache__/ytdlp_client.cpython-311.pyc b/bot/__pycache__/ytdlp_client.cpython-311.pyc new file mode 100644 index 0000000..f2ebb83 Binary files /dev/null and b/bot/__pycache__/ytdlp_client.cpython-311.pyc differ diff --git a/bot/__pycache__/ytdlp_client.cpython-312.pyc b/bot/__pycache__/ytdlp_client.cpython-312.pyc new file mode 100644 index 0000000..e8a4b9e Binary files /dev/null and b/bot/__pycache__/ytdlp_client.cpython-312.pyc differ diff --git a/bot/app.py b/bot/app.py new file mode 100644 index 0000000..96ef0e7 --- /dev/null +++ b/bot/app.py @@ -0,0 +1,24 @@ +from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, CallbackQueryHandler, filters + +from bot.logging_conf import setup_logging +from bot.config import TELEGRAM_TOKEN, BOT_BASE_URL, CONNECT_TIMEOUT, READ_TIMEOUT +from bot.handlers import Handlers + +def main(): + setup_logging() + h = Handlers() + app = ( + ApplicationBuilder() + .token(TELEGRAM_TOKEN) + .base_url(BOT_BASE_URL) + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(READ_TIMEOUT) + .build() + ) + app.add_handler(CommandHandler("start", h.start)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, h.on_message)) + app.add_handler(CallbackQueryHandler(h.on_callback)) + app.run_polling() + +if __name__ == "__main__": + main() diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..a11e3da --- /dev/null +++ b/bot/config.py @@ -0,0 +1,14 @@ +import os +from pathlib import Path + +TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "REPLACE_ME") +BOT_BASE_URL = os.getenv("BOT_BASE_URL", "http://127.0.0.1:8081/bot") + +DOWNLOAD_DIR = Path(os.getenv("DOWNLOAD_DIR", "downloads")) +DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) + +COOKIES_FILE = Path(os.getenv("COOKIES_FILE", "")) if os.getenv("COOKIES_FILE") else Path() +BROWSER = os.getenv("BROWSER", "") + +CONNECT_TIMEOUT = int(os.getenv("CONNECT_TIMEOUT", 600)) +READ_TIMEOUT = int(os.getenv("READ_TIMEOUT", 1800)) diff --git a/bot/handlers.py b/bot/handlers.py new file mode 100644 index 0000000..cd392d5 --- /dev/null +++ b/bot/handlers.py @@ -0,0 +1,396 @@ +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 diff --git a/bot/init.py b/bot/init.py new file mode 100644 index 0000000..7e7ccf8 --- /dev/null +++ b/bot/init.py @@ -0,0 +1 @@ +# empty marker for package diff --git a/bot/logging_conf.py b/bot/logging_conf.py new file mode 100644 index 0000000..7c0ea01 --- /dev/null +++ b/bot/logging_conf.py @@ -0,0 +1,7 @@ +import logging + +def setup_logging(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s" + ) diff --git a/bot/telegram_send.py b/bot/telegram_send.py new file mode 100644 index 0000000..b581fe5 --- /dev/null +++ b/bot/telegram_send.py @@ -0,0 +1,41 @@ +from pathlib import Path +from telegram import Message + +async def send_video(message: Message, filepath: Path, connect_timeout: int, read_timeout: int): + with filepath.open('rb') as f: + await message.chat.send_video( + video=f, + supports_streaming=True, + connect_timeout=connect_timeout, + read_timeout=read_timeout + ) + +async def send_audio(message: Message, filepath: Path, title: str | None, connect_timeout: int, read_timeout: int): + with filepath.open('rb') as f: + await message.chat.send_audio( + audio=f, + title=title, + connect_timeout=connect_timeout, + read_timeout=read_timeout + ) + +async def send_document(message: Message, filepath: Path, connect_timeout: int, read_timeout: int): + with filepath.open('rb') as f: + await message.chat.send_document( + document=f, + filename=filepath.name, + connect_timeout=connect_timeout, + read_timeout=read_timeout + ) + +async def send_photo(message: Message, filepath: Path, connect_timeout: int, read_timeout: int): + # Фото до ~20MB лучше отправлять как фото, большие — как документ + if filepath.stat().st_size <= 20 * 1024 * 1024: + with filepath.open('rb') as f: + await message.chat.send_photo( + photo=f, + connect_timeout=connect_timeout, + read_timeout=read_timeout + ) + else: + await send_document(message, filepath, connect_timeout, read_timeout) diff --git a/bot/transcode.py b/bot/transcode.py new file mode 100644 index 0000000..7294ff8 --- /dev/null +++ b/bot/transcode.py @@ -0,0 +1,46 @@ +import asyncio +from pathlib import Path +import shutil +import subprocess + +FFMPEG_BIN = shutil.which("ffmpeg") or "ffmpeg" + +async def transcode_to_mobile_mp4(src: Path) -> Path: + """ + Перекодировать видео в MP4, совместимый с мобильными клиентами Telegram: + - H.264 (libx264), профиль main + - pix_fmt yuv420p + - movflags +faststart + - AAC 128k + Возвращает путь к новому файлу (.tg.mp4). + """ + if not src.exists(): + raise FileNotFoundError(f"Source file not found: {src}") + + dst = src.with_suffix(".tg.mp4") + cmd = [ + FFMPEG_BIN, + "-y", + "-i", str(src), + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "23", + "-profile:v", "main", + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + "-c:a", "aac", + "-b:a", "128k", + str(dst), + ] + + def _run(): + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if proc.returncode != 0: + raise RuntimeError(f"ffmpeg failed: {proc.stderr.decode('utf-8', errors='ignore')}") + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, _run) + + if not dst.exists(): + raise RuntimeError("Transcode failed: output file not created") + return dst diff --git a/bot/utils.py b/bot/utils.py new file mode 100644 index 0000000..d1c027d --- /dev/null +++ b/bot/utils.py @@ -0,0 +1,32 @@ +def is_instagram_story_url(url: str) -> bool: + return "instagram.com/stories" in url + +VIDEO_SUFFIXES = ('.mp4', '.mov', '.mkv') +IMAGE_SUFFIXES = ('.jpg', '.jpeg', '.png', '.webp') + +def extract_instagram_username(url: str) -> str | None: + # Поддержка: + # https://www.instagram.com/stories/USERNAME/... + # https://www.instagram.com/USERNAME/ + try: + base = url.split("?")[0].strip("/") + parts = base.split("/") + if "instagram.com" not in url: + return None + if "stories" in parts: + i = parts.index("stories") + if i + 1 < len(parts): + return parts[i + 1] + # профиль + host_idx = 0 + for i, p in enumerate(parts): + if "instagram.com" in p: + host_idx = i + break + if host_idx + 1 < len(parts): + candidate = parts[host_idx + 1] + if candidate and candidate not in ("stories", "reel", "p", "tv"): + return candidate + except Exception: + return None + return None diff --git a/bot/ytdlp_client.py b/bot/ytdlp_client.py new file mode 100644 index 0000000..59cad1e --- /dev/null +++ b/bot/ytdlp_client.py @@ -0,0 +1,73 @@ +import asyncio +from functools import partial +from pathlib import Path +import os +import yt_dlp + +def build_opts(for_video: bool, outdir: Path, cookiefile: Path | None, browser: str | None, message=None, out_for_playlist: bool = True) -> dict: + postprocessors = [] + if for_video: + # На этапе постпроцессора делаем простую конвертацию в mp4 (дальше будет наша принудительная перекодировка) + postprocessors.append({'key': 'FFmpegVideoConvertor', 'preferedformat': 'mp4'}) + + # Для плейлистов (stories/галереи) — уникальные имена, чтобы не затирать + outtmpl = '%(uploader)s_%(id)s.%(ext)s' if out_for_playlist else '%(title)s.%(ext)s' + + opts: dict = { + 'outtmpl': str(outdir / outtmpl), + 'quiet': True, + 'merge_output_format': 'mp4', + 'postprocessors': postprocessors, + 'retries': 5, + 'fragment_retries': 5, + 'concurrent_fragment_downloads': 5, + 'noprogress': True, + 'source_address': '0.0.0.0', # форс IPv4 + } + + proxy = os.getenv('HTTPS_PROXY') or os.getenv('HTTP_PROXY') or '' + if proxy: + opts['proxy'] = proxy + + if cookiefile and cookiefile.exists(): + opts['cookiefile'] = str(cookiefile) + elif browser: + opts['cookiesfrombrowser'] = (browser,) + + if message: + opts['progress_hooks'] = [partial(progress_hook_async_bridge, message=message)] + + return opts + +async def progress_hook_async_bridge(d, message): + try: + if d.get('status') == 'downloading': + percent = d.get('_percent_str', '').strip() + speed = d.get('_speed_str', '').strip() + eta = d.get('_eta_str', '').strip() + text = f"⬇️ Загрузка: {percent or '?'}\n🚀 Скорость: {speed or '?'}\n⏱ Осталось: {eta or '?'}" + await message.edit_text(text) + except Exception: + pass + +async def run(url: str, opts: dict) -> tuple[Path | None, dict | None]: + loop = asyncio.get_running_loop() + info, filepath = None, None + + def _do(): + nonlocal info, filepath + with yt_dlp.YoutubeDL(opts) as ydl: + info = ydl.extract_info(url, download=True) + out = None + if isinstance(info, dict): + out = info.get('_filename') + if not out: + rd = info.get('requested_downloads') or [] + for it in rd[::-1]: + out = it.get('filepath') or it.get('filename') + if out: + break + filepath = Path(out) if out else None + + await loop.run_in_executor(None, _do) + return filepath, info diff --git a/bot1.py b/bot1.py deleted file mode 100644 index 12fb42b..0000000 --- a/bot1.py +++ /dev/null @@ -1,330 +0,0 @@ -import os -import logging -import asyncio -import json -import time -from functools import partial -from pathlib import Path -from typing import Optional, Tuple, Dict - -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import ( - ApplicationBuilder, - CommandHandler, - MessageHandler, - CallbackQueryHandler, - ContextTypes, - filters, -) -import yt_dlp -import instaloader - -# Настройки из переменных окружения с дефолтами -BASE_URL = os.getenv("BOT_BASE_URL", "http://127.0.0.1:8081/bot") - -DOWNLOAD_DIR = Path(os.getenv("DOWNLOAD_DIR", "downloads")) -COOKIES_FILE = Path(os.getenv("COOKIES_FILE", "cookies.txt")) -INSTALOADER_SESSION = Path(os.getenv("INSTALOADER_SESSION", "session-smolkik_adm")) -INSTALOADER_USER = os.getenv("INSTALOADER_USER", "smolkik_adm") - -CONNECT_TIMEOUT = int(os.getenv("CONNECT_TIMEOUT", 600)) -READ_TIMEOUT = int(os.getenv("READ_TIMEOUT", 3600)) - -DOWNLOAD_DIR.mkdir(exist_ok=True) - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s" -) - -class Downloader: - def __init__(self): - self.user_data = {} - self.cookies_enabled = COOKIES_FILE.exists() - self.session_enabled = INSTALOADER_SESSION.exists() - self._last_progress_update = 0 - - if self.cookies_enabled: - logging.info(f"Обнаружен файл cookies: {COOKIES_FILE}") - if self.session_enabled: - logging.info(f"Обнаружен файл сессии InstaLoader: {INSTALOADER_SESSION}") - - async def download_media(self, url: str, options: dict, message: Optional[object] = None) -> Tuple[str, dict]: - ydl_opts = { - 'outtmpl': str(DOWNLOAD_DIR / '%(title)s.%(ext)s'), - 'quiet': True, - **options - } - if self.cookies_enabled: - ydl_opts['cookiefile'] = str(COOKIES_FILE) - if message: - ydl_opts['progress_hooks'] = [partial(self.progress_hook, message=message)] - - logging.info(f"Начинаю загрузку: {url}") - try: - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - loop = asyncio.get_running_loop() - info = await loop.run_in_executor(None, ydl.extract_info, url, True) - filepath = ydl.prepare_filename(info) - if options.get('extract_audio'): - filepath = os.path.splitext(filepath)[0] + '.mp3' - logging.info(f"Загрузка завершена: {filepath}") - return filepath, info - except yt_dlp.utils.DownloadError as e: - if "Private video" in str(e) and self.cookies_enabled: - logging.error("Не удалось получить доступ к приватному видео даже с cookies") - raise Exception("Не удалось получить доступ к видео. Проверьте cookies.") - raise - - async def download_instagram_story(self, url: str, message: object): - if not self.session_enabled: - await message.edit_text("⚠️ Нет Instagram session файла. Загрузите session-smolkik_adm в папку с ботом.") - return - - try: - username = self.extract_username_from_url(url) - loop = asyncio.get_running_loop() - - await message.edit_text("⏳ Проверяю сессию Instagram...") - - valid = await loop.run_in_executor(None, self.check_instagram_session) - if not valid: - await message.edit_text("⚠️ Сессия Instagram недействительна. Пожалуйста, обновите session-smolkik_adm.") - return - - await message.edit_text(f"⏳ Ищу сторис пользователя @{username}...") - await loop.run_in_executor(None, self._instaloader_download, username) - - story_files = [f for f in DOWNLOAD_DIR.glob(f"{username}*")] - if not story_files: - await message.edit_text("❌ Нет доступных сторис у пользователя.") - return - - for file in story_files: - with file.open('rb') as story_file: - await message.bot.send_document( - chat_id=message.chat_id, - document=story_file, - filename=file.name, - connect_timeout=CONNECT_TIMEOUT, - read_timeout=READ_TIMEOUT - ) - file.unlink() - await message.edit_text("✅ Истории успешно отправлены!") - - except Exception as e: - logging.error(f"Ошибка скачивания сторис: {str(e)}") - await message.edit_text(f"⚠️ Ошибка скачивания Stories: {str(e)}") - - def extract_username_from_url(self, url: str) -> Optional[str]: - parts = url.split('/') - username = None - if 'stories' in parts: - idx = parts.index('stories') + 1 - if idx < len(parts): - username = parts[idx] - else: - for i, part in enumerate(parts): - if 'instagram.com' in part and i + 1 < len(parts): - username = parts[i + 1] - break - if username: - username = username.split('?')[0] - return username - - def check_instagram_session(self) -> bool: - """Проверяет, что сессия Instaloader действительна""" - try: - L = instaloader.Instaloader() - L.load_session_from_file(INSTALOADER_USER, str(INSTALOADER_SESSION)) - user = L.test_login() - logging.info(f"✅ Instagram-сессия активна: {user}") - return True - except Exception as e: - logging.warning(f"❌ Сессия Instagram недействительна: {e}") - return False - - def _instaloader_download(self, username: str): - L = instaloader.Instaloader( - dirname_pattern=str(DOWNLOAD_DIR), - download_videos=True, - download_video_thumbnails=False - ) - if not INSTALOADER_SESSION.exists(): - raise FileNotFoundError(f"Session file {INSTALOADER_SESSION} не найден.") - L.load_session_from_file(INSTALOADER_USER, str(INSTALOADER_SESSION)) - profile = instaloader.Profile.from_username(L.context, username) - - for story in L.get_stories(userids=[profile.userid]): - for item in story.get_items(): - try: - L.download_storyitem(item, target=username) - except Exception as e: - logging.error(f"Ошибка при обработке элемента сторис: {e}") - - async def progress_hook(self, d: dict, message: Optional[object] = None): - now = time.time() - if d.get('status') == 'downloading' and message: - # Обновлять не чаще чем раз в секунду - if now - self._last_progress_update > 1: - percent = d.get('_percent_str', '?') - speed = d.get('_speed_str', '?') - eta = d.get('_eta_str', '?') - text = f"⬇️ Загрузка: {percent}\n🚀 Скорость: {speed}\n⏱ Осталось: {eta}" - try: - await message.edit_text(text) - except Exception: - pass - self._last_progress_update = now - - # Все методы ниже получили аннотации типов - async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - text = "🎬 Пришлите ссылку на видео или профиль Instagram (для Stories)\n" - if self.cookies_enabled: - text += "\n🔐 Используется cookies для скачивания приватного контента." - if self.session_enabled: - text += "\n🔐 Используется Instagram-сессия для сторис." - await update.message.reply_text(text) - - async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - url = update.message.text.strip() - if not url.startswith(('http://', 'https://')): - await update.message.reply_text("⚠️ Пожалуйста, отправьте корректную ссылку") - return - self.user_data[update.message.from_user.id] = {'url': url} - keyboard = [ - [InlineKeyboardButton("🎥 Видео", callback_data='video')], - [InlineKeyboardButton("🎧 Аудио", callback_data='audio')], - [InlineKeyboardButton("📁 Оригинал", callback_data='original')], - [InlineKeyboardButton("🌟 Сторис Instagram", callback_data='story')], - ] - reply_markup = InlineKeyboardMarkup(keyboard) - await update.message.reply_text("Выберите тип загрузки:", reply_markup=reply_markup) - - async def handle_quality_choice(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - query = update.callback_query - await query.answer() - user_id = query.from_user.id - data = query.data - if data == 'story': - url = self.user_data[user_id]['url'] - msg = await query.edit_message_text("⏳ Начинаю загрузку Instagram Stories...") - await self.download_instagram_story(url, msg) - elif data == 'audio': - await self.download_audio(query) - elif data == 'original': - await self.download_original(query) - elif data == 'video': - await self.select_video_quality(query) - elif data.startswith('quality_'): - await self.download_video(query, data.split('_')[1]) - - async def select_video_quality(self, query: object) -> None: - keyboard = [ - [InlineKeyboardButton("🔥 Максимальное", callback_data='quality_max')], - [InlineKeyboardButton("🖥 1080p", callback_data='quality_1080')], - [InlineKeyboardButton("📺 720p", callback_data='quality_720')], - [InlineKeyboardButton("📱 480p", callback_data='quality_480')], - ] - reply_markup = InlineKeyboardMarkup(keyboard) - await query.edit_message_text("Выберите качество видео:", reply_markup=reply_markup) - - async def download_audio(self, query: object) -> None: - user_id = query.from_user.id - url = self.user_data[user_id]['url'] - msg = await query.edit_message_text("⏳ Начинаю загрузку аудио...") - try: - options = { - 'format': 'bestaudio/best', - 'extract_audio': True, - 'audio_format': 'mp3', - 'postprocessors': [{ - 'key': 'FFmpegExtractAudio', - 'preferredcodec': 'mp3', - 'preferredquality': '192', - }], - } - filepath, info = await self.download_media(url, options, msg) - title = info.get('title', 'Аудио') - artist = info.get('uploader', 'Неизвестен') - with open(filepath, 'rb') as audio_file: - await query.message.reply_audio( - audio=audio_file, - title=title, - performer=artist, - connect_timeout=CONNECT_TIMEOUT, - read_timeout=READ_TIMEOUT - ) - await msg.edit_text("✅ Аудио успешно отправлено!") - os.remove(filepath) - except Exception as e: - logging.error(f"Ошибка: {str(e)}") - await msg.edit_text(f"⚠️ Ошибка: {str(e)}") - - async def download_original(self, query: object) -> None: - user_id = query.from_user.id - url = self.user_data[user_id]['url'] - msg = await query.edit_message_text("⏳ Начинаю загрузку оригинального файла...") - try: - options = {'format': 'best'} - filepath, info = await self.download_media(url, options, msg) - with open(filepath, 'rb') as file: - await query.message.reply_document( - document=file, - filename=os.path.basename(filepath), - connect_timeout=CONNECT_TIMEOUT, - read_timeout=READ_TIMEOUT - ) - await msg.edit_text("✅ Файл успешно отправлен!") - os.remove(filepath) - except Exception as e: - logging.error(f"Ошибка: {str(e)}") - await msg.edit_text(f"⚠️ Ошибка: {str(e)}") - - async def download_video(self, query: object, quality: str) -> None: - user_id = query.from_user.id - url = self.user_data[user_id]['url'] - quality_map = { - 'max': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', - '1080': 'bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[height<=1080][ext=mp4]/best', - '720': 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720][ext=mp4]/best', - '480': 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480][ext=mp4]/best', - } - msg = await query.edit_message_text(f"⏳ Начинаю загрузку видео ({quality})...") - try: - options = { - 'format': quality_map.get(quality, 'best'), - 'merge_output_format': 'mp4', - } - filepath, info = await self.download_media(url, options, msg) - with open(filepath, 'rb') as video_file: - await query.message.reply_video( - video=video_file, - supports_streaming=True, - connect_timeout=CONNECT_TIMEOUT, - read_timeout=READ_TIMEOUT - ) - await msg.edit_text("✅ Видео успешно отправлено!") - os.remove(filepath) - except Exception as e: - logging.error(f"Ошибка: {str(e)}") - await msg.edit_text(f"⚠️ Ошибка: {str(e)}") - -def main(): - downloader = Downloader() - app = ( - ApplicationBuilder() - .token(TOKEN) - .base_url(BASE_URL) - .connect_timeout(CONNECT_TIMEOUT) - .read_timeout(READ_TIMEOUT) - .build() - ) - app.add_handler(CommandHandler("start", downloader.start)) - app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, downloader.handle_message)) - app.add_handler(CallbackQueryHandler(downloader.handle_quality_choice)) - logging.info("✅ Бот запущен и готов к работе (используется локальный Bot API)") - app.run_polling() - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/downloads/.DS_Store b/downloads/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/downloads/.DS_Store differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1f79462 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +python-telegram-bot==20.7 +yt-dlp>=2025.1.1 +httpx>=0.25.2 +instaloader>=4.13.1 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..6fb5d76 --- /dev/null +++ b/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +if [ -f ".env" ]; then + while IFS='=' read -r key value; do + [[ -n "$key" && ! "$key" =~ ^[[:space:]]*# ]] && export "$key"="$value" + done < <(grep -E '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]*$' .env | sed 's/[[:space:]]*#.*$//' | tr -d '\r') +fi + +python3 -m venv .venv || true +source .venv/bin/activate + +python -m pip install --upgrade pip +python -m pip install -U -r requirements.txt +python -m pip install -U yt-dlp + +mkdir -p downloads + +exec python -m bot.app