Compare commits

3 Commits

Author SHA1 Message Date
smolkik-code
7feac6f3a4 update v0.1.1.1 bed 2025-12-27 02:16:41 +07:00
smolkik-code
bc42c8775d update v0.1.1 2025-12-27 01:37:53 +07:00
smolkik-code
66a37ab5cb update v0.1 2025-12-27 01:20:49 +07:00
17 changed files with 256 additions and 117 deletions

BIN
.DS_Store vendored

Binary file not shown.

4
.gitignore vendored
View File

@@ -1,2 +1,4 @@
.env .env
cookies.txt cookies.txt
.venv
session-smolkikadm

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.

View File

@@ -3,12 +3,13 @@ from pathlib import Path
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "REPLACE_ME") TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "REPLACE_ME")
BOT_BASE_URL = os.getenv("BOT_BASE_URL", "http://127.0.0.1:8081/bot") BOT_BASE_URL = os.getenv("BOT_BASE_URL", "http://127.0.0.1:8081/bot")
INSTA_SESSION_FILE = Path("session-smolkikadm")
INSTA_USERNAME = "smolkikadm"
DOWNLOAD_DIR = Path(os.getenv("DOWNLOAD_DIR", "downloads")) DOWNLOAD_DIR = Path(os.getenv("DOWNLOAD_DIR", "downloads"))
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
COOKIES_FILE = Path(os.getenv("COOKIES_FILE", "")) if os.getenv("COOKIES_FILE") else Path() COOKIES_FILE = Path(os.getenv("COOKIES_FILE", "")) if os.getenv("COOKIES_FILE") else Path()
BROWSER = os.getenv("BROWSER", "") BROWSER = os.getenv("BROWSER", "chrome")
CONNECT_TIMEOUT = int(os.getenv("CONNECT_TIMEOUT", 600)) CONNECT_TIMEOUT = int(os.getenv("CONNECT_TIMEOUT", 600))
READ_TIMEOUT = int(os.getenv("READ_TIMEOUT", 1800)) READ_TIMEOUT = int(os.getenv("READ_TIMEOUT", 1800))

View File

