videoDownloadTGbot/youtube-downloader/app.py

556 lines
23 KiB
Python
Raw Normal View History

"""
YouTube Video Downloader Service
Отдельный микросервис для скачивания видео с YouTube
Версия 2: subprocess-based yt-dlp CLI (обход SSL бага в gunicorn pre-fork)
"""
import os
import time
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
PLAYER_CLIENTS = 'web,android'
EXTRACTOR_ARGS = 'youtube:player_client=web,android:skip=translated_subs,hls'
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', 'node',
'--remote-components', 'ejs:github',
'--no-playlist',
]
if cookies_file.exists() and cookies_file.stat().st_size > 0:
cmd += ['--cookies', str(cookies_file.absolute())]
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]}")
# Try without cookies on cookies-related errors
if ('cookies' in stderr.lower() or 'bot' in stderr.lower() or 'sign in' in stderr.lower()) \
and '--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
@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 через скрипт:\n"
" ./youtube-downloader/get_youtube_cookies.sh\n"
"Затем перезапустите сервис."
)
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)