исправили баг когда видео из плейлиста

This commit is contained in:
vrubel 2026-01-28 17:48:52 +03:00
parent 8a21cbe18a
commit 09347b45ec
2 changed files with 76 additions and 8 deletions

View file

@ -17,7 +17,13 @@ from telegram.request import HTTPXRequest
from app.config import Config from app.config import Config
from app.queue_manager import QueueManager, Task 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.admin_manager import AdminManager
from app.statistics import Statistics from app.statistics import Statistics
@ -47,14 +53,16 @@ async def process_task(task: Task, config: Config, admin_manager: AdminManager,
Returns: Returns:
Путь к созданному MP3 файлу Путь к созданному 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: if not title:
# Сохраняем информацию о запросе # Сохраняем информацию о запросе
output_path = config.workdir / f"task_{task.task_id}" 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 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}" 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) return str(mp3_path)

View file

@ -3,6 +3,8 @@ import asyncio
import logging import logging
import re import re
import subprocess import subprocess
from collections import deque
from urllib.parse import parse_qs, urlparse
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -11,13 +13,14 @@ from app.config import Config
logger = logging.getLogger(__name__) 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: Args:
filename: Исходное имя файла filename: Исходное имя файла
max_length: Максимальная длина имени файла max_length: Максимальная длина имени файла
max_bytes: Максимальная длина имени файла в байтах (UTF-8)
Returns: Returns:
Безопасное имя файла Безопасное имя файла
@ -35,6 +38,18 @@ def sanitize_filename(filename: str, max_length: int = 150) -> str:
if len(sanitized) > max_length: if len(sanitized) > max_length:
sanitized = 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: if not sanitized:
sanitized = "audio" sanitized = "audio"
@ -51,6 +66,41 @@ def is_youtube_url(url: str) -> bool:
return any(re.search(pattern, url) for pattern in patterns) 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/<id>
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=<id> or /shorts/<id>
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]: async def get_video_title(url: str, config: Optional[Config] = None) -> Optional[str]:
""" """
Получить название видео через yt-dlp. Получить название видео через yt-dlp.
@ -68,6 +118,7 @@ async def get_video_title(url: str, config: Optional[Config] = None) -> Optional
'--skip-download', '--skip-download',
'--get-title', '--get-title',
'--no-warnings', '--no-warnings',
'--no-playlist',
url url
] ]
if config: if config:
@ -147,6 +198,7 @@ async def download_and_convert(
'--no-warnings', '--no-warnings',
'--progress', '--progress',
'--newline', '--newline',
'--no-playlist',
url url
] ]
if config: if config:
@ -167,20 +219,24 @@ async def download_and_convert(
stderr=asyncio.subprocess.PIPE 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: while True:
line = await stream.readline() line = await stream.readline()
if not line: if not line:
break break
text = line.decode('utf-8', errors='ignore').strip() text = line.decode('utf-8', errors='ignore').strip()
if text: if text:
tail.append(text)
if level == "info": if level == "info":
logger.info(f"yt-dlp: {text}") logger.info(f"yt-dlp: {text}")
else: else:
logger.warning(f"yt-dlp: {text}") logger.warning(f"yt-dlp: {text}")
stderr_task = asyncio.create_task(_log_stream(process.stderr, "info")) stderr_task = asyncio.create_task(_log_stream(process.stderr, "warn", stderr_tail))
stdout_task = asyncio.create_task(_log_stream(process.stdout, "info")) stdout_task = asyncio.create_task(_log_stream(process.stdout, "info", stdout_tail))
await process.wait() await process.wait()
await stderr_task await stderr_task
@ -188,6 +244,10 @@ async def download_and_convert(
if process.returncode != 0: if process.returncode != 0:
logger.error("yt-dlp failed") 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 завершился с ошибкой") raise Exception("Ошибка скачивания: yt-dlp завершился с ошибкой")
# Находим скачанный файл (yt-dlp создаст файл с расширением) # Находим скачанный файл (yt-dlp создаст файл с расширением)