Files
snowspeeder 436b3fc8b1
Rebuild & Deploy / rebuild (push) Successful in 2m7s
refactor: split music streaming into Hope Music
Move the media server, downloader, library volume, and browser player to the
standalone hope-media-music product. Keep Hope's music tools as a thin HTTP
client of the LAN service on port 8094.

Use a stable voice-room player scope instead of ephemeral LiveKit room names.
The agent now owns the conversation handshake: fresh reported playback mutes
its input, joining pauses music, and the last participant leaving resumes only
music it paused. Peer failures remain non-fatal.

Also move the voice deploy workflow onto the active Unraid Gitea runner and
remove yt-dlp/ffmpeg/media health and volume dependencies from the image.
2026-09-12 06:35:15 -04:00

168 lines
5.9 KiB
Python

"""Voice-side integration tests for the standalone Hope Music service.
The library, downloads, stream proxy, web player, and their exhaustive tests
live in ``hope-media-music`` now. This module guards the remaining product
boundary: the voice repo contains only the MCP/HTTP client, uses one stable
player scope, and can reach the LAN service.
"""
import os
import socket
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import urlparse
import httpx
import pytest
from conftest import IS_LOCAL_DEPLOYMENT, WEB_BASE_URL
ROOT = Path(__file__).parents[1]
MUSIC_BASE_URL = os.environ.get(
"MUSIC_BASE_URL", "http://192.168.86.2:8094"
).rstrip("/")
MUSIC_ROOM = os.environ.get("MUSIC_ROOM", "voice-room")
MEDIA_API = "/media/api"
def test_external_music_service_health_and_library():
"""The peer service used by the deployed agent is reachable over the LAN."""
with httpx.Client(base_url=MUSIC_BASE_URL, timeout=15) as client:
health = client.get(f"{MEDIA_API}/healthz")
assert health.status_code == 200
assert health.json()["ok"] is True
library = client.get(f"{MEDIA_API}/library")
assert library.status_code == 200
assert isinstance(library.json()["tracks"], list)
def test_voice_repo_contains_only_the_music_client():
"""Regression guard for the product split: backend/player cannot creep back."""
agent = (ROOT / "agent" / "agent.py").read_text()
client = (ROOT / "agent" / "media_mcp.py").read_text()
web_js = (ROOT / "web" / "app.js").read_text()
web_html = (ROOT / "web" / "index.html").read_text()
assert not (ROOT / "web" / "media_server.py").exists()
assert not (ROOT / "media").exists()
assert "MUSIC_BASE_URL" in agent and "MUSIC_ROOM" in agent
assert "MUSIC_BASE_URL" in client and "MUSIC_ROOM" in client
assert "MEDIA_SERVER_URL" not in agent + client
assert "media-control" not in agent + web_js
assert "media_state" not in agent + web_js
assert "/media/api" not in web_js
assert 'id="mediaPanel"' not in web_html
assert 'id="mediaAudio"' not in web_html
def test_voice_uses_a_stable_music_scope():
"""The ephemeral LiveKit room must not be the standalone player's scope."""
assert MUSIC_ROOM == "voice-room" or os.environ.get("MUSIC_ROOM")
source = (ROOT / "agent" / "agent.py").read_text()
assert 'MUSIC_ROOM = os.environ.get("MUSIC_ROOM", "voice-room")' in source
assert '"MUSIC_ROOM": MUSIC_ROOM' in source
# These exercise the actual MCP subprocess inside a local voice container. The
# default suite targets Unraid remotely, where docker-exec intentionally skips.
needs_local_container = pytest.mark.skipif(
not IS_LOCAL_DEPLOYMENT,
reason="docker-exec MCP probes require a local voice container",
)
MEDIA_MCP_ENV = {
"MUSIC_BASE_URL": MUSIC_BASE_URL,
"MUSIC_ROOM": "mcp-test-room",
}
def _serve_fake_audio(payload: bytes):
"""Tiny Range-capable upstream reachable by a host-networked container."""
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "audio/mpeg")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, format, *args): # noqa: A002
pass
server = HTTPServer(("0.0.0.0", 0), Handler)
port = server.server_address[1]
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
target = urlparse(WEB_BASE_URL).hostname or "192.168.86.2"
probe.connect((target, urlparse(WEB_BASE_URL).port or 9178))
host = probe.getsockname()[0]
except OSError:
host = "127.0.0.1"
finally:
probe.close()
threading.Thread(target=server.serve_forever, daemon=True).start()
return server, f"http://{host}:{port}/audio.mp3"
@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):
upstream, url = _serve_fake_audio(b"\x00" * 16)
track_id = None
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
with httpx.Client(base_url=MUSIC_BASE_URL, timeout=15) as music:
library = music.get(f"{MEDIA_API}/library").json()["tracks"]
probe = next(t for t in library if t["title"] == "MCP Test Radio")
track_id = probe["id"]
music.post(
f"{MEDIA_API}/play",
json={"room": "mcp-test-room", "track_id": track_id},
).raise_for_status()
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:
upstream.shutdown()
if track_id:
with httpx.Client(base_url=MUSIC_BASE_URL, timeout=15) as music:
music.delete(f"{MEDIA_API}/tracks/{track_id}")
@needs_local_container
def test_mcp_media_resume_without_load(mcp_client_factory):
room = "mcp-empty-room"
with httpx.Client(base_url=MUSIC_BASE_URL, timeout=15) as music:
music.post(
f"{MEDIA_API}/control",
json={"room": room, "action": "stop"},
).raise_for_status()
text, is_error = mcp_client_factory(
"media_mcp.py", "resume_music", {},
env={"MUSIC_BASE_URL": MUSIC_BASE_URL, "MUSIC_ROOM": room},
)
assert not is_error
assert "nothing loaded" in text.lower()