feat: progressive music streaming, library-first search, player fixes
Rebuild & Deploy / rebuild (push) Successful in 59s

- 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.
This commit is contained in:
snowspeeder committed 2026-08-29 16:41:38 -04:00
1 parent d89918fd2a
commit 4f8eb00c9f
7 files changed
+440 -107

No files matched your search

+85 -3
View File
@@ -18,7 +18,9 @@ from urllib.parse import urlparse
import httpx
import pytest
from conftest import TOKEN_URL, docker_exec, IS_LOCAL_DEPLOYMENT, WEB_BASE_URL
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]}"
@@ -346,6 +348,85 @@ needs_local_container = pytest.mark.skipif(
)
# ── 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(
@@ -425,8 +506,9 @@ def test_media_search_live(client):
@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
"""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")