397 lines
19 KiB
Python
397 lines
19 KiB
Python
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
|