add finders
This commit is contained in:
@@ -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 отдаёт пустые ответы).
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# empty marker for package
|
||||
@@ -0,0 +1,7 @@
|
||||
import logging
|
||||
|
||||
def setup_logging():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
python-telegram-bot==20.7
|
||||
yt-dlp>=2025.1.1
|
||||
httpx>=0.25.2
|
||||
instaloader>=4.13.1
|
||||
@@ -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