Rebuild & Deploy / rebuild (push) Successful in 14s
The fake audio upstream bound the test host's loopback, which the Unraid media server cannot reach (and its LAN route is firewalled). Local runs keep the controlled upstream; remote runs use a public reference mp3 and assert Range semantics without byte equality. docker-exec MCP probes skip on remote targets (they necessarily exercise the local container).
464 lines
17 KiB
Python
464 lines
17 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, 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",
|
|
)
|
|
|
|
|
|
@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 + ffmpeg transcode ->
|
|
static mp3 with Range support in the player. 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}")
|