fix: harden media server — temp-file yt-dlp stderr, parse guards, UI failure surfacing
Rebuild & Deploy / rebuild (push) Failing after 4m27s
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:
1 parent
f534b8ccb5
commit
48b92585c6
1 file changed
+46
-37
+46
-37
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user