diff --git a/backend/services/ytdlp.py b/backend/services/ytdlp.py index 1792f41..271290e 100644 --- a/backend/services/ytdlp.py +++ b/backend/services/ytdlp.py @@ -1,8 +1,11 @@ """Subprocess wrapper for yt-dlp.""" import json +import os import random import re +import shutil import subprocess +import tempfile import threading import time import urllib.request @@ -14,9 +17,36 @@ from typing import Any from ..config import settings +def _make_private_cookie_copy(args: list[str]) -> tuple[list[str], str | None]: + """Replace --cookies with a private temp copy so concurrent yt-dlp + processes never write to the same cookie jar simultaneously.""" + for i, arg in enumerate(args): + if arg == "--cookies" and i + 1 < len(args): + source = args[i + 1] + if Path(source).exists(): + try: + tmp = tempfile.NamedTemporaryFile(suffix=".txt", delete=False) + tmp.close() + shutil.copy2(source, tmp.name) + modified = list(args) + modified[i + 1] = tmp.name + return modified, tmp.name + except Exception: + break + return list(args), None + + def _run(args: list[str], timeout: int = 60) -> tuple[str, str, int]: - result = subprocess.run(args, capture_output=True, text=True, timeout=timeout) - return result.stdout, result.stderr, result.returncode + args, tmp_path = _make_private_cookie_copy(args) + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=timeout) + return result.stdout, result.stderr, result.returncode + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except Exception: + pass # Global rate limiter for all metadata fetches — prevents concurrent tasks from @@ -857,49 +887,62 @@ def start_download( with _SEMAPHORE: cookie_args = _cookie_args() print(f"[ytdlp] cookie_args={cookie_args!r}", flush=True) - process = subprocess.Popen( - [ - "yt-dlp", url, - "-f", fmt, - "--merge-output-format", "mp4", - "--no-part", "--no-mtime", - "-o", output_template, - "--newline", "--progress", "--no-colors", - *subtitle_args, - *cookie_args, - ], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) + cmd = [ + "yt-dlp", url, + "-f", fmt, + "--merge-output-format", "mp4", + "--no-part", "--no-mtime", + "-o", output_template, + "--newline", "--progress", "--no-colors", + *subtitle_args, + *cookie_args, + ] + # Private cookie copy — download runs for minutes; without this, + # concurrent metadata calls would write-back to the same cookie file + # and corrupt the session. + cmd, tmp_cookie_path = _make_private_cookie_copy(cmd) + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) - file_path = None - stream_index = 0 - output_lines: list[str] = [] - for line in process.stdout: - line = line.strip() - output_lines.append(line) - if re.search(r"\[download\] Destination:", line): - stream_index += 1 - m = re.search(r"\[download\]\s+([\d.]+)%", line) - if m: - pct = float(m.group(1)) - scaled = pct * 0.85 if stream_index <= 1 else 85.0 + pct * 0.10 - on_progress(download_id, min(scaled, 95.0)) - m2 = re.search(r"\[(?:download|Merger)\] Destination: (.+)", line) - if m2: - file_path = m2.group(1).strip() + try: + file_path = None + stream_index = 0 + output_lines: list[str] = [] + for line in process.stdout: + line = line.strip() + output_lines.append(line) + if re.search(r"\[download\] Destination:", line): + stream_index += 1 + m = re.search(r"\[download\]\s+([\d.]+)%", line) + if m: + pct = float(m.group(1)) + scaled = pct * 0.85 if stream_index <= 1 else 85.0 + pct * 0.10 + on_progress(download_id, min(scaled, 95.0)) + m2 = re.search(r"\[(?:download|Merger)\] Destination: (.+)", line) + if m2: + file_path = m2.group(1).strip() - process.wait() - if process.returncode == 0: - _strip_vtt_cue_settings(video_id) - resolution = detect_resolution(file_path) if file_path else None - on_complete(download_id, file_path, resolution) - else: - tail = "\n".join(output_lines[-20:]) if output_lines else "(no output)" - import logging - logging.getLogger(__name__).error("yt-dlp failed (code %d):\n%s", process.returncode, tail) - on_error(download_id, f"yt-dlp exited with code {process.returncode}:\n{tail}") + process.wait() + if process.returncode == 0: + _strip_vtt_cue_settings(video_id) + resolution = detect_resolution(file_path) if file_path else None + on_complete(download_id, file_path, resolution) + else: + tail = "\n".join(output_lines[-20:]) if output_lines else "(no output)" + import logging + logging.getLogger(__name__).error("yt-dlp failed (code %d):\n%s", process.returncode, tail) + on_error(download_id, f"yt-dlp exited with code {process.returncode}:\n{tail}") + finally: + if tmp_cookie_path: + try: + os.unlink(tmp_cookie_path) + except Exception: + pass thread = threading.Thread(target=_run_download, daemon=True) thread.start()