11 Commits

10 changed files with 547 additions and 1151 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,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}")

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,297 +1,117 @@
# 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, 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"] in ["downloading", "finished"] and progress_cb:
progress_cb(d)
return hook
# ---------------- STANDARD 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": f"best[height<={quality}]/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},
},
"concurrent_fragment_downloads": 4,
"http_chunk_size": 10485760,
}
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
]
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
# ---------------- ORIGINAL QUALITY ----------------
def download_original_quality(
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,
"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},
},
"concurrent_fragment_downloads": 4,
"http_chunk_size": 10485760,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
# ---------------- ADD METADATA TO AUDIO ----------------
def add_metadata_to_audio(
input_path: str,
output_path: str,
metadata: dict,
):
"""Добавляет метаданные к аудио файлу"""
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}")
if process.returncode != 0:
logger.error(f"FFmpeg error: {stderr.decode()}")
shutil.copy2(input_path, output_path)
# ---------------- PLAYLIST DOWNLOAD ----------------
def download_playlist_videos(
playlist_info: dict,
output_dir: str,
cookies: str | None,
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,
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
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,
"ignoreerrors": True,
"extract_flat": False,
"nooverwrites": True,
"concurrent_fragment_downloads": 4,
"http_chunk_size": 10485760,
"http_chunk_size": 1024 ** 2,
}
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)
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"
try:
await msg.edit_text(progress_text, parse_mode="HTML")
except Exception as e:
logger.error(f"Error updating progress message: {e}")
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}")
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")
return downloaded_files
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"Error downloading playlist: {e}")
logger.error(f"Download or optimization error: {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])
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,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)

File diff suppressed because it is too large Load Diff

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