"""Tests for the media player subsystem. Covers the media server API (via nginx HTTPS and direct), the stream proxy with HTTP Range passthrough, static file serving of the ./media volume, the web player markup, and Hope's media MCP toolset. Download tests are gated behind HOPE_RUN_YTDLP_DOWNLOAD=1 (network-heavy, occasionally flaky). """ import json import os import subprocess import threading import time import uuid from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse import httpx import pytest from conftest import ( TOKEN_URL, docker_exec, docker_exec_python, IS_LOCAL_DEPLOYMENT, WEB_BASE_URL ) MEDIA_API = "/media/api" TEST_ROOM = f"media-test-{uuid.uuid4().hex[:8]}" # ── local upstream for stream-proxy tests ─────────────────────────────────── def _serve_fake_audio(payload: bytes): """Tiny Range-capable HTTP server on an ephemeral port. Binds 0.0.0.0 and advertises this host's LAN IP so the deployment host's media server (which may be a different machine — Unraid) can reach it over the LAN when fetching external stream URLs. """ import socket class Handler(BaseHTTPRequestHandler): def do_GET(self): rng = self.headers.get("Range") if rng and rng.startswith("bytes="): start_s, _, end_s = rng[6:].partition("-") start = int(start_s or 0) end = int(end_s) if end_s else len(payload) - 1 end = min(end, len(payload) - 1) body = payload[start : end + 1] self.send_response(206) self.send_header( "Content-Range", f"bytes {start}-{end}/{len(payload)}" ) else: body = payload self.send_response(200) self.send_header("Content-Type", "audio/mpeg") self.send_header("Content-Length", str(len(body))) self.send_header("Accept-Ranges", "bytes") self.end_headers() self.wfile.write(body) def log_message(self, *args): pass server = HTTPServer(("0.0.0.0", 0), Handler) port = server.server_address[1] # The local IP toward the deployment host (works across the LAN). probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: probe.connect((urlparse(WEB_BASE_URL).hostname or "192.168.86.2", 9178)) lan_ip = probe.getsockname()[0] except OSError: lan_ip = "127.0.0.1" finally: probe.close() threading.Thread(target=server.serve_forever, daemon=True).start() return server, f"http://{lan_ip}:{port}/audio.mp3" # ── health + basic API shape ──────────────────────────────────────────────── def test_media_server_health_via_nginx(client): resp = client.get(f"{MEDIA_API}/healthz") assert resp.status_code == 200 assert resp.json()["ok"] is True def test_media_library_endpoint(client): resp = client.get(f"{MEDIA_API}/library") assert resp.status_code == 200 data = resp.json() assert isinstance(data["tracks"], list) assert data["version"] >= 1 for track in data["tracks"]: assert {"id", "kind", "title", "status"} <= set(track) def test_media_search_requires_query(client): resp = client.post(f"{MEDIA_API}/search", json={"query": ""}) assert resp.status_code == 400 def test_media_play_requires_target(client): resp = client.post(f"{MEDIA_API}/play", json={"room": TEST_ROOM}) assert resp.status_code == 400 def test_media_unknown_stream_404(client): resp = client.get(f"{MEDIA_API}/stream/does-not-exist") assert resp.status_code == 404 # ── player state flow (uses a live stream track as the fixture) ───────────── def test_media_player_state_flow(client): """add stream -> play -> state -> report -> pause -> seek hint -> stop.""" upstream, url = _serve_fake_audio(b"\x00" * 64) try: added = client.post( f"{MEDIA_API}/streams", json={"name": "Test FM", "url": url} ) assert added.status_code == 200 track = added.json()["track"] track_id = track["id"] assert track["kind"] == "stream" assert track["status"] == "ready" assert track["audio_url"].startswith(f"{MEDIA_API}/stream/") played = client.post( f"{MEDIA_API}/play", json={"room": TEST_ROOM, "track_id": track_id} ).json() assert played["desired"] == "playing" assert played["track"]["id"] == track_id state = client.get(f"{MEDIA_API}/state", params={"room": TEST_ROOM}).json() assert state["track"]["id"] == track_id client.post( f"{MEDIA_API}/report", json={"room": TEST_ROOM, "track_id": track_id, "state": "playing", "position": 12.5}, ) state = client.get(f"{MEDIA_API}/state", params={"room": TEST_ROOM}).json() assert abs(state["reported"]["position"] - 12.5) < 0.01 client.post(f"{MEDIA_API}/control", json={"room": TEST_ROOM, "action": "pause"}) assert client.get(f"{MEDIA_API}/state", params={"room": TEST_ROOM}).json()["desired"] == "paused" client.post( f"{MEDIA_API}/control", json={"room": TEST_ROOM, "action": "seek", "position": 42}, ) hint = client.get(f"{MEDIA_API}/state", params={"room": TEST_ROOM}).json()["seek_hint"] assert hint["position"] == 42 # start_paused variant: scope transfer without starting playback transfer = client.post( f"{MEDIA_API}/play", json={"room": TEST_ROOM + "-b", "track_id": track_id, "start_paused": True}, ).json() assert transfer["desired"] == "paused" # playing clears any open suggestion choices client.post( f"{MEDIA_API}/choices", json={"room": TEST_ROOM, "choices": [{"video_id": "x", "title": "X"}]}, ) client.post( f"{MEDIA_API}/play", json={"room": TEST_ROOM, "track_id": track_id} ) assert client.get(f"{MEDIA_API}/state", params={"room": TEST_ROOM}).json()["choices"] == [] stopped = client.post( f"{MEDIA_API}/control", json={"room": TEST_ROOM, "action": "stop"} ).json() assert stopped["desired"] == "stopped" finally: upstream.shutdown() # Remove the test stream from the permanent library. try: client.delete(f"{MEDIA_API}/tracks/{track_id}") except (httpx.HTTPError, UnboundLocalError): pass def test_media_stream_proxy_and_range(client): """External audio URLs must be reachable same-origin (HTTPS) and support Range passthrough so the browser can seek progressive streams. Local deployments test against a controlled fake upstream; remote deployments (Unraid) cannot reach the test host's ephemeral ports, so a public reference mp3 is used and Range semantics are asserted without byte equality.""" if IS_LOCAL_DEPLOYMENT: payload = b"0123456789" * 100 upstream, url = _serve_fake_audio(payload) track_id = None try: track_id = client.post( f"{MEDIA_API}/streams", json={"name": "Range FM", "url": url} ).json()["track"]["id"] full = client.get(f"{MEDIA_API}/stream/{track_id}") assert full.status_code == 200 assert full.content == payload ranged = client.get( f"{MEDIA_API}/stream/{track_id}", headers={"Range": "bytes=100-199"} ) assert ranged.status_code == 206 assert ranged.content == payload[100:200] assert ranged.headers["content-range"] == f"bytes 100-199/{len(payload)}" finally: upstream.shutdown() if track_id: try: client.delete(f"{MEDIA_API}/tracks/{track_id}") except httpx.HTTPError: pass return # Remote deployment: public reference audio (SoundHelix). url = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3" track_id = None try: track_id = client.post( f"{MEDIA_API}/streams", json={"name": "Range FM public", "url": url} ).json()["track"]["id"] full = client.get(f"{MEDIA_API}/stream/{track_id}") assert full.status_code == 200 assert full.headers.get("content-type", "").startswith("audio/") assert len(full.content) > 100_000 ranged = client.get( f"{MEDIA_API}/stream/{track_id}", headers={"Range": "bytes=0-1023"} ) assert ranged.status_code == 206 assert len(ranged.content) == 1024 assert (ranged.headers.get("content-range") or "").startswith("bytes 0-1023/") finally: if track_id: try: client.delete(f"{MEDIA_API}/tracks/{track_id}") except httpx.HTTPError: pass def test_media_files_static_range_support(client): """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 marker = uuid.uuid4().hex[:8] host_media = pathlib.Path(__file__).parents[1] / "media" / "files" host_media.mkdir(parents=True, exist_ok=True) fname = f"test-{marker}.mp3" payload = b"IMHEADDATA" + b"\x01" * 2048 (host_media / fname).write_bytes(payload) try: full = client.get(f"/media/files/{fname}") assert full.status_code == 200 assert full.content == payload ranged = client.get(f"/media/files/{fname}", headers={"Range": "bytes=0-9"}) assert ranged.status_code == 206 assert ranged.content == b"IMHEADDATA" finally: (host_media / fname).unlink(missing_ok=True) def test_media_library_persists_in_volume(client): """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 upstream, url = _serve_fake_audio(b"\x00" * 16) track_id = None try: track_id = client.post( f"{MEDIA_API}/streams", json={"name": "Persist FM", "url": url} ).json()["track"]["id"] library_file = pathlib.Path(__file__).parents[1] / "media" / "library.json" data = json.loads(library_file.read_text()) assert track_id in data["tracks"] finally: upstream.shutdown() if track_id: try: client.delete(f"{MEDIA_API}/tracks/{track_id}") except httpx.HTTPError: pass # ── web player UI ─────────────────────────────────────────────────────────── def test_ui_media_panel_serves(client): resp = client.get("/") body = resp.text assert 'id="mediaBtn"' in body assert 'id="mediaPanel"' in body assert 'id="playPauseBtn"' in body assert 'id="seekBar"' in body assert 'id="mediaAudio"' in body def test_ui_media_logic_serves(client): resp = client.get("/app.js") body = resp.text assert "reconcileMedia" in body assert "media-control" in body # data-channel topic for the agent assert "media_state" in body # playback state reports assert "/media/api/state" in body # reconciliation polling def test_ui_media_styles_serve(client): resp = client.get("/style.css") assert ".media-panel" in resp.text assert ".media-row" in resp.text def test_token_response_includes_room(): resp = httpx.post(TOKEN_URL, json={}, timeout=10, verify=False) assert resp.status_code == 200 assert resp.json()["room"].startswith("voice-") # ── Hope's media MCP toolset ──────────────────────────────────────────────── # These spawn the MCP server inside the LOCAL dev container (docker exec), # whose media server is the local one — they can only be consistent when # the test target IS the local deployment. MEDIA_MCP_ENV = { "MEDIA_SERVER_URL": "http://127.0.0.1:8093", "MEDIA_ROOM": "mcp-test-room", } needs_local_container = pytest.mark.skipif( not IS_LOCAL_DEPLOYMENT, reason="docker-exec MCP probes run against the local dev container", ) # ── media server internals (in-process, inside the container) ─────────────── @needs_local_container def test_media_search_prefers_library_matches(client): """Search returns ready library matches ahead of YouTube results, so Hope prefers an already-downloaded copy. Runs the endpoint in-process with _run_search stubbed — fully deterministic, no network.""" code = ''' import sys sys.path.insert(0, "/opt/voice") import media_server as ms from fastapi.testclient import TestClient ms._library["tracks"]["libpref01"] = { "id": "libpref01", "kind": "youtube", "video_id": "libpref01", "title": "Preference Probe Song", "channel": "Probe Artist", "duration": 120, "status": "ready", "ext": "webm", "error": None, "added_at": 1.0, } ms._run_search = lambda q, limit: [ {"video_id": "remote01", "title": "Preference Probe Song (cover)", "channel": "", "duration": 99, "view_count": 1, "in_library": False, "track_id": None, "library_status": None}, ] c = TestClient(ms.app) r = c.post("/media/api/search", json={"query": "preference probe", "limit": 5}) assert r.status_code == 200, r.text results = r.json()["results"] assert results, "no results" assert results[0]["video_id"] == "libpref01", results assert results[0]["in_library"] is True assert any(x["video_id"] == "remote01" for x in results), results print("OK") ''' proc = docker_exec_python(code, timeout=60, check=False) assert proc.returncode == 0, proc.stderr[-800:] assert "OK" in proc.stdout @needs_local_container def test_media_stream_endpoint_serves_ready_track(client): """GET /media/api/stream/{id} for a finished youtube track serves the file bytes with native Range support (progressive-playback endpoint).""" code = ''' import sys sys.path.insert(0, "/opt/voice") import media_server as ms from fastapi.testclient import TestClient probe = ms.FILES_DIR / "probetrk.mp3" probe.write_bytes(b"PROBEDATA" * 100) ms._library["tracks"]["probetrk"] = { "id": "probetrk", "kind": "youtube", "video_id": "probetrk", "title": "Probe", "channel": "", "duration": 1, "status": "ready", "ext": "mp3", "error": None, "added_at": 1.0, } try: c = TestClient(ms.app) full = c.get("/media/api/stream/probetrk") assert full.status_code == 200, full.status_code assert full.content == b"PROBEDATA" * 100 part = c.get("/media/api/stream/probetrk", headers={"Range": "bytes=0-8"}) assert part.status_code == 206, part.status_code assert part.content == b"PROBEDATA" assert (part.headers.get("content-range") or "").startswith("bytes 0-8/") # track dict exposes a stable stream URL while downloading and ready d = ms._track_dict(ms._library["tracks"]["probetrk"]) assert d["audio_url"] == "/media/api/stream/probetrk", d print("OK") finally: probe.unlink(missing_ok=True) ms._library["tracks"].pop("probetrk", None) ''' proc = docker_exec_python(code, timeout=60, check=False) assert proc.returncode == 0, proc.stderr[-800:] assert "OK" in proc.stdout @needs_local_container def test_mcp_media_list_library(mcp_client_factory): text, is_error = mcp_client_factory( "media_mcp.py", "list_library", {}, env=MEDIA_MCP_ENV ) assert not is_error assert "library" in text.lower() @needs_local_container def test_mcp_media_add_stream_and_now_playing(mcp_client_factory, client): upstream, url = _serve_fake_audio(b"\x00" * 16) try: text, is_error = mcp_client_factory( "media_mcp.py", "add_stream", {"name": "MCP Test Radio", "url": url}, env=MEDIA_MCP_ENV, ) assert not is_error, text assert "Added" in text library = client.get(f"{MEDIA_API}/library").json()["tracks"] probe = next(t for t in library if t["title"] == "MCP Test Radio") try: text, is_error = mcp_client_factory( "media_mcp.py", "now_playing", {}, env=MEDIA_MCP_ENV ) assert not is_error # Nothing loaded in mcp-test-room: the tool must say so plainly. assert "Nothing is loaded" in text client.post( f"{MEDIA_API}/play", json={"room": "mcp-test-room", "track_id": probe["id"]} ) text, is_error = mcp_client_factory( "media_mcp.py", "now_playing", {}, env=MEDIA_MCP_ENV ) assert not is_error assert "MCP Test Radio" in text finally: client.delete(f"{MEDIA_API}/tracks/{probe['id']}") finally: upstream.shutdown() @needs_local_container def test_mcp_media_resume_without_load(mcp_client_factory, client): """Errors from the media server reach the LLM as tool text (not MCP errors) so Hope can explain them naturally.""" client.post( f"{MEDIA_API}/control", json={"room": "mcp-empty-room", "action": "stop"} ) text, is_error = mcp_client_factory( "media_mcp.py", "resume_music", {}, env={ "MEDIA_SERVER_URL": "http://127.0.0.1:8093", "MEDIA_ROOM": "mcp-empty-room", } ) assert not is_error assert "nothing loaded" in text.lower() # ── live YouTube Music (network-dependent) ────────────────────────────────── @pytest.mark.slow def test_media_search_live(client): resp = client.post( f"{MEDIA_API}/search", json={"query": "blind guardian timeless miracle", "limit": 3}, ) assert resp.status_code == 200 results = resp.json()["results"] assert len(results) >= 1 for r in results: assert r["video_id"] assert r["title"] @pytest.mark.slow def test_media_download_and_stream_end_to_end(client): """Full pipeline: search -> play -> yt-dlp download -> playable stream. The audio_url stays on /media/api/stream/ for the whole lifecycle; once ready it serves the finished file with Range support. Gated: run with HOPE_RUN_YTDLP_DOWNLOAD=1 (takes 1-3 minutes).""" if os.environ.get("HOPE_RUN_YTDLP_DOWNLOAD") != "1": pytest.skip("set HOPE_RUN_YTDLP_DOWNLOAD=1 to run the real download") search = client.post( f"{MEDIA_API}/search", json={"query": "kingdom hearts dearly beloved", "limit": 3} ) results = search.json()["results"] assert results, "search returned nothing" video = results[0] played = client.post( f"{MEDIA_API}/play", json={"room": TEST_ROOM, "video_id": video["video_id"], "title": video["title"], "channel": video["channel"]}, ).json() track_id = played["track"]["id"] try: deadline = time.time() + 240 track = played["track"] while time.time() < deadline: library = client.get(f"{MEDIA_API}/library").json()["tracks"] track = next(t for t in library if t["id"] == track_id) if track["status"] in ("ready", "failed"): break time.sleep(3) assert track["status"] == "ready", f"download failed: {track.get('error')}" stream = client.get(track["audio_url"], headers={"Range": "bytes=0-1023"}) assert stream.status_code in (200, 206) assert len(stream.content) >= 1 finally: client.delete(f"{MEDIA_API}/tracks/{track_id}")