@@ -1,13 +1,14 @@
from pathlib import Path from pathlib import Path
import httpx import httpx
from typing import Optional
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from .config import DOWNLOAD_DIR, COOKIES_FILE, BROWSER, CONNECT_TIMEOUT, READ_TIMEOUT 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 .utils import is_instagram_story_url, VIDEO_SUFFIXES, IMAGE_SUFFIXES, extract_instagram_username
from .ytdlp_client import build_opts, run from .ytdlp_client import build_opts, run
from .telegram_send import send_video, send_audio, send_document, send_photo from .telegram_send import send_video, send_audio, send_document, send_photo
from .transcode import transcode_to_mobile_mp4 from .transcode import transcode_to_mobile_mp4
from .instaloader_client import download_user_stories
# Профили качества для видео # Профили качества для видео
QUALITY_MAP = { QUALITY_MAP = {
@@ -96,37 +97,37 @@ class Handlers:
# -------- Stories: одиночная -------- # -------- Stories: одиночная --------
async def download_story(self, url: str, message, chat_id: int): async def download_story(self, url: str, message, chat_id: int):
try: try:
opts = build_opts(for_video=True, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=True) username = extract_instagram_username(url)
# Для видео принудительно mp4, изображения не пострадают, т.к. merge только для видео if not username:
opts['format'] = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]' await message.edit_text("⚠️ Не удалось определить username из ссылки.")
filepath, info = await run(url, opts) return
count = await self._send_playlist_mixed_with_images(info, message, chat_id) await message.edit_text("⏳ Загрузка Stories через Instaloader...")
# fallback одиночного файла
if count == 0 and filepath and filepath.exists(): files = download_user_stories(username)
suffix = (filepath.suffix or '').lower() if not files:
await message.edit_text(" Stories не найдены или доступ ограничен.")
return
count = 0
for fp in files:
suffix = fp.suffix.lower()
if suffix in IMAGE_SUFFIXES: if suffix in IMAGE_SUFFIXES:
await self._send_photo_safe(message, filepath, chat_id) await self._send_photo_safe(message, fp, chat_id)
count = 1
elif suffix in VIDEO_SUFFIXES: elif suffix in VIDEO_SUFFIXES:
out_mp4 = await transcode_to_mobile_mp4(filepath) out_mp4 = await transcode_to_mobile_mp4(fp)
try: try:
await self._send_video_safe(message, out_mp4, chat_id) await self._send_video_safe(message, out_mp4, chat_id)
count = 1
finally: finally:
out_mp4.unlink(missing_ok=True) out_mp4.unlink(missing_ok=True)
else: else:
await self._send_document_safe(message, filepath, chat_id) await self._send_document_safe(message, fp, chat_id)
count = 1 count += 1
filepath.unlink(missing_ok=True)
await message.edit_text(f"✅ Stories отправлены! ({count} шт.)")
if count > 0:
await message.edit_text(f"✅ Stories отправлены! ({count} шт.)")
else:
await message.edit_text(" Stories не найдены или доступ ограничен.")
except Exception as e: except Exception as e:
await message.edit_text(f"⚠️ Ошибка Stories: {e}") await message.edit_text(f"⚠️ Ошибка Stories (Instaloader): {e}")
# -------- Stories: все активные у автора -------- # -------- Stories: все активные у автора --------
async def download_all_stories(self, url: str, message, chat_id: int): async def download_all_stories(self, url: str, message, chat_id: int):
try: try:
@@ -135,18 +136,32 @@ class Handlers:
await message.edit_text("⚠️ Не удалось определить username из ссылки.") await message.edit_text("⚠️ Не удалось определить username из ссылки.")
return return
list_url = f"https://www.instagram.com/stories/{username}/" await message.edit_text("⏳ Загрузка всех активных Stories автора через Instaloader...")
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) files = download_user_stories(username)
if count: if not files:
await message.edit_text(f"✅ Отправлено элементов: {count} (у @{username})")
else:
await message.edit_text(f" У @{username} нет активных сторис или доступ ограничен.") await message.edit_text(f" У @{username} нет активных сторис или доступ ограничен.")
return
count = 0
for fp in files:
suffix = fp.suffix.lower()
if suffix in IMAGE_SUFFIXES:
await self._send_photo_safe(message, fp, chat_id)
elif suffix in VIDEO_SUFFIXES:
out_mp4 = await transcode_to_mobile_mp4(fp)
try:
await self._send_video_safe(message, out_mp4, chat_id)
finally:
out_mp4.unlink(missing_ok=True)
else:
await self._send_document_safe(message, fp, chat_id)
count += 1
await message.edit_text(f"✅ Отправлено элементов: {count} (у @{username})")
except Exception as e: except Exception as e:
await message.edit_text(f"⚠️ Ошибка при загрузке всех Stories автора: {e}") await message.edit_text(f"⚠️ Ошибка при загрузке всех Stories автора (Instaloader): {e}")
# -------- Видео (с выбором качества) -------- # -------- Видео (с выбором качества) --------
async def download_video(self, url: str, message, quality_key: str, chat_id: int): async def download_video(self, url: str, message, quality_key: str, chat_id: int):
@@ -251,7 +266,7 @@ class Handlers:
finally: finally:
self.sending_active[chat_id] = False self.sending_active[chat_id] = False
async def _send_audio_safe(self, message, filepath: Path, title: str | None, chat_id: int): async def _send_audio_safe(self, message, filepath: Path, title: Optional[str], chat_id: int):
self.sending_active[chat_id] = True self.sending_active[chat_id] = True
try: try:
await send_audio(message, filepath, title, CONNECT_TIMEOUT, READ_TIMEOUT) await send_audio(message, filepath, title, CONNECT_TIMEOUT, READ_TIMEOUT)
@@ -273,12 +288,15 @@ class Handlers:
self.sending_active[chat_id] = False self.sending_active[chat_id] = False
async def _download_file(self, url: str, into: Path) -> Path: async def _download_file(self, url: str, into: Path) -> Path:
print(f"[DBG] _download_file: url={url}, into={into}") # DBG
into.parent.mkdir(parents=True, exist_ok=True) into.parent.mkdir(parents=True, exist_ok=True)
timeout = httpx.Timeout(30.0) timeout = httpx.Timeout(30.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
r = await client.get(url) r = await client.get(url)
r.raise_for_status() r.raise_for_status()
ctype = r.headers.get('content-type', '') ctype = r.headers.get('content-type', '')
print(f"[DBG] _download_file: content-type={ctype}") # DBG
ext = '' ext = ''
if 'image/jpeg' in ctype: if 'image/jpeg' in ctype:
ext = '.jpg' ext = '.jpg'
@@ -290,12 +308,15 @@ class Handlers:
ext = '.avif' ext = '.avif'
elif 'image/heic' in ctype or 'image/heif' in ctype: elif 'image/heic' in ctype or 'image/heif' in ctype:
ext = '.heic' ext = '.heic'
if not into.suffix: if not into.suffix:
into = into.with_suffix(ext or '.jpg') into = into.with_suffix(ext or '.jpg')
into.write_bytes(r.content)
return into
def _pick_path(self, entry) -> Path | None: into.write_bytes(r.content)
print(f"[DBG] _download_file: saved {into}") # DBG
return into
def _pick_path(self, entry) -> Optional[Path]:
if isinstance(entry, dict): if isinstance(entry, dict):
rd = entry.get('requested_downloads') or [] rd = entry.get('requested_downloads') or []
for it in rd[::-1]: for it in rd[::-1]:
@@ -304,6 +325,7 @@ class Handlers:
p = Path(fp) p = Path(fp)
if p.exists(): if p.exists():
return p return p
fn = entry.get('_filename') fn = entry.get('_filename')
if fn: if fn:
p = Path(fn) p = Path(fn)
@@ -311,14 +333,19 @@ class Handlers:
return p return p
return None return None
async def _send_images_from_playlist_with_fallback(self, info: dict | None, message, chat_id: int) -> int: async def _send_images_from_playlist_with_fallback(
self, info: Optional[dict], message, chat_id: int
) -> int:
if not isinstance(info, dict): if not isinstance(info, dict):
return 0 return 0
entries = info.get('entries') or [] entries = info.get('entries') or []
sent = 0 sent = 0
for idx, entry in enumerate(entries): for idx, entry in enumerate(entries):
fp = self._pick_path(entry) fp = self._pick_path(entry)
temp_file: Path | None = None temp_file: Optional[Path] = None
try: try:
if fp and fp.exists(): if fp and fp.exists():
suffix = (fp.suffix or '').lower() suffix = (fp.suffix or '').lower()
@@ -334,10 +361,12 @@ class Handlers:
urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')] urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')]
if urls: if urls:
image_url = max(urls, key=len) image_url = max(urls, key=len)
if not image_url and isinstance(entry, dict): if not image_url and isinstance(entry, dict):
cand = entry.get('url') or entry.get('thumbnail') cand = entry.get('url') or entry.get('thumbnail')
if isinstance(cand, str) and cand.startswith(('http://', 'https://')): if isinstance(cand, str) and cand.startswith(('http://', 'https://')):
image_url = cand image_url = cand
if image_url: if image_url:
temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"img_{idx}") temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"img_{idx}")
await self._send_photo_safe(message, temp_file, chat_id) await self._send_photo_safe(message, temp_file, chat_id)
@@ -347,23 +376,35 @@ class Handlers:
temp_file.unlink(missing_ok=True) temp_file.unlink(missing_ok=True)
if fp: if fp:
fp.unlink(missing_ok=True) fp.unlink(missing_ok=True)
return sent return sent
async def _send_playlist_mixed_with_images(self, info: dict | None, message, chat_id: int) -> int: async def _send_playlist_mixed_with_images(
self, info: Optional[dict], message, chat_id: int
) -> int:
if not isinstance(info, dict): if not isinstance(info, dict):
print("[DBG] _send_playlist_mixed_with_images: info is not dict")
return 0 return 0
entries = info.get('entries') or [] entries = info.get('entries') or []
print(f"[DBG] playlist entries={len(entries)}")
sent = 0 sent = 0
for idx, entry in enumerate(entries): for idx, entry in enumerate(entries):
print(f"[DBG] entry[{idx}] keys={list(entry.keys()) if isinstance(entry, dict) else type(entry)}")
fp = self._pick_path(entry) fp = self._pick_path(entry)
temp_file: Path | None = None temp_file: Optional[Path] = None
try: try:
if fp and fp.exists(): if fp and fp.exists():
suffix = (fp.suffix or '').lower() suffix = (fp.suffix or '').lower()
print(f"[DBG] entry[{idx}] local file={fp}, suffix={suffix}")
if suffix in IMAGE_SUFFIXES: if suffix in IMAGE_SUFFIXES:
print(f"[DBG] entry[{idx}] send as photo")
await self._send_photo_safe(message, fp, chat_id) await self._send_photo_safe(message, fp, chat_id)
sent += 1 sent += 1
elif suffix in VIDEO_SUFFIXES: elif suffix in VIDEO_SUFFIXES:
print(f"[DBG] entry[{idx}] transcode/send video")
out_mp4 = await transcode_to_mobile_mp4(fp) out_mp4 = await transcode_to_mobile_mp4(fp)
try: try:
await self._send_video_safe(message, out_mp4, chat_id) await self._send_video_safe(message, out_mp4, chat_id)
@@ -371,26 +412,37 @@ class Handlers:
finally: finally:
out_mp4.unlink(missing_ok=True) out_mp4.unlink(missing_ok=True)
else: else:
print(f"[DBG] entry[{idx}] send as document")
await self._send_document_safe(message, fp, chat_id) await self._send_document_safe(message, fp, chat_id)
sent += 1 sent += 1
else: else:
print(f"[DBG] entry[{idx}] no local file, try thumbnails/url")
image_url = None image_url = None
thumbs = entry.get('thumbnails') if isinstance(entry, dict) else None thumbs = entry.get('thumbnails') if isinstance(entry, dict) else None
if isinstance(thumbs, list) and thumbs: if isinstance(thumbs, list) and thumbs:
urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')] urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')]
if urls: if urls:
image_url = max(urls, key=len) image_url = max(urls, key=len)
print(f"[DBG] entry[{idx}] thumbnail url={image_url}")
if not image_url and isinstance(entry, dict): if not image_url and isinstance(entry, dict):
cand = entry.get('url') or entry.get('thumbnail') cand = entry.get('url') or entry.get('thumbnail')
if isinstance(cand, str) and cand.startswith(('http://', 'https://')): if isinstance(cand, str) and cand.startswith(('http://', 'https://')):
image_url = cand image_url = cand
print(f"[DBG] entry[{idx}] direct url={image_url}")
if image_url: if image_url:
temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"story_img_{idx}") temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"story_img_{idx}")
await self._send_photo_safe(message, temp_file, chat_id) await self._send_photo_safe(message, temp_file, chat_id)
sent += 1 sent += 1
else:
print(f"[DBG] entry[{idx}] no image url found")
finally: finally:
if temp_file: if temp_file:
temp_file.unlink(missing_ok=True) temp_file.unlink(missing_ok=True)
if fp: if fp:
fp.unlink(missing_ok=True) fp.unlink(missing_ok=True)
print(f"[DBG] _send_playlist_mixed_with_images: sent={sent}")
return sent return sent

