Files
hope-voice-api/tests/test_media.py
T
snowspeeder 4f8eb00c9f
Rebuild & Deploy / rebuild (push) Successful in 59s
feat: progressive music streaming, library-first search, player fixes
- Stream downloads as they grow: raw best-audio (no mp3 transcode) served
  chunked from the stream endpoint mid-download, then with native Range
  once complete. Songs play within seconds of hitting Play.
- Fix searched songs never starting: reconcileMedia now wires the stream
  URL when a track that was preparing gains one; audio_url is stable for
  the whole lifecycle so the player never reloads mid-playback.
- Progress bars now parse yt-dlp stderr (stdout never carried [download]).
- Prefer downloaded songs: /media/api/search merges ready library matches
  ahead of YouTube results; search_music/play_song tooling and the agent
  prompt tell Hope to always pick in_library=true.
- Mute button turns solid red while the mic is muted.
2026-08-29 16:41:38 -04:00

546 lines
20 KiB
Python

"""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}")