From 48b92585c66f6a66f10f8ea23885fa72ca4a526f Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sat, 29 Aug 2026 13:27:58 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20harden=20media=20server=20=E2=80=94=20te?= =?UTF-8?q?mp-file=20yt-dlp=20stderr,=20parse=20guards,=20UI=20failure=20s?= =?UTF-8?q?urfacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - yt-dlp stderr now goes to a temp file so a chatty stderr can never deadlock the stdout progress loop. - report/search/seek numeric parsing returns 400 instead of 500. - now-playing card reflects failed/preparing download states. - media runtime data (library.json, jobs/) is gitignored; only .gitkeep is tracked. --- web/media_server.py | 83 +++++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/web/media_server.py b/web/media_server.py index 391a2a0..dee26bf 100644 --- a/web/media_server.py +++ b/web/media_server.py @@ -29,6 +29,7 @@ import os import re import subprocess import sys +import tempfile import threading import time import uuid @@ -259,42 +260,46 @@ def _download_worker(job: dict, track: dict, video_id: str) -> None: ] job["stage"] = "downloading" info: dict = {} - try: - proc = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, errors="replace", - ) - deadline = time.monotonic() + DOWNLOAD_TIMEOUT - assert proc.stdout is not None and proc.stderr is not None - for line in proc.stdout: - if time.monotonic() > deadline: - proc.kill() - proc.wait(timeout=30) - raise RuntimeError(f"Download exceeded {DOWNLOAD_TIMEOUT}s") - line = line.strip() - m = _PROGRESS_RE.search(line) - if m: - job["progress"] = min(85.0, float(m.group(1)) * 0.85) - _persist_job(job) - elif line.startswith(("[ExtractAudio]", "[Metadata]", "[EmbedThumbnail]")): - job["stage"] = "transcoding" - job["progress"] = max(job["progress"], 86.0) - _persist_job(job) - elif line.startswith("{"): - try: - info = json.loads(line) - except json.JSONDecodeError: - pass - proc.wait(timeout=30) - if proc.returncode != 0: - stderr_tail = (proc.stderr.read() or "").strip().splitlines() - detail = stderr_tail[-1] if stderr_tail else f"exit code {proc.returncode}" - raise RuntimeError(detail) - if not target.exists(): - raise RuntimeError("yt-dlp finished but no mp3 was produced") - except Exception as exc: # noqa: BLE001 — any failure marks the track failed - _fail_job(job, track, str(exc)) - return + # stderr goes to a temp file: reading it from a PIPE while the stdout + # loop blocks could deadlock if yt-dlp ever fills the stderr buffer. + with tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") as errfile: + try: + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=errfile, + text=True, errors="replace", + ) + deadline = time.monotonic() + DOWNLOAD_TIMEOUT + assert proc.stdout is not None + for line in proc.stdout: + if time.monotonic() > deadline: + proc.kill() + proc.wait(timeout=30) + raise RuntimeError(f"Download exceeded {DOWNLOAD_TIMEOUT}s") + line = line.strip() + m = _PROGRESS_RE.search(line) + if m: + job["progress"] = min(85.0, float(m.group(1)) * 0.85) + _persist_job(job) + elif line.startswith(("[ExtractAudio]", "[Metadata]", "[EmbedThumbnail]")): + job["stage"] = "transcoding" + job["progress"] = max(job["progress"], 86.0) + _persist_job(job) + elif line.startswith("{"): + try: + info = json.loads(line) + except json.JSONDecodeError: + pass + proc.wait(timeout=30) + if proc.returncode != 0: + errfile.seek(0) + stderr_tail = (errfile.read() or "").strip().splitlines() + detail = stderr_tail[-1] if stderr_tail else f"exit code {proc.returncode}" + raise RuntimeError(detail) + if not target.exists(): + raise RuntimeError("yt-dlp finished but no mp3 was produced") + except Exception as exc: # noqa: BLE001 — any failure marks the track failed + _fail_job(job, track, str(exc)) + return thumb_src = FILES_DIR / f"{track['id']}.jpg" try: @@ -548,12 +553,16 @@ async def report(request: Request) -> dict: """The browser reports real playback state (source of truth for the player).""" body = await request.json() room = str(body.get("room") or "default") + try: + position = float(body.get("position") or 0.0) + except (TypeError, ValueError): + position = 0.0 with _lock: st = _room_state(room) st["reported"] = { "track_id": body.get("track_id"), "state": str(body.get("state") or "stopped"), - "position": float(body.get("position") or 0.0), + "position": position, "duration": body.get("duration"), "ts": time.time(), }