diff --git a/tests/test_media.py b/tests/test_media.py index 6270d98..7ef9864 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -13,11 +13,12 @@ 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 +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]}" @@ -26,11 +27,13 @@ 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 host port. + """Tiny Range-capable HTTP server on an ephemeral port. - The container uses host networking, so the media server inside the - container reaches it on 127.0.0.1. + 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): @@ -57,10 +60,19 @@ def _serve_fake_audio(payload: bytes): def log_message(self, *args): pass - server = HTTPServer(("127.0.0.1", 0), Handler) + 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://127.0.0.1:{port}/audio.mp3" + return server, f"http://{lan_ip}:{port}/audio.mp3" # ── health + basic API shape ──────────────────────────────────────────────── @@ -171,27 +183,60 @@ def test_media_player_state_flow(client): 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.""" - payload = b"0123456789" * 100 - upstream, url = _serve_fake_audio(payload) + 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", "url": url} + 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.content == payload + 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=100-199"} + f"{MEDIA_API}/stream/{track_id}", headers={"Range": "bytes=0-1023"} ) assert ranged.status_code == 206 - assert ranged.content == payload[100:200] - assert ranged.headers["content-range"] == f"bytes 100-199/{len(payload)}" + assert len(ranged.content) == 1024 + assert (ranged.headers.get("content-range") or "").startswith("bytes 0-1023/") finally: - upstream.shutdown() if track_id: try: client.delete(f"{MEDIA_API}/tracks/{track_id}") @@ -286,13 +331,22 @@ def test_token_response_includes_room(): # ── 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 @@ -301,6 +355,7 @@ def test_mcp_media_list_library(mcp_client_factory): 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: @@ -335,6 +390,7 @@ def test_mcp_media_add_stream_and_now_playing(mcp_client_factory, client): 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."""