fix: move media server to 8093 — YOURLS/Apache owns 8092 on xNAS
Rebuild & Deploy / rebuild (push) Successful in 1m0s

The Unraid deployment's /media/api requests were answered by an
Apache/YOURLS stack squatting on 8092, surfacing as UI 503s; the CI
healthcheck (curl without -f) counted that 503 as healthy, so run #15
deployed green against a broken media server.

- media server port 8092 -> 8093 everywhere (server, MCP, agent, nginx,
  compose, CI, .env.example, docs, tests).
- healthchecks use curl -f in compose and rebuild.yml so HTTP error
  statuses fail deployment.
- tests default to the Unraid deployment (VOICE_WEB_URL=
  https://192.168.86.2:9178); token goes through nginx (loopback :8091
  is unreachable off-host); bind-mount-coupled tests skip on remote
  targets. AGENTS.md documents the convention and both failure modes.
This commit is contained in:
snowspeeder committed 2026-08-29 16:01:26 -04:00
1 parent d7c385301e
commit ce8fd1e046
11 files changed
+72 -28

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ VOICE_DEFAULT_USER=shane
# media/files/ transcoded mp3 tracks # media/files/ transcoded mp3 tracks
# media/thumbs/ cover art # media/thumbs/ cover art
# media/jobs/ download job status # media/jobs/ download job status
# MEDIA_SERVER_PORT=8092 # MEDIA_SERVER_PORT=8093 (8092 is taken by YOURLS/Apache on xNAS)
# #
# Optional: export cookies.txt from a browser logged into YouTube and drop it # Optional: export cookies.txt from a browser logged into YouTube and drop it
# in ./media/cookies.txt to avoid "confirm you're not a bot" download blocks. # in ./media/cookies.txt to avoid "confirm you're not a bot" download blocks.
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
--name voice \ --name voice \
--network host \ --network host \
--restart unless-stopped \ --restart unless-stopped \
--health-cmd='curl -sk https://localhost:${WEB_PORT:-9178}/ -o /dev/null && curl -s http://localhost:7880/ -o /dev/null && curl -s http://localhost:${MEDIA_SERVER_PORT:-8092}/media/api/healthz -o /dev/null' \ --health-cmd='curl -sfk https://localhost:${WEB_PORT:-9178}/ -o /dev/null && curl -sf http://localhost:7880/ -o /dev/null && curl -sf http://localhost:${MEDIA_SERVER_PORT:-8093}/media/api/healthz -o /dev/null' \
--health-interval=15s \ --health-interval=15s \
--health-timeout=5s \ --health-timeout=5s \
--health-retries=3 \ --health-retries=3 \
@@ -50,7 +50,7 @@ jobs:
-e VOICE_USERS_FILE=/etc/hope/users.json \ -e VOICE_USERS_FILE=/etc/hope/users.json \
-e VOICE_DEFAULT_USER=shane \ -e VOICE_DEFAULT_USER=shane \
-e MEDIA_DIR=/data/media \ -e MEDIA_DIR=/data/media \
-e MEDIA_SERVER_PORT=8092 \ -e MEDIA_SERVER_PORT=8093 \
-e YTDLP_COOKIES=/data/media/cookies.txt \ -e YTDLP_COOKIES=/data/media/cookies.txt \
hope-voice-api-voice:latest hope-voice-api-voice:latest
+18 -2
View File
@@ -9,7 +9,7 @@ One Docker container runs four processes via supervisord:
1. **LiveKit server** — open-source WebRTC media transport (port 7880 TCP, 7882 UDP muxed media) 1. **LiveKit server** — open-source WebRTC media transport (port 7880 TCP, 7882 UDP muxed media)
2. **Voice agent** — Python LiveKit Agents pipeline: Azure STT → LLM (OpenWebUI) → Azure TTS 2. **Voice agent** — Python LiveKit Agents pipeline: Azure STT → LLM (OpenWebUI) → Azure TTS
3. **Web frontend** — static HTML served by nginx (port 9178 by default) 3. **Web frontend** — static HTML served by nginx (port 9178 by default)
4. **Media server** — FastAPI music library/player coordinator (port 8092 internal, proxied at /media/api/) 4. **Media server** — FastAPI music library/player coordinator (port 8093 internal, proxied at /media/api/)
``` ```
Browser ──WebRTC──► LiveKit Server ──audio──► Agent Browser ──WebRTC──► LiveKit Server ──audio──► Agent
@@ -44,7 +44,7 @@ LAN access requires firewall rules: `9178/tcp` (UI + signaling), `7882/udp`
| 7880 | TCP | LiveKit HTTP/WS | internal only (proxied via nginx at /livekit/) | | 7880 | TCP | LiveKit HTTP/WS | internal only (proxied via nginx at /livekit/) |
| 7881 | TCP | LiveKit RTC media (TCP fallback) | LAN | | 7881 | TCP | LiveKit RTC media (TCP fallback) | LAN |
| 7882 | UDP | LiveKit RTC media (muxed) | LAN | | 7882 | UDP | LiveKit RTC media (muxed) | LAN |
| 8092 | TCP | Media server (library, player state, stream proxy) | internal only (proxied via nginx at /media/api/) | | 8093 | TCP | Media server (library, player state, stream proxy) | internal only (proxied via nginx at /media/api/) |
| 9178 | TCP | Web frontend (HTTPS) | LAN | | 9178 | TCP | Web frontend (HTTPS) | LAN |
## Configuration ## Configuration
@@ -107,10 +107,23 @@ The LLM is instructed to:
## Testing ## Testing
**Tests run against the Unraid deployment by default** (`https://192.168.86.2:9178`,
via `VOICE_WEB_URL` in tests/conftest.py) — that is the instance users hit
and the one CI (rebuild.yml) deploys on every push to master. The local dev
compose instance is for development only; set `VOICE_WEB_URL=https://localhost:9178`
to target it instead.
```bash ```bash
# Verify the container is running # Verify the container is running
docker compose ps docker compose ps
# Run the suite against the Unraid deployment (default)
python -m pytest tests/ -v -m "not slow"
# Real end-to-end YouTube Music download (network-heavy, gated)
HOPE_RUN_YTDLP_DOWNLOAD=1 python -m pytest tests/test_media.py -m slow
```
# Check all logs (single service: "voice") # Check all logs (single service: "voice")
docker compose logs -f voice docker compose logs -f voice
@@ -179,6 +192,9 @@ curl -s "https://eastus.tts.speech.microsoft.com/cognitiveservices/v1" \
- **External stream URLs are proxied, never played directly.** An `http://` radio URL would be blocked as mixed content on the HTTPS UI, so `GET /media/api/stream/{id}` re-serves the upstream over HTTPS with Range passthrough. Downloaded tracks on that endpoint just redirect to `/media/files/`. - **External stream URLs are proxied, never played directly.** An `http://` radio URL would be blocked as mixed content on the HTTPS UI, so `GET /media/api/stream/{id}` re-serves the upstream over HTTPS with Range passthrough. Downloaded tracks on that endpoint just redirect to `/media/files/`.
- **Player state is per-room desired state, reconciled by the browser.** The media server never streams to anything; it records what SHOULD play per LiveKit room and the browser polls and reports back. Track ids for YouTube tracks are the video ids (dedupe by video_id); stream tracks get `s<uuid>`. - **Player state is per-room desired state, reconciled by the browser.** The media server never streams to anything; it records what SHOULD play per LiveKit room and the browser polls and reports back. Track ids for YouTube tracks are the video ids (dedupe by video_id); stream tracks get `s<uuid>`.
- **Anonymous yt-dlp first, cookies when flaky.** Search (`ytsearch`) and downloads work anonymously from a residential IP; if YouTube starts bot-blocking, drop a cookies.txt into `./media/cookies.txt` and set `YTDLP_COOKIES=/data/media/cookies.txt`. - **Anonymous yt-dlp first, cookies when flaky.** Search (`ytsearch`) and downloads work anonymously from a residential IP; if YouTube starts bot-blocking, drop a cookies.txt into `./media/cookies.txt` and set `YTDLP_COOKIES=/data/media/cookies.txt`.
- **Never hard-require container env in supervisord.** `%(ENV_X)s` expansion aborts the whole supervisord config when the var is missing, taking every program down (this silently killed the run-151 deploy). Programs with safe in-code defaults (media server) must inherit the container env instead of listing it.
- **Healthchecks must use `curl -f`.** Without it, any HTTP response — including a 503 from a squatter — counts as healthy. Run #15 deployed "healthy" against a broken media port this way.
- **Port 8092 is taken on xNAS.** An Apache/YOURLS stack occupies 8092 on the Unraid host; the media server uses 8093. Before picking a new internal port, probe the Unraid deployment host, not just the dev box.
## Git ## Git
+1 -1
View File
@@ -688,7 +688,7 @@ def build_mcp_toolsets(user_profile: dict, task_dir: str) -> list[mcp.MCPToolset
# Media tools talk to the local media server; the room name scopes player # Media tools talk to the local media server; the room name scopes player
# state so concurrent sessions don't fight over one virtual player. # state so concurrent sessions don't fight over one virtual player.
media_server_url = os.environ.get("MEDIA_SERVER_URL", "http://127.0.0.1:8092") media_server_url = os.environ.get("MEDIA_SERVER_URL", "http://127.0.0.1:8093")
media_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "media_mcp.py") media_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "media_mcp.py")
toolsets.append( toolsets.append(
mcp.MCPToolset( mcp.MCPToolset(
+2 -2
View File
@@ -7,7 +7,7 @@ server and reports playback back, so voice controls and the web panel stay
in sync. in sync.
Per-session environment (set by the agent when spawning this MCP): Per-session environment (set by the agent when spawning this MCP):
- MEDIA_SERVER_URL e.g. http://127.0.0.1:8092 - MEDIA_SERVER_URL e.g. http://127.0.0.1:8093
- MEDIA_ROOM LiveKit room name this session controls - MEDIA_ROOM LiveKit room name this session controls
The "stop listening while music plays" rule lives in the agent itself: the The "stop listening while music plays" rule lives in the agent itself: the
@@ -22,7 +22,7 @@ import os
import httpx import httpx
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
MEDIA_SERVER_URL = os.environ.get("MEDIA_SERVER_URL", "http://127.0.0.1:8092").rstrip("/") MEDIA_SERVER_URL = os.environ.get("MEDIA_SERVER_URL", "http://127.0.0.1:8093").rstrip("/")
MEDIA_ROOM = os.environ.get("MEDIA_ROOM", "default") MEDIA_ROOM = os.environ.get("MEDIA_ROOM", "default")
mcp = FastMCP("media") mcp = FastMCP("media")
+2 -2
View File
@@ -19,7 +19,7 @@ services:
VOICE_USERS_FILE: "/etc/hope/users.json" VOICE_USERS_FILE: "/etc/hope/users.json"
VOICE_DEFAULT_USER: "${VOICE_DEFAULT_USER:-shane}" VOICE_DEFAULT_USER: "${VOICE_DEFAULT_USER:-shane}"
MEDIA_DIR: "/data/media" MEDIA_DIR: "/data/media"
MEDIA_SERVER_PORT: "${MEDIA_SERVER_PORT:-8092}" MEDIA_SERVER_PORT: "${MEDIA_SERVER_PORT:-8093}"
YTDLP_COOKIES: "${YTDLP_COOKIES:-/data/media/cookies.txt}" YTDLP_COOKIES: "${YTDLP_COOKIES:-/data/media/cookies.txt}"
volumes: volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro - ./livekit.yaml:/etc/livekit.yaml:ro
@@ -27,7 +27,7 @@ services:
- ./skills:/skills - ./skills:/skills
- ./media:/data/media - ./media:/data/media
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -sk https://localhost:$${WEB_PORT:-9178}/ -o /dev/null && curl -s http://localhost:7880/ -o /dev/null && curl -s http://localhost:$${MEDIA_SERVER_PORT:-8092}/media/api/healthz -o /dev/null"] test: ["CMD-SHELL", "curl -sfk https://localhost:$${WEB_PORT:-9178}/ -o /dev/null && curl -sf http://localhost:7880/ -o /dev/null && curl -sf http://localhost:$${MEDIA_SERVER_PORT:-8093}/media/api/healthz -o /dev/null"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 3 retries: 3
+1 -1
View File
@@ -27,7 +27,7 @@ server {
# external live-stream URLs (http:// sources would be blocked as mixed # external live-stream URLs (http:// sources would be blocked as mixed
# content in the browser, so they are re-served over HTTPS from here). # content in the browser, so they are re-served over HTTPS from here).
location /media/api/ { location /media/api/ {
proxy_pass http://127.0.0.1:8092/media/api/; proxy_pass http://127.0.0.1:8093/media/api/;
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
proxy_send_timeout 3600s; proxy_send_timeout 3600s;
+21 -8
View File
@@ -1,23 +1,35 @@
"""Shared fixtures for the Hope voice-assistant test suite. """Shared fixtures for the Hope voice-assistant test suite.
Tests run on the HOST machine against the live "voice" container: Tests run against the **Unraid deployment** by default — that is the
- HTTPS web UI -> https://localhost:9178 (self-signed cert, verify=False) instance users hit and the one CI (rebuild.yml) keeps up to date:
- Token server -> http://127.0.0.1:8091 (host network mode) - HTTPS web UI -> https://192.168.86.2:9178 (self-signed cert, verify=False)
- Token server -> via the same nginx (https://.../token; the loopback
:8091 socket is only reachable on the deployment host itself)
- LLM API -> LLM_BASE_URL (default http://192.168.86.2:3005/api) - LLM API -> LLM_BASE_URL (default http://192.168.86.2:3005/api)
- In-container -> docker exec voice ... - In-container -> docker exec voice ... (targets the LOCAL dev container
unless a docker context/DOCKER_HOST for the Unraid host is configured)
Override VOICE_WEB_URL to test a different instance (e.g. the local dev
compose deployment at https://localhost:9178).
""" """
import json import json
import os import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
import httpx import httpx
import pytest import pytest
CONTAINER = "voice" CONTAINER = "voice"
WEB_BASE_URL = os.environ.get("VOICE_WEB_URL", "https://localhost:9178") WEB_BASE_URL = os.environ.get("VOICE_WEB_URL", "https://192.168.86.2:9178").rstrip("/")
TOKEN_URL = os.environ.get("VOICE_TOKEN_URL", "http://127.0.0.1:8091/token") TOKEN_URL = os.environ.get(
"VOICE_TOKEN_URL", f"{WEB_BASE_URL}/token"
)
# Filesystem-coupled tests (repo ./media bind mount) only make sense when
# the test host IS the deployment host.
IS_LOCAL_DEPLOYMENT = urlparse(WEB_BASE_URL).hostname in ("localhost", "127.0.0.1")
LLM_BASE_URL = ( LLM_BASE_URL = (
os.environ.get("LLM_BASE_URL") os.environ.get("LLM_BASE_URL")
or os.environ.get("GEMMA_BASE_URL") or os.environ.get("GEMMA_BASE_URL")
@@ -91,8 +103,9 @@ def client(base_url):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def token() -> dict: def token() -> dict:
"""Fetch a fresh LiveKit token from the token endpoint (direct, no proxy).""" """Fetch a fresh LiveKit token (via nginx by default; the loopback
resp = httpx.post(TOKEN_URL, json={}, timeout=10) :8091 socket is only reachable on the deployment host itself)."""
resp = httpx.post(TOKEN_URL, json={}, timeout=10, verify=False)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
assert "token" in data, f"token endpoint did not return a token: {data.keys()}" assert "token" in data, f"token endpoint did not return a token: {data.keys()}"
+15 -6
View File
@@ -17,7 +17,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
import httpx import httpx
import pytest import pytest
from conftest import TOKEN_URL, docker_exec from conftest import TOKEN_URL, docker_exec, IS_LOCAL_DEPLOYMENT
MEDIA_API = "/media/api" MEDIA_API = "/media/api"
TEST_ROOM = f"media-test-{uuid.uuid4().hex[:8]}" TEST_ROOM = f"media-test-{uuid.uuid4().hex[:8]}"
@@ -200,7 +200,12 @@ def test_media_stream_proxy_and_range(client):
def test_media_files_static_range_support(client): def test_media_files_static_range_support(client):
"""Downloaded tracks are nginx static files with native Range support.""" """Downloaded tracks are nginx static files with native Range support.
Writes a probe file into the repo's ./media bind mount, so it only runs
when the test host IS the deployment host (local dev deployment)."""
if not IS_LOCAL_DEPLOYMENT:
pytest.skip("writes to the repo ./media bind mount — local deployments only")
import pathlib import pathlib
marker = uuid.uuid4().hex[:8] marker = uuid.uuid4().hex[:8]
@@ -222,7 +227,11 @@ def test_media_files_static_range_support(client):
def test_media_library_persists_in_volume(client): def test_media_library_persists_in_volume(client):
"""The library JSON is written to the ./media bind mount (permanent).""" """The library JSON is written to the ./media bind mount (permanent).
Reads the repo's ./media directory — local deployments only."""
if not IS_LOCAL_DEPLOYMENT:
pytest.skip("reads the repo ./media bind mount — local deployments only")
import pathlib import pathlib
upstream, url = _serve_fake_audio(b"\x00" * 16) upstream, url = _serve_fake_audio(b"\x00" * 16)
@@ -271,7 +280,7 @@ def test_ui_media_styles_serve(client):
def test_token_response_includes_room(): def test_token_response_includes_room():
resp = httpx.post(TOKEN_URL, json={}, timeout=10) resp = httpx.post(TOKEN_URL, json={}, timeout=10, verify=False)
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["room"].startswith("voice-") assert resp.json()["room"].startswith("voice-")
@@ -279,7 +288,7 @@ def test_token_response_includes_room():
# ── Hope's media MCP toolset ──────────────────────────────────────────────── # ── Hope's media MCP toolset ────────────────────────────────────────────────
MEDIA_MCP_ENV = { MEDIA_MCP_ENV = {
"MEDIA_SERVER_URL": "http://127.0.0.1:8092", "MEDIA_SERVER_URL": "http://127.0.0.1:8093",
"MEDIA_ROOM": "mcp-test-room", "MEDIA_ROOM": "mcp-test-room",
} }
@@ -334,7 +343,7 @@ def test_mcp_media_resume_without_load(mcp_client_factory, client):
) )
text, is_error = mcp_client_factory( text, is_error = mcp_client_factory(
"media_mcp.py", "resume_music", {}, env={ "media_mcp.py", "resume_music", {}, env={
"MEDIA_SERVER_URL": "http://127.0.0.1:8092", "MEDIA_SERVER_URL": "http://127.0.0.1:8093",
"MEDIA_ROOM": "mcp-empty-room", "MEDIA_ROOM": "mcp-empty-room",
} }
) )
+5 -2
View File
@@ -54,8 +54,11 @@ def test_livekit_client_bundle_serves(client):
def test_token_endpoint_direct(): def test_token_endpoint_direct():
"""Token server on 127.0.0.1:8091 (host network mode).""" """Token endpoint via TOKEN_URL (nginx path on remote deployments,
resp = httpx.post("http://127.0.0.1:8091/token", json={}, timeout=10) loopback :8091 when VOICE_TOKEN_URL points at the deployment host)."""
import conftest
resp = httpx.post(conftest.TOKEN_URL, json={}, timeout=10, verify=False)
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert "token" in data and data["token"].count(".") == 2 # JWT shape assert "token" in data and data["token"].count(".") == 2 # JWT shape
+4 -1
View File
@@ -49,7 +49,10 @@ JOBS_DIR = MEDIA_DIR / "jobs"
LIBRARY_FILE = MEDIA_DIR / "library.json" LIBRARY_FILE = MEDIA_DIR / "library.json"
COOKIES_FILE = os.environ.get("YTDLP_COOKIES", "") COOKIES_FILE = os.environ.get("YTDLP_COOKIES", "")
MEDIA_HOST = os.environ.get("MEDIA_SERVER_HOST", "127.0.0.1") MEDIA_HOST = os.environ.get("MEDIA_SERVER_HOST", "127.0.0.1")
MEDIA_PORT = int(os.environ.get("MEDIA_SERVER_PORT", "8092")) # 8093, NOT 8092: 8092 is occupied by an Apache/YOURLS stack on the Unraid
# deployment host (xNAS) — a bind conflict there looks like a healthy
# deployment because the healthcheck curl (without -f) accepts any response.
MEDIA_PORT = int(os.environ.get("MEDIA_SERVER_PORT", "8093"))
DOWNLOAD_TIMEOUT = int(os.environ.get("MEDIA_DOWNLOAD_TIMEOUT", "600")) DOWNLOAD_TIMEOUT = int(os.environ.get("MEDIA_DOWNLOAD_TIMEOUT", "600"))
SEARCH_TIMEOUT = int(os.environ.get("MEDIA_SEARCH_TIMEOUT", "45")) SEARCH_TIMEOUT = int(os.environ.get("MEDIA_SEARCH_TIMEOUT", "45"))