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 hashlib
import os
def cache_key(url: str, quality: str, audio: bool = False) -> str: def cache_key(url: str, quality: str, audio: bool = False) -> str:
"""Генерирует ключ кэша на основе URL, качества и типа"""
key_str = f"{url}_{quality}_{audio}" key_str = f"{url}_{quality}_{audio}"
return hashlib.sha256(key_str.encode()).hexdigest() return hashlib.sha256(key_str.encode()).hexdigest()
def cache_path(cache_dir: str, key: str, extension: str) -> str: def cache_path(cache_dir: str, key: str, extension: str) -> str:
"""Создает путь к файлу в кэше"""
# Создаем вложенную структуру для лучшей организации
subdir = key[:2] subdir = key[:2]
import os
full_dir = os.path.join(cache_dir, subdir) full_dir = os.path.join(cache_dir, subdir)
os.makedirs(full_dir, exist_ok=True) os.makedirs(full_dir, exist_ok=True)
return os.path.join(full_dir, f"{key}.{extension}") return os.path.join(full_dir, f"{key}.{extension}")

View File

@@ -1,3 +1,4 @@
# config.py
import os import os
from dotenv import load_dotenv 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)) MAX_DURATION_SECONDS = int(os.getenv("MAX_DURATION_SECONDS", 1800))
RATE_LIMIT_SECONDS = int(os.getenv("RATE_LIMIT_SECONDS", 20)) RATE_LIMIT_SECONDS = int(os.getenv("RATE_LIMIT_SECONDS", 20))
MAX_FILE_SIZE_MB = 2000 MAX_FILE_SIZE_MB = 2000
CACHE_MAX_AGE_DAYS = 7 # Удалять файлы старше 7 дней CACHE_MAX_AGE_DAYS = 7
CACHE_MAX_SIZE_MB = 4096 # Максимальный размер кэша 1 ГБ (0 = без ограничения) CACHE_MAX_SIZE_MB = 4096
# Авто-качества для TikTok и Instagram (без выбора)
DEFAULT_TIKTOK_VIDEO_QUALITY = "1080"
DEFAULT_TIKTOK_AUDIO_QUALITY = "192" # кбит/с
DEFAULT_INSTAGRAM_VIDEO_QUALITY = "1080"
# Private mode # Private mode
ALLOWED_USERS = set( ALLOWED_USERS = set(
int(uid.strip()) int(uid.strip())
@@ -30,4 +37,4 @@ ALLOWED_USERS = set(
# Ensure directories exist # Ensure directories exist
os.makedirs(CACHE_DIR, exist_ok=True) os.makedirs(CACHE_DIR, exist_ok=True)
os.makedirs(TMP_DIR, exist_ok=True) os.makedirs(TMP_DIR, exist_ok=True)

View File

@@ -1,32 +1,60 @@
# downloader.py
import yt_dlp import yt_dlp
import threading import threading
import os import os
import logging import logging
import subprocess import subprocess
import json
import shutil 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__) logger = logging.getLogger(__name__)
class DownloadCancelled(Exception): class DownloadCancelled(Exception):
pass pass
def _progress_hook(cancel_event, progress_cb): def _progress_hook(cancel_event, progress_cb):
def hook(d): def hook(d):
if cancel_event.is_set(): if cancel_event.is_set():
raise DownloadCancelled("Cancelled by user") raise DownloadCancelled("Cancelled by user")
if d["status"] in ["downloading", "finished"] and progress_cb: if d["status"] in ["downloading", "finished"] and progress_cb:
progress_cb(d) progress_cb(d)
return hook 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( def download_video(
url: str, url: str,
quality: str, quality: str,
@@ -38,15 +66,10 @@ def download_video(
ydl_opts = { ydl_opts = {
"format": f"best[height<={quality}]/best", "format": f"best[height<={quality}]/best",
"outtmpl": out_path, "outtmpl": out_path,
"merge_output_format": "mp4", "cookiefile": cookies if cookies and os.path.exists(cookies) else None,
"cookiefile": cookies,
"progress_hooks": [_progress_hook(cancel_event, progress_cb)], "progress_hooks": [_progress_hook(cancel_event, progress_cb)],
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
"extractor_args": {
"tiktok": {"skip_impersonation": True},
"instagram": {"skip_impersonation": True},
},
"concurrent_fragment_downloads": 4, "concurrent_fragment_downloads": 4,
"http_chunk_size": 10485760, "http_chunk_size": 10485760,
} }
@@ -54,65 +77,48 @@ def download_video(
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url]) 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, url: str,
out_path: str, out_path: str,
cookies: str | None, cookies: str | None,
cancel_event: threading.Event, cancel_event: threading.Event,
progress_cb, 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 = { ydl_opts = {
"format": "bestaudio/best", "format": "bestaudio/best",
"outtmpl": out_path.replace('.mp3', ''), "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)], "progress_hooks": [_progress_hook(cancel_event, progress_cb)],
"postprocessors": [ "postprocessors": [{
{ "key": "FFmpegExtractAudio",
"key": "FFmpegExtractAudio", "preferredcodec": "mp3",
"preferredcodec": "mp3", "preferredquality": "192",
"preferredquality": "192", }],
},
],
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
"extractor_args": {
"tiktok": {"skip_impersonation": True},
},
"concurrent_fragment_downloads": 4, "concurrent_fragment_downloads": 4,
"http_chunk_size": 10485760, "http_chunk_size": 10485760,
} }
@@ -120,64 +126,158 @@ def download_tiktok_music(
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url]) 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( # Конвертируем в mp3
input_path: str, if actual_audio != out_path:
output_path: str, optimized = base + "_telegram.mp3"
metadata: dict, 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: video_opts = {
# Проверяем metadata "format": f"bestvideo[height<={DEFAULT_TIKTOK_VIDEO_QUALITY}]+bestaudio/best",
if metadata is None: "outtmpl": video_path,
metadata = {} "cookiefile": cookies if cookies and os.path.exists(cookies) else None,
"progress_hooks": [_progress_hook(cancel_event, progress_cb)],
if not input_path.lower().endswith('.mp3'): "quiet": True,
shutil.copy2(input_path, output_path) "no_warnings": False,
return "concurrent_fragment_downloads": 4,
"http_chunk_size": 10485760,
metadata_args = [] }
# Добавляем метаданные with yt_dlp.YoutubeDL(video_opts) as ydl:
if metadata.get('title'): ydl.download([url])
metadata_args.extend(['-metadata', f'title={metadata["title"][:100]}'])
# Ищем видеофайл
if metadata.get('artist'): base_video = video_path.rsplit('.', 1)[0]
metadata_args.extend(['-metadata', f'artist={metadata["artist"][:100]}']) actual_video = None
for ext in ['', '.mp4', '.mov', '.webm']:
if metadata.get('album'): candidate = base_video + ext
metadata_args.extend(['-metadata', f'album={metadata["album"][:100]}']) if os.path.exists(candidate):
actual_video = candidate
cmd = [ break
'ffmpeg', if not actual_video:
'-i', input_path, raise Exception("TikTok video file not created")
'-c', 'copy',
'-id3v2_version', '3', # Конвертируем в Telegram-safe mp4
'-loglevel', 'error', optimized_video = base_video + "_telegram.mp4"
'-y', optimize_for_telegram(actual_video, optimized_video)
*metadata_args, for path in [video_path, actual_video]:
output_path if os.path.exists(path): os.remove(path)
] os.rename(optimized_video, video_path)
result = subprocess.run( # Аудио
cmd, audio_opts = {
capture_output=True, "format": "bestaudio/best",
text=True, "outtmpl": audio_path.replace('.mp3', ''),
timeout=30 "cookiefile": cookies if cookies and os.path.exists(cookies) else None,
) "postprocessors": [{
"key": "FFmpegExtractAudio",
if result.returncode != 0: "preferredcodec": "mp3",
shutil.copy2(input_path, output_path) "preferredquality": DEFAULT_TIKTOK_AUDIO_QUALITY,
}],
except Exception as e: "quiet": True,
logger.error(f"Metadata error: {e}") "no_warnings": False,
shutil.copy2(input_path, output_path) "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( def download_playlist_videos(
playlist_info: dict, playlist_info: dict,
output_dir: str, output_dir: str,
@@ -185,14 +285,11 @@ def download_playlist_videos(
cancel_event: threading.Event, cancel_event: threading.Event,
progress_cb, progress_cb,
) -> List[str]: ) -> List[str]:
"""Скачивает все видео из плейлиста"""
downloaded_files = [] downloaded_files = []
ydl_opts = { ydl_opts = {
"format": "best[height<=1080]/best", "format": "best[height<=1080]/best",
"outtmpl": os.path.join(output_dir, "%(title)s [%(id)s].%(ext)s"), "outtmpl": os.path.join(output_dir, "%(title)s [%(id)s].%(ext)s"),
"merge_output_format": "mp4", "cookiefile": cookies if cookies and os.path.exists(cookies) else None,
"cookiefile": cookies,
"progress_hooks": [_progress_hook(cancel_event, progress_cb)], "progress_hooks": [_progress_hook(cancel_event, progress_cb)],
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
@@ -206,92 +303,20 @@ def download_playlist_videos(
try: try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
entries = playlist_info.get('entries', []) 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): for i, entry in enumerate(entries, 1):
if cancel_event.is_set(): if cancel_event.is_set():
raise DownloadCancelled("Cancelled by user") raise DownloadCancelled("Cancelled by user")
if not entry.get('url'): if not entry.get('url'):
logger.warning(f"Entry {i} has no URL, skipping")
continue continue
video_url = entry['url']
video_title = entry.get('title', f'Video {i}')
logger.info(f"Downloading video {i}/{total_videos}: {video_title}")
try: try:
# Загружаем видео info = ydl.extract_info(entry['url'], download=True)
info = ydl.extract_info(video_url, download=True) if info:
filename = ydl.prepare_filename(info)
if not info: if os.path.exists(filename):
logger.error(f"Failed to extract info for video {i}") downloaded_files.append(filename)
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: except Exception as e:
logger.error(f"Error downloading video {i} ({video_title}): {e}") logger.error(f"Error downloading video {i}: {e}")
continue
logger.info(f"Playlist download complete. Downloaded {len(downloaded_files)} files")
return downloaded_files return downloaded_files
except Exception as e: except Exception as e:
logger.error(f"Error downloading playlist: {e}") logger.error(f"Playlist download error: {e}")
raise 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 from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
def quality_keyboard(): def youtube_quality_keyboard():
"""Клавиатура выбора качества""" """YouTube/VK: выбор качества"""
keyboard = [ keyboard = [
[ [
InlineKeyboardButton(text="📹 480p", callback_data="q:480"), InlineKeyboardButton(text="📹 480p", callback_data="q:480"),
InlineKeyboardButton(text="📹 720p", callback_data="q:720"), InlineKeyboardButton(text="📹 720p", callback_data="q:720"),
],
[
InlineKeyboardButton(text="🎬 1080p", callback_data="q:1080"), InlineKeyboardButton(text="🎬 1080p", callback_data="q:1080"),
InlineKeyboardButton(text="🎥 1440p", callback_data="q:1440"),
], ],
[ [
InlineKeyboardButton(text="🎧 Аудио", callback_data="audio"), InlineKeyboardButton(text="🎧 Аудио", callback_data="audio"),
@@ -19,7 +21,7 @@ def quality_keyboard():
def cancel_keyboard(): def cancel_keyboard():
"""Клавиатура с кнопкой отмены""" """Клавиатура с кнопкой отмены (для плейлистов)"""
keyboard = [ keyboard = [
[InlineKeyboardButton(text="⛔ Отменить загрузку", callback_data="cancel")] [InlineKeyboardButton(text="⛔ Отменить загрузку", callback_data="cancel")]
] ]
@@ -27,7 +29,7 @@ def cancel_keyboard():
def playlist_keyboard(confirm=False): def playlist_keyboard(confirm=False):
"""Клавиатура для плейлистов""" """Клавиатура для плейлистов YouTube"""
if confirm: if confirm:
keyboard = [ keyboard = [
[ [
@@ -45,45 +47,4 @@ def playlist_keyboard(confirm=False):
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel"), 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) 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