From 09347b45ecad247cee5259692bd48b8d9ee9a084 Mon Sep 17 00:00:00 2001 From: vrubel Date: Wed, 28 Jan 2026 17:48:52 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=B1=D0=B0=D0=B3=20=D0=BA=D0=BE=D0=B3=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=20=D0=B8=D0=B7=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9=D0=BB=D0=B8=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/user_bot.py | 16 ++++++--- app/youtube_downloader.py | 68 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/app/user_bot.py b/app/user_bot.py index 5b11f85..5a26f61 100644 --- a/app/user_bot.py +++ b/app/user_bot.py @@ -17,7 +17,13 @@ from telegram.request import HTTPXRequest from app.config import Config from app.queue_manager import QueueManager, Task -from app.youtube_downloader import is_youtube_url, get_video_title, download_and_convert, sanitize_filename +from app.youtube_downloader import ( + is_youtube_url, + get_video_title, + download_and_convert, + sanitize_filename, + normalize_youtube_url, +) from app.admin_manager import AdminManager from app.statistics import Statistics @@ -47,14 +53,16 @@ async def process_task(task: Task, config: Config, admin_manager: AdminManager, Returns: Путь к созданному MP3 файлу """ + normalized_url = normalize_youtube_url(task.url) + # Получаем название видео - title = await get_video_title(task.url, config=config) + title = await get_video_title(normalized_url, config=config) # Если название не получено, запрашиваем у пользователя if not title: # Сохраняем информацию о запросе output_path = config.workdir / f"task_{task.task_id}" - pending_filename_requests[task.user_id] = (task.url, output_path) + pending_filename_requests[task.user_id] = (normalized_url, output_path) # Отправляем запрос пользователю status_callback = task.callback @@ -79,7 +87,7 @@ async def process_task(task: Task, config: Config, admin_manager: AdminManager, output_path = config.workdir / f"task_{task.task_id}_{safe_title}" # Скачиваем и конвертируем - mp3_path = await download_and_convert(task.url, output_path, custom_title=safe_title, config=config) + mp3_path = await download_and_convert(normalized_url, output_path, custom_title=safe_title, config=config) return str(mp3_path) diff --git a/app/youtube_downloader.py b/app/youtube_downloader.py index 2851b50..5a694d5 100644 --- a/app/youtube_downloader.py +++ b/app/youtube_downloader.py @@ -3,6 +3,8 @@ import asyncio import logging import re import subprocess +from collections import deque +from urllib.parse import parse_qs, urlparse from pathlib import Path from typing import Optional @@ -11,13 +13,14 @@ from app.config import Config logger = logging.getLogger(__name__) -def sanitize_filename(filename: str, max_length: int = 150) -> str: +def sanitize_filename(filename: str, max_length: int = 150, max_bytes: int = 120) -> str: """ Очистка имени файла от запрещённых символов. Args: filename: Исходное имя файла max_length: Максимальная длина имени файла + max_bytes: Максимальная длина имени файла в байтах (UTF-8) Returns: Безопасное имя файла @@ -34,6 +37,18 @@ def sanitize_filename(filename: str, max_length: int = 150) -> str: # Ограничиваем длину if len(sanitized) > max_length: sanitized = sanitized[:max_length] + + # Ограничиваем длину в байтах (на случай UTF-8 и лимита FS ~255 байт) + if len(sanitized.encode('utf-8')) > max_bytes: + trimmed = [] + total_bytes = 0 + for ch in sanitized: + ch_bytes = len(ch.encode('utf-8')) + if total_bytes + ch_bytes > max_bytes: + break + trimmed.append(ch) + total_bytes += ch_bytes + sanitized = ''.join(trimmed) # Если имя пустое, используем дефолтное if not sanitized: @@ -51,6 +66,41 @@ def is_youtube_url(url: str) -> bool: return any(re.search(pattern, url) for pattern in patterns) +def normalize_youtube_url(url: str) -> str: + """Свести URL к одиночному видео (убрать list/index и т.п.).""" + try: + parsed = urlparse(url) + host = parsed.netloc.lower() + path = parsed.path or "" + + # youtu.be/ + if "youtu.be" in host: + video_id = path.strip("/").split("/")[0] + if video_id: + qs = parse_qs(parsed.query) + t = qs.get("t") or qs.get("start") + t_suffix = f"&t={t[0]}" if t else "" + return f"https://www.youtube.com/watch?v={video_id}{t_suffix}" + return url + + # youtube.com/watch?v= or /shorts/ + qs = parse_qs(parsed.query) + video_id = None + if "v" in qs and qs["v"]: + video_id = qs["v"][0] + elif path.startswith("/shorts/"): + video_id = path.split("/")[2] if len(path.split("/")) > 2 else None + + if video_id: + t = qs.get("t") or qs.get("start") + t_suffix = f"&t={t[0]}" if t else "" + return f"https://www.youtube.com/watch?v={video_id}{t_suffix}" + except Exception: + return url + + return url + + async def get_video_title(url: str, config: Optional[Config] = None) -> Optional[str]: """ Получить название видео через yt-dlp. @@ -68,6 +118,7 @@ async def get_video_title(url: str, config: Optional[Config] = None) -> Optional '--skip-download', '--get-title', '--no-warnings', + '--no-playlist', url ] if config: @@ -147,6 +198,7 @@ async def download_and_convert( '--no-warnings', '--progress', '--newline', + '--no-playlist', url ] if config: @@ -167,20 +219,24 @@ async def download_and_convert( stderr=asyncio.subprocess.PIPE ) - async def _log_stream(stream, level: str): + stderr_tail = deque(maxlen=12) + stdout_tail = deque(maxlen=12) + + async def _log_stream(stream, level: str, tail: deque[str]): while True: line = await stream.readline() if not line: break text = line.decode('utf-8', errors='ignore').strip() if text: + tail.append(text) if level == "info": logger.info(f"yt-dlp: {text}") else: logger.warning(f"yt-dlp: {text}") - stderr_task = asyncio.create_task(_log_stream(process.stderr, "info")) - stdout_task = asyncio.create_task(_log_stream(process.stdout, "info")) + stderr_task = asyncio.create_task(_log_stream(process.stderr, "warn", stderr_tail)) + stdout_task = asyncio.create_task(_log_stream(process.stdout, "info", stdout_tail)) await process.wait() await stderr_task @@ -188,6 +244,10 @@ async def download_and_convert( if process.returncode != 0: logger.error("yt-dlp failed") + tail_lines = list(stderr_tail) + list(stdout_tail) + if tail_lines: + tail_text = "\n".join(tail_lines[-12:]) + raise Exception(f"Ошибка скачивания: yt-dlp завершился с ошибкой\n{tail_text}") raise Exception("Ошибка скачивания: yt-dlp завершился с ошибкой") # Находим скачанный файл (yt-dlp создаст файл с расширением)