videoDownloadTGbot/youtube-downloader/app.py
vrubelroman c9972f3d68
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 52s
fix(cookies): не давать yt-dlp затирать мастер-файл кук, честнее алерты
Реальный инцидент: 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>
2026-07-31 15:18:13 +00:00

615 lines
26 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 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)