74
bot/instaloader_client.py Normal file
View File

@@ -0,0 +1,74 @@
from pathlib import Path
from typing import List, Optional
import instaloader
from instaloader import Profile
from .config import DOWNLOAD_DIR, INSTA_USERNAME, INSTA_SESSION_FILE
_instaloader: Optional[instaloader.Instaloader] = None
def get_instaloader() -> instaloader.Instaloader:
"""Создаёт Instaloader и грузит session-файл один раз."""
global _instaloader
if _instaloader is not None:
return _instaloader
L = instaloader.Instaloader(
dirname_pattern=str(DOWNLOAD_DIR / "{target}"),
filename_pattern="{date_utc}_story_{shortcode}",
download_video_thumbnails=False,
save_metadata=False,
download_geotags=False,
compress_json=False,
)
session_path = Path(INSTA_SESSION_FILE)
if not INSTA_USERNAME or not session_path.exists():
raise RuntimeError(
f"Instaloader session not configured: {INSTA_USERNAME=}, {session_path=}"
)
# загружаем сессию, созданную командой: instaloader -l INSTA_USERNAME
L.load_session_from_file(INSTA_USERNAME, filename=str(session_path))
_instaloader = L
return L
def download_user_stories(username: str) -> List[Path]:
"""Скачивает все актуальные stories пользователя и возвращает список файлов."""
L = get_instaloader()
try:
profile = Profile.from_username(L.context, username)
# Скачиваем stories для данного user id
L.download_stories(userids=[profile.userid])
except KeyError as e:
# Типичный кейс несовместимости Instaloader с текущим ответом Instagram:
# Instagram возвращает ответ без поля 'data' в GraphQL.
if str(e) == "'data'":
raise RuntimeError(
"Instagram вернул ответ без поля 'data' для GraphQL-запроса stories. "
"Это, как правило, несовместимость текущей версии Instaloader с API Instagram. "
"Обновите instaloader до последней версии или проверьте открытые issue проекта."
) from e
raise
except Exception as e:
raise RuntimeError(f"Ошибка при работе с @{username}: {e}") from e
user_dir = DOWNLOAD_DIR / username
if not user_dir.exists():
return []
# Возвращаем только медиа-файлы сторис
return sorted(
[
p
for p in user_dir.iterdir()
if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png", ".mp4"}
]
)

