docs: media player subsystem architecture and conventions

Document the media server process, /media/ port map, library layout on
the ./media bind mount, Hope's media MCP toolset, the music/mic
mutual-exclusion rule, and yt-dlp download/proxy conventions.
This commit is contained in:
snowspeeder committed 2026-08-29 13:22:01 -04:00
1 parent 04c4a53594
commit a53c090882
9 files changed
+479 -13

No files matched your search

+10 -2
View File
@@ -1263,7 +1263,13 @@ function clearMediaAudio() {
function updateMediaNowCard(track) {
mediaTitleEl.textContent = track.title || track.id;
mediaChannelEl.textContent = track.channel || (track.kind === "stream" ? "Live stream" : "");
if (track.status === "failed") {
mediaChannelEl.textContent = "Download failed — pick another result or try again.";
} else if (track.status === "downloading") {
mediaChannelEl.textContent = "Preparing…";
} else {
mediaChannelEl.textContent = track.channel || (track.kind === "stream" ? "Live stream" : "");
}
if (track.art_url) {
mediaArt.src = track.art_url;
mediaArt.hidden = false;
@@ -1403,7 +1409,9 @@ mediaAudio.addEventListener("timeupdate", () => {
});
mediaAudio.addEventListener("durationchange", updateSeekUI);
mediaAudio.addEventListener("error", () => {
if (mediaCurrentTrackId) {
// An empty src (track still downloading) also fires this — only surface
// real playback failures.
if (mediaCurrentTrackId && mediaAudio.getAttribute("src")) {
mediaChannelEl.textContent = "Playback error — the track may still be preparing.";
}
});
+17 -6
View File
@@ -63,6 +63,12 @@ _executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="media-dl")
for _d in (FILES_DIR, THUMBS_DIR, JOBS_DIR):
_d.mkdir(parents=True, exist_ok=True)
# The bind mount may be inspected/cleaned from the host by a non-root
# user (Unraid share convention) — keep the dirs world-traversable.
try:
os.chmod(_d, 0o777)
except OSError:
pass
# ── Library store ───────────────────────────────────────────────────────────
@@ -263,6 +269,7 @@ def _download_worker(job: dict, track: dict, video_id: str) -> 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)
@@ -385,7 +392,10 @@ async def search(request: Request) -> dict:
query = str(body.get("query", "")).strip()
if not query:
raise HTTPException(400, "query is required")
limit = max(1, min(int(body.get("limit", 6)), 15))
try:
limit = max(1, min(int(body.get("limit", 6)), 15))
except (TypeError, ValueError):
raise HTTPException(400, "limit must be an integer")
return {"query": query, "results": _run_search(query, limit)}
@@ -479,10 +489,11 @@ async def control(request: Request) -> dict:
elif action == "stop":
st["desired"] = "stopped"
elif action == "seek":
st["seek_hint"] = {
"position": max(0.0, float(body.get("position", 0))),
"ts": time.time(),
}
try:
position = max(0.0, float(body.get("position", 0)))
except (TypeError, ValueError):
raise HTTPException(400, "position must be a number")
st["seek_hint"] = {"position": position, "ts": time.time()}
elif action == "skip":
current = st["track_id"]
candidates = sorted(
@@ -574,7 +585,7 @@ async def stream_proxy(track_id: str, request: Request):
raise HTTPException(409, f"track is {track.get('status')}, not ready")
range_header = request.headers.get("range")
headers = {"Range": range_header} if range_header else {"Range": "bytes=0-"}
headers = {"Range": range_header} if range_header else {}
client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=600), follow_redirects=True)
try:
upstream = await client.send(client.build_request("GET", track["url"], headers=headers), stream=True)