videoDownloadTGbot/youtube-downloader/app.py

424 lines
25 KiB
Python
Raw 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
"""
import os
import logging
import traceback
from pathlib import Path
from flask import Flask, request, jsonify
from flask_cors import CORS
import yt_dlp
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) # Разрешаем CORS для взаимодействия с основным ботом
# Директория для временных файлов
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 _is_valid_cookies_file(cookies_path: Path) -> bool:
"""Проверяет, что файл cookies существует и содержит данные (не только заголовки)"""
logger.info(f"[COOKIES CHECK] Проверка файла cookies: {cookies_path.absolute()}")
if not cookies_path.exists():
logger.warning(f"[COOKIES CHECK] Файл не существует: {cookies_path.absolute()}")
return False
try:
file_size = cookies_path.stat().st_size
logger.info(f"[COOKIES CHECK] Размер файла: {file_size} байт")
with open(cookies_path, 'r', encoding='utf-8', errors='ignore') as f:
all_lines = f.readlines()
lines = [line.strip() for line in all_lines if line.strip() and not line.strip().startswith('#')]
logger.info(f"[COOKIES CHECK] Всего строк в файле: {len(all_lines)}, валидных строк (не комментариев): {len(lines)}")
# Логируем первые 3 строки для диагностики (без чувствительных данных)
if lines:
preview_lines = []
for i, line in enumerate(lines[:3]):
# Маскируем значения cookie для безопасности
if '\t' in line:
parts = line.split('\t')
if len(parts) > 6:
masked_line = '\t'.join(parts[:6]) + '\t***MASKED***'
preview_lines.append(f" Строка {i+1}: {masked_line[:100]}")
else:
preview_lines.append(f" Строка {i+1}: {line[:100]}")
logger.info(f"[COOKIES CHECK] Превью первых строк:\n" + "\n".join(preview_lines))
# Проверяем, что есть хотя бы одна строка с данными cookie
is_valid = len(lines) > 0
logger.info(f"[COOKIES CHECK] Результат проверки: {'VALID' if is_valid else 'INVALID'}")
return is_valid
except Exception as e:
logger.error(f"[COOKIES CHECK] Ошибка при проверке файла cookies: {e}")
logger.error(f"[COOKIES CHECK] Traceback:\n{traceback.format_exc()}")
return False
def download_youtube_video(url: str, max_retries: int = 3) -> Path:
"""Скачивает видео с YouTube - используем cookies для обхода блокировок"""
logger.info(f"[DOWNLOAD] Начало скачивания: {url}")
cookies_file = os.getenv('YOUTUBE_COOKIES_FILE', 'youtube_cookies.txt')
cookies_file_path = Path(cookies_file)
logger.info(f"[DOWNLOAD] Путь к файлу cookies из env: {cookies_file}, абсолютный: {cookies_file_path.absolute()}")
cookies_valid = _is_valid_cookies_file(cookies_file_path)
if not cookies_valid:
logger.warning(f"[DOWNLOAD] Файл cookies не найден или невалиден ({cookies_file_path}). "
f"Работаем без cookies. Для лучшей работы рекомендуется обновить cookies через скрипт get_youtube_cookies.sh")
else:
logger.info(f"[DOWNLOAD] Cookies файл валиден, будет использован: {cookies_file_path.absolute()}")
user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
last_error = None
for attempt in range(max_retries):
try:
# Определяем, это Shorts или обычное видео
is_shorts = '/shorts/' in url
# Базовые настройки для получения информации
# ВАЖНО: android и ios клиенты НЕ поддерживают cookies!
# Если используем cookies, используем только web клиент
# Если без cookies, используем android/ios/web для лучшей совместимости
if cookies_valid:
# С cookies используем только web клиент
player_clients = ['web']
logger.info(f"[DOWNLOAD] Используем только web клиент, т.к. android/ios не поддерживают cookies")
else:
# Без cookies используем все доступные клиенты
player_clients = ['android', 'ios', 'web'] if is_shorts else ['android', 'web']
logger.info(f"[DOWNLOAD] Используем клиенты без cookies: {player_clients}")
ydl_opts_info = {
'quiet': False,
'no_warnings': False,
'user_agent': user_agent,
'socket_timeout': 60,
'extractor_args': {
'youtube': {
'player_client': player_clients,
'player_skip': ['webpage'],
},
},
'http_headers': {
'User-Agent': user_agent,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-us,en;q=0.5',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'keep-alive',
},
}
# Если есть валидный файл с cookies, используем его
if cookies_valid:
ydl_opts_info['cookiefile'] = str(cookies_file_path.absolute())
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: используем cookies из {cookies_file_path.absolute()}")
logger.info(f"[DOWNLOAD] Опции yt-dlp для получения info: cookiefile={cookies_file_path.absolute()}, player_clients={player_clients}")
else:
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: работаем без cookies")
# Пробуем получить информацию о видео
info = None
try:
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: извлечение информации о видео с URL: {url}")
with yt_dlp.YoutubeDL(ydl_opts_info) as ydl:
info = ydl.extract_info(url, download=False)
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: информация о видео успешно получена")
except Exception as info_error:
error_str = str(info_error)
error_lower = error_str.lower()
logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка при получении информации о видео: {error_str}")
logger.error(f"[DOWNLOAD] Полный traceback:\n{traceback.format_exc()}")
# Если не получилось с cookies, пробуем без них
# Проверяем различные признаки проблем с cookies:
# - явные ошибки с cookies/bot/sign in
# - ошибки формата (могут быть из-за блокировки YouTube при неполных cookies)
# - "Only images are available" (признак блокировки YouTube)
# - "Missing required Data Sync ID" (неполные cookies)
should_retry_without_cookies = cookies_valid and (
'cookies' in error_lower or
'bot' in error_lower or
'sign in' in error_lower or
'authentication' in error_lower or
'format is not available' in error_lower or
'only images are available' in error_lower or
'missing required data sync id' in error_lower or
'challenge solving failed' in error_lower
)
if should_retry_without_cookies:
logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: обнаружена ошибка, возможно связанная с cookies: {error_str[:200]}")
logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: пробуем без cookies с android/ios клиентами...")
ydl_opts_info.pop('cookiefile', None)
# Обновляем player_clients для работы без cookies
player_clients = ['android', 'ios', 'web'] if is_shorts else ['android', 'web']
ydl_opts_info['extractor_args']['youtube']['player_client'] = player_clients
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: обновлены player_clients для работы без cookies: {player_clients}")
try:
with yt_dlp.YoutubeDL(ydl_opts_info) as ydl:
info = ydl.extract_info(url, download=False)
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно получена информация без cookies!")
cookies_valid = False # Отключаем cookies для скачивания тоже
except Exception as retry_error:
logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка даже без cookies: {retry_error}")
logger.error(f"[DOWNLOAD] Полный traceback без cookies:\n{traceback.format_exc()}")
raise
else:
raise
video_title = info.get('title', 'video') if info else 'video'
logger.info(f"YouTube: получена информация о видео: {video_title}")
# Настройки для скачивания с более гибким форматом
# Пробуем разные варианты форматов, если один не работает
format_options = [
'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', # Предпочтительный
'best[ext=mp4]/best', # Простой fallback
'bestvideo+bestaudio/best', # Без ограничения по расширению
'best', # Самый простой вариант
]
download_success = False
for format_option in format_options:
ydl_opts_download = {
'format': format_option,
'outtmpl': _safe_filename(video_title),
'quiet': False,
'no_warnings': False,
'user_agent': user_agent,
'socket_timeout': 60,
'extractor_args': {
'youtube': {
# Используем те же клиенты, что и для получения информации
'player_client': player_clients,
'player_skip': ['webpage'],
},
},
'http_headers': {
'User-Agent': user_agent,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-us,en;q=0.5',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'keep-alive',
},
}
# Если есть валидный файл с cookies, используем его для скачивания
use_cookies_this_attempt = cookies_valid
if use_cookies_this_attempt:
ydl_opts_download['cookiefile'] = str(cookies_file_path.absolute())
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: используем cookies для скачивания: {cookies_file_path.absolute()}")
else:
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: скачивание без cookies")
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}/{max_retries}: начинаем скачивание (Shorts: {is_shorts}, формат: {format_option}, cookies: {use_cookies_this_attempt})")
try:
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: запуск yt-dlp для скачивания с форматом {format_option}")
with yt_dlp.YoutubeDL(ydl_opts_download) as ydl:
ydl.download([url])
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано с форматом {format_option}")
download_success = True
break
except Exception as download_error:
error_str = str(download_error)
error_lower = error_str.lower()
logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка при скачивании формата {format_option}: {error_str}")
logger.error(f"[DOWNLOAD] Полный traceback:\n{traceback.format_exc()}")
# Если ошибка с cookies, пробуем без них
if use_cookies_this_attempt and ('cookies' in error_lower or 'bot' in error_lower or 'sign in' in error_lower or 'authentication' in error_lower):
logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: обнаружена ошибка связанная с cookies для формата {format_option}: {error_str[:200]}")
logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: пробуем без cookies...")
ydl_opts_download.pop('cookiefile', None)
try:
with yt_dlp.YoutubeDL(ydl_opts_download) as ydl:
ydl.download([url])
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано без cookies")
download_success = True
cookies_valid = False # Отключаем cookies для следующих попыток
break
except Exception as retry_error:
logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка даже без cookies: {retry_error}")
logger.error(f"[DOWNLOAD] Полный traceback без cookies:\n{traceback.format_exc()}")
# Если и без cookies не получилось, пробуем следующий формат
logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: не удалось скачать без cookies, пробуем следующий формат...")
continue
# Если ошибка формата, пробуем следующий формат
elif 'format is not available' in error_lower or 'requested format' in error_lower:
logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: формат {format_option} недоступен, пробуем следующий...")
continue
else:
# Другая ошибка - пробуем следующий формат
logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка при скачивании формата {format_option}: {error_str[:200]}, пробуем следующий...")
continue
if not download_success:
raise Exception("Не удалось скачать видео ни с одним из доступных форматов")
# Находим скачанный файл
downloaded_files = list(DOWNLOADS_DIR.glob('*'))
if downloaded_files:
downloaded_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
return downloaded_files[0]
else:
raise Exception("Файл не был найден после скачивания")
except Exception as e:
last_error = e
error_str = str(e)
error_lower = error_str.lower()
logger.error(f"[DOWNLOAD] Попытка {attempt + 1}/{max_retries} не удалась: {error_str}")
logger.error(f"[DOWNLOAD] Полный traceback попытки {attempt + 1}:\n{traceback.format_exc()}")
# Если ошибка связана с форматом, пробуем другие настройки
if 'format is not available' in error_lower or 'requested format' in error_lower:
logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: проблема с форматом, пробуем другие настройки на следующей попытке")
# На следующей попытке попробуем другие player_client
if attempt < max_retries - 1:
import time
sleep_time = (attempt + 1) * 2
logger.info(f"[DOWNLOAD] Ожидание {sleep_time} секунд перед следующей попыткой...")
time.sleep(sleep_time)
continue
# Если ошибка связана с cookies и они были использованы, попробуем без cookies на следующей попытке
if 'cookies' in error_lower or 'bot' in error_lower or 'sign in' in error_lower or 'authentication' in error_lower:
if cookies_valid and attempt == 0:
logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: обнаружена ошибка связанная с cookies: {error_str[:200]}")
logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: на следующей попытке попробуем без cookies")
# На следующей попытке попробуем без cookies
cookies_valid = False
if attempt < max_retries - 1:
import time
sleep_time = (attempt + 1) * 2
logger.info(f"[DOWNLOAD] Ожидание {sleep_time} секунд перед следующей попыткой...")
time.sleep(sleep_time)
raise last_error or Exception("Неизвестная ошибка при скачивании с YouTube")
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({'status': 'ok', 'service': 'youtube-downloader'}), 200
@app.route('/download/stream', methods=['POST'])
def download_stream():
"""Скачивает видео с YouTube и возвращает бинарные данные"""
request_id = str(uuid.uuid4())[:8]
logger.info(f"[REQUEST {request_id}] ========== НОВЫЙ ЗАПРОС ==========")
logger.info(f"[REQUEST {request_id}] Метод: {request.method}")
logger.info(f"[REQUEST {request_id}] URL: {request.url}")
logger.info(f"[REQUEST {request_id}] Remote Address: {request.remote_addr}")
logger.info(f"[REQUEST {request_id}] Headers: {dict(request.headers)}")
try:
data = request.get_json()
logger.info(f"[REQUEST {request_id}] Body (JSON): {data}")
if not data or 'url' not in data:
logger.warning(f"[REQUEST {request_id}] Ошибка: URL не предоставлен в запросе")
return jsonify({'error': 'URL is required'}), 400
url = data['url']
logger.info(f"[REQUEST {request_id}] Получен запрос на скачивание (stream): {url}")
# Проверяем, что это YouTube URL
if 'youtube.com' not in url and 'youtu.be' not in url:
logger.warning(f"[REQUEST {request_id}] Ошибка: URL не является YouTube URL: {url}")
return jsonify({'error': 'Only YouTube URLs are supported'}), 400
# Скачиваем видео
logger.info(f"[REQUEST {request_id}] Начинаем скачивание видео...")
video_path = download_youtube_video(url)
logger.info(f"[REQUEST {request_id}] Видео успешно скачано: {video_path}")
# Читаем файл и отправляем
file_size = video_path.stat().st_size
logger.info(f"[REQUEST {request_id}] Размер файла: {file_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 safe_filename.endswith(('.mp4', '.webm', '.mkv')):
safe_filename = 'youtube_video.mp4'
# Определяем content-type
content_type = 'video/mp4'
if video_path.suffix == '.webm':
content_type = 'video/webm'
elif video_path.suffix == '.mkv':
content_type = 'video/x-matroska'
logger.info(f"[REQUEST {request_id}] Отправляем файл: {safe_filename}, Content-Type: {content_type}, размер: {len(video_data)} байт")
# Удаляем временный файл
video_path.unlink()
logger.info(f"[REQUEST {request_id}] Временный файл удален")
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)
error_lower = error_str.lower()
logger.error(f"[REQUEST {request_id}] ========== ОШИБКА В ЗАПРОСЕ ==========")
logger.error(f"[REQUEST {request_id}] Ошибка при скачивании: {error_str}")
logger.error(f"[REQUEST {request_id}] Полный traceback:\n{traceback.format_exc()}")
# Улучшаем сообщение об ошибке, если проблема с cookies
if 'cookies' in error_lower or 'bot' in error_lower or 'sign in' in error_lower or 'authentication' in error_lower:
logger.error(f"[REQUEST {request_id}] Обнаружена ошибка связанная с cookies!")
error_msg = (
f"{error_str}\n\n"
"💡 Совет: Cookies устарели или недействительны. "
"Обновите cookies, запустив скрипт:\n"
" ./youtube-downloader/get_youtube_cookies.sh\n"
"Затем перезапустите сервис."
)
else:
error_msg = error_str
logger.error(f"[REQUEST {request_id}] Возвращаем 500 ошибку клиенту")
logger.error(f"[REQUEST {request_id}] ========== КОНЕЦ ОБРАБОТКИ ОШИБКИ ==========")
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)