17 Commits

Author SHA1 Message Date
9126c55502 Apply changes to downloader.py for async support and progress tracking 2026-06-14 23:32:43 +07:00
a5df66d371 Merge pull request 'update_0.2' (#1) from update_0.2 into main
Reviewed-on: http://10.0.1.48:3010/smolkik_adm/downloads-all-bot/pulls/1
2026-02-19 03:22:57 +00:00
c49e080d41 Resolve merge conflicts 2026-02-19 10:15:04 +07:00
089e90b36a bug vix4 2026-02-18 23:30:11 +07:00
5b0152e47c bug vix3 2026-02-18 22:34:53 +07:00
817819c382 bug vix2 2026-02-18 18:58:25 +07:00
525d52cc76 bug vix1 2026-02-18 17:17:55 +07:00
c7ef7cdd57 update to 2.0 2026-02-18 14:05:36 +07:00
7a2e73939f Удаляю wg0.conf из истории репозитория 2026-01-06 01:52:09 +07:00
effc2044f4 add ./downloads/ 2026-01-06 01:46:30 +07:00
6042be7343 Remove media files from git and add to .gitignore 2026-01-06 01:00:25 +07:00
16b1d1e0a2 update 2026-01-06 00:44:56 +07:00
705acccf8f исправление бага при отправке плейлиста 2026-01-05 21:41:23 +07:00
493bc0baeb исправление бага при отправке плейлиста 2026-01-05 21:39:26 +07:00
c1fa52fa33 Добавление функций, описание ко всем видео файлам, cкачивать отдельно звук из TikTok с информацией о музыке, cохранять метаданные в файлы 2026-01-05 21:27:23 +07:00
a1c257695e Добавление функций, скачивание плейлистов + скачивание видео тикток и инстаграмм 2026-01-05 20:55:29 +07:00
111f40d67a init 2026-01-05 20:37:55 +07:00
12 changed files with 840 additions and 521 deletions

BIN
.DS_Store vendored

Binary file not shown.

178
.gitignore vendored
View File

@@ -1,5 +1,177 @@
cookies.txt
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sbtarget/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
*.mp4
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Медиа файлы
*.mp3
wireguard/wg_confs/wg0.conf
*.mp4
*.mp4.info.json
*.mp3.info.json
*.jpg
*.jpeg
*.png
*.gif
*.mov
*.avi
*.mkv
*.webm
*.flv
# Кэш и временные файлы бота
/cache/
/tmp/
downloads/
*.cache
*.tmp
# Конфигурационные файлы с чувствительными данными
config.py
secrets.py
cookies.txt
*.key
*.pem
*.crt
# Логи
*.log
logs/
# Операционные системы
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Редакторы
.vscode/
.idea/
*.swp
*.swo
*~
**.wg0.conf

0
CHANGELOG.md Normal file
View File

View File

@@ -1,9 +1,13 @@
# cache.py
import hashlib
import os
def cache_key(url, quality, audio=False):
raw = f"{url}:{quality}:{audio}"
return hashlib.sha256(raw.encode()).hexdigest()
def cache_key(url: str, quality: str, audio: bool = False) -> str:
key_str = f"{url}_{quality}_{audio}"
return hashlib.sha256(key_str.encode()).hexdigest()
def cache_path(cache_dir, key, ext):
return os.path.join(cache_dir, f"{key}.{ext}")
def cache_path(cache_dir: str, key: str, extension: str) -> str:
subdir = key[:2]
full_dir = os.path.join(cache_dir, subdir)
os.makedirs(full_dir, exist_ok=True)
return os.path.join(full_dir, f"{key}.{extension}")

View File

@@ -1,9 +1,53 @@
# cleanup.py
import os
import time
def cleanup_tmp(path, max_age=3600):
def get_size_in_mb(path: str) -> float:
total_size = 0
for dirpath, _, filenames in os.walk(path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
return total_size / (1024 ** 2)
def clear_old_files(directory: str, max_age_seconds: int = 3600):
now = time.time()
for f in os.listdir(path):
p = os.path.join(path, f)
if os.path.isfile(p) and now - os.path.getmtime(p) > max_age:
os.remove(p)
for root, _, files in os.walk(directory):
for file in files:
file_path = os.path.join(root, file)
try:
if now - os.path.getmtime(file_path) > max_age_seconds:
os.remove(file_path)
except Exception as e:
pass
def ensure_cache_size(limit_mb: float, cleanup_directory: str):
while get_size_in_mb(cleanup_directory) > limit_mb:
oldest_mtime = float('inf')
oldest_file = None
for root, _, files in os.walk(cleanup_directory):
for file in files:
file_path = os.path.join(root, file)
try:
mtime = os.path.getmtime(file_path)
if mtime < oldest_mtime:
oldest_mtime = mtime
oldest_file = file_path
except Exception:
continue
if oldest_file:
try:
os.remove(oldest_file)
except Exception:
break
else:
break
def cleanup_tmp(path: str, max_age: int = 3600):
clear_old_files(path, max_age)

View File

@@ -1,33 +1,41 @@
# config.py
import os
from dotenv import load_dotenv
import logging
load_dotenv()
# Telegram
BOT_TOKEN = os.getenv("BOT_TOKEN")
LOCAL_API_URL = os.getenv("LOCAL_API_URL")
if not BOT_TOKEN:
raise RuntimeError("Не задан BOT_TOKEN в переменных окружения")
# Paths
LOCAL_API_URL = os.getenv("LOCAL_API_URL")
DOWNLOAD_DIR = "/downloads"
CACHE_DIR = "/downloads/cache"
TMP_DIR = "/downloads/tmp"
# yt-dlp
COOKIES_FILE = os.getenv("COOKIES_FILE")
if COOKIES_FILE and not os.path.exists(COOKIES_FILE):
print(f"Указанный файл кукисов {COOKIES_FILE} не найден")
# Limits
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 = без ограничения)
# Private mode
CACHE_MAX_AGE_DAYS = 7
CACHE_MAX_SIZE_MB = 4096
DEFAULT_TIKTOK_VIDEO_QUALITY = int(os.getenv("DEFAULT_TIKTOK_VIDEO_QUALITY", "1080"))
DEFAULT_TIKTOK_AUDIO_QUALITY = int(os.getenv("DEFAULT_TIKTOK_AUDIO_QUALITY", "192"))
DEFAULT_INSTAGRAM_VIDEO_QUALITY = int(os.getenv("DEFAULT_INSTAGRAM_VIDEO_QUALITY", "1080"))
ALLOWED_USERS = set(
int(uid.strip())
for uid in os.getenv("ALLOWED_USERS", "").split(",")
if uid.strip()
)
# Ensure directories exist
os.makedirs(CACHE_DIR, exist_ok=True)
os.makedirs(TMP_DIR, exist_ok=True)
logging.basicConfig(level=logging.INFO)

View File

@@ -1,89 +1,117 @@
# downloader.py
import yt_dlp
import threading
import os
import logging
import subprocess
import shutil
import asyncio
from typing import List, Optional, Callable
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"] == "downloading":
if d["status"] in ["downloading", "finished"] and progress_cb:
progress_cb(d)
return hook
# ---------------- VIDEO ----------------
def optimize_for_telegram(input_path: str, output_path: str) -> None:
"""
Асинхронная функция: конвертирует видео в Telegram-safe mp4
"""
file_size_mb = os.path.getsize(input_path) / (1024 ** 2)
crf = 28 if file_size_mb > 50 else 23
def download_video(
url: str,
quality: str,
out_path: str,
cookies: str | None,
cancel_event: threading.Event,
progress_cb,
):
ydl_opts = {
"format": quality,
"outtmpl": out_path,
"merge_output_format": "mp4",
"cookiefile": cookies,
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
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
]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.error(f"FFmpeg error: {stderr.decode()}")
shutil.copy2(input_path, output_path)
async def process_download(message, url: str, output_path: str, cookies_file: Optional[str] = None, cancel_event: threading.Event = None):
ydl_options = {
"format": f"bestvideo[height<={DEFAULT_INSTAGRAM_VIDEO_QUALITY}] + bestaudio/best",
"outtmpl": output_path,
"cookiefile": cookies_file if cookies_file and os.path.exists(cookies_file) else None,
"progress_hooks": [lambda d: asyncio.create_task(progress_callback(message, d))],
"quiet": True,
"no_warnings": True,
# Настройки для TikTok
"extractor_args": {
"tiktok": {
"skip_impersonation": True # Пропускаем имитацию если зависимости не установлены
}
},
"concurrent_fragment_downloads": 4,
"http_chunk_size": 1024 ** 2,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
async def progress_callback(msg, d):
if d["status"] == "downloading":
downloaded_bytes = d.get("downloaded_bytes", 0)
total_bytes = d.get("total_bytes_est", None)
if total_bytes:
percent_downloaded = (downloaded_bytes / total_bytes) * 100
download_speed_bps = d.get("speed", 1e-6)
estimated_remaining_time_seconds = max(0, (total_bytes - downloaded_bytes) / download_speed_bps)
# ---------------- AUDIO FROM VIDEO ----------------
progress_text = "<b>Downloading...</b>\n"
progress_text += f"Progress: {percent_downloaded:.2f}%\n"
progress_text += f"Downloaded: {downloaded_bytes / (1024 ** 2):.2f} MB\n"
progress_text += f"Total Size: {total_bytes / (1024 ** 2):.2f} MB\n"
progress_text += f"[{d.get('filename')}] @ {int(download_speed_bps) / (1024 ** 2)} MB/s\n"
progress_text += f"ETA: {estimated_remaining_time_seconds:.1f} сек."
else:
progress_text = "<b>Downloading...</b>\n"
progress_text += f"Downloaded Bytes: {downloaded_bytes}\n"
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": "320",
},
{"key": "FFmpegMetadata"},
{"key": "EmbedThumbnail"},
],
"writethumbnail": True,
"quiet": True,
"no_warnings": True,
# Настройки для TikTok
"extractor_args": {
"tiktok": {
"skip_impersonation": True
}
},
}
try:
await msg.edit_text(progress_text, parse_mode="HTML")
except Exception as e:
logger.error(f"Error updating progress message: {e}")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
try:
with yt_dlp.YoutubeDL(ydl_options) as ydl:
ydl.download([url])
await optimize_for_telegram(input_path=output_path, output_path=output_path + "_optimized")
except Exception as e:
logger.error(f"Download or optimization error: {e}")
raise
async def download_ytdlp(url: str, ydl_options: dict):
try:
with yt_dlp.YoutubeDL(ydl_options) as ydl:
info = ydl.extract_info(url, download=True)
return info
except Exception as e:
logger.error(f"Error downloading from {url}: {e}")
raise

