fix: harden media server — temp-file yt-dlp stderr, parse guards, UI failure surfacing
Rebuild & Deploy / rebuild (push) Failing after 4m27s

- 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.
This commit is contained in:
snowspeeder committed 2026-08-29 13:27:58 -04:00
1 parent f534b8ccb5
commit 48b92585c6
1 file changed
+46 -37
+46 -37
View File
@@ -29,6 +29,7 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import tempfile
import threading import threading
import time import time
import uuid import uuid
@@ -259,42 +260,46 @@ def _download_worker(job: dict, track: dict, video_id: str) -> None:
] ]
job["stage"] = "downloading" job["stage"] = "downloading"
info: dict = {} info: dict = {}
try: # stderr goes to a temp file: reading it from a PIPE while the stdout
proc = subprocess.Popen( # loop blocks could deadlock if yt-dlp ever fills the stderr buffer.
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, with tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") as errfile:
text=True, errors="replace", try:
) proc = subprocess.Popen(
deadline = time.monotonic() + DOWNLOAD_TIMEOUT cmd, stdout=subprocess.PIPE, stderr=errfile,
assert proc.stdout is not None and proc.stderr is not None text=True, errors="replace",
for line in proc.stdout: )
if time.monotonic() > deadline: deadline = time.monotonic() + DOWNLOAD_TIMEOUT
proc.kill() assert proc.stdout is not None
proc.wait(timeout=30) for line in proc.stdout:
raise RuntimeError(f"Download exceeded {DOWNLOAD_TIMEOUT}s") if time.monotonic() > deadline:
line = line.strip() proc.kill()
m = _PROGRESS_RE.search(line) proc.wait(timeout=30)
if m: raise RuntimeError(f"Download exceeded {DOWNLOAD_TIMEOUT}s")
job["progress"] = min(85.0, float(m.group(1)) * 0.85) line = line.strip()
_persist_job(job) m = _PROGRESS_RE.search(line)
elif line.startswith(("[ExtractAudio]", "[Metadata]", "[EmbedThumbnail]")): if m:
job["stage"] = "transcoding" job["progress"] = min(85.0, float(m.group(1)) * 0.85)
job["progress"] = max(job["progress"], 86.0) _persist_job(job)
_persist_job(job) elif line.startswith(("[ExtractAudio]", "[Metadata]", "[EmbedThumbnail]")):
elif line.startswith("{"): job["stage"] = "transcoding"
try: job["progress"] = max(job["progress"], 86.0)
info = json.loads(line) _persist_job(job)
except json.JSONDecodeError: elif line.startswith("{"):
pass try:
proc.wait(timeout=30) info = json.loads(line)
if proc.returncode != 0: except json.JSONDecodeError:
stderr_tail = (proc.stderr.read() or "").strip().splitlines() pass
detail = stderr_tail[-1] if stderr_tail else f"exit code {proc.returncode}" proc.wait(timeout=30)
raise RuntimeError(detail) if proc.returncode != 0:
if not target.exists(): errfile.seek(0)
raise RuntimeError("yt-dlp finished but no mp3 was produced") stderr_tail = (errfile.read() or "").strip().splitlines()
except Exception as exc: # noqa: BLE001 — any failure marks the track failed detail = stderr_tail[-1] if stderr_tail else f"exit code {proc.returncode}"
_fail_job(job, track, str(exc)) raise RuntimeError(detail)
return 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" thumb_src = FILES_DIR / f"{track['id']}.jpg"
try: try:
@@ -548,12 +553,16 @@ async def report(request: Request) -> dict:
"""The browser reports real playback state (source of truth for the player).""" """The browser reports real playback state (source of truth for the player)."""
body = await request.json() body = await request.json()
room = str(body.get("room") or "default") room = str(body.get("room") or "default")
try:
position = float(body.get("position") or 0.0)
except (TypeError, ValueError):
position = 0.0
with _lock: with _lock:
st = _room_state(room) st = _room_state(room)
st["reported"] = { st["reported"] = {
"track_id": body.get("track_id"), "track_id": body.get("track_id"),
"state": str(body.get("state") or "stopped"), "state": str(body.get("state") or "stopped"),
"position": float(body.get("position") or 0.0), "position": position,
"duration": body.get("duration"), "duration": body.get("duration"),
"ts": time.time(), "ts": time.time(),
} }