add finders
This commit is contained in:
23
README.md
Normal file
23
README.md
Normal file
@@ -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 отдаёт пустые ответы).
|
||||
BIN
bot/__pycache__/app.cpython-311.pyc
Normal file
BIN
bot/__pycache__/app.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/app.cpython-312.pyc
Normal file
BIN
bot/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/config.cpython-311.pyc
Normal file
BIN
bot/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/config.cpython-312.pyc
Normal file
BIN
bot/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/handlers.cpython-311.pyc
Normal file
BIN
bot/__pycache__/handlers.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/handlers.cpython-312.pyc
Normal file
BIN
bot/__pycache__/handlers.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/logging_conf.cpython-311.pyc
Normal file
BIN
bot/__pycache__/logging_conf.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/logging_conf.cpython-312.pyc
Normal file
BIN
bot/__pycache__/logging_conf.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/telegram_send.cpython-311.pyc
Normal file
BIN
bot/__pycache__/telegram_send.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/telegram_send.cpython-312.pyc
Normal file
BIN
bot/__pycache__/telegram_send.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/transcode.cpython-311.pyc
Normal file
BIN
bot/__pycache__/transcode.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/transcode.cpython-312.pyc
Normal file
BIN
bot/__pycache__/transcode.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/utils.cpython-311.pyc
Normal file
BIN
bot/__pycache__/utils.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/utils.cpython-312.pyc
Normal file
BIN
bot/__pycache__/utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/ytdlp_client.cpython-311.pyc
Normal file
BIN
bot/__pycache__/ytdlp_client.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/ytdlp_client.cpython-312.pyc
Normal file
BIN
bot/__pycache__/ytdlp_client.cpython-312.pyc
Normal file
Binary file not shown.
24
bot/app.py
Normal file
24
bot/app.py
Normal file
@@ -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()
|
||||
14
bot/config.py
Normal file
14
bot/config.py
Normal file
@@ -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))
|
||||
396
bot/handlers.py
Normal file
396
bot/handlers.py
Normal file
@@ -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
|
||||
1
bot/init.py
Normal file
1
bot/init.py
Normal file
@@ -0,0 +1 @@
|
||||
# empty marker for package
|
||||
7
bot/logging_conf.py
Normal file
7
bot/logging_conf.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import logging
|
||||
|
||||
def setup_logging():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s"
|
||||
)
|
||||
41
bot/telegram_send.py
Normal file
41
bot/telegram_send.py
Normal file
@@ -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)
|
||||
46
bot/transcode.py
Normal file
46
bot/transcode.py
Normal file
@@ -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
|
||||
32
bot/utils.py
Normal file
32
bot/utils.py
Normal file
@@ -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
|
||||
73
bot/ytdlp_client.py
Normal file
73
bot/ytdlp_client.py
Normal file
@@ -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
|
||||
330
bot1.py
330
bot1.py
@@ -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()
|
||||
BIN
downloads/.DS_Store
vendored
Normal file
BIN
downloads/.DS_Store
vendored
Normal file
Binary file not shown.
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
python-telegram-bot==20.7
|
||||
yt-dlp>=2025.1.1
|
||||
httpx>=0.25.2
|
||||
instaloader>=4.13.1
|
||||
20
run.sh
Executable file
20
run.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user