View File

@@ -1,12 +1,93 @@
import yt_dlp
import re
def extract_info(url, cookies):
opts = {
def extract_info(url: str, cookies_file: str = None) -> dict:
"""Извлекает информацию о видео"""
ydl_opts = {
"quiet": True,
"skip_download": True,
"no_warnings": True,
"cookiefile": cookies_file,
"extractor_args": {
"tiktok": {"skip_impersonation": True},
"instagram": {"skip_impersonation": True},
},
}
if cookies:
opts["cookiefile"] = cookies
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
return ydl.extract_info(url, download=False)
except:
return None
with yt_dlp.YoutubeDL(opts) as ydl:
return ydl.extract_info(url, download=False)
def is_playlist(url: str) -> bool:
"""Проверяет, является ли ссылка плейлистом"""
# Паттерны для плейлистов
playlist_patterns = [
r"youtube\.com/playlist", # YouTube плейлист
r"youtube\.com/watch.*list=", # YouTube видео из плейлиста
r"youtu\.be/.*list=", # Сокращенная ссылка YouTube
]
for pattern in playlist_patterns:
if re.search(pattern, url, re.IGNORECASE):
return True
# Используем yt-dlp для более точной проверки
try:
ydl_opts = {"quiet": True, "no_warnings": True}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False, process=False)
return info.get('_type') == 'playlist'
except:
return False
def get_platform_info(url: str) -> str:
"""Определяет платформу по ссылке"""
patterns = {
"youtube": r"(youtube\.com|youtu\.be)",
"tiktok": r"tiktok\.com",
"instagram": r"(instagram\.com|instagr\.am)",
"twitter": r"(twitter\.com|x\.com)",
"facebook": r"facebook\.com",
"vk": r"vk\.com",
"rutube": r"rutube\.ru",
"vimeo": r"vimeo\.com",
"twitch": r"twitch\.tv",
"dailymotion": r"dailymotion\.com",
}
for platform, pattern in patterns.items():
if re.search(pattern, url, re.IGNORECASE):
return platform
return "other"
def get_playlist_info(url: str) -> dict:
"""Получает информацию о плейлисте"""
ydl_opts = {
"quiet": True,
"no_warnings": True,
"extract_flat": True,
"playlistend": 50, # Ограничиваем количество видео (можно изменить)
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
return ydl.extract_info(url, download=False)
except:
return None
def get_first_video_from_playlist(url: str) -> str:
"""Получает первую ссылку на видео из плейлиста"""
playlist_info = get_playlist_info(url)
if playlist_info and playlist_info.get('entries'):
first_video = playlist_info['entries'][0]
return first_video.get('url')
return None

View File

@@ -1,28 +1,50 @@
# keyboards.py
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
def quality_keyboard():
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="480p", callback_data="q:best[height<=480]"),
InlineKeyboardButton(text="720p", callback_data="q:best[height<=720]"),
],
[
InlineKeyboardButton(text="1080p", callback_data="q:best[height<=1080]"),
InlineKeyboardButton(text="4K", callback_data="q:bestvideo+bestaudio"),
],
[
InlineKeyboardButton(text="🎧 Аудио из видео", callback_data="audio"),
]
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="🎧 Аудио", callback_data="audio"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel"),
]
)
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def cancel_keyboard():
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=" Отменить", callback_data="cancel")]
"""Клавиатура с кнопкой отмены (для плейлистов)"""
keyboard = [
[InlineKeyboardButton(text=" Отменить загрузку", callback_data="cancel")]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def playlist_keyboard(confirm=False):
"""Клавиатура для плейлистов YouTube"""
if confirm:
keyboard = [
[
InlineKeyboardButton(text="✅ Да, загрузить все", callback_data="playlist_confirm_yes"),
InlineKeyboardButton(text="❌ Нет, отменить", callback_data="playlist_confirm_no"),
]
]
)
else:
keyboard = [
[
InlineKeyboardButton(text="📁 Загрузить все видео", callback_data="playlist_all"),
InlineKeyboardButton(text="🎬 Только первое видео", callback_data="playlist_first"),
],
[
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel"),
]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)

