diff --git a/.DS_Store b/.DS_Store
index fec1682..db8bed2 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e69de29
diff --git a/bot/cache.py b/bot/cache.py
index 285f673..40b5772 100644
--- a/bot/cache.py
+++ b/bot/cache.py
@@ -1,17 +1,13 @@
+# cache.py
import hashlib
-
+import os
def cache_key(url: str, quality: str, audio: bool = False) -> str:
- """Генерирует ключ кэша на основе URL, качества и типа"""
key_str = f"{url}_{quality}_{audio}"
return hashlib.sha256(key_str.encode()).hexdigest()
-
def cache_path(cache_dir: str, key: str, extension: str) -> str:
- """Создает путь к файлу в кэше"""
- # Создаем вложенную структуру для лучшей организации
subdir = key[:2]
- import os
full_dir = os.path.join(cache_dir, subdir)
os.makedirs(full_dir, exist_ok=True)
return os.path.join(full_dir, f"{key}.{extension}")
\ No newline at end of file
diff --git a/bot/config.py b/bot/config.py
index 4d7df84..e1719ae 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -1,3 +1,4 @@
+# config.py
import os
from dotenv import load_dotenv
@@ -19,8 +20,14 @@ COOKIES_FILE = os.getenv("COOKIES_FILE")
MAX_DURATION_SECONDS = int(os.getenv("MAX_DURATION_SECONDS", 1800))
RATE_LIMIT_SECONDS = int(os.getenv("RATE_LIMIT_SECONDS", 20))
MAX_FILE_SIZE_MB = 2000
-CACHE_MAX_AGE_DAYS = 7 # Удалять файлы старше 7 дней
-CACHE_MAX_SIZE_MB = 4096 # Максимальный размер кэша 1 ГБ (0 = без ограничения)
+CACHE_MAX_AGE_DAYS = 7
+CACHE_MAX_SIZE_MB = 4096
+
+# Авто-качества для TikTok и Instagram (без выбора)
+DEFAULT_TIKTOK_VIDEO_QUALITY = "1080"
+DEFAULT_TIKTOK_AUDIO_QUALITY = "192" # кбит/с
+DEFAULT_INSTAGRAM_VIDEO_QUALITY = "1080"
+
# Private mode
ALLOWED_USERS = set(
int(uid.strip())
@@ -30,4 +37,4 @@ ALLOWED_USERS = set(
# Ensure directories exist
os.makedirs(CACHE_DIR, exist_ok=True)
-os.makedirs(TMP_DIR, exist_ok=True)
+os.makedirs(TMP_DIR, exist_ok=True)
\ No newline at end of file
diff --git a/bot/downloader.py b/bot/downloader.py
index 7510fe8..f08127a 100644
--- a/bot/downloader.py
+++ b/bot/downloader.py
@@ -1,32 +1,60 @@
+# downloader.py
import yt_dlp
import threading
import os
import logging
import subprocess
-import json
import shutil
-from typing import List, Optional
+import asyncio
+from typing import List
+
+from config import (
+ DEFAULT_TIKTOK_VIDEO_QUALITY,
+ DEFAULT_TIKTOK_AUDIO_QUALITY,
+ DEFAULT_INSTAGRAM_VIDEO_QUALITY,
+)
logger = logging.getLogger(__name__)
-
class DownloadCancelled(Exception):
pass
-
def _progress_hook(cancel_event, progress_cb):
def hook(d):
if cancel_event.is_set():
raise DownloadCancelled("Cancelled by user")
-
if d["status"] in ["downloading", "finished"] and progress_cb:
progress_cb(d)
-
return hook
+def optimize_for_telegram(input_path: str, output_path: str) -> None:
+ """
+ Синхронная функция: конвертирует видео в Telegram-safe mp4
+ """
+ file_size_mb = os.path.getsize(input_path) / (1024 * 1024)
+ crf = 28 if file_size_mb > 50 else 23
-# ---------------- STANDARD VIDEO ----------------
+ cmd = [
+ 'ffmpeg',
+ '-i', input_path,
+ '-c:v', 'libx264',
+ '-preset', 'fast',
+ '-crf', str(crf),
+ '-c:a', 'aac',
+ '-b:a', '128k',
+ '-movflags', '+faststart',
+ '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
+ '-y',
+ output_path
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
+ if result.returncode != 0:
+ logger.error(f"FFmpeg error: {result.stderr}")
+ shutil.copy2(input_path, output_path)
+
+
+# ---------------- YOUTUBE/VK: Video (с выбором качества) ------
def download_video(
url: str,
quality: str,
@@ -38,15 +66,10 @@ def download_video(
ydl_opts = {
"format": f"best[height<={quality}]/best",
"outtmpl": out_path,
- "merge_output_format": "mp4",
- "cookiefile": cookies,
+ "cookiefile": cookies if cookies and os.path.exists(cookies) else None,
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
"quiet": True,
"no_warnings": True,
- "extractor_args": {
- "tiktok": {"skip_impersonation": True},
- "instagram": {"skip_impersonation": True},
- },
"concurrent_fragment_downloads": 4,
"http_chunk_size": 10485760,
}
@@ -54,65 +77,48 @@ def download_video(
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
+ # Ищем реальный файл
+ base = out_path.rsplit('.', 1)[0]
+ actual_video = None
+ for ext in ['', '.mp4', '.mov', '.webm', '.mkv']:
+ candidate = base + ext
+ if os.path.exists(candidate):
+ actual_video = candidate
+ break
-# ---------------- ORIGINAL QUALITY ----------------
+ if not actual_video:
+ raise Exception(f"Video file not created for {url}")
-def download_original_quality(
+ # Конвертируем в Telegram-safe mp4 (синхронно)
+ optimized_path = base + "_telegram.mp4"
+ optimize_for_telegram(actual_video, optimized_path) # ← без await!
+
+ # Удаляем старый файл и ставим конвертированный
+ for path in [out_path, actual_video]:
+ if os.path.exists(path): os.remove(path)
+ os.rename(optimized_path, out_path)
+
+
+# ---------------- AUDIO (YouTube/VK) ------
+def download_audio(
url: str,
out_path: str,
cookies: str | None,
cancel_event: threading.Event,
progress_cb,
):
- """Скачивает видео в оригинальном качестве"""
- ydl_opts = {
- "format": "best",
- "outtmpl": out_path,
- "merge_output_format": "mp4",
- "cookiefile": cookies,
- "progress_hooks": [_progress_hook(cancel_event, progress_cb)],
- "quiet": True,
- "no_warnings": True,
- "extractor_args": {
- "tiktok": {"skip_impersonation": True},
- "instagram": {"skip_impersonation": True},
- },
- "format_sort": ["quality", "res", "codec", "size"],
- "concurrent_fragment_downloads": 4,
- "http_chunk_size": 10485760,
- }
-
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
- ydl.download([url])
-
-
-# ---------------- TIKTOK MUSIC ONLY ----------------
-
-def download_tiktok_music(
- url: str,
- out_path: str,
- cookies: str | None,
- cancel_event: threading.Event,
- progress_cb,
-):
- """Скачивает только звук из TikTok"""
ydl_opts = {
"format": "bestaudio/best",
"outtmpl": out_path.replace('.mp3', ''),
- "cookiefile": cookies,
+ "cookiefile": cookies if cookies and os.path.exists(cookies) else None,
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
- "postprocessors": [
- {
- "key": "FFmpegExtractAudio",
- "preferredcodec": "mp3",
- "preferredquality": "192",
- },
- ],
+ "postprocessors": [{
+ "key": "FFmpegExtractAudio",
+ "preferredcodec": "mp3",
+ "preferredquality": "192",
+ }],
"quiet": True,
"no_warnings": True,
- "extractor_args": {
- "tiktok": {"skip_impersonation": True},
- },
"concurrent_fragment_downloads": 4,
"http_chunk_size": 10485760,
}
@@ -120,64 +126,158 @@ def download_tiktok_music(
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
+ # Ищем файл
+ base = out_path.rsplit('.', 1)[0]
+ actual_audio = None
+ for ext in ['', '.mp3', '.m4a', '.opus', '.weba']:
+ candidate = base + ext
+ if os.path.exists(candidate):
+ actual_audio = candidate
+ break
-# ---------------- ADD METADATA TO AUDIO ----------------
+ if not actual_audio:
+ raise Exception(f"Audio file not created for {url}")
-def add_metadata_to_audio(
- input_path: str,
- output_path: str,
- metadata: dict,
+ # Конвертируем в mp3
+ if actual_audio != out_path:
+ optimized = base + "_telegram.mp3"
+ optimize_for_telegram_audio(actual_audio, optimized) # ← отдельная функция
+ if os.path.exists(out_path): os.remove(out_path)
+ os.rename(optimized, out_path)
+
+
+# ---------------- TikTok: Video + Audio (автоматически) ------
+def download_tiktok_video_and_audio(
+ url: str,
+ video_path: str,
+ audio_path: str,
+ cookies: str | None,
+ cancel_event: threading.Event,
+ progress_cb,
):
- """Добавляет метаданные к аудио файлу"""
- try:
- # Проверяем metadata
- if metadata is None:
- metadata = {}
-
- if not input_path.lower().endswith('.mp3'):
- shutil.copy2(input_path, output_path)
- return
-
- metadata_args = []
-
- # Добавляем метаданные
- if metadata.get('title'):
- metadata_args.extend(['-metadata', f'title={metadata["title"][:100]}'])
-
- if metadata.get('artist'):
- metadata_args.extend(['-metadata', f'artist={metadata["artist"][:100]}'])
-
- if metadata.get('album'):
- metadata_args.extend(['-metadata', f'album={metadata["album"][:100]}'])
-
- cmd = [
- 'ffmpeg',
- '-i', input_path,
- '-c', 'copy',
- '-id3v2_version', '3',
- '-loglevel', 'error',
- '-y',
- *metadata_args,
- output_path
- ]
-
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- timeout=30
- )
-
- if result.returncode != 0:
- shutil.copy2(input_path, output_path)
-
- except Exception as e:
- logger.error(f"Metadata error: {e}")
- shutil.copy2(input_path, output_path)
+ # Скачиваем видео
+ video_opts = {
+ "format": f"bestvideo[height<={DEFAULT_TIKTOK_VIDEO_QUALITY}]+bestaudio/best",
+ "outtmpl": video_path,
+ "cookiefile": cookies if cookies and os.path.exists(cookies) else None,
+ "progress_hooks": [_progress_hook(cancel_event, progress_cb)],
+ "quiet": True,
+ "no_warnings": False,
+ "concurrent_fragment_downloads": 4,
+ "http_chunk_size": 10485760,
+ }
+
+ with yt_dlp.YoutubeDL(video_opts) as ydl:
+ ydl.download([url])
+
+ # Ищем видеофайл
+ base_video = video_path.rsplit('.', 1)[0]
+ actual_video = None
+ for ext in ['', '.mp4', '.mov', '.webm']:
+ candidate = base_video + ext
+ if os.path.exists(candidate):
+ actual_video = candidate
+ break
+ if not actual_video:
+ raise Exception("TikTok video file not created")
+
+ # Конвертируем в Telegram-safe mp4
+ optimized_video = base_video + "_telegram.mp4"
+ optimize_for_telegram(actual_video, optimized_video)
+ for path in [video_path, actual_video]:
+ if os.path.exists(path): os.remove(path)
+ os.rename(optimized_video, video_path)
+
+ # Аудио
+ audio_opts = {
+ "format": "bestaudio/best",
+ "outtmpl": audio_path.replace('.mp3', ''),
+ "cookiefile": cookies if cookies and os.path.exists(cookies) else None,
+ "postprocessors": [{
+ "key": "FFmpegExtractAudio",
+ "preferredcodec": "mp3",
+ "preferredquality": DEFAULT_TIKTOK_AUDIO_QUALITY,
+ }],
+ "quiet": True,
+ "no_warnings": False,
+ "concurrent_fragment_downloads": 4,
+ "http_chunk_size": 10485760,
+ }
+
+ with yt_dlp.YoutubeDL(audio_opts) as ydl:
+ ydl.download([url])
+
+ base_audio = audio_path.rsplit('.', 1)[0]
+ actual_audio = None
+ for ext in ['', '.mp3', '.m4a', '.opus']:
+ candidate = base_audio + ext
+ if os.path.exists(candidate):
+ actual_audio = candidate
+ break
+ if not actual_audio:
+ raise Exception("TikTok audio file not created")
+
+ if actual_audio != audio_path:
+ optimized_audio = base_audio + "_telegram.mp3"
+ optimize_for_telegram_audio(actual_audio, optimized_audio)
+ if os.path.exists(audio_path): os.remove(audio_path)
+ os.rename(optimized_audio, audio_path)
-# ---------------- PLAYLIST DOWNLOAD ----------------
+# ---------------- Instagram: Video (1080p) ------
+def download_instagram_video(
+ url: str,
+ out_path: str,
+ cookies: str | None,
+ cancel_event: threading.Event,
+ progress_cb,
+):
+ ydl_opts = {
+ "format": f"bestvideo[height<={DEFAULT_INSTAGRAM_VIDEO_QUALITY}]+bestaudio/best",
+ "outtmpl": out_path,
+ "cookiefile": cookies if cookies and os.path.exists(cookies) else None,
+ "progress_hooks": [_progress_hook(cancel_event, progress_cb)],
+ "quiet": True,
+ "no_warnings": False,
+ "concurrent_fragment_downloads": 4,
+ "http_chunk_size": 10485760,
+ }
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+ ydl.download([url])
+
+ base = out_path.rsplit('.', 1)[0]
+ actual = None
+ for ext in ['', '.mp4', '.mov', '.webm']:
+ candidate = base + ext
+ if os.path.exists(candidate):
+ actual = candidate
+ break
+ if not actual:
+ raise Exception("Instagram video not created")
+
+ optimized = base + "_telegram.mp4"
+ optimize_for_telegram(actual, optimized)
+ if os.path.exists(out_path): os.remove(out_path)
+ os.rename(optimized, out_path)
+
+
+# ---------------- Audio helper (синхронная) ------
+def optimize_for_telegram_audio(input_path: str, output_path: str) -> None:
+ """Конвертирует аудио в Telegram-safe mp3"""
+ cmd = [
+ 'ffmpeg',
+ '-i', input_path,
+ '-c:a', 'aac',
+ '-b:a', '192k',
+ '-loglevel', 'error',
+ '-y',
+ output_path
+ ]
+ subprocess.run(cmd, capture_output=True, text=True, timeout=600)
+
+
+# ---------------- Playlist ------
def download_playlist_videos(
playlist_info: dict,
output_dir: str,
@@ -185,14 +285,11 @@ def download_playlist_videos(
cancel_event: threading.Event,
progress_cb,
) -> List[str]:
- """Скачивает все видео из плейлиста"""
downloaded_files = []
-
ydl_opts = {
"format": "best[height<=1080]/best",
"outtmpl": os.path.join(output_dir, "%(title)s [%(id)s].%(ext)s"),
- "merge_output_format": "mp4",
- "cookiefile": cookies,
+ "cookiefile": cookies if cookies and os.path.exists(cookies) else None,
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
"quiet": True,
"no_warnings": True,
@@ -206,92 +303,20 @@ def download_playlist_videos(
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
entries = playlist_info.get('entries', [])
- total_videos = len(entries)
-
- logger.info(f"Starting playlist download with {total_videos} videos")
-
for i, entry in enumerate(entries, 1):
if cancel_event.is_set():
raise DownloadCancelled("Cancelled by user")
-
if not entry.get('url'):
- logger.warning(f"Entry {i} has no URL, skipping")
continue
-
- video_url = entry['url']
- video_title = entry.get('title', f'Video {i}')
-
- logger.info(f"Downloading video {i}/{total_videos}: {video_title}")
-
try:
- # Загружаем видео
- info = ydl.extract_info(video_url, download=True)
-
- if not info:
- logger.error(f"Failed to extract info for video {i}")
- continue
-
- # Получаем имя фактически созданного файла
- filename = ydl.prepare_filename(info)
-
- # Проверяем существование файла
- if os.path.exists(filename):
- downloaded_files.append(filename)
- logger.info(f"Successfully downloaded: {os.path.basename(filename)}")
- else:
- # Ищем файл с другим расширением
- base_name = filename.rsplit('.', 1)[0]
- for ext in ['.mp4', '.mkv', '.webm', '.flv']:
- alt_path = base_name + ext
- if os.path.exists(alt_path):
- downloaded_files.append(alt_path)
- logger.info(f"Found file with extension {ext}: {os.path.basename(alt_path)}")
- break
- else:
- logger.error(f"File not found for video {i}")
-
+ info = ydl.extract_info(entry['url'], download=True)
+ if info:
+ filename = ydl.prepare_filename(info)
+ if os.path.exists(filename):
+ downloaded_files.append(filename)
except Exception as e:
- logger.error(f"Error downloading video {i} ({video_title}): {e}")
- continue
-
- logger.info(f"Playlist download complete. Downloaded {len(downloaded_files)} files")
+ logger.error(f"Error downloading video {i}: {e}")
return downloaded_files
-
except Exception as e:
- logger.error(f"Error downloading playlist: {e}")
- raise
-
-
-# ---------------- STANDARD AUDIO ----------------
-
-def download_audio(
- url: str,
- out_path: str,
- cookies: str | None,
- cancel_event: threading.Event,
- progress_cb,
-):
- ydl_opts = {
- "format": "bestaudio/best",
- "outtmpl": out_path.replace('.mp3', ''),
- "cookiefile": cookies,
- "progress_hooks": [_progress_hook(cancel_event, progress_cb)],
- "postprocessors": [
- {
- "key": "FFmpegExtractAudio",
- "preferredcodec": "mp3",
- "preferredquality": "192",
- },
- ],
- "quiet": True,
- "no_warnings": True,
- "extractor_args": {
- "tiktok": {"skip_impersonation": True},
- "instagram": {"skip_impersonation": True},
- },
- "concurrent_fragment_downloads": 4,
- "http_chunk_size": 10485760,
- }
-
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
- ydl.download([url])
\ No newline at end of file
+ logger.error(f"Playlist download error: {e}")
+ raise
\ No newline at end of file
diff --git a/bot/keyboards.py b/bot/keyboards.py
index 6a3274c..356b70e 100644
--- a/bot/keyboards.py
+++ b/bot/keyboards.py
@@ -1,14 +1,16 @@
+# keyboards.py
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
-def quality_keyboard():
- """Клавиатура выбора качества"""
+def youtube_quality_keyboard():
+ """YouTube/VK: выбор качества"""
keyboard = [
[
InlineKeyboardButton(text="📹 480p", callback_data="q:480"),
InlineKeyboardButton(text="📹 720p", callback_data="q:720"),
+ ],
+ [
InlineKeyboardButton(text="🎬 1080p", callback_data="q:1080"),
- InlineKeyboardButton(text="🎥 1440p", callback_data="q:1440"),
],
[
InlineKeyboardButton(text="🎧 Аудио", callback_data="audio"),
@@ -19,7 +21,7 @@ def quality_keyboard():
def cancel_keyboard():
- """Клавиатура с кнопкой отмены"""
+ """Клавиатура с кнопкой отмены (для плейлистов)"""
keyboard = [
[InlineKeyboardButton(text="⛔ Отменить загрузку", callback_data="cancel")]
]
@@ -27,7 +29,7 @@ def cancel_keyboard():
def playlist_keyboard(confirm=False):
- """Клавиатура для плейлистов"""
+ """Клавиатура для плейлистов YouTube"""
if confirm:
keyboard = [
[
@@ -45,45 +47,4 @@ def playlist_keyboard(confirm=False):
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel"),
]
]
- return InlineKeyboardMarkup(inline_keyboard=keyboard)
-
-
-def platform_keyboard(platform):
- """Клавиатура для Instagram"""
- keyboard = [
- [
- InlineKeyboardButton(text=f"🎬 Оригинальное качество", callback_data="original_quality"),
- ],
- [
- InlineKeyboardButton(text="📹 480p", callback_data="q:480"),
- InlineKeyboardButton(text="📹 720p", callback_data="q:720"),
- InlineKeyboardButton(text="🎬 1080p", callback_data="q:1080"),
- ],
- [
- InlineKeyboardButton(text="🎧 Аудио", callback_data="audio"),
- InlineKeyboardButton(text="❌ Отмена", callback_data="cancel"),
- ]
- ]
- return InlineKeyboardMarkup(inline_keyboard=keyboard)
-
-
-def tiktok_keyboard():
- """Специальная клавиатура для TikTok"""
- keyboard = [
- [
- InlineKeyboardButton(text="🎬 Оригинальное качество", callback_data="original_quality"),
- ],
- [
- InlineKeyboardButton(text="📹 480p", callback_data="q:480"),
- InlineKeyboardButton(text="📹 720p", callback_data="q:720"),
- InlineKeyboardButton(text="🎬 1080p", callback_data="q:1080"),
- ],
- [
- InlineKeyboardButton(text="🎧 Аудио из видео", callback_data="audio"),
- InlineKeyboardButton(text="🎵 Только звук (TikTok)", callback_data="tiktok_music"),
- ],
- [
- InlineKeyboardButton(text="❌ Отмена", callback_data="cancel"),
- ]
- ]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
\ No newline at end of file
diff --git a/bot/main.py b/bot/main.py
index 4575f2b..d48115a 100644
--- a/bot/main.py
+++ b/bot/main.py
@@ -1,14 +1,13 @@
+# main.py
import os
import asyncio
import threading
import logging
-import subprocess
import time
-from datetime import datetime
from typing import Optional
from aiogram import Bot, Dispatcher, F
-from aiogram.types import Message, CallbackQuery, FSInputFile, InputMediaDocument
+from aiogram.types import Message, CallbackQuery, FSInputFile
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
@@ -18,43 +17,25 @@ from config import (
CACHE_DIR,
TMP_DIR,
COOKIES_FILE,
- RATE_LIMIT_SECONDS,
- CACHE_MAX_AGE_DAYS,
- CACHE_MAX_SIZE_MB,
-)
-from keyboards import (
- quality_keyboard,
- cancel_keyboard,
- playlist_keyboard,
- platform_keyboard,
- tiktok_keyboard,
)
+from keyboards import youtube_quality_keyboard, cancel_keyboard, playlist_keyboard
from downloader import (
- download_video,
- download_audio,
- download_original_quality,
+ download_tiktok_video_and_audio,
+ download_instagram_video,
+ download_video,
+ download_audio,
download_playlist_videos,
- download_tiktok_music,
- add_metadata_to_audio, # Только для аудио
+ optimize_for_telegram,
DownloadCancelled,
)
from middleware import PrivateMiddleware
-from rate_limit import check_rate_limit
from info import extract_info, is_playlist, get_platform_info
from cache import cache_key, cache_path
from cleanup import cleanup_tmp
-# -------------------- init --------------------
-
-# Настройка логирования
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
-# Создаем директории
-os.makedirs(CACHE_DIR, exist_ok=True)
-os.makedirs(TMP_DIR, exist_ok=True)
-
-# Инициализация бота с локальным API (если используется)
if LOCAL_API_URL:
api_server = TelegramAPIServer.from_base(LOCAL_API_URL)
session = AiohttpSession(api=api_server)
@@ -63,8 +44,6 @@ else:
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
-
-# Регистрация middleware
private_middleware = PrivateMiddleware()
dp.message.middleware(private_middleware)
dp.callback_query.middleware(private_middleware)
@@ -73,126 +52,10 @@ USER_URLS: dict[int, str] = {}
USER_DATA: dict[int, dict] = {}
ACTIVE_DOWNLOADS: dict[int, dict] = {}
-# -------------------- cache cleaning --------------------
-
-def cleanup_old_cache():
- """
- Очищает старые файлы из кэша
- """
- try:
- current_time = time.time()
- deleted_count = 0
- deleted_size = 0
-
- # Удаляем файлы старше CACHE_MAX_AGE_DAYS дней
- if CACHE_MAX_AGE_DAYS > 0:
- cutoff_time = current_time - (CACHE_MAX_AGE_DAYS * 24 * 3600)
-
- for root, dirs, files in os.walk(CACHE_DIR):
- for file in files:
- file_path = os.path.join(root, file)
- try:
- file_mtime = os.path.getmtime(file_path)
- if file_mtime < cutoff_time:
- file_size = os.path.getsize(file_path)
- os.remove(file_path)
- deleted_count += 1
- deleted_size += file_size
- logger.info(f"Deleted old cache file: {file}")
- except Exception as e:
- logger.error(f"Error deleting file {file}: {e}")
-
- # Если указан максимальный размер кэша, проверяем его
- if CACHE_MAX_SIZE_MB > 0:
- total_size_mb = get_cache_size_mb()
- if total_size_mb > CACHE_MAX_SIZE_MB:
- # Сортируем файлы по времени изменения (старые первыми)
- files_with_mtime = []
- for root, dirs, files in os.walk(CACHE_DIR):
- for file in files:
- file_path = os.path.join(root, file)
- try:
- mtime = os.path.getmtime(file_path)
- size = os.path.getsize(file_path)
- files_with_mtime.append((file_path, mtime, size))
- except:
- pass
-
- # Сортируем по времени (старые первыми)
- files_with_mtime.sort(key=lambda x: x[1])
-
- # Удаляем старые файлы пока не достигнем лимита
- target_size_mb = CACHE_MAX_SIZE_MB * 0.8
-
- for file_path, mtime, size in files_with_mtime:
- if total_size_mb <= target_size_mb:
- break
-
- try:
- os.remove(file_path)
- deleted_count += 1
- deleted_size += size
- total_size_mb -= size / (1024 * 1024)
- logger.info(f"Deleted cache file to free space: {os.path.basename(file_path)}")
- except Exception as e:
- logger.error(f"Error deleting file {file_path}: {e}")
-
- if deleted_count > 0:
- logger.info(f"Cache cleanup: deleted {deleted_count} files, freed {deleted_size / (1024*1024):.2f} MB")
- else:
- logger.info("Cache cleanup: no files to delete")
-
- except Exception as e:
- logger.error(f"Error in cache cleanup: {e}")
-
-
-def get_cache_size_mb():
- """Возвращает размер кэша в МБ"""
- total_size = 0
- for root, dirs, files in os.walk(CACHE_DIR):
- for file in files:
- file_path = os.path.join(root, file)
- try:
- total_size += os.path.getsize(file_path)
- except:
- pass
- return total_size / (1024 * 1024)
-
-
-async def scheduled_cache_cleanup():
- """Периодическая очистка кэша"""
- # Запускаем очистку сразу при старте
- logger.info("Running initial cache cleanup...")
- await asyncio.to_thread(cleanup_old_cache)
-
- last_cleanup_day = datetime.now().day
-
- while True:
- try:
- now = datetime.now()
- current_day = now.day
-
- # Запускаем очистку если наступил новый день И сейчас между 3:00 и 3:59
- if current_day != last_cleanup_day and now.hour == 3:
- logger.info("Starting scheduled cache cleanup...")
- await asyncio.to_thread(cleanup_old_cache)
- last_cleanup_day = current_day
-
- # Ждем 5 минут перед следующей проверкой
- await asyncio.sleep(300)
-
- except Exception as e:
- logger.error(f"Error in scheduled cleanup: {e}")
- await asyncio.sleep(60)
-
-
-# -------------------- helpers --------------------
-
def render_bar(percent: float, size: int = 10) -> str:
filled = int(size * percent / 100)
return "█" * filled + "░" * (size - filled)
-
def make_progress_cb(loop, message):
last_percent = {"value": 0}
last_update = {"time": 0}
@@ -201,519 +64,195 @@ def make_progress_cb(loop, message):
try:
downloaded = d.get("downloaded_bytes", 0)
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 1
-
if total <= 0:
return
-
percent = min(100, downloaded * 100 / total)
-
current_time = time.time()
if percent - last_percent["value"] < 2 and current_time - last_update["time"] < 2:
return
-
last_percent["value"] = percent
last_update["time"] = current_time
-
- bar = render_bar(percent)
eta = d.get("eta")
-
- if eta is None or eta == "?":
- eta_str = "?"
- else:
- try:
- eta_str = str(int(float(eta)))
- except (ValueError, TypeError):
- eta_str = "?"
-
- text = (
- "⏬ Загрузка\n"
- f"{bar} {percent:.0f}%\n"
- f"⏱ Осталось: {eta_str} сек"
- )
-
- await message.edit_text(
- text,
- reply_markup=cancel_keyboard(),
- parse_mode="HTML"
- )
+ eta_str = str(int(float(eta))) if eta and eta != "?" else "?"
+ bar = render_bar(percent)
+ text = f"⏬ Загрузка\n{bar} {percent:.0f}%\n⏱ Осталось: {eta_str} сек"
+ await message.edit_text(text, reply_markup=cancel_keyboard() if "playlist" in message.text.lower() else None, parse_mode="HTML")
except Exception as e:
- logger.error(f"Error updating progress: {e}")
+ logger.error(f"Progress error: {e}")
def cb(d):
asyncio.run_coroutine_threadsafe(update(d), loop)
-
return cb
+# ---------------- TikTok + Instagram (автозагрузка) ----------------
+async def process_tiktok_auto(message: Message, user_id: int, url: str):
+ status = await message.answer("🎵 Загружаю TikTok (видео + аудио)...", parse_mode="HTML")
-def make_playlist_progress_cb(loop, message, total_videos: int):
- current_video = {"value": 0}
- last_update = {"time": 0}
+ key_video = cache_key(url, "tiktok_video", audio=False)
+ key_audio = cache_key(url, "tiktok_audio", audio=True)
- async def update(d):
+ video_cache = cache_path(CACHE_DIR, key_video, "mp4")
+ tmp_video = cache_path(TMP_DIR, key_video, "mp4")
+ os.makedirs(os.path.dirname(tmp_video), exist_ok=True)
+
+ audio_cache = cache_path(CACHE_DIR, key_audio, "mp3")
+ tmp_audio = cache_path(TMP_DIR, key_audio, "mp3")
+ os.makedirs(os.path.dirname(tmp_audio), exist_ok=True)
+
+ if os.path.exists(video_cache) and os.path.exists(audio_cache):
+ await status.edit_text("📤 Отправляю из кэша...", parse_mode="HTML")
try:
- current_time = time.time()
- if current_time - last_update["time"] < 3:
- return
-
- last_update["time"] = current_time
-
- if d.get("status") == "finished":
- current_video["value"] += 1
-
- text = (
- f"📁 Загрузка плейлиста\n"
- f"📹 Видео: {current_video['value']}/{total_videos}\n"
- f"⏳ Продолжаем загрузку..."
- )
-
- await message.edit_text(
- text,
- parse_mode="HTML"
- )
+ await message.answer_video(FSInputFile(video_cache), supports_streaming=True)
+ await message.answer_audio(FSInputFile(audio_cache))
+ size_mb = (os.path.getsize(video_cache) + os.path.getsize(audio_cache)) / (1024 * 1024)
+ await message.answer(f"✅ Готово! (TikTok)\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
except Exception as e:
- logger.error(f"Error updating playlist progress: {e}")
+ logger.error(f"TikTok cache error: {e}")
+ await status.edit_text("❌ Ошибка при отправке")
+ finally:
+ cleanup_tmp(TMP_DIR)
+ return
- def cb(d):
- asyncio.run_coroutine_threadsafe(update(d), loop)
+ cancel_event = threading.Event()
+ ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event}
+ loop = asyncio.get_running_loop()
+ progress_cb = make_progress_cb(loop, status)
- return cb
-
-
-def optimize_for_telegram(input_path: str, output_path: str) -> bool:
- """
- Оптимизирует видео для телеграма (без метаданных)
- """
try:
- import shutil
-
- # Проверяем размер файла
- file_size_mb = os.path.getsize(input_path) / (1024 * 1024)
-
- # Если файл больше 50 МБ, сжимаем его
- if file_size_mb > 50:
- crf = 28
- else:
- crf = 23
-
- cmd = [
- 'ffmpeg',
- '-i', input_path,
- '-c:v', 'libx264',
- '-preset', 'fast',
- '-crf', str(crf),
- '-c:a', 'aac',
- '-b:a', '128k',
- '-movflags', '+faststart',
- '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
- '-y',
- output_path
- ]
-
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- timeout=300
+ await asyncio.to_thread(
+ download_tiktok_video_and_audio,
+ url,
+ tmp_video,
+ tmp_audio,
+ COOKIES_FILE,
+ cancel_event,
+ progress_cb,
)
-
- if result.returncode != 0:
- logger.error(f"FFmpeg error: {result.stderr}")
- shutil.copy2(input_path, output_path)
- return False
-
- return True
-
+ if cancel_event.is_set():
+ await status.edit_text("⛔ Загрузка отменена")
+ for p in [tmp_video, tmp_audio]:
+ if os.path.exists(p): os.remove(p)
+ return
+
+ # Уже готовы Telegram-safe mp4/m3, просто перемещаем
+ if os.path.exists(video_cache): os.remove(video_cache)
+ os.rename(tmp_video, video_cache)
+ if os.path.exists(audio_cache): os.remove(audio_cache)
+ os.rename(tmp_audio, audio_cache)
+
+ await status.edit_text("📤 Отправляю видео + аудио...", parse_mode="HTML")
+ await message.answer_video(FSInputFile(video_cache), supports_streaming=True)
+ await message.answer_audio(FSInputFile(audio_cache))
+ size_mb = (os.path.getsize(video_cache) + os.path.getsize(audio_cache)) / (1024 * 1024)
+ await message.answer(f"✅ Готово! (TikTok)\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
+
except Exception as e:
- logger.error(f"Error optimizing video: {e}")
- import shutil
- shutil.copy2(input_path, output_path)
- return False
+ logger.error(f"TikTok download error: {e}")
+ await status.edit_text(f"❌ Ошибка: {str(e)[:120]}")
+ finally:
+ ACTIVE_DOWNLOADS.pop(user_id, None)
+ cleanup_tmp(TMP_DIR)
+async def process_instagram_auto(message: Message, user_id: int, url: str):
+ status = await message.answer("📸 Загружаю Instagram (1080p)...", parse_mode="HTML")
-def get_video_info_for_audio(video_info: dict) -> dict:
- """Получает информацию о видео для метаданных аудио"""
- if not video_info:
- return {}
-
- metadata = {
- 'title': video_info.get('title', ''),
- 'artist': video_info.get('uploader', ''),
- 'album': video_info.get('title', '')[:50],
- 'url': video_info.get('webpage_url', ''),
- }
-
- # Для TikTok добавляем информацию о музыке
- if video_info.get('extractor') == 'TikTok':
- if video_info.get('track'):
- metadata['title'] = video_info.get('track', metadata['title'])
- if video_info.get('artist'):
- metadata['artist'] = video_info.get('artist', metadata['artist'])
-
- return metadata
+ key = cache_key(url, "instagram_video", audio=False)
+ final_cache = cache_path(CACHE_DIR, key, "mp4")
+ tmp_path = cache_path(TMP_DIR, key, "mp4")
+ os.makedirs(os.path.dirname(tmp_path), exist_ok=True)
+ if os.path.exists(final_cache):
+ await status.edit_text("📤 Отправляю из кэша...", parse_mode="HTML")
+ try:
+ await message.answer_video(FSInputFile(final_cache), supports_streaming=True)
+ size_mb = os.path.getsize(final_cache) / (1024 * 1024)
+ await message.answer(f"✅ Готово! (Instagram 1080p)\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
+ except Exception as e:
+ logger.error(f"Instagram cache error: {e}")
+ await status.edit_text("❌ Ошибка при отправке")
+ finally:
+ cleanup_tmp(TMP_DIR)
+ return
-# -------------------- handlers --------------------
+ cancel_event = threading.Event()
+ ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event}
+ loop = asyncio.get_running_loop()
+ progress_cb = make_progress_cb(loop, status)
+ try:
+ await asyncio.to_thread(
+ download_instagram_video,
+ url,
+ tmp_path,
+ COOKIES_FILE,
+ cancel_event,
+ progress_cb,
+ )
+ if cancel_event.is_set():
+ await status.edit_text("⛔ Загрузка отменена")
+ if os.path.exists(tmp_path): os.remove(tmp_path)
+ return
+
+ if os.path.exists(final_cache): os.remove(final_cache)
+ os.rename(tmp_path, final_cache)
+
+ await status.edit_text("📤 Отправляю видео...", parse_mode="HTML")
+ await message.answer_video(FSInputFile(final_cache), supports_streaming=True)
+ size_mb = os.path.getsize(final_cache) / (1024 * 1024)
+ await message.answer(f"✅ Готово! (Instagram 1080p)\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
+
+ except Exception as e:
+ logger.error(f"Instagram download error: {e}")
+ await status.edit_text(f"❌ Ошибка: {str(e)[:120]}")
+ finally:
+ ACTIVE_DOWNLOADS.pop(user_id, None)
+ cleanup_tmp(TMP_DIR)
+
+# ---------------- Handlers ----------------
@dp.message(F.text == "/start")
async def start(message: Message):
await message.answer(
"👋 Привет!\n\n"
- "📥 Я скачиваю видео и звук из видео по ссылке.\n\n"
+ "📥 Скачиваю видео и аудио по ссылке.\n\n"
"✨ Новые возможности:\n"
- "• 🎬 Оригинальное качество (Instagram, TikTok)\n"
+ "• 🎬 TikTok: автоматически видео + аудио (Telegram-safe)\n"
+ "• 📸 Instagram: автоматически видео в 1080p (Telegram-safe)\n"
+ "• 🎵 Аудио из любого видео\n"
"• 📁 Плейлисты YouTube\n"
- "• 🎵 Отдельный звук для TikTok\n"
- "• 🔄 Автоочистка кэша\n\n"
"👉 Просто отправь ссылку.",
parse_mode="HTML"
)
-
-@dp.message(F.text == "/help")
-async def help_command(message: Message):
- await message.answer(
- "📚 Справка по командам:\n\n"
- "• /start - Начать работу\n"
- "• /help - Эта справка\n"
- "• /cache_stats - Статистика кэша\n\n"
- "✨ Поддерживаемые платформы:\n"
- "• YouTube (видео и плейлисты)\n"
- "• TikTok (оригинальное качество + звук отдельно)\n"
- "• Instagram (Reels, видео, IGTV)\n"
- "• Twitter/X, Facebook, VK и другие\n\n"
- "🎯 Особенности:\n"
- "• 🎵 TikTok: можно скачать отдельно звук\n"
- "• 🎬 Instagram/TikTok: оригинальное качество\n"
- "• 📁 Плейлисты: загрузка всех видео\n"
- "• 🎧 Аудио: извлечение звука из любого видео",
- parse_mode="HTML"
- )
-
-
-@dp.message(F.text == "/cache_stats")
-async def cache_stats(message: Message):
- """Показывает статистику кэша"""
- try:
- total_size_mb = get_cache_size_mb()
- file_count = 0
-
- for root, dirs, files in os.walk(CACHE_DIR):
- file_count += len(files)
-
- await message.answer(
- f"📊 Статистика кэша:\n\n"
- f"📁 Файлов: {file_count}\n"
- f"💾 Размер: {total_size_mb:.2f} МБ\n"
- f"⏰ Очистка: ежедневно в 3:00",
- parse_mode="HTML"
- )
- except Exception as e:
- logger.error(f"Error getting cache stats: {e}")
- await message.answer("❌ Ошибка при получении статистики кэша")
-
-
@dp.message(F.text.startswith("http"))
async def handle_link(message: Message):
url = message.text.strip()
user_id = message.from_user.id
-
USER_URLS[user_id] = url
-
- # Определяем платформу
+
platform_info = await asyncio.to_thread(get_platform_info, url)
-
- # Для TikTok показываем специальное меню
+
if platform_info == "tiktok":
- USER_DATA[user_id] = {"platform": platform_info}
- await message.answer(
- f"🎵 Ссылка с TikTok\n\n"
- "Выберите что скачать:",
- reply_markup=tiktok_keyboard(),
- parse_mode="HTML"
- )
+ await process_tiktok_auto(message, user_id, url)
return
-
- # Проверяем, является ли ссылка плейлистом (кроме TikTok)
- try:
- is_playlist_url = await asyncio.to_thread(is_playlist, url)
- if is_playlist_url:
- USER_DATA[user_id] = {"is_playlist": True}
- await message.answer(
- "📁 Обнаружен плейлист!\n\n"
- "Выберите действие:",
- reply_markup=playlist_keyboard(),
- parse_mode="HTML"
- )
- return
- except:
- pass
-
- # Для Instagram предлагаем оригинальное качество
+
if platform_info == "instagram":
- USER_DATA[user_id] = {"platform": platform_info}
+ await process_instagram_auto(message, user_id, url)
+ return
+
+ if await asyncio.to_thread(is_playlist, url):
await message.answer(
- f"📸 Ссылка с Instagram\n\n"
- "Выберите качество загрузки:",
- reply_markup=platform_keyboard(platform_info),
+ "📁 Обнаружен плейлист!\n\n"
+ "Выберите действие:",
+ reply_markup=playlist_keyboard(),
parse_mode="HTML"
)
- else:
- # Для других платформ обычное меню
- await message.answer(
- "🔽 Выбери формат загрузки:",
- reply_markup=quality_keyboard(),
- parse_mode="HTML"
- )
-
-
-@dp.callback_query(F.data == "cancel")
-async def cancel_download(callback: CallbackQuery):
- user_id = callback.from_user.id
- data = ACTIVE_DOWNLOADS.get(user_id)
-
- if data:
- data["cancel"].set()
- ACTIVE_DOWNLOADS.pop(user_id, None)
- await callback.answer("⛔ Загрузка отменена", show_alert=True)
- else:
- await callback.answer("❌ Нет активной загрузки", show_alert=True)
-
-
-# ---------------- TIKTOK SPECIAL HANDLERS ----------------
-
-@dp.callback_query(F.data == "tiktok_music")
-async def handle_tiktok_music(callback: CallbackQuery):
- """Отдельная загрузка звука из TikTok"""
- await callback.answer()
- user_id = callback.from_user.id
- url = USER_URLS.get(user_id)
-
- if not url:
- await callback.message.answer("❌ Ссылка не найдена")
return
-
- if not check_rate_limit(user_id, RATE_LIMIT_SECONDS):
- await callback.message.answer("⏳ Подожди немного перед следующим запросом")
- return
-
- await callback.message.edit_reply_markup(reply_markup=None)
- status = await callback.message.answer("🎵 Извлекаю звук из TikTok…", parse_mode="HTML")
-
- key = cache_key(url, "tiktok_music", audio=True)
- final_path = cache_path(CACHE_DIR, key, "mp3")
- tmp_path = os.path.join(TMP_DIR, f"{key}.mp3")
-
- # Создаем директории если не существуют
- os.makedirs(TMP_DIR, exist_ok=True)
- os.makedirs(os.path.dirname(final_path), exist_ok=True)
-
- # Проверяем кэш
- if os.path.exists(final_path):
- await status.edit_text("📤 Отправляю звук из кэша…", parse_mode="HTML")
- try:
- await callback.message.answer_audio(FSInputFile(final_path))
- size_mb = os.path.getsize(final_path) / 1024 / 1024
- await callback.message.answer(
- f"✅ Готово! (Звук из TikTok)\n📦 Размер: {size_mb:.1f} МБ",
- parse_mode="HTML"
- )
- except Exception as e:
- logger.error(f"Error sending cached audio: {e}")
- await status.edit_text("❌ Ошибка при отправке звука")
- return
-
- cancel_event = threading.Event()
- ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event}
- loop = asyncio.get_running_loop()
- progress_cb = make_progress_cb(loop, status)
-
- try:
- # Загружаем звук из TikTok
- await asyncio.to_thread(
- download_tiktok_music,
- url,
- tmp_path,
- COOKIES_FILE,
- cancel_event,
- progress_cb,
- )
-
- # Проверяем, был ли отменен процесс
- if cancel_event.is_set():
- await status.edit_text("⛔ Загрузка отменена")
- if os.path.exists(tmp_path):
- os.remove(tmp_path)
- return
-
- # Проверяем, создан ли файл
- if not os.path.exists(tmp_path):
- raise Exception("Аудио файл не был создан")
-
- # Проверяем размер файла
- file_size = os.path.getsize(tmp_path)
- if file_size == 0:
- os.remove(tmp_path)
- raise Exception("Создан пустой аудио файл")
-
- # Добавляем метаданные к аудио
- try:
- info = await asyncio.to_thread(extract_info, url, COOKIES_FILE)
- if info:
- metadata = get_video_info_for_audio(info)
- await asyncio.to_thread(add_metadata_to_audio, tmp_path, tmp_path + "_meta.mp3", metadata)
- if os.path.exists(tmp_path + "_meta.mp3"):
- os.remove(tmp_path)
- os.rename(tmp_path + "_meta.mp3", tmp_path)
- except Exception as e:
- logger.error(f"Error adding metadata to audio: {e}")
-
- # Перемещаем файл в кэш
- if os.path.exists(final_path):
- os.remove(final_path)
- os.rename(tmp_path, final_path)
-
- except DownloadCancelled:
- await status.edit_text("⛔ Загрузка отменена")
- if os.path.exists(tmp_path):
- os.remove(tmp_path)
- return
- except Exception as e:
- logger.error(f"Error downloading TikTok music: {str(e)}")
- await status.edit_text(f"❌ Ошибка: {str(e)[:100]}")
- if os.path.exists(tmp_path):
- try:
- os.remove(tmp_path)
- except:
- pass
- return
- finally:
- ACTIVE_DOWNLOADS.pop(user_id, None)
-
- await status.edit_text("📤 Отправляю звук…", parse_mode="HTML")
-
- try:
- await callback.message.answer_audio(FSInputFile(final_path))
- size_mb = os.path.getsize(final_path) / 1024 / 1024
- await callback.message.answer(
- f"✅ Готово! (Звук из TikTok)\n📦 Размер: {size_mb:.1f} МБ",
- parse_mode="HTML"
- )
- except Exception as e:
- logger.error(f"Error sending audio: {e}")
- await status.edit_text("❌ Ошибка при отправке звука")
-
- cleanup_tmp(TMP_DIR)
-
-# ---------------- ORIGINAL QUALITY HANDLERS ----------------
-
-@dp.callback_query(F.data == "original_quality")
-async def handle_original_quality(callback: CallbackQuery):
- await callback.answer()
- user_id = callback.from_user.id
- url = USER_URLS.get(user_id)
-
- if not url:
- await callback.message.answer("❌ Ссылка не найдена")
- return
-
- if not check_rate_limit(user_id, RATE_LIMIT_SECONDS):
- await callback.message.answer("⏳ Подожди немного перед следующим запросом")
- return
-
- await callback.message.edit_reply_markup(reply_markup=None)
- status = await callback.message.answer("🎬 Загрузка в оригинальном качестве…", parse_mode="HTML")
-
- key = cache_key(url, "original", audio=False)
- final_path = cache_path(CACHE_DIR, key, "mp4")
- tmp_path = os.path.join(TMP_DIR, f"{key}.mp4")
-
- # Проверяем кэш
- if os.path.exists(final_path):
- await status.edit_text("📤 Отправляю файл из кэша…", parse_mode="HTML")
- try:
- await callback.message.answer_video(FSInputFile(final_path))
- size_mb = os.path.getsize(final_path) / 1024 / 1024
- await callback.message.answer(
- f"✅ Готово! (Оригинальное качество)\n📦 Размер: {size_mb:.1f} МБ",
- parse_mode="HTML"
- )
- except Exception as e:
- logger.error(f"Error sending cached file: {e}")
- await status.edit_text("❌ Ошибка при отправке файла")
- return
-
- cancel_event = threading.Event()
- ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event}
- loop = asyncio.get_running_loop()
- progress_cb = make_progress_cb(loop, status)
-
- try:
- await asyncio.to_thread(
- download_original_quality,
- url,
- tmp_path,
- COOKIES_FILE,
- cancel_event,
- progress_cb,
- )
-
- if cancel_event.is_set():
- if os.path.exists(tmp_path):
- os.remove(tmp_path)
- await status.edit_text("⛔ Загрузка отменена")
- return
-
- os.rename(tmp_path, final_path)
-
- except DownloadCancelled:
- await status.edit_text("⛔ Загрузка отменена")
- if os.path.exists(tmp_path):
- os.remove(tmp_path)
- return
- except Exception as e:
- logger.error(f"Error downloading original quality: {e}")
- await status.edit_text(
- "❌ Не удалось скачать в оригинальном качестве\n"
- "💡 Попробуй обычное качество"
- )
- if os.path.exists(tmp_path):
- os.remove(tmp_path)
- return
- finally:
- ACTIVE_DOWNLOADS.pop(user_id, None)
-
- await status.edit_text("📤 Отправляю видео…", parse_mode="HTML")
-
- try:
- await callback.message.answer_video(
- FSInputFile(final_path),
- supports_streaming=True
- )
- size_mb = os.path.getsize(final_path) / 1024 / 1024
- await callback.message.answer(
- f"✅ Готово! (Оригинальное качество)\n📦 Размер: {size_mb:.1f} МБ",
- parse_mode="HTML"
- )
- except Exception as e:
- logger.error(f"Error sending video: {e}")
- try:
- await callback.message.answer_document(FSInputFile(final_path))
- size_mb = os.path.getsize(final_path) / 1024 / 1024
- await callback.message.answer(
- f"✅ Отправлено как документ (Оригинальное качество)\n📦 Размер: {size_mb:.1f} МБ",
- parse_mode="HTML"
- )
- except Exception as e2:
- logger.error(f"Error sending as document: {e2}")
- await status.edit_text("❌ Ошибка при отправке файла")
- if os.path.exists(final_path):
- os.remove(final_path)
-
- cleanup_tmp(TMP_DIR)
-
-
-# ---------------- STANDARD VIDEO HANDLER ----------------
+ await message.answer(
+ "🔽 Выбери качество:",
+ reply_markup=youtube_quality_keyboard(),
+ parse_mode="HTML"
+ )
@dp.callback_query(F.data.startswith("q:"))
async def handle_video(callback: CallbackQuery):
@@ -726,28 +265,20 @@ async def handle_video(callback: CallbackQuery):
await callback.message.answer("❌ Ссылка не найдена")
return
- if not check_rate_limit(user_id, RATE_LIMIT_SECONDS):
- await callback.message.answer("⏳ Подожди немного перед следующим запросом")
- return
-
await callback.message.edit_reply_markup(reply_markup=None)
status = await callback.message.answer("🔍 Анализирую ссылку…", parse_mode="HTML")
key = cache_key(url, quality, audio=False)
final_path = cache_path(CACHE_DIR, key, "mp4")
- tmp_path = os.path.join(TMP_DIR, f"{key}.mp4")
- optimized_path = os.path.join(TMP_DIR, f"{key}_optimized.mp4")
+ tmp_path = cache_path(TMP_DIR, key, "mp4")
+ os.makedirs(os.path.dirname(tmp_path), exist_ok=True)
- # Проверяем кэш
if os.path.exists(final_path):
await status.edit_text("📤 Отправляю файл из кэша…", parse_mode="HTML")
try:
await callback.message.answer_video(FSInputFile(final_path))
- size_mb = os.path.getsize(final_path) / 1024 / 1024
- await callback.message.answer(
- f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ",
- parse_mode="HTML"
- )
+ size_mb = os.path.getsize(final_path) / (1024 * 1024)
+ await callback.message.answer(f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
except Exception as e:
logger.error(f"Error sending cached file: {e}")
await status.edit_text("❌ Ошибка при отправке файла")
@@ -768,78 +299,39 @@ async def handle_video(callback: CallbackQuery):
cancel_event,
progress_cb,
)
-
- # Проверяем, был ли отменен процесс
if cancel_event.is_set():
- if os.path.exists(tmp_path):
- os.remove(tmp_path)
+ if os.path.exists(tmp_path): os.remove(tmp_path)
await status.edit_text("⛔ Загрузка отменена")
return
-
- # Оптимизируем видео для телеграма (кроме оригинального качества)
- if quality != "original":
- await status.edit_text("⚙️ Оптимизирую видео для телеграма…", parse_mode="HTML")
- await asyncio.to_thread(optimize_for_telegram, tmp_path, optimized_path)
-
- # Удаляем исходный файл и используем оптимизированный
- if os.path.exists(tmp_path):
- os.remove(tmp_path)
-
- os.rename(optimized_path, final_path)
- else:
- # Для оригинального качества не оптимизируем
- os.rename(tmp_path, final_path)
-
+
+ if os.path.exists(final_path): os.remove(final_path)
+ os.rename(tmp_path, final_path)
+
except DownloadCancelled:
+ if os.path.exists(tmp_path): os.remove(tmp_path)
await status.edit_text("⛔ Загрузка отменена")
- for path in [tmp_path, optimized_path]:
- if os.path.exists(path):
- os.remove(path)
return
except Exception as e:
logger.error(f"Error downloading video: {e}")
- await status.edit_text(
- "❌ Не удалось скачать видео\n"
- "💡 Попробуй другое качество"
- )
- for path in [tmp_path, optimized_path]:
- if os.path.exists(path):
- os.remove(path)
+ if os.path.exists(tmp_path): os.remove(tmp_path)
+ await status.edit_text("❌ Не удалось скачать видео")
return
finally:
ACTIVE_DOWNLOADS.pop(user_id, None)
- await status.edit_text("📤 Отправляю видео…", parse_mode="HTML")
-
try:
- await callback.message.answer_video(
- FSInputFile(final_path),
- supports_streaming=True
- )
- size_mb = os.path.getsize(final_path) / 1024 / 1024
- await callback.message.answer(
- f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ",
- parse_mode="HTML"
- )
+ await callback.message.answer_video(FSInputFile(final_path), supports_streaming=True)
+ size_mb = os.path.getsize(final_path) / (1024 * 1024)
+ await callback.message.answer(f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
except Exception as e:
logger.error(f"Error sending video: {e}")
try:
await callback.message.answer_document(FSInputFile(final_path))
- size_mb = os.path.getsize(final_path) / 1024 / 1024
- await callback.message.answer(
- f"✅ Отправлено как документ\n📦 Размер: {size_mb:.1f} МБ",
- parse_mode="HTML"
- )
+ size_mb = os.path.getsize(final_path) / (1024 * 1024)
+ await callback.message.answer(f"✅ Отправлено как документ\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
except Exception as e2:
logger.error(f"Error sending as document: {e2}")
await status.edit_text("❌ Ошибка при отправке файла")
- if os.path.exists(final_path):
- os.remove(final_path)
-
- cleanup_tmp(TMP_DIR)
-
-
-# ---------------- STANDARD AUDIO HANDLER ----------------
@dp.callback_query(F.data == "audio")
async def handle_audio(callback: CallbackQuery):
@@ -851,38 +343,20 @@ async def handle_audio(callback: CallbackQuery):
await callback.message.answer("❌ Ссылка не найдена")
return
- if not check_rate_limit(user_id, RATE_LIMIT_SECONDS):
- await callback.message.answer("⏳ Подожди немного перед следующим запросом")
- return
-
await callback.message.edit_reply_markup(reply_markup=None)
status = await callback.message.answer("🎧 Подготовка аудио…", parse_mode="HTML")
- # Получаем информацию о видео для метаданных
- video_info = None
- try:
- video_info = await asyncio.to_thread(extract_info, url, COOKIES_FILE)
- except:
- pass
-
key = cache_key(url, "audio", audio=True)
final_path = cache_path(CACHE_DIR, key, "mp3")
- tmp_path = os.path.join(TMP_DIR, f"{key}.mp3")
+ tmp_path = cache_path(TMP_DIR, key, "mp3")
+ os.makedirs(os.path.dirname(tmp_path), exist_ok=True)
- # Создаем директории если не существуют
- os.makedirs(TMP_DIR, exist_ok=True)
- os.makedirs(os.path.dirname(final_path), exist_ok=True)
-
- # Проверяем кэш
if os.path.exists(final_path):
await status.edit_text("📤 Отправляю аудио из кэша…", parse_mode="HTML")
try:
await callback.message.answer_audio(FSInputFile(final_path))
- size_mb = os.path.getsize(final_path) / 1024 / 1024
- await callback.message.answer(
- f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ",
- parse_mode="HTML"
- )
+ size_mb = os.path.getsize(final_path) / (1024 * 1024)
+ await callback.message.answer(f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
except Exception as e:
logger.error(f"Error sending cached audio: {e}")
await status.edit_text("❌ Ошибка при отправке аудио")
@@ -902,104 +376,62 @@ async def handle_audio(callback: CallbackQuery):
cancel_event,
progress_cb,
)
-
- # Проверяем, был ли отменен процесс
if cancel_event.is_set():
await status.edit_text("⛔ Загрузка отменена")
- if os.path.exists(tmp_path):
- os.remove(tmp_path)
+ if os.path.exists(tmp_path): os.remove(tmp_path)
return
-
- # Проверяем, создан ли файл
+
if not os.path.exists(tmp_path):
raise Exception("Аудио файл не был создан")
-
- # Проверяем размер файла
- file_size = os.path.getsize(tmp_path)
- if file_size == 0:
+ if os.path.getsize(tmp_path) == 0:
os.remove(tmp_path)
raise Exception("Создан пустой аудио файл")
-
- # Добавляем метаданные к аудио
- if video_info:
- metadata = get_video_info_for_audio(video_info)
- await asyncio.to_thread(add_metadata_to_audio, tmp_path, tmp_path + "_meta.mp3", metadata)
- if os.path.exists(tmp_path + "_meta.mp3"):
- os.remove(tmp_path)
- os.rename(tmp_path + "_meta.mp3", tmp_path)
-
- # Перемещаем файл в кэш
- if os.path.exists(final_path):
- os.remove(final_path)
+
+ if os.path.exists(final_path): os.remove(final_path)
os.rename(tmp_path, final_path)
-
+
except DownloadCancelled:
await status.edit_text("⛔ Загрузка отменена")
- if os.path.exists(tmp_path):
- os.remove(tmp_path)
+ if os.path.exists(tmp_path): os.remove(tmp_path)
return
except Exception as e:
logger.error(f"Error downloading audio: {str(e)}")
await status.edit_text(f"❌ Ошибка: {str(e)[:100]}")
- if os.path.exists(tmp_path):
- try:
- os.remove(tmp_path)
- except:
- pass
+ if os.path.exists(tmp_path): os.remove(tmp_path)
return
finally:
ACTIVE_DOWNLOADS.pop(user_id, None)
await status.edit_text("📤 Отправляю аудио…", parse_mode="HTML")
-
try:
await callback.message.answer_audio(FSInputFile(final_path))
- size_mb = os.path.getsize(final_path) / 1024 / 1024
- await callback.message.answer(
- f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ",
- parse_mode="HTML"
- )
+ size_mb = os.path.getsize(final_path) / (1024 * 1024)
+ await callback.message.answer(f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
except Exception as e:
logger.error(f"Error sending audio: {e}")
await status.edit_text("❌ Ошибка при отправке аудио")
- cleanup_tmp(TMP_DIR)
-
-
-# ---------------- PLAYLIST HANDLERS ----------------
-
+# ---------------- Playlist Handlers ----------------
@dp.callback_query(F.data == "playlist_all")
async def handle_playlist_all(callback: CallbackQuery):
await callback.answer()
user_id = callback.from_user.id
url = USER_URLS.get(user_id)
-
if not url:
await callback.message.answer("❌ Ссылка не найдена")
return
-
- if not check_rate_limit(user_id, RATE_LIMIT_SECONDS * 3):
- await callback.message.answer("⏳ Подожди немного перед следующим запросом")
- return
-
await callback.message.edit_reply_markup(reply_markup=None)
status = await callback.message.answer("📁 Анализирую плейлист…", parse_mode="HTML")
-
try:
- # Получаем информацию о плейлисте
from info import get_playlist_info
playlist_info = await asyncio.to_thread(get_playlist_info, url)
-
if not playlist_info or 'entries' not in playlist_info:
await status.edit_text("❌ Не удалось получить информацию о плейлисте")
return
-
video_count = len(playlist_info['entries'])
if video_count == 0:
await status.edit_text("❌ Плейлист пуст")
return
-
- # Подтверждение для больших плейлистов
if video_count > 10:
await callback.message.answer(
f"⚠️ Внимание!\n\n"
@@ -1011,58 +443,43 @@ async def handle_playlist_all(callback: CallbackQuery):
)
USER_DATA[user_id] = {"playlist_info": playlist_info, "status_message": status}
return
-
await download_playlist_confirm(callback, user_id, playlist_info, status)
-
except Exception as e:
logger.error(f"Error analyzing playlist: {e}")
await status.edit_text("❌ Ошибка при анализе плейлиста")
-
@dp.callback_query(F.data == "playlist_confirm_yes")
async def handle_playlist_confirm(callback: CallbackQuery):
await callback.answer()
user_id = callback.from_user.id
-
data = USER_DATA.get(user_id, {})
playlist_info = data.get("playlist_info")
status = data.get("status_message")
-
if not playlist_info or not status:
await callback.message.answer("❌ Данные плейлиста не найдены")
return
-
await callback.message.edit_reply_markup(reply_markup=None)
await download_playlist_confirm(callback, user_id, playlist_info, status)
-
async def download_playlist_confirm(callback, user_id, playlist_info, status):
- """Загружает плейлист после подтверждения"""
+ import uuid
+ import shutil
+ video_count = len(playlist_info['entries'])
+ playlist_title = playlist_info.get('title', 'Плейлист')
+ await status.edit_text(
+ f"📁 Начинаю загрузку плейлиста\n\n"
+ f"🎬 Название: {playlist_title}\n"
+ f"📹 Видео: {video_count}\n"
+ f"⏳ Подготовка...",
+ parse_mode="HTML"
+ )
+ cancel_event = threading.Event()
+ ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event}
+ loop = asyncio.get_running_loop()
+ progress_cb = make_progress_cb(loop, status)
+ playlist_dir = os.path.join(TMP_DIR, f"playlist_{uuid.uuid4().hex[:8]}")
+ os.makedirs(playlist_dir, exist_ok=True)
try:
- import uuid
- import shutil
-
- video_count = len(playlist_info['entries'])
- playlist_title = playlist_info.get('title', 'Плейлист')
-
- await status.edit_text(
- f"📁 Начинаю загрузку плейлиста\n\n"
- f"🎬 Название: {playlist_title}\n"
- f"📹 Видео: {video_count}\n"
- f"⏳ Подготовка...",
- parse_mode="HTML"
- )
-
- cancel_event = threading.Event()
- ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event}
- loop = asyncio.get_running_loop()
- progress_cb = make_playlist_progress_cb(loop, status, video_count)
-
- # Создаем временную директорию для плейлиста
- playlist_dir = os.path.join(TMP_DIR, f"playlist_{uuid.uuid4().hex[:8]}")
- os.makedirs(playlist_dir, exist_ok=True)
-
- # Загружаем плейлист
downloaded_files = await asyncio.to_thread(
download_playlist_videos,
playlist_info,
@@ -1071,50 +488,34 @@ async def download_playlist_confirm(callback, user_id, playlist_info, status):
cancel_event,
progress_cb
)
-
if cancel_event.is_set():
await status.edit_text("⛔ Загрузка плейлиста отменена")
shutil.rmtree(playlist_dir, ignore_errors=True)
return
-
if not downloaded_files:
await status.edit_text("❌ Не удалось загрузить видео из плейлиста")
shutil.rmtree(playlist_dir, ignore_errors=True)
return
-
- # Отправляем файлы частями
await status.edit_text(f"📤 Отправляю {len(downloaded_files)} видео…", parse_mode="HTML")
-
- # Сортируем файлы по размеру (сначала маленькие)
downloaded_files.sort(key=lambda x: os.path.getsize(x))
-
sent_count = 0
for i, file_path in enumerate(downloaded_files, 1):
if cancel_event.is_set():
break
-
try:
file_name = os.path.basename(file_path)
- # Убираем расширение для имени файла
display_name = os.path.splitext(file_name)[0]
-
- # Отправляем файл с номером в подписи
await callback.message.answer_document(
FSInputFile(file_path),
caption=f"🎬 Видео {i}/{len(downloaded_files)}\n📁 {display_name[:50]}"
)
sent_count += 1
-
- # Небольшая пауза между отправками
await asyncio.sleep(1)
-
except Exception as e:
logger.error(f"Error sending file {file_path}: {e}")
continue
-
total_size = sum(os.path.getsize(f) for f in downloaded_files)
total_size_mb = total_size / (1024 * 1024)
-
await callback.message.answer(
f"✅ Плейлист загружен!\n\n"
f"📁 Видео в плейлисте: {video_count}\n"
@@ -1123,10 +524,7 @@ async def download_playlist_confirm(callback, user_id, playlist_info, status):
f"🎬 Название: {playlist_title}",
parse_mode="HTML"
)
-
- # Очищаем временные файлы
shutil.rmtree(playlist_dir, ignore_errors=True)
-
except DownloadCancelled:
await status.edit_text("⛔ Загрузка плейлиста отменена")
except Exception as e:
@@ -1136,69 +534,55 @@ async def download_playlist_confirm(callback, user_id, playlist_info, status):
ACTIVE_DOWNLOADS.pop(user_id, None)
cleanup_tmp(TMP_DIR)
-
@dp.callback_query(F.data == "playlist_confirm_no")
async def handle_playlist_cancel(callback: CallbackQuery):
await callback.answer("Отменено", show_alert=True)
- await callback.message.edit_reply_markup(reply_markup=None)
-
@dp.callback_query(F.data == "playlist_first")
async def handle_playlist_first(callback: CallbackQuery):
await callback.answer()
user_id = callback.from_user.id
url = USER_URLS.get(user_id)
-
if not url:
await callback.message.answer("❌ Ссылка не найдена")
return
-
- # Получаем первое видео из плейлиста
try:
from info import get_first_video_from_playlist
video_url = await asyncio.to_thread(get_first_video_from_playlist, url)
-
if not video_url:
await callback.message.answer("❌ Не удалось получить видео из плейлиста")
return
-
- # Сохраняем новую ссылку и показываем меню выбора качества
USER_URLS[user_id] = video_url
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer(
"🔽 Выбери формат загрузки для первого видео:",
- reply_markup=quality_keyboard(),
+ reply_markup=youtube_quality_keyboard(),
parse_mode="HTML"
)
-
except Exception as e:
logger.error(f"Error getting first video: {e}")
await callback.message.answer("❌ Ошибка при получении видео из плейлиста")
-
-# -------------------- entrypoint --------------------
+@dp.callback_query(F.data == "cancel")
+async def cancel_download(callback: CallbackQuery):
+ user_id = callback.from_user.id
+ data = ACTIVE_DOWNLOADS.get(user_id)
+ if data:
+ data["cancel"].set()
+ ACTIVE_DOWNLOADS.pop(user_id, None)
+ await callback.answer("⛔ Загрузка отменена", show_alert=True)
+ else:
+ await callback.answer("❌ Нет активной загрузки", show_alert=True)
async def main():
- # Очистка временных файлов при старте
cleanup_tmp(TMP_DIR)
-
- # Запускаем задачу очистки кэша в фоне
- cleanup_task = asyncio.create_task(scheduled_cache_cleanup())
-
try:
await dp.start_polling(bot)
- finally:
- # Отменяем задачу очистки при выходе
- cleanup_task.cancel()
- try:
- await cleanup_task
- except asyncio.CancelledError:
- pass
+ except KeyboardInterrupt:
+ logger.info("Bot stopped by user")
if __name__ == "__main__":
try:
asyncio.run(main())
- except KeyboardInterrupt:
- logger.info("Bot stopped by user")
except Exception as e:
logger.error(f"Fatal error: {e}")
\ No newline at end of file
diff --git a/wireguard/wg_confs/wg0.conf b/wireguard/wg_confs/wg0.conf
new file mode 100644
index 0000000..ee3c0b9
--- /dev/null
+++ b/wireguard/wg_confs/wg0.conf
@@ -0,0 +1,11 @@
+[Interface]
+Address = 10.7.0.13/24
+DNS = 1.0.0.1, 1.1.1.1, 1.0.0.1, 1.1.1.1
+PrivateKey = 8GTpMYtpOhWDp6AKXxukwFRsiwvERf2UVCjCrdB+M10=
+
+[Peer]
+PublicKey = ekvrE4SMgydQhkWdWgJazlrILSEAlRbwCI4KcXUQv3A=
+PresharedKey = eaCOqeiYdfso4v7/ucrvFzB1MtDnXy1yTiujL08jJIE=
+AllowedIPs = 0.0.0.0/0, ::/0
+Endpoint = 109.107.170.183:51821
+PersistentKeepalive = 25
\ No newline at end of file