feat: progressive music streaming, library-first search, player fixes
Rebuild & Deploy / rebuild (push) Successful in 59s
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:
1 parent
d89918fd2a
commit
4f8eb00c9f
7 files changed
+440
-107
No files matched your search
+85
-3
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user