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 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(),
}