update v0.1

This commit is contained in:
smolkik-code
2025-12-27 01:20:49 +07:00
parent b687ebb3c0
commit 66a37ab5cb
16 changed files with 186 additions and 144 deletions

BIN
.DS_Store vendored

Binary file not shown.

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.env
cookies.txt
cookies.txt
.venv

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

@@ -8,7 +8,7 @@ DOWNLOAD_DIR = Path(os.getenv("DOWNLOAD_DIR", "downloads"))
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
COOKIES_FILE = Path(os.getenv("COOKIES_FILE", "")) if os.getenv("COOKIES_FILE") else Path()
BROWSER = os.getenv("BROWSER", "")
BROWSER = os.getenv("BROWSER", "chrome")
CONNECT_TIMEOUT = int(os.getenv("CONNECT_TIMEOUT", 600))
READ_TIMEOUT = int(os.getenv("READ_TIMEOUT", 1800))

View File

@@ -1,3 +1,4 @@
from typing import Optional
from pathlib import Path
import httpx
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
@@ -125,8 +126,10 @@ class Handlers:
else:
await message.edit_text(" Stories не найдены или доступ ограничен.")
except Exception as e:
await message.edit_text(f"⚠️ Ошибка Stories: {e}")
try:
await message.edit_text(f"⚠️ Ошибка Stories: {str(e)[:100]}")
except:
await message.reply_text(f"⚠️ Ошибка Stories: {str(e)[:100]}")
# -------- Stories: все активные у автора --------
async def download_all_stories(self, url: str, message, chat_id: int):
try:
@@ -251,7 +254,7 @@ class Handlers:
finally:
self.sending_active[chat_id] = False
async def _send_audio_safe(self, message, filepath: Path, title: str | None, chat_id: int):
async def _send_audio_safe(self, message, filepath: Path, title: Optional[str], chat_id: int):
self.sending_active[chat_id] = True
try:
await send_audio(message, filepath, title, CONNECT_TIMEOUT, READ_TIMEOUT)
@@ -273,29 +276,35 @@ class Handlers:
self.sending_active[chat_id] = False
async def _download_file(self, url: str, into: Path) -> Path:
into.parent.mkdir(parents=True, exist_ok=True)
timeout = httpx.Timeout(30.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
r = await client.get(url)
r.raise_for_status()
ctype = r.headers.get('content-type', '')
ext = ''
if 'image/jpeg' in ctype:
ext = '.jpg'
elif 'image/png' in ctype:
ext = '.png'
elif 'image/webp' in ctype:
ext = '.webp'
elif 'image/avif' in ctype:
ext = '.avif'
elif 'image/heic' in ctype or 'image/heif' in ctype:
ext = '.heic'
if not into.suffix:
into = into.with_suffix(ext or '.jpg')
into.write_bytes(r.content)
print(f"[DBG] _download_file: url={url}, into={into}") # DBG
into.parent.mkdir(parents=True, exist_ok=True)
timeout = httpx.Timeout(30.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
r = await client.get(url)
r.raise_for_status()
ctype = r.headers.get('content-type', '')
print(f"[DBG] _download_file: content-type={ctype}") # DBG
ext = ''
if 'image/jpeg' in ctype:
ext = '.jpg'
elif 'image/png' in ctype:
ext = '.png'
elif 'image/webp' in ctype:
ext = '.webp'
elif 'image/avif' in ctype:
ext = '.avif'
elif 'image/heic' in ctype or 'image/heif' in ctype:
ext = '.heic'
if not into.suffix:
into = into.with_suffix(ext or '.jpg')
into.write_bytes(r.content)
print(f"[DBG] _download_file: saved {into}") # DBG
return into
def _pick_path(self, entry) -> Path | None:
def _pick_path(self, entry) -> Optional[Path]:
if isinstance(entry, dict):
rd = entry.get('requested_downloads') or []
for it in rd[::-1]:
@@ -318,7 +327,7 @@ class Handlers:
sent = 0
for idx, entry in enumerate(entries):
fp = self._pick_path(entry)
temp_file: Path | None = None
temp_file: Optional[Path] = None
try:
if fp and fp.exists():
suffix = (fp.suffix or '').lower()
@@ -350,47 +359,69 @@ class Handlers:
return sent
async def _send_playlist_mixed_with_images(self, info: dict | None, message, chat_id: int) -> int:
if not isinstance(info, dict):
return 0
entries = info.get('entries') or []
sent = 0
for idx, entry in enumerate(entries):
fp = self._pick_path(entry)
temp_file: Path | None = None
try:
if fp and fp.exists():
suffix = (fp.suffix or '').lower()
if suffix in IMAGE_SUFFIXES:
await self._send_photo_safe(message, fp, chat_id)
sent += 1
elif suffix in VIDEO_SUFFIXES:
out_mp4 = await transcode_to_mobile_mp4(fp)
try:
await self._send_video_safe(message, out_mp4, chat_id)
sent += 1
finally:
out_mp4.unlink(missing_ok=True)
else:
await self._send_document_safe(message, fp, chat_id)
if not isinstance(info, dict):
print("[DBG] _send_playlist_mixed_with_images: info is not dict") # DBG
return 0
entries = info.get('entries') or []
print(f"[DBG] playlist entries={len(entries)}") # DBG
sent = 0
for idx, entry in enumerate(entries):
print(f"[DBG] entry[{idx}] keys={list(entry.keys()) if isinstance(entry, dict) else type(entry)}") # DBG
fp = self._pick_path(entry)
temp_file: Path | None = None
try:
if fp and fp.exists():
suffix = (fp.suffix or '').lower()
print(f"[DBG] entry[{idx}] local file={fp}, suffix={suffix}") # DBG
if suffix in IMAGE_SUFFIXES:
print(f"[DBG] entry[{idx}] send as photo") # DBG
await self._send_photo_safe(message, fp, chat_id)
sent += 1
elif suffix in VIDEO_SUFFIXES:
print(f"[DBG] entry[{idx}] transcode/send video") # DBG
out_mp4 = await transcode_to_mobile_mp4(fp)
try:
await self._send_video_safe(message, out_mp4, chat_id)
sent += 1
finally:
out_mp4.unlink(missing_ok=True)
else:
image_url = None
thumbs = entry.get('thumbnails') if isinstance(entry, dict) else None
if isinstance(thumbs, list) and thumbs:
urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')]
if urls:
image_url = max(urls, key=len)
if not image_url and isinstance(entry, dict):
cand = entry.get('url') or entry.get('thumbnail')
if isinstance(cand, str) and cand.startswith(('http://', 'https://')):
image_url = cand
if image_url:
temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"story_img_{idx}")
await self._send_photo_safe(message, temp_file, chat_id)
sent += 1
finally:
if temp_file:
temp_file.unlink(missing_ok=True)
if fp:
fp.unlink(missing_ok=True)
return sent
print(f"[DBG] entry[{idx}] send as document") # DBG
await self._send_document_safe(message, fp, chat_id)
sent += 1
else:
print(f"[DBG] entry[{idx}] no local file, try thumbnails/url") # DBG
image_url = None
thumbs = entry.get('thumbnails') if isinstance(entry, dict) else None
if isinstance(thumbs, list) and thumbs:
urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')]
if urls:
image_url = max(urls, key=len)
print(f"[DBG] entry[{idx}] thumbnail url={image_url}") # DBG
if not image_url and isinstance(entry, dict):
cand = entry.get('url') or entry.get('thumbnail')
if isinstance(cand, str) and cand.startswith(('http://', 'https://')):
image_url = cand
print(f"[DBG] entry[{idx}] direct url={image_url}") # DBG
if image_url:
temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"story_img_{idx}")
await self._send_photo_safe(message, temp_file, chat_id)
sent += 1
else:
print(f"[DBG] entry[{idx}] no image url found") # DBG
finally:
if temp_file:
temp_file.unlink(missing_ok=True)
if fp:
fp.unlink(missing_ok=True)
print(f"[DBG] _send_playlist_mixed_with_images: sent={sent}") # DBG
return sent

View File

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

View File

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

View File

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

View File

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