All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 52s
Реальный инцидент: cookies "протухли" за пару дней вместо ~года. Причина —
не срок годности, а сам yt-dlp: --cookies FILE читает И дописывает cookie
jar обратно в файл после каждого запуска (--help: "read cookies from and
dump cookie jar in"), а когда Instagram-экстрактор решает, что сессия
невалидна, он явно чистит sessionid из jar'а — и это тут же сохраняется на
диск через YoutubeDL.close(). Наш собственный health-check (каждые 30 мин)
и обычные скачивания медленно, но верно стирали себе рабочие cookies.
Фикс: yt-dlp больше никогда не видит мастер-файл, только одноразовую копию
в фиксированном /tmp-пути (безопасно — оба сервиса --workers=1, гонок нет).
Проверено: md5sum/mtime мастер-файлов не меняются ни после серии
/cookies/check, ни после реального /download/stream.
Заодно в bot.py: notify_admin_cookie_alert больше не заявляет "это НЕ
cookies" для extraction_failed — на практике это оказалось не всегда
верно (анонимный rate-limit тоже "не cookies" по факту, но валидная
сессия могла бы его обойти). В алерты добавлена проверяемая ссылка
(test_url из /cookies/check), чтобы сразу было видно, что это health-check
дёргает тестовый ролик, а не реальная ссылка пользователя. Новый статус
cookies_incomplete детектирует "файл есть, но sessionid нет" ещё до
сетевых проверок — ловит именно тот случай, что привёл к инциденту.
Отдельно: пользователю теперь показывается понятное сообщение, когда
Instagram сам блокирует контент как возрастной/чувствительный
("can't be seen by certain audiences") — вместо общего "Something went
wrong", раз повторная попытка всё равно не поможет. Админ по-прежнему
получает полный технический текст без изменений.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
615 lines
26 KiB
Python
615 lines
26 KiB
Python
"""
|
||
YouTube Video Downloader Service
|
||
Отдельный микросервис для скачивания видео с YouTube
|
||
|
||
Версия 2: subprocess-based yt-dlp CLI (обход SSL бага в gunicorn pre-fork)
|
||
"""
|
||
import os
|
||
import time
|
||
import shutil
|
||
import logging
|
||
import traceback
|
||
import subprocess
|
||
import json as json_lib
|
||
from pathlib import Path
|
||
from flask import Flask, request, jsonify
|
||
from flask_cors import CORS
|
||
import uuid
|
||
import re
|
||
|
||
# Настройка логирования
|
||
logging.basicConfig(
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||
level=logging.INFO
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
app = Flask(__name__)
|
||
CORS(app)
|
||
|
||
# Директория для временных файлов
|
||
DOWNLOADS_DIR = Path('downloads')
|
||
DOWNLOADS_DIR.mkdir(exist_ok=True)
|
||
|
||
|
||
def _safe_filename(title: str) -> str:
|
||
"""Создает безопасное имя файла"""
|
||
safe_title = re.sub(r'[<>:"/\\|?*]', '', title)[:100]
|
||
return str(DOWNLOADS_DIR / f'{uuid.uuid4()}_{safe_title}.%(ext)s')
|
||
|
||
|
||
def _cleanup_downloads():
|
||
"""Удаляет все файлы из папки загрузок"""
|
||
for f in DOWNLOADS_DIR.glob('*'):
|
||
try:
|
||
f.unlink()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _find_latest_downloaded() -> Path | None:
|
||
"""Возвращает самый свежий файл в папке загрузок (не .part/.ytdl)."""
|
||
files = [f for f in DOWNLOADS_DIR.glob('*')
|
||
if f.suffix not in ('.part', '.ytdl')]
|
||
if not files:
|
||
return None
|
||
files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
||
return files[0]
|
||
|
||
|
||
def _file_has_video_stream(filepath: Path) -> bool:
|
||
"""Проверяет через ffprobe, содержит ли файл видео-поток."""
|
||
try:
|
||
result = subprocess.run(
|
||
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
|
||
'-show_entries', 'stream=codec_type', '-of', 'csv=p=0',
|
||
str(filepath)],
|
||
capture_output=True, text=True, timeout=15
|
||
)
|
||
return result.stdout.strip() == 'video'
|
||
except Exception as e:
|
||
logger.warning(f"[VALIDATE] Не удалось проверить видео-поток в {filepath.name}: {e}")
|
||
return True
|
||
|
||
|
||
def _file_has_audio_stream(filepath: Path) -> bool:
|
||
"""Проверяет через ffprobe, содержит ли файл аудио-поток."""
|
||
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_video_file() -> Path | None:
|
||
"""Находит видеофайл среди загрузок. Если видео+аудио раздельные — мержит ffmpeg."""
|
||
files = [f for f in DOWNLOADS_DIR.glob('*') if f.suffix not in ('.part', '.ytdl')]
|
||
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 _file_has_video_stream(f):
|
||
if _file_has_audio_stream(f):
|
||
return f # combined stream
|
||
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:
|
||
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:
|
||
video_file.unlink(missing_ok=True)
|
||
audio_file.unlink(missing_ok=True)
|
||
return merged
|
||
logger.error(f"[MERGE] Ошибка ffmpeg: {result.stderr[-300:]}")
|
||
return video_file
|
||
|
||
return video_file
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# CORE: subprocess-based yt-dlp
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
YTDLP_CMD = 'yt-dlp'
|
||
DOWNLOAD_TIMEOUT = 300
|
||
INFO_TIMEOUT = 60
|
||
|
||
EXTRACTOR_ARGS = 'youtube:player_client=web,android;skip=translated_subs,hls'
|
||
|
||
# yt-dlp трактует --cookies как read-write: после каждого запуска он дописывает cookie
|
||
# jar обратно в файл (--help: "read cookies from and dump cookie jar in"), а когда
|
||
# решает, что cookies "no longer valid" — сам чистит куки сессии из jar'а и тут же
|
||
# сохраняет это на диск. Поэтому yt-dlp всегда получает одноразовую копию, мастер-файл
|
||
# он больше не видит. Фиксированное имя безопасно — сервис в один воркер (--workers=1).
|
||
YOUTUBE_COOKIES_SCRATCH = Path('/tmp/youtube_cookies_scratch.txt')
|
||
|
||
|
||
def _build_ytdlp_base_cmd() -> list:
|
||
"""Базовые аргументы yt-dlp CLI."""
|
||
cookies_file = Path(os.getenv('YOUTUBE_COOKIES_FILE', '/app/youtube_cookies.txt'))
|
||
cmd = [
|
||
YTDLP_CMD,
|
||
'--socket-timeout', '15',
|
||
'--extractor-args', EXTRACTOR_ARGS,
|
||
'--js-runtimes', 'deno',
|
||
'--remote-components', 'ejs:github',
|
||
'--no-playlist',
|
||
]
|
||
if cookies_file.exists() and cookies_file.stat().st_size > 0:
|
||
shutil.copy2(cookies_file, YOUTUBE_COOKIES_SCRATCH)
|
||
cmd += ['--cookies', str(YOUTUBE_COOKIES_SCRATCH)]
|
||
return cmd
|
||
|
||
|
||
def _run_ytdlp(args: list, timeout: int = DOWNLOAD_TIMEOUT) -> subprocess.CompletedProcess:
|
||
"""Запускает yt-dlp CLI как subprocess (чистый SSL стек)."""
|
||
logger.info(f"[YTDLP] {' '.join(args)}")
|
||
return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# YouTube formatter parser (shared with old codebase)
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def _parse_height(format_dict: dict) -> int:
|
||
"""Извлекает реальную высоту из формата."""
|
||
h = format_dict.get('height')
|
||
w = format_dict.get('width')
|
||
if h and w and isinstance(h, (int, float)) and isinstance(w, (int, float)):
|
||
return min(int(h), int(w))
|
||
if h and isinstance(h, (int, float)) and h > 0:
|
||
return int(h)
|
||
if w and isinstance(w, (int, float)) and w > 0:
|
||
return int(w)
|
||
note = str(format_dict.get('format_note', '') or '')
|
||
match = re.search(r'(\d+)\s*p', note)
|
||
if match:
|
||
return int(match.group(1))
|
||
match = re.search(r'(\d+)\s*x\s*(\d+)', note, re.IGNORECASE)
|
||
if match:
|
||
return min(int(match.group(1)), int(match.group(2)))
|
||
res = str(format_dict.get('resolution', '') or '')
|
||
match = re.search(r'(\d+)\s*x\s*(\d+)', res, re.IGNORECASE)
|
||
if match:
|
||
return min(int(match.group(1)), int(match.group(2)))
|
||
return 0
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Форматы (--dump-json)
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def get_youtube_formats(url: str) -> list[dict]:
|
||
"""Получает список доступных форматов через subprocess yt-dlp --dump-json."""
|
||
logger.info(f"[FORMATS] Получение списка форматов для: {url}")
|
||
|
||
cmd = _build_ytdlp_base_cmd() + ['--dump-json', '--quiet', '--no-warnings', url]
|
||
try:
|
||
result = _run_ytdlp(cmd, timeout=INFO_TIMEOUT)
|
||
except Exception as e:
|
||
logger.error(f"[FORMATS] Ошибка subprocess: {e}")
|
||
raise Exception(f"Не удалось получить информацию о видео: {e}")
|
||
|
||
if result.returncode != 0:
|
||
err = result.stderr.strip()[-500:]
|
||
logger.error(f"[FORMATS] yt-dlp failed: {err}")
|
||
raise Exception(f"yt-dlp error: {err}")
|
||
|
||
try:
|
||
info = json_lib.loads(result.stdout)
|
||
except json_lib.JSONDecodeError as e:
|
||
raise Exception(f"Failed to parse --dump-json: {e}")
|
||
|
||
formats = info.get('formats', [])
|
||
duration = info.get('duration')
|
||
logger.info(f"[FORMATS] Всего форматов: {len(formats)}, длительность: {duration}с")
|
||
|
||
def _get_filesize(f: dict) -> int:
|
||
size = f.get('filesize') or f.get('filesize_approx') or 0
|
||
if size:
|
||
return size
|
||
if duration:
|
||
tbr = f.get('tbr') or 0
|
||
if tbr:
|
||
return int(tbr * 1024 / 8 * duration)
|
||
vbr = f.get('vbr') or 0
|
||
abr = f.get('abr') or 0
|
||
if vbr or abr:
|
||
return int((vbr + abr) * 1024 / 8 * duration)
|
||
return 0
|
||
|
||
quality_tiers = [
|
||
(2160, '4K'), (1440, '1440p'), (1080, '1080p'), (720, '720p'),
|
||
(480, '480p'), (360, '360p'), (240, '240p'), (144, '144p'),
|
||
]
|
||
|
||
available_heights = set()
|
||
best_audio_info = {'size': 0, 'ext': 'm4a', 'format_id': None}
|
||
|
||
for f in formats:
|
||
vcodec = f.get('vcodec', 'none')
|
||
acodec = f.get('acodec', 'none')
|
||
height = _parse_height(f)
|
||
if vcodec != 'none' and height > 0:
|
||
available_heights.add(height)
|
||
if vcodec == 'none' and acodec != 'none' and best_audio_info['format_id'] is None:
|
||
best_audio_info = {'size': _get_filesize(f), 'ext': f.get('ext', 'm4a'),
|
||
'format_id': f.get('format_id', '')}
|
||
|
||
max_actual_height = max(available_heights) if available_heights else 2160
|
||
result = []
|
||
used_heights = set()
|
||
|
||
for max_height, label in quality_tiers:
|
||
if max_height > max_actual_height:
|
||
continue
|
||
|
||
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 or height > max_height:
|
||
continue
|
||
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 or best_video_height in used_heights:
|
||
continue
|
||
used_heights.add(best_video_height)
|
||
|
||
video_size = _get_filesize(best_video)
|
||
has_audio = best_video.get('acodec', 'none') != 'none'
|
||
total_size = video_size + (best_audio_info['size'] if not has_audio else 0)
|
||
video_ext = best_video.get('ext', 'mp4')
|
||
video_format_id = best_video.get('format_id', '')
|
||
|
||
format_note = best_video.get('format_note', '') or ''
|
||
if format_note and str(best_video_height) in format_note:
|
||
display_label = format_note
|
||
else:
|
||
display_label = f"{best_video_height}p"
|
||
|
||
if has_audio:
|
||
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/"
|
||
f"best[height<={best_video_height}]"
|
||
)
|
||
else:
|
||
format_selector = f"{video_format_id}+bestaudio/best[height<={best_video_height}]/best"
|
||
|
||
result.append({
|
||
'format_id': format_selector,
|
||
'label': f"{display_label} ({video_ext})",
|
||
'quality': display_label,
|
||
'ext': video_ext,
|
||
'filesize_mb': round(total_size / 1024 / 1024, 1) if total_size else None,
|
||
})
|
||
|
||
if best_audio_info['size']:
|
||
result.append({
|
||
'format_id': 'bestaudio/best',
|
||
'label': f"Audio only ({best_audio_info['ext']})",
|
||
'quality': 'audio',
|
||
'ext': best_audio_info['ext'],
|
||
'filesize_mb': round(best_audio_info['size'] / 1024 / 1024, 1) if best_audio_info['size'] else None,
|
||
})
|
||
|
||
# Fallsback: если форматов нет — оценочные
|
||
if len(result) == 0:
|
||
logger.info(f"[FORMATS] Реальных форматов не найдено, генерируем оценочные")
|
||
max_possible_height = max_actual_height
|
||
if duration:
|
||
typical_bitrates = {2160: 40000, 1440: 20000, 1080: 10000, 720: 5000,
|
||
480: 2500, 360: 1200, 240: 600, 144: 300}
|
||
for max_height, label in quality_tiers:
|
||
if max_height > max_possible_height:
|
||
continue
|
||
video_kbps = typical_bitrates.get(max_height, 1000)
|
||
total_kbps = video_kbps + 128
|
||
bytes_est = total_kbps * 1000 / 8 * duration
|
||
result.append({
|
||
'format_id': f"bestvideo[height<={max_height}]+bestaudio/best[height<={max_height}]",
|
||
'label': f"{label} (mp4)",
|
||
'quality': label,
|
||
'ext': 'mp4',
|
||
'filesize_mb': round(bytes_est / 1024 / 1024, 1),
|
||
})
|
||
audio_bytes = 128 * 1000 / 8 * duration
|
||
result.append({
|
||
'format_id': 'bestaudio/best',
|
||
'label': 'Audio only (m4a)',
|
||
'quality': 'audio',
|
||
'ext': 'm4a',
|
||
'filesize_mb': round(audio_bytes / 1024 / 1024, 1),
|
||
})
|
||
|
||
logger.info(f"[FORMATS] Возвращаем {len(result)} форматов")
|
||
return result
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Скачивание (subprocess yt-dlp CLI)
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None = None) -> tuple[Path, str]:
|
||
"""Скачивает видео через subprocess yt-dlp CLI.
|
||
Возвращает (путь_к_файлу, 'cli')."""
|
||
logger.info(f"[DOWNLOAD] Начало скачивания: {url} (format={format_id})")
|
||
|
||
if not format_id:
|
||
# Fallback chain через yt-dlp format selector
|
||
format_id = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best'
|
||
|
||
safe_tmpl = str(DOWNLOADS_DIR / f'%(title)s_%(id)s.%(ext)s')
|
||
|
||
for attempt in range(max_retries):
|
||
_cleanup_downloads()
|
||
|
||
cmd = _build_ytdlp_base_cmd() + [
|
||
'--downloader', 'aria2c',
|
||
'--downloader-args',
|
||
'aria2c:--connect-timeout=15 --timeout=120 --max-tries=1',
|
||
'-f', format_id,
|
||
'-o', safe_tmpl,
|
||
url,
|
||
]
|
||
|
||
try:
|
||
result = _run_ytdlp(cmd, timeout=DOWNLOAD_TIMEOUT)
|
||
except subprocess.TimeoutExpired:
|
||
logger.error(f"[DOWNLOAD] yt-dlp timeout ({DOWNLOAD_TIMEOUT}s)")
|
||
if attempt < max_retries - 1:
|
||
time.sleep((attempt + 1) * 2)
|
||
continue
|
||
raise Exception(f"Превышен таймаут скачивания ({DOWNLOAD_TIMEOUT}с)")
|
||
|
||
if result.returncode == 0:
|
||
for line in result.stdout.split('\n'):
|
||
if 'Destination:' in line:
|
||
logger.info(f"[DOWNLOAD] {line.strip()}")
|
||
|
||
file = _find_latest_downloaded()
|
||
if file:
|
||
logger.info(f"[DOWNLOAD] Скачан файл: {file.name} ({file.stat().st_size} bytes)")
|
||
return file, 'cli'
|
||
|
||
logger.error("[DOWNLOAD] Файл не найден после успешного yt-dlp")
|
||
raise Exception("Файл не найден после скачивания")
|
||
|
||
# Обработка ошибок
|
||
stderr = result.stderr.strip()[-800:]
|
||
logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: yt-dlp failed: {stderr[:300]}")
|
||
|
||
# Пробуем без cookies при любой ошибке, а не только по ключевым словам:
|
||
# cookies отключают клиент "android" (см. "does not support cookies"),
|
||
# который часто остаётся единственным, кто отдаёт форматы при SABR-only
|
||
# ограничениях YouTube — так что без cookies шанс скачать выше почти всегда.
|
||
if '--cookies' in ' '.join(cmd):
|
||
logger.warning("[DOWNLOAD] Пробуем без cookies")
|
||
cmd_no_cookies = [a for i, a in enumerate(cmd) if a != '--cookies' and cmd[i-1] != '--cookies']
|
||
try:
|
||
result2 = _run_ytdlp(cmd_no_cookies, timeout=DOWNLOAD_TIMEOUT)
|
||
if result2.returncode == 0:
|
||
file = _find_latest_downloaded()
|
||
if file:
|
||
return file, 'cli-no-cookies'
|
||
except Exception:
|
||
pass
|
||
|
||
if attempt < max_retries - 1:
|
||
time.sleep((attempt + 1) * 2)
|
||
|
||
raise Exception(f"Не удалось скачать видео после {max_retries} попыток")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Кэш форматов
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
_formats_cache: dict[str, tuple[float, list[dict]]] = {}
|
||
_FORMATS_CACHE_TTL = 30 * 60 # 30 минут
|
||
|
||
|
||
def _normalize_youtube_url(url: str) -> str:
|
||
m = re.search(r'(youtu\.be/|youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})', url)
|
||
if m:
|
||
prefix, video_id = m.group(1), m.group(2)
|
||
return f"https://www.youtube.com/watch?v={video_id}"
|
||
return url
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Flask endpoints
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
@app.route('/health', methods=['GET'])
|
||
def health():
|
||
return jsonify({'status': 'ok', 'service': 'youtube-downloader'}), 200
|
||
|
||
|
||
@app.route('/formats', methods=['POST'])
|
||
def formats():
|
||
request_id = str(uuid.uuid4())[:8]
|
||
logger.info(f"[FORMATS {request_id}] ========== ЗАПРОС ФОРМАТОВ ==========")
|
||
|
||
try:
|
||
data = request.get_json()
|
||
if not data or 'url' not in data:
|
||
return jsonify({'error': 'URL is required'}), 400
|
||
|
||
url = data['url']
|
||
if 'youtube.com' not in url and 'youtu.be' not in url:
|
||
return jsonify({'error': 'Only YouTube URLs are supported'}), 400
|
||
|
||
cache_key = _normalize_youtube_url(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"[FORMATS {request_id}] Кэш: {len(cached_formats)} форматов ({now - cached_time:.0f}с)")
|
||
return jsonify({'formats': cached_formats}), 200
|
||
del _formats_cache[cache_key]
|
||
|
||
format_list = get_youtube_formats(url)
|
||
_formats_cache[cache_key] = (time.time(), format_list)
|
||
return jsonify({'formats': format_list}), 200
|
||
|
||
except Exception as e:
|
||
logger.error(f"[FORMATS {request_id}] Ошибка: {e}")
|
||
logger.error(traceback.format_exc())
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
|
||
YOUTUBE_COOKIE_TEST_URL = os.getenv(
|
||
'YOUTUBE_COOKIE_TEST_URL',
|
||
'https://www.youtube.com/watch?v=jNQXAC9IVRw' # "Me at the zoo" — первое видео на YouTube, публичное, без возрастных ограничений
|
||
)
|
||
# Маркеры, по которым сбой однозначно относится к cookies/авторизации.
|
||
# Всё остальное считаем поломкой извлечения (версия yt-dlp, JS-рантайм, сеть) и
|
||
# про cookies ничего не утверждаем — иначе мониторинг уводит диагностику не туда.
|
||
YOUTUBE_COOKIE_INVALID_MARKERS = (
|
||
'no longer valid',
|
||
'have likely been rotated',
|
||
'sign in to confirm',
|
||
)
|
||
|
||
|
||
@app.route('/cookies/check', methods=['POST'])
|
||
def cookies_check():
|
||
"""Проверяет, рабочие ли сейчас YouTube cookies (для проактивного мониторинга)."""
|
||
cookies_file = Path(os.getenv('YOUTUBE_COOKIES_FILE', '/app/youtube_cookies.txt'))
|
||
if not (cookies_file.exists() and cookies_file.stat().st_size > 0):
|
||
return jsonify({'cookies_present': False, 'cookies_valid': None,
|
||
'status': 'no_cookies', 'test_url': YOUTUBE_COOKIE_TEST_URL,
|
||
'detail': 'cookies file missing or empty'}), 200
|
||
|
||
# Без --quiet/--no-warnings: сигнал ротации кук — это подавляемый WARNING, а не ошибка с ненулевым returncode
|
||
cmd = _build_ytdlp_base_cmd() + ['--dump-json', YOUTUBE_COOKIE_TEST_URL]
|
||
try:
|
||
result = _run_ytdlp(cmd, timeout=INFO_TIMEOUT)
|
||
except Exception as e:
|
||
return jsonify({'cookies_present': True, 'cookies_valid': None,
|
||
'status': 'extraction_failed', 'test_url': YOUTUBE_COOKIE_TEST_URL,
|
||
'detail': f'yt-dlp invocation failed: {e}'}), 200
|
||
|
||
stderr = result.stderr
|
||
detail = stderr.strip()[-500:]
|
||
|
||
if any(marker in stderr.lower() for marker in YOUTUBE_COOKIE_INVALID_MARKERS):
|
||
return jsonify({'cookies_present': True, 'cookies_valid': False,
|
||
'status': 'cookies_invalid', 'test_url': YOUTUBE_COOKIE_TEST_URL,
|
||
'detail': detail}), 200
|
||
|
||
if result.returncode != 0:
|
||
return jsonify({'cookies_present': True, 'cookies_valid': None,
|
||
'status': 'extraction_failed', 'test_url': YOUTUBE_COOKIE_TEST_URL,
|
||
'detail': detail}), 200
|
||
|
||
return jsonify({'cookies_present': True, 'cookies_valid': True,
|
||
'status': 'ok', 'test_url': YOUTUBE_COOKIE_TEST_URL, 'detail': ''}), 200
|
||
|
||
|
||
@app.route('/download/stream', methods=['POST'])
|
||
def download_stream():
|
||
request_id = str(uuid.uuid4())[:8]
|
||
logger.info(f"[REQUEST {request_id}] ========== НОВЫЙ ЗАПРОС ==========")
|
||
|
||
try:
|
||
data = request.get_json()
|
||
if not data or 'url' not in data:
|
||
return jsonify({'error': 'URL is required'}), 400
|
||
|
||
url = data['url']
|
||
format_id = data.get('format_id')
|
||
logger.info(f"[REQUEST {request_id}] Скачивание: {url}, format_id={format_id}")
|
||
|
||
if 'youtube.com' not in url and 'youtu.be' not in url:
|
||
return jsonify({'error': 'Only YouTube URLs are supported'}), 400
|
||
|
||
video_path, used_downloader = download_youtube_video(url, format_id=format_id)
|
||
logger.info(f"[REQUEST {request_id}] Видео скачано: {video_path} ({used_downloader})")
|
||
|
||
file_size = video_path.stat().st_size
|
||
with open(video_path, 'rb') as f:
|
||
video_data = f.read()
|
||
|
||
safe_filename = video_path.name.encode('ascii', 'ignore').decode('ascii') or 'youtube_video.mp4'
|
||
if not any(safe_filename.endswith(ext) for ext in ('.mp4', '.webm', '.mkv', '.m4a', '.mp3')):
|
||
safe_filename = 'youtube_video.mp4'
|
||
|
||
ext = video_path.suffix.lower()
|
||
content_type_map = {
|
||
'.webm': 'video/webm', '.mkv': 'video/x-matroska',
|
||
'.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.mp3': 'audio/mpeg',
|
||
}
|
||
content_type = content_type_map.get(ext, 'video/mp4')
|
||
|
||
video_path.unlink()
|
||
logger.info(f"[REQUEST {request_id}] ========== ЗАПРОС УСПЕШНО ЗАВЕРШЕН ==========")
|
||
|
||
return video_data, 200, {
|
||
'Content-Type': content_type,
|
||
'Content-Disposition': f'attachment; filename="{safe_filename}"'
|
||
}
|
||
|
||
except Exception as e:
|
||
error_str = str(e)
|
||
logger.error(f"[REQUEST {request_id}] ========== ОШИБКА ==========")
|
||
logger.error(f"[REQUEST {request_id}] {error_str}")
|
||
logger.error(traceback.format_exc())
|
||
|
||
if any(kw in error_str.lower() for kw in ('cookies', 'bot', 'sign in', 'authentication')):
|
||
error_msg = (
|
||
f"{error_str}\n\n"
|
||
"💡 Совет: Cookies устарели или недействительны. "
|
||
"Обновите их вручную по инструкции в cookies/README.md "
|
||
"(для YouTube — только через приватное окно, иначе сессия ротируется) "
|
||
"и доставьте через cookies/deliver_cookies.sh. "
|
||
"Перезапуск сервиса не требуется."
|
||
)
|
||
else:
|
||
error_msg = error_str
|
||
|
||
return jsonify({'error': error_msg}), 500
|
||
|
||
|
||
if __name__ == '__main__':
|
||
port = int(os.getenv('PORT', 5000))
|
||
host = os.getenv('HOST', '0.0.0.0')
|
||
logger.info(f"Запуск YouTube Downloader сервиса на {host}:{port}")
|
||
app.run(host=host, port=port, debug=False)
|