View File

@@ -1,10 +1,10 @@
# 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
@@ -17,29 +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
from downloader import download_video, download_audio, DownloadCancelled
from keyboards import youtube_quality_keyboard, cancel_keyboard, playlist_keyboard
from downloader import (
download_tiktok_video_and_audio,
download_instagram_video,
download_video,
download_audio,
download_playlist_videos,
optimize_for_telegram,
DownloadCancelled,
)
from middleware import PrivateMiddleware
from rate_limit import check_rate_limit
from info import extract_info
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)
@@ -48,135 +44,18 @@ else:
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
# Регистрация middleware
private_middleware = PrivateMiddleware()
dp.message.middleware(private_middleware)
dp.callback_query.middleware(private_middleware)
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 # Оставляем 80% от лимита
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}
@@ -185,189 +64,196 @@ 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)
# Обновляем раз в ~2% и не чаще чем раз в 2 секунды
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")
# Безопасное форматирование ETA
if eta is None or eta == "?":
eta_str = "?"
else:
try:
eta_str = str(int(float(eta)))
except (ValueError, TypeError):
eta_str = "?"
text = (
"⏬ <b>Загрузка</b>\n"
f"<code>{bar}</code> {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"⏬ <b>Загрузка</b>\n<code>{bar}</code> {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("🎵 <b>Загружаю TikTok (видео + аудио)...</b>", parse_mode="HTML")
key_video = cache_key(url, "tiktok_video", audio=False)
key_audio = cache_key(url, "tiktok_audio", audio=True)
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("📤 <b>Отправляю из кэша...</b>", parse_mode="HTML")
try:
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"✅ <b>Готово! (TikTok)</b>\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
except Exception as e:
logger.error(f"TikTok cache error: {e}")
await status.edit_text("❌ Ошибка при отправке")
finally:
cleanup_tmp(TMP_DIR)
return
cancel_event = threading.Event()
ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event}
loop = asyncio.get_running_loop()
progress_cb = make_progress_cb(loop, status)
def optimize_for_telegram(input_path: str, output_path: str) -> bool:
"""
Оптимизирует видео для телеграма
"""
try:
# Проверяем размер файла
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}")
import shutil
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("📤 <b>Отправляю видео + аудио...</b>", 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"✅ <b>Готово! (TikTok)</b>\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("📸 <b>Загружаю Instagram (1080p)...</b>", parse_mode="HTML")
# -------------------- handlers --------------------
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("📤 <b>Отправляю из кэша...</b>", 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"✅ <b>Готово! (Instagram 1080p)</b>\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
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("📤 <b>Отправляю видео...</b>", 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"✅ <b>Готово! (Instagram 1080p)</b>\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(
"👋 <b>Привет!</b>\n\n"
"📥 Я скачиваю <b>видео</b> и <b>звук из видео</b> по ссылке.\n\n"
"🔄 Кэш автоматически очищается раз в сутки\n"
"📥 Скачиваю видео и аудио по ссылке.\n\n"
"✨ <b>Новые возможности:</b>\n"
"• 🎬 TikTok: автоматически видео + аудио (Telegram-safe)\n"
"• 📸 Instagram: автоматически видео в 1080p (Telegram-safe)\n"
"• 🎵 Аудио из любого видео\n"
"• 📁 Плейлисты YouTube\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)
# Получаем время последнего изменения самого старого файла
oldest_time = None
newest_time = None
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)
if oldest_time is None or mtime < oldest_time:
oldest_time = mtime
if newest_time is None or mtime > newest_time:
newest_time = mtime
except:
pass
if oldest_time:
oldest_str = datetime.fromtimestamp(oldest_time).strftime("%d.%m.%Y %H:%M")
newest_str = datetime.fromtimestamp(newest_time).strftime("%d.%m.%Y %H:%M")
age_info = f"🗓 Самый старый: {oldest_str}\n" \
f"🆕 Самый новый: {newest_str}"
else:
age_info = "🗓 Кэш пуст"
await message.answer(
f"📊 <b>Статистика кэша:</b>\n\n"
f"📁 Файлов: {file_count}\n"
f"💾 Размер: {total_size_mb:.2f} МБ\n"
f"⏰ Очистка: ежедневно в 3:00\n\n"
f"{age_info}",
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_URLS[message.from_user.id] = url
user_id = message.from_user.id
USER_URLS[user_id] = url
platform_info = await asyncio.to_thread(get_platform_info, url)
if platform_info == "tiktok":
await process_tiktok_auto(message, user_id, url)
return
if platform_info == "instagram":
await process_instagram_auto(message, user_id, url)
return
if await asyncio.to_thread(is_playlist, url):
await message.answer(
"📁 <b>Обнаружен плейлист!</b>\n\n"
"Выберите действие:",
reply_markup=playlist_keyboard(),
parse_mode="HTML"
)
return
await message.answer(
"🔽 <b>Выбери формат загрузки:</b>",
reply_markup=quality_keyboard(),
"🔽 <b>Выбери качество:</b>",
reply_markup=youtube_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)
# ---------------- VIDEO ----------------
@dp.callback_query(F.data.startswith("q:"))
async def handle_video(callback: CallbackQuery):
await callback.answer()
@@ -379,39 +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("🔍 <b>Анализирую ссылку…</b>", parse_mode="HTML")
# Извлекаем информацию о видео
try:
info = await asyncio.to_thread(extract_info, url, COOKIES_FILE)
if not info:
await status.edit_text("Не удалось получить информацию о видео")
return
except Exception as e:
logger.error(f"Error extracting info: {e}")
await status.edit_text("❌ Ошибка при анализе ссылки")
return
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("📤 <b>Отправляю файл из кэша…</b>", 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"✅ <b>Готово!</b>\n📦 Размер: {size_mb:.1f} МБ",
parse_mode="HTML"
)
size_mb = os.path.getsize(final_path) / (1024 * 1024)
await callback.message.answer(f"✅ <b>Готово!</b>\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
except Exception as e:
logger.error(f"Error sending cached file: {e}")
await status.edit_text("❌ Ошибка при отправке файла")
@@ -432,74 +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
# Оптимизируем видео для телеграма
await status.edit_text("⚙️ <b>Оптимизирую видео для телеграма…</b>", 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)
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("📤 <b>Отправляю видео…</b>", 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"✅ <b>Готово!</b>\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"✅ <b>Готово!</b>\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"✅ <b>Отправлено как документ</b>\n📦 Размер: {size_mb:.1f} МБ",
parse_mode="HTML"
)
size_mb = os.path.getsize(final_path) / (1024 * 1024)
await callback.message.answer(f"✅ <b>Отправлено как документ</b>\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)
# ---------------- AUDIO FROM VIDEO ----------------
@dp.callback_query(F.data == "audio")
async def handle_audio(callback: CallbackQuery):
@@ -511,31 +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("🎧 <b>Подготовка аудио…</b>", parse_mode="HTML")
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("📤 <b>Отправляю аудио из кэша…</b>", 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"✅ <b>Готово!</b>\n📦 Размер: {size_mb:.1f} МБ",
parse_mode="HTML"
)
size_mb = os.path.getsize(final_path) / (1024 * 1024)
await callback.message.answer(f"✅ <b>Готово!</b>\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML")
except Exception as e:
logger.error(f"Error sending cached audio: {e}")
await status.edit_text("❌ Ошибка при отправке аудио")
@@ -555,85 +376,213 @@ 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 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("📤 <b>Отправляю аудио…</b>", 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"✅ <b>Готово!</b>\n📦 Размер: {size_mb:.1f} МБ",
parse_mode="HTML"
)
size_mb = os.path.getsize(final_path) / (1024 * 1024)
await callback.message.answer(f"✅ <b>Готово!</b>\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 ----------------
@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
await callback.message.edit_reply_markup(reply_markup=None)
status = await callback.message.answer("📁 <b>Анализирую плейлист…</b>", 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"⚠️ <b>Внимание!</b>\n\n"
f"Плейлист содержит <b>{video_count}</b> видео.\n"
f"Это может занять много времени и места.\n\n"
f"Продолжить загрузку?",
reply_markup=playlist_keyboard(confirm=True),
parse_mode="HTML"
)
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)
# -------------------- entrypoint --------------------
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"📁 <b>Начинаю загрузку плейлиста</b>\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:
downloaded_files = await asyncio.to_thread(
download_playlist_videos,
playlist_info,
playlist_dir,
COOKIES_FILE,
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"📤 <b>Отправляю {len(downloaded_files)} видео…</b>", 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"✅ <b>Плейлист загружен!</b>\n\n"
f"📁 Видео в плейлисте: {video_count}\n"
f"📤 Отправлено: {sent_count}\n"
f"💾 Общий размер: {total_size_mb:.1f} МБ\n"
f"🎬 Название: {playlist_title}",
parse_mode="HTML"
)
shutil.rmtree(playlist_dir, ignore_errors=True)
except DownloadCancelled:
await status.edit_text("⛔ Загрузка плейлиста отменена")
except Exception as e:
logger.error(f"Error downloading playlist: {e}")
await status.edit_text(f"❌ Ошибка: {str(e)[:100]}")
finally:
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)
@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(
"🔽 <b>Выбери формат загрузки для первого видео:</b>",
reply_markup=youtube_quality_keyboard(),
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Error getting first video: {e}")
await callback.message.answer("❌ Ошибка при получении видео из плейлиста")
@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}")

BIN
wireguard/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -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