refactor: split music streaming into Hope Music
Rebuild & Deploy / rebuild (push) Successful in 2m7s
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:
1 parent
d4ea4fc984
commit
436b3fc8b1
23 files changed
+356
-2518
No files matched your search
+24
-23
@@ -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 {}
|
||||
|
||||
Reference in new issue
Block a user