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
This commit was merged in pull request #1.
This commit is contained in:
2026-02-19 03:22:57 +00:00
8 changed files with 455 additions and 1071 deletions

BIN
.DS_Store vendored

Binary file not shown.

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,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())

View File

@@ -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 = {}
# Скачиваем видео
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,
}
if not input_path.lower().endswith('.mp3'):
shutil.copy2(input_path, output_path)
return
with yt_dlp.YoutubeDL(video_opts) as ydl:
ydl.download([url])
metadata_args = []
# Ищем видеофайл
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")
# Добавляем метаданные
if metadata.get('title'):
metadata_args.extend(['-metadata', f'title={metadata["title"][:100]}'])
# Конвертируем в 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)
if metadata.get('artist'):
metadata_args.extend(['-metadata', f'artist={metadata["artist"][:100]}'])
# Аудио
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,
}
if metadata.get('album'):
metadata_args.extend(['-metadata', f'album={metadata["album"][:100]}'])
with yt_dlp.YoutubeDL(audio_opts) as ydl:
ydl.download([url])
cmd = [
'ffmpeg',
'-i', input_path,
'-c', 'copy',
'-id3v2_version', '3',
'-loglevel', 'error',
'-y',
*metadata_args,
output_path
]
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")
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)
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}")
logger.error(f"Playlist download 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])

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 = [
[
@@ -46,44 +48,3 @@ def playlist_keyboard(confirm=False):
]
]
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