diff --git a/.DS_Store b/.DS_Store index 24adf06..43eff61 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 97810c7..83d7beb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env -cookies.txt \ No newline at end of file +cookies.txt +.venv \ No newline at end of file diff --git a/bot/__pycache__/app.cpython-314.pyc b/bot/__pycache__/app.cpython-314.pyc new file mode 100644 index 0000000..8ef3c0c Binary files /dev/null and b/bot/__pycache__/app.cpython-314.pyc differ diff --git a/bot/__pycache__/config.cpython-314.pyc b/bot/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..96b2fbc Binary files /dev/null and b/bot/__pycache__/config.cpython-314.pyc differ diff --git a/bot/__pycache__/handlers.cpython-314.pyc b/bot/__pycache__/handlers.cpython-314.pyc new file mode 100644 index 0000000..f3d6143 Binary files /dev/null and b/bot/__pycache__/handlers.cpython-314.pyc differ diff --git a/bot/__pycache__/logging_conf.cpython-314.pyc b/bot/__pycache__/logging_conf.cpython-314.pyc new file mode 100644 index 0000000..a781d96 Binary files /dev/null and b/bot/__pycache__/logging_conf.cpython-314.pyc differ diff --git a/bot/__pycache__/telegram_send.cpython-314.pyc b/bot/__pycache__/telegram_send.cpython-314.pyc new file mode 100644 index 0000000..6f9de05 Binary files /dev/null and b/bot/__pycache__/telegram_send.cpython-314.pyc differ diff --git a/bot/__pycache__/transcode.cpython-314.pyc b/bot/__pycache__/transcode.cpython-314.pyc new file mode 100644 index 0000000..2b7d95f Binary files /dev/null and b/bot/__pycache__/transcode.cpython-314.pyc differ diff --git a/bot/__pycache__/utils.cpython-314.pyc b/bot/__pycache__/utils.cpython-314.pyc new file mode 100644 index 0000000..1a6856d Binary files /dev/null and b/bot/__pycache__/utils.cpython-314.pyc differ diff --git a/bot/__pycache__/ytdlp_client.cpython-314.pyc b/bot/__pycache__/ytdlp_client.cpython-314.pyc new file mode 100644 index 0000000..97c5dae Binary files /dev/null and b/bot/__pycache__/ytdlp_client.cpython-314.pyc differ diff --git a/bot/config.py b/bot/config.py index a11e3da..6caca0f 100644 --- a/bot/config.py +++ b/bot/config.py @@ -8,7 +8,7 @@ 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", "") +BROWSER = os.getenv("BROWSER", "chrome") 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 index cd392d5..47012dc 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -1,3 +1,4 @@ +from typing import Optional from pathlib import Path import httpx from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup @@ -125,8 +126,10 @@ class Handlers: else: await message.edit_text("ℹ️ Stories не найдены или доступ ограничен.") except Exception as e: - await message.edit_text(f"⚠️ Ошибка Stories: {e}") - + try: + await message.edit_text(f"⚠️ Ошибка Stories: {str(e)[:100]}") + except: + await message.reply_text(f"⚠️ Ошибка Stories: {str(e)[:100]}") # -------- Stories: все активные у автора -------- async def download_all_stories(self, url: str, message, chat_id: int): try: @@ -251,7 +254,7 @@ class Handlers: finally: self.sending_active[chat_id] = False - async def _send_audio_safe(self, message, filepath: Path, title: str | None, chat_id: int): + async def _send_audio_safe(self, message, filepath: Path, title: Optional[str], chat_id: int): self.sending_active[chat_id] = True try: await send_audio(message, filepath, title, CONNECT_TIMEOUT, READ_TIMEOUT) @@ -273,29 +276,35 @@ class Handlers: 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) + print(f"[DBG] _download_file: url={url}, into={into}") # DBG + 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', '') + print(f"[DBG] _download_file: content-type={ctype}") # DBG + + 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) + print(f"[DBG] _download_file: saved {into}") # DBG return into - def _pick_path(self, entry) -> Path | None: + def _pick_path(self, entry) -> Optional[Path]: if isinstance(entry, dict): rd = entry.get('requested_downloads') or [] for it in rd[::-1]: @@ -318,7 +327,7 @@ class Handlers: sent = 0 for idx, entry in enumerate(entries): fp = self._pick_path(entry) - temp_file: Path | None = None + temp_file: Optional[Path] = None try: if fp and fp.exists(): suffix = (fp.suffix or '').lower() @@ -350,47 +359,69 @@ class Handlers: 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) + if not isinstance(info, dict): + print("[DBG] _send_playlist_mixed_with_images: info is not dict") # DBG + return 0 + + entries = info.get('entries') or [] + print(f"[DBG] playlist entries={len(entries)}") # DBG + + sent = 0 + for idx, entry in enumerate(entries): + print(f"[DBG] entry[{idx}] keys={list(entry.keys()) if isinstance(entry, dict) else type(entry)}") # DBG + fp = self._pick_path(entry) + temp_file: Path | None = None + + try: + if fp and fp.exists(): + suffix = (fp.suffix or '').lower() + print(f"[DBG] entry[{idx}] local file={fp}, suffix={suffix}") # DBG + + if suffix in IMAGE_SUFFIXES: + print(f"[DBG] entry[{idx}] send as photo") # DBG + await self._send_photo_safe(message, fp, chat_id) + sent += 1 + elif suffix in VIDEO_SUFFIXES: + print(f"[DBG] entry[{idx}] transcode/send video") # DBG + 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: - 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 + print(f"[DBG] entry[{idx}] send as document") # DBG + await self._send_document_safe(message, fp, chat_id) + sent += 1 + else: + print(f"[DBG] entry[{idx}] no local file, try thumbnails/url") # DBG + 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) + print(f"[DBG] entry[{idx}] thumbnail url={image_url}") # DBG + + 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 + print(f"[DBG] entry[{idx}] direct url={image_url}") # DBG + + 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 + else: + print(f"[DBG] entry[{idx}] no image url found") # DBG + + finally: + if temp_file: + temp_file.unlink(missing_ok=True) + if fp: + fp.unlink(missing_ok=True) + + print(f"[DBG] _send_playlist_mixed_with_images: sent={sent}") # DBG + return sent diff --git a/bot/telegram_send.py b/bot/telegram_send.py index b581fe5..0b32b44 100644 --- a/bot/telegram_send.py +++ b/bot/telegram_send.py @@ -1,41 +1,59 @@ +import asyncio from pathlib import Path +from typing import Optional from telegram import Message +from telegram.constants import ParseMode +from telegram.error import TelegramError +import logging -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 - ) +logger = logging.getLogger(__name__) -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 +async def send_video(message: Message, filepath: Path, title: Optional[str] = None, *args, **kwargs): + """Универсальная функция - принимает любые аргументы""" + try: + with open(filepath, 'rb') as video_file: + await message.reply_video( + video=video_file, + caption=title or "Видео", + supports_streaming=True, + parse_mode=ParseMode.HTML, + **kwargs # ✅ принимает любые доп. аргументы ) - else: - await send_document(message, filepath, connect_timeout, read_timeout) + except TelegramError as e: + logger.error(f"Ошибка отправки видео: {e}") + await message.reply_text("❌ Ошибка при отправке видео") + +async def send_audio(message: Message, filepath: Path, title: Optional[str] = None, *args, **kwargs): + try: + with open(filepath, 'rb') as audio_file: + await message.reply_audio( + audio=audio_file, + title=title or "Аудио", + parse_mode=ParseMode.HTML, + **kwargs + ) + except TelegramError as e: + logger.error(f"Ошибка отправки аудио: {e}") + await message.reply_text("❌ Ошибка при отправке аудио") + +async def send_document(message: Message, filepath: Path, *args, **kwargs): + try: + with open(filepath, 'rb') as doc_file: + await message.reply_document( + document=doc_file, + **kwargs + ) + except TelegramError as e: + logger.error(f"Ошибка отправки документа: {e}") + await message.reply_text("❌ Ошибка при отправке документа") + +async def send_photo(message: Message, filepath: Path, *args, **kwargs): + try: + with open(filepath, 'rb') as photo_file: + await message.reply_photo( + photo=photo_file, + **kwargs + ) + except TelegramError as e: + logger.error(f"Ошибка отправки фото: {e}") + await message.reply_text("❌ Ошибка при отправке фото") diff --git a/bot/utils.py b/bot/utils.py index d1c027d..994aff3 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -4,7 +4,9 @@ def is_instagram_story_url(url: str) -> bool: VIDEO_SUFFIXES = ('.mp4', '.mov', '.mkv') IMAGE_SUFFIXES = ('.jpg', '.jpeg', '.png', '.webp') -def extract_instagram_username(url: str) -> str | None: +from typing import Optional + +def extract_instagram_username(url: Optional[str]) -> Optional[str]: # Поддержка: # https://www.instagram.com/stories/USERNAME/... # https://www.instagram.com/USERNAME/ diff --git a/bot/ytdlp_client.py b/bot/ytdlp_client.py index 59cad1e..cb70989 100644 --- a/bot/ytdlp_client.py +++ b/bot/ytdlp_client.py @@ -1,18 +1,18 @@ import asyncio from functools import partial from pathlib import Path +from typing import Optional 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: + +def build_opts(for_video: bool, outdir: Path, cookiefile: Optional[Path], browser: Optional[str], 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, @@ -21,53 +21,43 @@ def build_opts(for_video: bool, outdir: Path, cookiefile: Path | None, browser: 'retries': 5, 'fragment_retries': 5, 'concurrent_fragment_downloads': 5, - 'noprogress': True, - 'source_address': '0.0.0.0', # форс IPv4 + 'noprogress': True, # ✅ Отключаем прогресс-бар + 'source_address': '0.0.0.0', } - + 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)] - + + # ✅ УБРАЛИ progress_hooks - они ломают event loop + 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]: +async def run(url: str, opts: dict) -> tuple[Optional[Path], Optional[dict]]: 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 - + + 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/requirements.txt b/requirements.txt index 1f79462..ebea43f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-telegram-bot==20.7 +python-telegram-bot>=22.0 yt-dlp>=2025.1.1 -httpx>=0.25.2 +httpx>=0.27 instaloader>=4.13.1