From 34000632070ccdf315e4aec11299d0cc16d66c9b Mon Sep 17 00:00:00 2001 From: smolkik-code Date: Fri, 2 Jan 2026 17:48:48 +0700 Subject: [PATCH] Initial commit --- .DS_Store | Bin 0 -> 14340 bytes .env.example | 17 + .gitignore | 5 + Dockerfile | 17 + README.md | 70 ++++ bot/cache.py | 9 + bot/cleanup.py | 9 + bot/config.py | 33 ++ bot/downloader.py | 89 +++++ bot/info.py | 12 + bot/keyboards.py | 28 ++ bot/main.py | 639 ++++++++++++++++++++++++++++++++ bot/middleware.py | 16 + bot/rate_limit.py | 11 + docker-compose.yml | 23 ++ requirements.txt | 5 + wireguard/coredns/Corefile | 6 + wireguard/templates/peer.conf | 11 + wireguard/templates/server.conf | 6 + 19 files changed, 1006 insertions(+) create mode 100644 .DS_Store create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bot/cache.py create mode 100644 bot/cleanup.py create mode 100644 bot/config.py create mode 100644 bot/downloader.py create mode 100644 bot/info.py create mode 100644 bot/keyboards.py create mode 100644 bot/main.py create mode 100644 bot/middleware.py create mode 100644 bot/rate_limit.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 wireguard/coredns/Corefile create mode 100644 wireguard/templates/peer.conf create mode 100644 wireguard/templates/server.conf diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7d8877be108c4b84c54ad7e6ffeb2902d930d855 GIT binary patch literal 14340 zcmeGiTWl0n^qy&Zc~n|Kwxth>b$4fI$4+PJ&g?Fa zR7;G+SK`~mFQTHKM2$v_fuLU|N{pr}<@9yJ=@Ch8)K=z%{@% zz%{@%z%_7H4ba$&MJ!~Hw_F2U16%{68ldloAXW+^J{)Gydv#z#kMMM!9^t)$eVPtX zT+oLR9}Y8!GgyF;B4niCrx?J<5g+u|MSM8SAR{N>?TmcyS$IP{wTI^9t+s6@a{YK>x1ncy zl(|vca^r@jr@XY?&~#kV-ELT#oo%qwmgac0F*YGdqST`-ICQACqBOX!re>%#c&MT> zytXu0UAum0ND@mz<#oGy`pp5$evSzO{>y;OnjqNolch49Oq5BL7t2X?&i}R`N_Q2? zv()yE&MvvT!${gz%s8m`D03VXtqF%aQ+ftPYERl`dcbmel%izZOs37atJj+*U5vOn zPBLYi=1$u&T*J0HJ6wIgn`1O?s-w%XQ*P8_)}ttCN8hC*FC(KjB1dFZOn2KcNZB~M z^^}7!6bUmSrQ@^b2bQfYt6aY^vbm|{;gLB?QL!wS%xljWj*&3+&bX7*EzPic+p>n{ z_I06tE!Ws*=qX7mg3?wiKA?{jFIc$fp2e!l)?A^T2@z=B0;>>K?V7b8Lp5d#cAzFBVeR*RkbK7 zTALY9=%$a_P=~1P?HMMyk1TmWlsc03zyJ#4Fq|4~6h-CBN9e}QamU>;s9U3t6x}9@ zT~1#-rSHh13Fau8MbZ0V5l=}rYlds5JZ(I*R8>1Et9nh-H57W$f`!FuOl=dD?~q0@ ztc-!5p76I|U!cTL&gw#uRjeC2tqprUPsh?+h8=BbkW8J4{|6N6AOz zBsoRSkhA1Gxj=p+m&otr4{{muK!##i49lPl${`FDumS2|BW!~0&+t$$0-kss04 zFOo|z6AD3r0IY;HP=mI;1-3yGG(!v8cL#LB9_R)gdZ7;-16eo#2jMAr8g2a< zcotrP*Wh({1CGL5Xz|D3U9|W2;RE;(K7*5R3Qoi4p5>3g5AYNGJi(S*#@jMnoNUWu zK5J7Ckcfu9n3Hzu!E@?^;;kM$r?BS<0d#!C<)i7-Kml%@Pu7rL%>CVI&-^jjuc!!LZ{|NL z4?nu_qYFQ}+{~PRtgG<@3qP>jo(GmuE1qyn@h_!Kdsw_NAH;oWcu?d)kq5<_85FNL z8V`v)B;JN0@j5@A^ZbAM^7(7oc?Me?ruY9g|MUI-YhlUPat&||+-4ep{Ki;g13q-_ z-~EZtPw(0Vu|A5Gt~rgB7VLW@*w7_(;9bYV0limLLvgu+a}nA{ILsjSu>9{A0k3ww QhX31t$Nk%XC+7cu0|sSfsQ>@~ literal 0 HcmV?d00001 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8f44bb0 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Telegram +BOT_TOKEN= +LOCAL_API_URL=http://127.0.0.1:8081 + +TELEGRAM_API_ID= +TELEGRAM_API_HASH= + +# Access +ALLOWED_USERS= + +# Limits +MAX_DURATION_SECONDS=18000 +RATE_LIMIT_SECONDS=20 + +# yt-dlp +COOKIES_FILE=/cookies/cookies.txt + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee38381 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +cookies.txt +.env +*.mp4 +*.mp3 +wireguard/wg_confs/wg0.conf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fffd5ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +RUN apt update && apt install -y \ + ffmpeg \ + wireguard \ + iproute2 \ + iptables \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /DownloadSM_2 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY bot ./bot + +CMD ["python", "bot/main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e067aec --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# 🚀 DownloadSM 2.0 — Telegram Media Downloader + +Telegram-бот для скачивания видео и аудио из различных источников (YouTube, TikTok, Instagram и др.) с поддержкой локального API, кэширования и автоматического сжатия. + +## ✨ Особенности +- **Высокое качество:** Выбор качества видео (720p, 1080p и т.д.). +- **Конвертация в MP3:** Быстрое извлечение аудиодорожки. +- **Smart Compression:** Автоматическое сжатие видео через `FFmpeg`, если файл слишком тяжелый. +- **Local Telegram API:** Поддержка отправки файлов размером до 2 ГБ через локальный сервер API. +- **Кэширование:** Повторные запросы одной и той же ссылки обрабатываются мгновенно. +- **Dockerized Setup:** Простая установка и запуск всего стека через `docker-compose`. + +## 🛠 Технологии +- [Python 3.11+](www.python.org) +- [Aiogram 3.x](docs.aiogram.dev) (Telegram Bot Framework) +- [yt-dlp](github.com) (Ядро загрузчика) +- [FFmpeg](ffmpeg.org) (Для обработки и сжатия медиа) +- [Docker](www.docker.com) & `docker-compose.yml` (Оркестрация) +- [WireGuard](www.wireguard.com) (VPN-туннель для локального API) + +## 🔑 Получение `API_ID` и `API_HASH` + +Для работы локального сервера Telegram API (который позволяет скачивать файлы размером >50 МБ) вам потребуются специальные ключи: `TELEGRAM_API_ID` и `TELEGRAM_API_HASH`. + +1. **Авторизуйтесь** в своем аккаунте Telegram через веб-интерфейс: перейдите по ссылке [https://my.telegram.org](https://my.telegram.org). +2. Перейдите в раздел **"API development tools"** (Инструменты разработки API). +3. Заполните форму для создания нового приложения (App title, Short name могут быть любыми, например, "DownloadBot", "DSMBot"). Для поля Platform можете выбрать "Other" или "Desktop". +4. Нажмите **"Create application"**. +5. На открывшейся странице вы увидите сгенерированные значения `App api_id` и `App api_hash`. Скопируйте их. +6. Вставьте эти значения в ваш файл **`.env`** в корне проекта. + +```dotenv +TELEGRAM_API_ID=ВАШ_СКОПИРОВАННЫЙ_ID +TELEGRAM_API_HASH=ВАШ_СКОПИРОВАННЫЙ_ХЭШ +``` + + +🚀 Быстрый старт (Рекомендуемый способ) +Для запуска всех трех сервисов (VPN, Telegram API Server, Бот) используйте Docker Compose. +1. Клонирование репозитория +```bash +git clone github.com +cd DownloadSM_2.0 +```` +2. Настройка `.env` файла +```bash +cp .env.example .env +```` +3. Запуск сервисов +```bash +docker compose up -d --build +```` +Эта команда соберет образ бота, запустит контейнеры и свяжет их между собой через VPN-туннель. +4. Проверка +Вы можете проверить статус контейнеров командой: +```bash +docker compose ps +```` +⚙️ Использование (После запуска) +Просто отправьте боту ссылку на поддерживаемый медиа-ресурс (YouTube, TikTok и т.д.), и он предложит выбрать формат загрузки. +📂 Структура проекта +main.py — Точка входа и логика хэндлеров. +downloader.py — Логика взаимодействия с yt-dlp. +info.py — Извлечение метаданных видео. +cache.py — Управление кэшированными файлами. +keyboards.py — Интерактивные меню бота. +🤝 Контакты +Автор: @smolkik-code +Проект создан в учебных и практических целях. + diff --git a/bot/cache.py b/bot/cache.py new file mode 100644 index 0000000..c898252 --- /dev/null +++ b/bot/cache.py @@ -0,0 +1,9 @@ +import hashlib +import os + +def cache_key(url, quality, audio=False): + raw = f"{url}:{quality}:{audio}" + return hashlib.sha256(raw.encode()).hexdigest() + +def cache_path(cache_dir, key, ext): + return os.path.join(cache_dir, f"{key}.{ext}") \ No newline at end of file diff --git a/bot/cleanup.py b/bot/cleanup.py new file mode 100644 index 0000000..2ca9daf --- /dev/null +++ b/bot/cleanup.py @@ -0,0 +1,9 @@ +import os +import time + +def cleanup_tmp(path, max_age=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) diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..4d7df84 --- /dev/null +++ b/bot/config.py @@ -0,0 +1,33 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +# Telegram +BOT_TOKEN = os.getenv("BOT_TOKEN") +LOCAL_API_URL = os.getenv("LOCAL_API_URL") + +# Paths +DOWNLOAD_DIR = "/downloads" +CACHE_DIR = "/downloads/cache" +TMP_DIR = "/downloads/tmp" + +# yt-dlp +COOKIES_FILE = os.getenv("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 +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) diff --git a/bot/downloader.py b/bot/downloader.py new file mode 100644 index 0000000..b94454b --- /dev/null +++ b/bot/downloader.py @@ -0,0 +1,89 @@ +import yt_dlp +import threading +import os +import logging + +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": + progress_cb(d) + + return hook + + +# ---------------- VIDEO ---------------- + +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)], + "quiet": True, + "no_warnings": True, + # Настройки для TikTok + "extractor_args": { + "tiktok": { + "skip_impersonation": True # Пропускаем имитацию если зависимости не установлены + } + }, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + + +# ---------------- AUDIO FROM VIDEO ---------------- + +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 + } + }, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) \ No newline at end of file diff --git a/bot/info.py b/bot/info.py new file mode 100644 index 0000000..58fd38f --- /dev/null +++ b/bot/info.py @@ -0,0 +1,12 @@ +import yt_dlp + +def extract_info(url, cookies): + opts = { + "quiet": True, + "skip_download": True, + } + if cookies: + opts["cookiefile"] = cookies + + with yt_dlp.YoutubeDL(opts) as ydl: + return ydl.extract_info(url, download=False) \ No newline at end of file diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..c4a3377 --- /dev/null +++ b/bot/keyboards.py @@ -0,0 +1,28 @@ +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 cancel_keyboard(): + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отменить", callback_data="cancel")] + ] + ) diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..d8a1011 --- /dev/null +++ b/bot/main.py @@ -0,0 +1,639 @@ +import os +import asyncio +import threading +import logging +import subprocess +import time +from datetime import datetime + +from aiogram import Bot, Dispatcher, F +from aiogram.types import Message, CallbackQuery, FSInputFile +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.client.telegram import TelegramAPIServer + +from config import ( + BOT_TOKEN, + LOCAL_API_URL, + 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 middleware import PrivateMiddleware +from rate_limit import check_rate_limit +from info import extract_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) + bot = Bot(token=BOT_TOKEN, session=session) +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] = {} +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} + + async def update(d): + 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 = ( + "⏬ Загрузка\n" + f"{bar} {percent:.0f}%\n" + f"⏱ Осталось: {eta_str} сек" + ) + + await message.edit_text( + text, + reply_markup=cancel_keyboard(), + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Error updating progress: {e}") + + def cb(d): + asyncio.run_coroutine_threadsafe(update(d), loop) + + return cb + + +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 + ) + + if result.returncode != 0: + logger.error(f"FFmpeg error: {result.stderr}") + import shutil + shutil.copy2(input_path, output_path) + return False + + return True + + except Exception as e: + logger.error(f"Error optimizing video: {e}") + import shutil + shutil.copy2(input_path, output_path) + return False + + +# -------------------- handlers -------------------- + +@dp.message(F.text == "/start") +async def start(message: Message): + await message.answer( + "👋 Привет!\n\n" + "📥 Я скачиваю видео и звук из видео по ссылке.\n\n" + "🔄 Кэш автоматически очищается раз в сутки\n" + "👉 Просто отправь ссылку.", + parse_mode="HTML" + ) + + +@dp.message(F.text == "/cache_stats") +async def cache_stats(message: Message): + """Показывает статистику кэша""" + try: + total_size_mb = get_cache_size_mb() + file_count = 0 + + for root, dirs, files in os.walk(CACHE_DIR): + file_count += len(files) + + # Получаем время последнего изменения самого старого файла + 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"📊 Статистика кэша:\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 + await message.answer( + "🔽 Выбери формат загрузки:", + reply_markup=quality_keyboard(), + parse_mode="HTML" + ) + + +@dp.callback_query(F.data == "cancel") +async def cancel_download(callback: CallbackQuery): + user_id = callback.from_user.id + data = ACTIVE_DOWNLOADS.get(user_id) + + if data: + data["cancel"].set() + ACTIVE_DOWNLOADS.pop(user_id, None) + await callback.answer("⛔ Загрузка отменена", show_alert=True) + else: + await callback.answer("❌ Нет активной загрузки", show_alert=True) + + +# ---------------- VIDEO ---------------- + +@dp.callback_query(F.data.startswith("q:")) +async def handle_video(callback: CallbackQuery): + await callback.answer() + user_id = callback.from_user.id + url = USER_URLS.get(user_id) + quality = callback.data.split(":", 1)[1] + + if not url: + await callback.message.answer("❌ Ссылка не найдена") + return + + if not check_rate_limit(user_id, RATE_LIMIT_SECONDS): + await callback.message.answer("⏳ Подожди немного перед следующим запросом") + return + + await callback.message.edit_reply_markup(reply_markup=None) + status = await callback.message.answer("🔍 Анализирую ссылку…", parse_mode="HTML") + + # Извлекаем информацию о видео + 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") + + # Проверяем кэш + if os.path.exists(final_path): + await status.edit_text("📤 Отправляю файл из кэша…", parse_mode="HTML") + try: + await callback.message.answer_video(FSInputFile(final_path)) + size_mb = os.path.getsize(final_path) / 1024 / 1024 + await callback.message.answer( + f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Error sending cached file: {e}") + await status.edit_text("❌ Ошибка при отправке файла") + return + + cancel_event = threading.Event() + ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event} + loop = asyncio.get_running_loop() + progress_cb = make_progress_cb(loop, status) + + try: + await asyncio.to_thread( + download_video, + url, + quality, + tmp_path, + COOKIES_FILE, + cancel_event, + progress_cb, + ) + + # Проверяем, был ли отменен процесс + if cancel_event.is_set(): + if os.path.exists(tmp_path): + os.remove(tmp_path) + await status.edit_text("⛔ Загрузка отменена") + return + + # Оптимизируем видео для телеграма + await status.edit_text("⚙️ Оптимизирую видео для телеграма…", parse_mode="HTML") + await asyncio.to_thread(optimize_for_telegram, tmp_path, optimized_path) + + # Удаляем исходный файл и используем оптимизированный + if os.path.exists(tmp_path): + os.remove(tmp_path) + + os.rename(optimized_path, final_path) + + except DownloadCancelled: + 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) + return + finally: + ACTIVE_DOWNLOADS.pop(user_id, None) + + await status.edit_text("📤 Отправляю видео…", parse_mode="HTML") + + try: + await callback.message.answer_video( + FSInputFile(final_path), + supports_streaming=True + ) + size_mb = os.path.getsize(final_path) / 1024 / 1024 + await callback.message.answer( + f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Error sending video: {e}") + try: + await callback.message.answer_document(FSInputFile(final_path)) + size_mb = os.path.getsize(final_path) / 1024 / 1024 + await callback.message.answer( + f"✅ Отправлено как документ\n📦 Размер: {size_mb:.1f} МБ", + parse_mode="HTML" + ) + except Exception as e2: + logger.error(f"Error sending as document: {e2}") + await status.edit_text("❌ Ошибка при отправке файла") + if os.path.exists(final_path): + os.remove(final_path) + + cleanup_tmp(TMP_DIR) + + +# ---------------- AUDIO FROM VIDEO ---------------- + +@dp.callback_query(F.data == "audio") +async def handle_audio(callback: CallbackQuery): + await callback.answer() + user_id = callback.from_user.id + url = USER_URLS.get(user_id) + + if not url: + await callback.message.answer("❌ Ссылка не найдена") + return + + if not check_rate_limit(user_id, RATE_LIMIT_SECONDS): + await callback.message.answer("⏳ Подожди немного перед следующим запросом") + return + + await callback.message.edit_reply_markup(reply_markup=None) + status = await callback.message.answer("🎧 Подготовка аудио…", parse_mode="HTML") + + key = cache_key(url, "audio", audio=True) + final_path = cache_path(CACHE_DIR, key, "mp3") + tmp_path = os.path.join(TMP_DIR, f"{key}.mp3") + + # Создаем директории если не существуют + os.makedirs(TMP_DIR, exist_ok=True) + os.makedirs(os.path.dirname(final_path), exist_ok=True) + + # Проверяем кэш + if os.path.exists(final_path): + await status.edit_text("📤 Отправляю аудио из кэша…", parse_mode="HTML") + try: + await callback.message.answer_audio(FSInputFile(final_path)) + size_mb = os.path.getsize(final_path) / 1024 / 1024 + await callback.message.answer( + f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Error sending cached audio: {e}") + await status.edit_text("❌ Ошибка при отправке аудио") + return + + cancel_event = threading.Event() + ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event} + loop = asyncio.get_running_loop() + progress_cb = make_progress_cb(loop, status) + + try: + await asyncio.to_thread( + download_audio, + url, + tmp_path, + COOKIES_FILE, + cancel_event, + progress_cb, + ) + + # Проверяем, был ли отменен процесс + if cancel_event.is_set(): + await status.edit_text("⛔ Загрузка отменена") + if os.path.exists(tmp_path): + os.remove(tmp_path) + return + + # Проверяем, создан ли файл + if not os.path.exists(tmp_path): + raise Exception("Аудио файл не был создан") + + # Проверяем размер файла + file_size = os.path.getsize(tmp_path) + if file_size == 0: + os.remove(tmp_path) + raise Exception("Создан пустой аудио файл") + + # Перемещаем файл в кэш + if os.path.exists(final_path): + os.remove(final_path) + os.rename(tmp_path, final_path) + + except DownloadCancelled: + await status.edit_text("⛔ Загрузка отменена") + if os.path.exists(tmp_path): + os.remove(tmp_path) + return + except Exception as e: + logger.error(f"Error downloading audio: {str(e)}") + await status.edit_text(f"❌ Ошибка: {str(e)[:100]}") + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except: + pass + return + finally: + ACTIVE_DOWNLOADS.pop(user_id, None) + + await status.edit_text("📤 Отправляю аудио…", parse_mode="HTML") + + try: + await callback.message.answer_audio(FSInputFile(final_path)) + size_mb = os.path.getsize(final_path) / 1024 / 1024 + await callback.message.answer( + f"✅ Готово!\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) + + +# -------------------- entrypoint -------------------- + +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 + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Bot stopped by user") + except Exception as e: + logger.error(f"Fatal error: {e}") \ No newline at end of file diff --git a/bot/middleware.py b/bot/middleware.py new file mode 100644 index 0000000..9e4a69a --- /dev/null +++ b/bot/middleware.py @@ -0,0 +1,16 @@ +from aiogram import BaseMiddleware +from aiogram.types import Message, CallbackQuery +from config import ALLOWED_USERS + +class PrivateMiddleware(BaseMiddleware): + async def __call__(self, handler, event, data): + user_id = event.from_user.id + + if ALLOWED_USERS and user_id not in ALLOWED_USERS: + if isinstance(event, Message): + await event.answer("🚫 У тебя нет доступа к этому боту.") + elif isinstance(event, CallbackQuery): + await event.answer("🚫 Нет доступа", show_alert=True) + return + + return await handler(event, data) diff --git a/bot/rate_limit.py b/bot/rate_limit.py new file mode 100644 index 0000000..6926c3f --- /dev/null +++ b/bot/rate_limit.py @@ -0,0 +1,11 @@ +import time +from collections import defaultdict + +_last_request = defaultdict(float) + +def check_rate_limit(user_id, limit): + now = time.time() + if now - _last_request[user_id] < limit: + return False + _last_request[user_id] = now + return True \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ce9a546 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + wg: + image: linuxserver/wireguard + cap_add: + - NET_ADMIN + - SYS_MODULE + volumes: + - ./wireguard:/config + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + + telegram-bot-api: + image: aiogram/telegram-bot-api + env_file: .env + network_mode: "service:wg" + + bot: + build: . + env_file: .env + network_mode: "service:wg" + volumes: + - ./downloads:/downloads + - ./cookies:/cookies \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..503d986 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +aiogram==3.* +yt-dlp +python-dotenv +aiohttp +yt-dlp[tiktok] diff --git a/wireguard/coredns/Corefile b/wireguard/coredns/Corefile new file mode 100644 index 0000000..e26fbe6 --- /dev/null +++ b/wireguard/coredns/Corefile @@ -0,0 +1,6 @@ +. { + loop + errors + health + forward . /etc/resolv.conf +} diff --git a/wireguard/templates/peer.conf b/wireguard/templates/peer.conf new file mode 100644 index 0000000..d987dba --- /dev/null +++ b/wireguard/templates/peer.conf @@ -0,0 +1,11 @@ +[Interface] +Address = ${CLIENT_IP} +PrivateKey = $(cat /config/${PEER_ID}/privatekey-${PEER_ID}) +ListenPort = 51820 +DNS = ${PEERDNS} + +[Peer] +PublicKey = $(cat /config/server/publickey-server) +PresharedKey = $(cat /config/${PEER_ID}/presharedkey-${PEER_ID}) +Endpoint = ${SERVERURL}:${SERVERPORT} +AllowedIPs = ${ALLOWEDIPS} diff --git a/wireguard/templates/server.conf b/wireguard/templates/server.conf new file mode 100644 index 0000000..757682d --- /dev/null +++ b/wireguard/templates/server.conf @@ -0,0 +1,6 @@ +[Interface] +Address = ${INTERFACE}.1 +ListenPort = 51820 +PrivateKey = $(cat /config/server/privatekey-server) +PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth+ -j MASQUERADE +PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth+ -j MASQUERADE