View File

@@ -1,41 +1,59 @@
import asyncio
from pathlib import Path from pathlib import Path
from typing import Optional
from telegram import Message from telegram import Message
from telegram.constants import ParseMode
from telegram.error import TelegramError
import logging
async def send_video(message: Message, filepath: Path, connect_timeout: int, read_timeout: int): logger = logging.getLogger(__name__)
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): async def send_video(message: Message, filepath: Path, title: Optional[str] = None, *args, **kwargs):
with filepath.open('rb') as f: """Универсальная функция - принимает любые аргументы"""
await message.chat.send_audio( try:
audio=f, with open(filepath, 'rb') as video_file:
title=title, await message.reply_video(
connect_timeout=connect_timeout, video=video_file,
read_timeout=read_timeout caption=title or "Видео",
) supports_streaming=True,
parse_mode=ParseMode.HTML,
async def send_document(message: Message, filepath: Path, connect_timeout: int, read_timeout: int): **kwargs # ✅ принимает любые доп. аргументы
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: except TelegramError as e:
await send_document(message, filepath, connect_timeout, read_timeout) logger.error(f"Ошибка отправки видео: {e}")
await message.reply_text("❌ Ошибка при отправке видео")
async def send_audio(message: Message, filepath: Path, title: Optional[str] = None, *args, **kwargs):
try:
with open(filepath, 'rb') as audio_file:
await message.reply_audio(
audio=audio_file,
title=title or "Аудио",
parse_mode=ParseMode.HTML,
**kwargs
)
except TelegramError as e:
logger.error(f"Ошибка отправки аудио: {e}")
await message.reply_text("❌ Ошибка при отправке аудио")
async def send_document(message: Message, filepath: Path, *args, **kwargs):
try:
with open(filepath, 'rb') as doc_file:
await message.reply_document(
document=doc_file,
**kwargs
)
except TelegramError as e:
logger.error(f"Ошибка отправки документа: {e}")
await message.reply_text("❌ Ошибка при отправке документа")
async def send_photo(message: Message, filepath: Path, *args, **kwargs):
try:
with open(filepath, 'rb') as photo_file:
await message.reply_photo(
photo=photo_file,
**kwargs
)
except TelegramError as e:
logger.error(f"Ошибка отправки фото: {e}")
await message.reply_text("❌ Ошибка при отправке фото")

View File

@@ -4,7 +4,9 @@ def is_instagram_story_url(url: str) -> bool:
VIDEO_SUFFIXES = ('.mp4', '.mov', '.mkv') VIDEO_SUFFIXES = ('.mp4', '.mov', '.mkv')
IMAGE_SUFFIXES = ('.jpg', '.jpeg', '.png', '.webp') IMAGE_SUFFIXES = ('.jpg', '.jpeg', '.png', '.webp')
def extract_instagram_username(url: str) -> str | None: from typing import Optional
def extract_instagram_username(url: Optional[str]) -> Optional[str]:
# Поддержка: # Поддержка:
# https://www.instagram.com/stories/USERNAME/... # https://www.instagram.com/stories/USERNAME/...
# https://www.instagram.com/USERNAME/ # https://www.instagram.com/USERNAME/

View File

@@ -1,18 +1,18 @@
import asyncio import asyncio
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Optional
import os import os
import yt_dlp import yt_dlp
def build_opts(for_video: bool, outdir: Path, cookiefile: Path | None, browser: str | None, message=None, out_for_playlist: bool = True) -> dict:
def build_opts(for_video: bool, outdir: Path, cookiefile: Optional[Path], browser: Optional[str], message=None, out_for_playlist: bool = True) -> dict:
postprocessors = [] postprocessors = []
if for_video: if for_video:
# На этапе постпроцессора делаем простую конвертацию в mp4 (дальше будет наша принудительная перекодировка)
postprocessors.append({'key': 'FFmpegVideoConvertor', 'preferedformat': 'mp4'}) postprocessors.append({'key': 'FFmpegVideoConvertor', 'preferedformat': 'mp4'})
# Для плейлистов (stories/галереи) — уникальные имена, чтобы не затирать
outtmpl = '%(uploader)s_%(id)s.%(ext)s' if out_for_playlist else '%(title)s.%(ext)s' outtmpl = '%(uploader)s_%(id)s.%(ext)s' if out_for_playlist else '%(title)s.%(ext)s'
opts: dict = { opts: dict = {
'outtmpl': str(outdir / outtmpl), 'outtmpl': str(outdir / outtmpl),
'quiet': True, 'quiet': True,
@@ -21,53 +21,43 @@ def build_opts(for_video: bool, outdir: Path, cookiefile: Path | None, browser:
'retries': 5, 'retries': 5,
'fragment_retries': 5, 'fragment_retries': 5,
'concurrent_fragment_downloads': 5, 'concurrent_fragment_downloads': 5,
'noprogress': True, 'noprogress': True, # ✅ Отключаем прогресс-бар
'source_address': '0.0.0.0', # форс IPv4 'source_address': '0.0.0.0',
} }
proxy = os.getenv('HTTPS_PROXY') or os.getenv('HTTP_PROXY') or '' proxy = os.getenv('HTTPS_PROXY') or os.getenv('HTTP_PROXY') or ''
if proxy: if proxy:
opts['proxy'] = proxy opts['proxy'] = proxy
if cookiefile and cookiefile.exists(): if cookiefile and cookiefile.exists():
opts['cookiefile'] = str(cookiefile) opts['cookiefile'] = str(cookiefile)
elif browser: elif browser:
opts['cookiesfrombrowser'] = (browser,) opts['cookiesfrombrowser'] = (browser,)
if message: # ✅ УБРАЛИ progress_hooks - они ломают event loop
opts['progress_hooks'] = [partial(progress_hook_async_bridge, message=message)]
return opts return opts
async def progress_hook_async_bridge(d, message):
try:
if d.get('status') == 'downloading':
percent = d.get('_percent_str', '').strip()
speed = d.get('_speed_str', '').strip()
eta = d.get('_eta_str', '').strip()
text = f"⬇️ Загрузка: {percent or '?'}\n🚀 Скорость: {speed or '?'}\n⏱ Осталось: {eta or '?'}"
await message.edit_text(text)
except Exception:
pass
async def run(url: str, opts: dict) -> tuple[Path | None, dict | None]: async def run(url: str, opts: dict) -> tuple[Optional[Path], Optional[dict]]:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
info, filepath = None, None info, filepath = None, None
def _do(): def _do():
nonlocal info, filepath nonlocal info, filepath
with yt_dlp.YoutubeDL(opts) as ydl: with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=True) info = ydl.extract_info(url, download=True)
out = None
if isinstance(info, dict): out = None
out = info.get('_filename') if isinstance(info, dict):
if not out: out = info.get('_filename')
rd = info.get('requested_downloads') or [] if not out:
for it in rd[::-1]: rd = info.get('requested_downloads') or []
out = it.get('filepath') or it.get('filename') for it in rd[::-1]:
if out: out = it.get('filepath') or it.get('filename')
break if out:
filepath = Path(out) if out else None break
filepath = Path(out) if out else None
await loop.run_in_executor(None, _do) await loop.run_in_executor(None, _do)
return filepath, info return filepath, info

View File

@@ -1,4 +1,4 @@
python-telegram-bot==20.7 python-telegram-bot>=22.0
yt-dlp>=2025.1.1 yt-dlp>=2025.1.1
httpx>=0.25.2 httpx>=0.27
instaloader>=4.13.1 instaloader>=4.13.1