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