refactor: split music streaming into Hope Music
Rebuild & Deploy / rebuild (push) Successful in 2m7s

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.
This commit is contained in:
snowspeeder authored and Shane Maynard committed 2026-09-12 06:35:15 -04:00
1 parent d4ea4fc984
commit 436b3fc8b1
23 files changed
+356 -2518

No files matched your search

+24 -23
View File
@@ -1,18 +1,17 @@
"""Media MCP server — lets Hope find and control music by voice.
"""Media MCP client — lets Hope control the standalone Hope Music service.
Tools talk to the local media server (web/media_server.py) over HTTP, which
owns the library, YouTube Music search/download jobs and per-room player
state. The browser is the actual player: it reconciles against the media
server and reports playback back, so voice controls and the web panel stay
in sync.
Tools call the separate ``hope-media-music`` LAN service over HTTP. That
service owns the library, YouTube Music search/download jobs, stream proxy,
and per-room player state. Its standalone browser page is the actual audio
player: it reconciles against the service and reports real playback.
Per-session environment (set by the agent when spawning this MCP):
- MEDIA_SERVER_URL e.g. http://127.0.0.1:8093
- MEDIA_ROOM LiveKit room name this session controls
Environment (set by the voice agent when spawning this MCP):
- MUSIC_BASE_URL e.g. http://192.168.86.2:8094
- MUSIC_ROOM stable player scope shared with the Hope Music page
The "stop listening while music plays" rule lives in the agent itself: the
UI reports media_state over the data channel and the agent disables its mic
while a track is playing.
The voice agent polls the same service state to keep music and conversation
mutually exclusive; there is no music server or music player in the voice
container anymore.
"""
from __future__ import annotations
@@ -22,20 +21,22 @@ import os
import httpx
from mcp.server.fastmcp import FastMCP
MEDIA_SERVER_URL = os.environ.get("MEDIA_SERVER_URL", "http://127.0.0.1:8093").rstrip("/")
MEDIA_ROOM = os.environ.get("MEDIA_ROOM", "default")
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")
mcp = FastMCP("media")
def _media_get(path: str, params: dict | None = None) -> dict:
resp = httpx.get(f"{MEDIA_SERVER_URL}{path}", params=params, timeout=10)
resp = httpx.get(f"{MUSIC_BASE_URL}{path}", params=params, timeout=10)
resp.raise_for_status()
return resp.json()
def _media_post(path: str, body: dict) -> dict:
resp = httpx.post(f"{MEDIA_SERVER_URL}{path}", json=body, timeout=30)
resp = httpx.post(f"{MUSIC_BASE_URL}{path}", json=body, timeout=30)
if resp.status_code >= 400:
try:
detail = resp.json().get("error", resp.text)
@@ -46,7 +47,7 @@ def _media_post(path: str, body: dict) -> dict:
def _control(action: str, **extra) -> str:
data = _media_post("/media/api/control", {"room": MEDIA_ROOM, "action": action, **extra})
data = _media_post("/media/api/control", {"room": MUSIC_ROOM, "action": action, **extra})
if "error" in data:
return f"Could not {action} the music: {data['error']}"
track = data.get("track") or {}
@@ -87,14 +88,14 @@ def search_music(query: str, limit: int = 5) -> str:
@mcp.tool()
def play_song(video_id: str, title: str = "", channel: str = "") -> str:
"""Play a song in the user's web player. Pass the video_id from
"""Play a song in the user's standalone Hope Music player. Pass the video_id from
search_music — for an in_library result this plays the saved copy with no
download; a new song downloads and starts playing automatically while it
arrives. After calling this, tell the user what is playing in one short
sentence and stop talking — the player takes over and the conversation
pauses until they return."""
data = _media_post("/media/api/play", {
"room": MEDIA_ROOM, "video_id": video_id, "title": title, "channel": channel,
"room": MUSIC_ROOM, "video_id": video_id, "title": title, "channel": channel,
})
if "error" in data:
return f"Could not start playback: {data['error']}"
@@ -114,12 +115,12 @@ def play_song(video_id: str, title: str = "", channel: str = "") -> str:
def offer_song_choices(choices: list[dict]) -> str:
"""Offer the user 2-4 song options when a search is ambiguous. Pass a list
like [{"video_id": "...", "title": "...", "channel": "...", "duration": 213}].
The choices appear as clickable cards in the music panel; the user can
The choices appear as clickable cards in Hope Music; the user can
also just say which one they want. Briefly name the options aloud, then
wait for their pick."""
if not choices:
return "No choices provided."
data = _media_post("/media/api/choices", {"room": MEDIA_ROOM, "choices": choices})
data = _media_post("/media/api/choices", {"room": MUSIC_ROOM, "choices": choices})
if "error" in data:
return f"Could not present choices: {data['error']}"
names = [
@@ -162,7 +163,7 @@ def seek_music(delta_seconds: int) -> str:
jumps ahead, negative goes back (e.g. -30 rewinds 30 seconds, 20 skips
20 seconds forward)."""
try:
reported = (_media_get("/media/api/state", {"room": MEDIA_ROOM}).get("reported") or {})
reported = (_media_get("/media/api/state", {"room": MUSIC_ROOM}).get("reported") or {})
position = float(reported.get("position") or 0.0)
except Exception: # noqa: BLE001
position = 0.0
@@ -176,7 +177,7 @@ def now_playing() -> str:
its playback position. Use when the user asks what's playing or what this
song is."""
try:
st = _media_get("/media/api/state", {"room": MEDIA_ROOM})
st = _media_get("/media/api/state", {"room": MUSIC_ROOM})
except Exception as exc: # noqa: BLE001
return f"Media server unreachable: {exc}"
track = st.get("track") or {}