diff --git a/backend/services/ytdlp.py b/backend/services/ytdlp.py index 1e2f645..90e3320 100644 --- a/backend/services/ytdlp.py +++ b/backend/services/ytdlp.py @@ -55,10 +55,25 @@ _meta_lock = threading.Lock() _meta_last_call: float = 0.0 _META_MIN_GAP = 5.0 # seconds between any two metadata requests +# Active download counter — _meta_run pauses while any download is running so +# background discovery and a concurrent download never share the YouTube session +# at the same time. Downloads set/clear this; _meta_run reads it. +_active_downloads: int = 0 +_active_downloads_lock = threading.Lock() + def _meta_run(args: list[str], timeout: int = 60) -> tuple[str, str, int]: global _meta_last_call with _meta_lock: + # Pause background metadata calls while a download is active. + # Running both concurrently causes YouTube to see two requests from the + # same session simultaneously, which triggers cookie invalidation. + while True: + with _active_downloads_lock: + if _active_downloads == 0: + break + time.sleep(3) + now = time.monotonic() wait = _META_MIN_GAP - (now - _meta_last_call) if wait > 0: @@ -884,63 +899,71 @@ def start_download( ) def _run_download(): - with _SEMAPHORE: - cookie_args = _cookie_args() - print(f"[ytdlp] cookie_args={cookie_args!r}", flush=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() + global _active_downloads + # Signal to _meta_run that a download is active so it pauses all + # background discovery/metadata calls for the duration. Running both + # concurrently causes YouTube to see the same session used simultaneously + # and invalidates cookies. + with _active_downloads_lock: + _active_downloads += 1 + try: + with _SEMAPHORE: + cookie_args = _cookie_args() + print(f"[ytdlp] cookie_args={cookie_args!r}", flush=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, + ] + 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() - 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 + 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 + finally: + with _active_downloads_lock: + _active_downloads -= 1 thread = threading.Thread(target=_run_download, daemon=True) thread.start()