videoDownloadTGbot/youtube-downloader/app.py

555 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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)