fix: remove client-side format cache, prefer first audio track over largest
This commit is contained in:
parent
e2ead9db52
commit
60a0373d7f
3 changed files with 90 additions and 81 deletions
27
bot.py
27
bot.py
|
|
@ -22,10 +22,6 @@ HTTP_TIMEOUT = httpx.Timeout(connect=None, read=None, write=None, pool=None)
|
|||
# Таймаут для запроса форматов (не такой критичный, но не должен висеть вечно)
|
||||
FORMATS_TIMEOUT = httpx.Timeout(connect=15, read=30, write=15, pool=15)
|
||||
|
||||
# Клиентский кэш форматов: {normalized_url: (timestamp, formats)}
|
||||
_formats_cache: dict[str, tuple[float, list[dict]]] = {}
|
||||
_FORMATS_CACHE_TTL = 30 * 60 # 30 минут
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
|
|
@ -971,29 +967,10 @@ async def download_tiktok_video(url: str, chat_id: int, max_retries: int = 3) ->
|
|||
# ============================================================================
|
||||
|
||||
|
||||
def _normalize_youtube_url_for_cache(url: str) -> str:
|
||||
"""Нормализует URL для кэша: оставляет только video ID"""
|
||||
import re
|
||||
m = re.search(r'(youtu\.be/|youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})', url)
|
||||
if m:
|
||||
return f"https://www.youtube.com/watch?v={m.group(2)}"
|
||||
return url
|
||||
|
||||
|
||||
async def get_formats_from_service(url: str) -> list[dict] | None:
|
||||
"""Получает список доступных форматов для YouTube URL через сервис youtube-downloader"""
|
||||
logger.info(f"Получение форматов для YouTube: {url}")
|
||||
|
||||
cache_key = _normalize_youtube_url_for_cache(url)
|
||||
now = time.time()
|
||||
|
||||
if cache_key in _formats_cache:
|
||||
cached_time, cached_formats = _formats_cache[cache_key]
|
||||
if now - cached_time < _FORMATS_CACHE_TTL:
|
||||
logger.info(f"Форматы взяты из кэша ({len(cached_formats)} шт., возраст {now - cached_time:.0f}с)")
|
||||
return cached_formats
|
||||
del _formats_cache[cache_key]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=FORMATS_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
|
|
@ -1003,9 +980,7 @@ async def get_formats_from_service(url: str) -> list[dict] | None:
|
|||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
formats = data.get('formats', [])
|
||||
_formats_cache[cache_key] = (time.time(), formats)
|
||||
return formats
|
||||
return data.get('formats', [])
|
||||
logger.warning(f"Не удалось получить форматы: {response.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -150,8 +150,6 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None)
|
|||
'user_agent': user_agent,
|
||||
'socket_timeout': 60,
|
||||
'extractor_retries': 3,
|
||||
# Предпочитаем русскую озвучку — YouTube иногда подсовывает авто-дубляж на английском
|
||||
'format_sort': ['lang:ru'],
|
||||
'http_headers': {
|
||||
'User-Agent': user_agent,
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
|
|
@ -175,6 +173,68 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None)
|
|||
return opts
|
||||
|
||||
|
||||
def _find_video_file() -> Path | None:
|
||||
"""Находит видеофайл среди загрузок. Если видео+аудио раздельные — мержит ffmpeg.
|
||||
Возвращает путь к готовому видеофайлу или None если видео нет."""
|
||||
files = list(DOWNLOADS_DIR.glob('*'))
|
||||
if not files:
|
||||
return None
|
||||
files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
||||
|
||||
video_file = None
|
||||
audio_file = None
|
||||
|
||||
for f in files:
|
||||
if f.suffix in ('.part', '.ytdl'):
|
||||
continue
|
||||
if _file_has_video_stream(f):
|
||||
has_audio = _file_has_audio_stream(f)
|
||||
if has_audio:
|
||||
return f # уже смерженный или combined — сразу возвращаем
|
||||
if video_file is None:
|
||||
video_file = f
|
||||
elif not audio_file and _file_has_audio_stream(f):
|
||||
audio_file = f
|
||||
|
||||
if video_file is None:
|
||||
return None
|
||||
|
||||
if audio_file:
|
||||
# Мержим видео + аудио через ffmpeg
|
||||
import subprocess
|
||||
merged = DOWNLOADS_DIR / f"{video_file.stem}_merged{video_file.suffix}"
|
||||
logger.info(f"[MERGE] Мержим {video_file.name} + {audio_file.name} → {merged.name}")
|
||||
result = subprocess.run(
|
||||
['ffmpeg', '-y', '-i', str(video_file), '-i', str(audio_file),
|
||||
'-c', 'copy', '-map', '0:v:0', '-map', '1:a:0', str(merged)],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"[MERGE] Ошибка ffmpeg: {result.stderr[-300:]}")
|
||||
return video_file # fallback — только видео
|
||||
|
||||
video_file.unlink(missing_ok=True)
|
||||
audio_file.unlink(missing_ok=True)
|
||||
return merged
|
||||
|
||||
return video_file
|
||||
|
||||
|
||||
def _file_has_audio_stream(filepath: Path) -> bool:
|
||||
"""Проверяет через ffprobe, содержит ли файл аудио-поток."""
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ffprobe', '-v', 'error', '-select_streams', 'a:0',
|
||||
'-show_entries', 'stream=codec_type', '-of', 'csv=p=0',
|
||||
str(filepath)],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
return result.stdout.strip() == 'audio'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _find_latest_downloaded() -> Path | None:
|
||||
"""Возвращает самый свежий файл в папке загрузок."""
|
||||
files = list(DOWNLOADS_DIR.glob('*'))
|
||||
|
|
@ -287,9 +347,9 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None
|
|||
# Это важно, т.к. format_id из get_youtube_formats() может не совпадать
|
||||
# с format_id при повторном extract_info() в download_youtube_video().
|
||||
default_format_options = [
|
||||
'bestvideo[ext=mp4]+bestaudio[language=ru][ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
|
||||
'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
|
||||
'best[ext=mp4]/best',
|
||||
'bestvideo+bestaudio[language=ru]/bestvideo+bestaudio/best',
|
||||
'bestvideo+bestaudio/best',
|
||||
'best',
|
||||
]
|
||||
|
||||
|
|
@ -337,7 +397,6 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None
|
|||
'format': format_option,
|
||||
'outtmpl': _safe_filename(video_title),
|
||||
'fragment_retries': 3,
|
||||
# Явно включаем фикс растянутого/сплющенного aspect ratio
|
||||
'postprocessors': [{'key': 'FFmpegFixupStretched'}],
|
||||
})
|
||||
|
||||
|
|
@ -360,18 +419,6 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None
|
|||
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: реально скачан формат: id={actual_format_id}, height={actual_height}, ext={actual_ext}, size={actual_filesize}")
|
||||
|
||||
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано с форматом {format_option}")
|
||||
|
||||
# Проверяем, что файл содержит видео-поток (только для видео-форматов)
|
||||
if not is_audio_only:
|
||||
downloaded = _find_latest_downloaded()
|
||||
if downloaded and not _file_has_video_stream(downloaded):
|
||||
logger.warning(f"[DOWNLOAD] Файл {downloaded.name} не содержит видео-потока (только аудио). Удаляем и пробуем следующий формат...")
|
||||
try:
|
||||
downloaded.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
download_success = True
|
||||
break
|
||||
except Exception as download_error:
|
||||
|
|
@ -396,17 +443,6 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None
|
|||
with yt_dlp.YoutubeDL(ydl_opts_download_no_cookies) as ydl:
|
||||
ydl.download([url])
|
||||
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано без cookies")
|
||||
|
||||
if not is_audio_only:
|
||||
downloaded = _find_latest_downloaded()
|
||||
if downloaded and not _file_has_video_stream(downloaded):
|
||||
logger.warning(f"[DOWNLOAD] Файл {downloaded.name} без видео-потока. Удаляем и пробуем следующий формат...")
|
||||
try:
|
||||
downloaded.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
download_success = True
|
||||
cookies_valid = False # Отключаем cookies для следующих попыток
|
||||
break
|
||||
|
|
@ -600,10 +636,9 @@ def get_youtube_formats(url: str) -> list[dict]:
|
|||
if vcodec != 'none' and height > 0:
|
||||
available_heights.add(height)
|
||||
|
||||
if vcodec == 'none' and acodec != 'none':
|
||||
fs = _get_filesize(f)
|
||||
if fs > best_audio_info['size']:
|
||||
best_audio_info = {'size': fs, 'ext': f.get('ext', 'm4a'), 'format_id': format_id}
|
||||
if vcodec == 'none' and acodec != 'none' and best_audio_info['format_id'] is None:
|
||||
# Берём первый попавшийся аудиоформат — yt-dlp отдаёт оригинал первым
|
||||
best_audio_info = {'size': _get_filesize(f), 'ext': f.get('ext', 'm4a'), 'format_id': format_id}
|
||||
|
||||
logger.info(f"[FORMATS] Доступные разрешения: {sorted(available_heights)}")
|
||||
logger.info(f"[FORMATS] Лучший аудиопоток: {best_audio_info['size']} bytes, {best_audio_info['ext']}, format_id={best_audio_info['format_id']}")
|
||||
|
|
@ -620,16 +655,27 @@ def get_youtube_formats(url: str) -> list[dict]:
|
|||
|
||||
best_video = None
|
||||
best_video_height = 0
|
||||
is_best_dash = False
|
||||
|
||||
for f in formats:
|
||||
vcodec = f.get('vcodec', 'none')
|
||||
height = _parse_height(f)
|
||||
|
||||
if vcodec == 'none' or height <= 0:
|
||||
if vcodec == 'none' or height <= 0 or height > max_height:
|
||||
continue
|
||||
if height <= max_height and height > best_video_height:
|
||||
|
||||
is_dash = (f.get('acodec', 'none') == 'none')
|
||||
pick = False
|
||||
|
||||
if height > best_video_height:
|
||||
pick = True
|
||||
elif height == best_video_height and is_dash and not is_best_dash:
|
||||
pick = True
|
||||
|
||||
if pick:
|
||||
best_video = f
|
||||
best_video_height = height
|
||||
is_best_dash = is_dash
|
||||
|
||||
if not best_video:
|
||||
continue
|
||||
|
|
@ -654,28 +700,16 @@ def get_youtube_formats(url: str) -> list[dict]:
|
|||
|
||||
logger.info(f"[FORMATS] {display_label} (height={best_video_height}): video_size={video_size}, has_audio={has_audio}, total={total_size}, format_id={video_format_id}")
|
||||
|
||||
# format_selector: предпочитаем русскую озвучку (YouTube Shorts часто авто-дублирует)
|
||||
if has_audio:
|
||||
format_selector = (
|
||||
f"{video_format_id}/"
|
||||
f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/"
|
||||
f"bestvideo[height<={best_video_height}]+bestaudio/"
|
||||
f"best[height<={best_video_height}]"
|
||||
)
|
||||
format_selector = f"{video_format_id}/best[height<={best_video_height}]/best"
|
||||
elif best_audio_info['format_id']:
|
||||
format_selector = (
|
||||
f"{video_format_id}+{best_audio_info['format_id']}/"
|
||||
f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/"
|
||||
f"bestvideo[height<={best_video_height}]+bestaudio/"
|
||||
f"best[height<={best_video_height}]"
|
||||
)
|
||||
else:
|
||||
format_selector = (
|
||||
f"{video_format_id}+bestaudio[language=ru]/"
|
||||
f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/"
|
||||
f"bestvideo[height<={best_video_height}]+bestaudio/"
|
||||
f"best[height<={best_video_height}]"
|
||||
)
|
||||
format_selector = f"{video_format_id}+bestaudio/best[height<={best_video_height}]/best"
|
||||
|
||||
result.append({
|
||||
'format_id': format_selector,
|
||||
|
|
@ -685,10 +719,10 @@ def get_youtube_formats(url: str) -> list[dict]:
|
|||
'filesize_mb': round(total_size / 1024 / 1024, 1) if total_size else None,
|
||||
})
|
||||
|
||||
# Добавляем аудиодорожку (M4A в приоритете — Telegram поддерживает только MP3/M4A для reply_audio)
|
||||
# Добавляем аудиодорожку
|
||||
if best_audio_info['size']:
|
||||
result.append({
|
||||
'format_id': 'bestaudio[language=ru][ext=m4a]/bestaudio[language=ru][ext=mp3]/bestaudio[language=ru]/bestaudio/best',
|
||||
'format_id': 'bestaudio/best',
|
||||
'label': f"Audio only ({best_audio_info['ext']})",
|
||||
'quality': 'audio',
|
||||
'ext': best_audio_info['ext'],
|
||||
|
|
|
|||
|
|
@ -27,14 +27,14 @@
|
|||
.youtube.com TRUE / FALSE 1776287761000 ST-yve142 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn
|
||||
.youtube.com TRUE / TRUE 1807824503515 __Secure-1PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA
|
||||
.youtube.com TRUE / TRUE 1807824503517 __Secure-3PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA
|
||||
.youtube.com TRUE / TRUE 1793319671 VISITOR_INFO1_LIVE vFr43YvHJaE
|
||||
.youtube.com TRUE / TRUE 1793319671 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D
|
||||
.youtube.com TRUE / TRUE 1793371363 VISITOR_INFO1_LIVE vFr43YvHJaE
|
||||
.youtube.com TRUE / TRUE 1793371363 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D
|
||||
.youtube.com TRUE / FALSE 1776288519000 ST-tladcw session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn
|
||||
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=100&f6=40000000&hl=en
|
||||
.youtube.com TRUE / FALSE 1776288527000 ST-xuwub9 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn
|
||||
.youtube.com TRUE / FALSE 1809303671 SIDCC AKEyXzUEfCEbgyGrH1tNORzoWjcQ4T0V72l_bkp7S12EcJE1BUmxvYDz5rhspZYtTtFg7hzpWkbN
|
||||
.youtube.com TRUE / TRUE 1809303671 __Secure-1PSIDCC AKEyXzWZMgqSjtmJ_uHCIE-o8z-H3jLbJznGybAMvRcbKNzCej_YDlmQ-ScNrcaJ58mYfK6iaOei
|
||||
.youtube.com TRUE / TRUE 1809303671 __Secure-3PSIDCC AKEyXzU4244NG2Eo-friqIol4JdiWYR7Ew-fg1o9TggXgU7NJqk_X_6o7WvvME6so9LxwmuM3lmj
|
||||
.youtube.com TRUE / FALSE 1809355363 SIDCC AKEyXzXpgpaN5o2kMTOlzAUXbOF4YCfxl41G6ZutKAOlEzpeIdskBxux3PemZB5T0T-hVrRplzEw
|
||||
.youtube.com TRUE / TRUE 1809355363 __Secure-1PSIDCC AKEyXzVOgdvJviP9-nUDUnv0MXGY5L73_iQ1heqaYPuKh8JEpSnrLPN7dDb40XFFWlASf3sYC5Td
|
||||
.youtube.com TRUE / TRUE 1809355363 __Secure-3PSIDCC AKEyXzUfDASoJeAIdtNkO6vz8MF2NHsJWHdYWfhJlNp6RDnIIzD3XIv2eE7uff2BsY-JaNw5ANNX
|
||||
.youtube.com TRUE / FALSE 1776288585000 ST-3opvp5 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn
|
||||
.youtube.com TRUE / TRUE 0 YSC KTvrS45hA30
|
||||
.instagram.com TRUE / TRUE 1801240452128 datr hGdNaS-QqakSYV8X2eqVTIyA
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue