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
+144
-31
@@ -11,6 +11,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import textwrap
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -58,6 +59,18 @@ WEB_MCP_ENABLED = os.environ.get("WEB_MCP_ENABLED", "true").lower() in ("1", "tr
|
||||
FIRECRAWL_BASE = os.environ.get("FIRECRAWL_BASE", "http://192.168.86.2:3002")
|
||||
SKILLS_DIR = os.environ.get("SKILLS_DIR", "/skills")
|
||||
VOICE_USERS_FILE = os.environ.get("VOICE_USERS_FILE", "/etc/hope/users.json")
|
||||
# Hope Music is a separate LAN service. The agent is its control surface; the
|
||||
# standalone Hope Music page is the browser audio player.
|
||||
MUSIC_BASE_URL = os.environ.get(
|
||||
"MUSIC_BASE_URL", "http://192.168.86.2:8094"
|
||||
).rstrip("/")
|
||||
# This is deliberately stable rather than the short-lived LiveKit room name.
|
||||
# The Hope Music page defaults to the same scope, so playback survives across
|
||||
# voice sessions and both products address one virtual player.
|
||||
MUSIC_ROOM = os.environ.get("MUSIC_ROOM", "voice-room")
|
||||
# The player reports every few seconds while audio is active. A stale playing
|
||||
# report must never leave Hope's microphone disabled after the page closes.
|
||||
MUSIC_REPORT_TTL = float(os.environ.get("MUSIC_REPORT_TTL", "30"))
|
||||
|
||||
|
||||
def load_user_profile(user_id: str) -> dict:
|
||||
@@ -208,15 +221,15 @@ SYSTEM_PROMPT = textwrap.dedent("""\
|
||||
stalled); task_status can also be called to check up on a task. Use
|
||||
list_tasks to see every task you have dispatched.
|
||||
|
||||
# Music (media player in the web UI)
|
||||
You can play music on the user's web player. For any music request, call
|
||||
search_music first, then:
|
||||
# Music (standalone Hope Music player)
|
||||
You can control the user's separate Hope Music web player. For any music
|
||||
request, call search_music first, then:
|
||||
- One clearly-right result → play_song with its video_id. Announce it in
|
||||
one short sentence, then stop talking — playback pauses the
|
||||
conversation until the user comes back to you.
|
||||
- Ambiguous results → offer_song_choices with the top 2-3 options, name
|
||||
them briefly, and let the user pick by voice or in the player panel.
|
||||
Never start playback on a guess.
|
||||
them briefly, and let the user pick by voice or in Hope Music. Never
|
||||
start playback on a guess.
|
||||
Prefer what is already saved: search_music puts downloaded library
|
||||
matches first (in_library=true) — always pick one of those over a fresh
|
||||
YouTube result, and list_library shows the whole saved collection which
|
||||
@@ -693,9 +706,9 @@ def build_mcp_toolsets(user_profile: dict, task_dir: str) -> list[mcp.MCPToolset
|
||||
)
|
||||
logger.info("Dispatch MCP toolset enabled (%s)", task_dir)
|
||||
|
||||
# Media tools talk to the local media server; the room name scopes player
|
||||
# state so concurrent sessions don't fight over one virtual player.
|
||||
media_server_url = os.environ.get("MEDIA_SERVER_URL", "http://127.0.0.1:8093")
|
||||
# Media tools call the separate hope-media-music service over HTTP. A
|
||||
# stable scope lets the standalone player and every voice session address
|
||||
# the same virtual player.
|
||||
media_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "media_mcp.py")
|
||||
toolsets.append(
|
||||
mcp.MCPToolset(
|
||||
@@ -705,14 +718,14 @@ def build_mcp_toolsets(user_profile: dict, task_dir: str) -> list[mcp.MCPToolset
|
||||
args=[media_mcp_script],
|
||||
env={
|
||||
**os.environ,
|
||||
"MEDIA_SERVER_URL": media_server_url,
|
||||
"MEDIA_ROOM": os.path.basename(task_dir.rstrip("/")) or "default",
|
||||
"MUSIC_BASE_URL": MUSIC_BASE_URL,
|
||||
"MUSIC_ROOM": MUSIC_ROOM,
|
||||
},
|
||||
client_session_timeout_seconds=60,
|
||||
),
|
||||
)
|
||||
)
|
||||
logger.info("Media MCP toolset enabled (%s)", media_server_url)
|
||||
logger.info("Media MCP enabled (%s, room %s)", MUSIC_BASE_URL, MUSIC_ROOM)
|
||||
|
||||
extra = os.environ.get("EXTRA_MCP_SERVERS", "")
|
||||
if extra:
|
||||
@@ -828,13 +841,12 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("failed to publish model list: %s", e)
|
||||
|
||||
# Listen for conversation controls (voice selection and voice/text mode).
|
||||
# Defined here so it captures this job's session via closure.
|
||||
# Music and conversation are mutually exclusive by design: while a track
|
||||
# plays the agent's ears are off; re-engaging the conversation pauses the
|
||||
# music, and ending the conversation lets the user resume it.
|
||||
# Listen for conversation controls (voice selection, voice/text mode, and
|
||||
# model switching). Music state comes from the separate service, not from
|
||||
# the voice UI's data channel.
|
||||
session_mode = {"value": "voice"} # UI voice/text switch
|
||||
media_playback = {"value": "stopped"} # UI-reported player state
|
||||
media_playback = {"value": "stopped"} # fresh service-reported state
|
||||
music_paused_by_agent = {"value": False}
|
||||
|
||||
def _apply_listening() -> None:
|
||||
listening = (
|
||||
@@ -843,9 +855,108 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
)
|
||||
session.input.set_audio_enabled(listening)
|
||||
|
||||
async def _music_request(
|
||||
method: str, path: str, *, warn: bool = True, **kwargs
|
||||
) -> dict | None:
|
||||
import httpx
|
||||
|
||||
url = f"{MUSIC_BASE_URL}/media/api{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as http:
|
||||
response = await http.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Hope Music is a peer service: it being unavailable must never
|
||||
# take the conversation down with it.
|
||||
if warn:
|
||||
logger.warning("Music service %s %s failed: %s", method, path, exc)
|
||||
return None
|
||||
|
||||
async def _music_snapshot(*, warn: bool = True) -> dict | None:
|
||||
return await _music_request(
|
||||
"GET", "/state", warn=warn, params={"room": MUSIC_ROOM}
|
||||
)
|
||||
|
||||
def _music_is_playing(snapshot: dict | None) -> bool:
|
||||
"""Return true only for fresh, browser-reported playback."""
|
||||
reported = (snapshot or {}).get("reported") or {}
|
||||
if reported.get("state") != "playing":
|
||||
return False
|
||||
try:
|
||||
age = time.time() - float(reported.get("ts") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return 0 <= age < MUSIC_REPORT_TTL
|
||||
|
||||
async def _pause_music_for_conversation() -> None:
|
||||
"""Pause actual playback when the user engages Hope."""
|
||||
if music_paused_by_agent["value"]:
|
||||
return
|
||||
if not _music_is_playing(await _music_snapshot()):
|
||||
return
|
||||
result = await _music_request(
|
||||
"POST", "/control",
|
||||
json={"room": MUSIC_ROOM, "action": "pause"},
|
||||
)
|
||||
if result is not None:
|
||||
music_paused_by_agent["value"] = True
|
||||
logger.info("Paused Hope Music for the conversation (%s)", MUSIC_ROOM)
|
||||
|
||||
async def _resume_music_after_conversation() -> None:
|
||||
if not music_paused_by_agent["value"]:
|
||||
return
|
||||
result = await _music_request(
|
||||
"POST", "/control",
|
||||
json={"room": MUSIC_ROOM, "action": "resume"},
|
||||
)
|
||||
if result is not None:
|
||||
music_paused_by_agent["value"] = False
|
||||
logger.info("Resumed Hope Music after the conversation (%s)", MUSIC_ROOM)
|
||||
|
||||
async def _poll_music_state() -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(2)
|
||||
# Jobs deliberately survive page reloads. Do not keep making
|
||||
# network requests after the room has no human participant.
|
||||
if not ctx.room.remote_participants:
|
||||
continue
|
||||
snapshot = await _music_snapshot(warn=False)
|
||||
reported = (snapshot or {}).get("reported") or {}
|
||||
state = reported.get("state") or "stopped"
|
||||
if state == "playing" and not _music_is_playing(snapshot):
|
||||
state = "stopped"
|
||||
if state not in ("playing", "paused", "stopped"):
|
||||
state = "stopped"
|
||||
if state != media_playback["value"]:
|
||||
logger.info("Hope Music playback -> %s", state)
|
||||
media_playback["value"] = state
|
||||
_apply_listening()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Hope Music state poller failed")
|
||||
|
||||
def _on_participant_joined(*_args) -> None:
|
||||
"""A returning participant starts a new conversation handshake."""
|
||||
asyncio.create_task(_pause_music_for_conversation())
|
||||
|
||||
def _on_participant_left(*_args) -> None:
|
||||
"""When the last human leaves, hand playback back to Hope Music."""
|
||||
|
||||
async def _deferred() -> None:
|
||||
# The event can fire before the participant is removed from the
|
||||
# room object; re-check the roster after it settles.
|
||||
await asyncio.sleep(0.5)
|
||||
if not ctx.room.remote_participants:
|
||||
await _resume_music_after_conversation()
|
||||
|
||||
asyncio.create_task(_deferred())
|
||||
|
||||
def _on_room_data(packet) -> None:
|
||||
topic = getattr(packet, "topic", None)
|
||||
if topic and topic not in ("voice-control", "conversation-control", "media-control"):
|
||||
if topic and topic not in ("voice-control", "conversation-control"):
|
||||
return
|
||||
try:
|
||||
msg = json.loads(packet.data.decode("utf-8"))
|
||||
@@ -865,17 +976,6 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
session.output.set_audio_enabled(voice_enabled)
|
||||
_apply_listening()
|
||||
return
|
||||
|
||||
if msg.get("type") == "media_state":
|
||||
state = msg.get("state")
|
||||
if state not in ("playing", "paused", "stopped"):
|
||||
return
|
||||
logger.info("Media playback -> %s (agent listening: %s)",
|
||||
state, session_mode["value"] == "voice" and state != "playing")
|
||||
media_playback["value"] = state
|
||||
_apply_listening()
|
||||
return
|
||||
|
||||
if msg.get("type") == "set_voice":
|
||||
voice = msg.get("voice", "")
|
||||
if not voice:
|
||||
@@ -913,6 +1013,8 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
logger.warning("Failed to update model: %s", e)
|
||||
|
||||
ctx.room.on("data_received", _on_room_data)
|
||||
ctx.room.on("participant_connected", _on_participant_joined)
|
||||
ctx.room.on("participant_disconnected", _on_participant_left)
|
||||
|
||||
# Publish user/agent transcripts to the room so the web UI can render them.
|
||||
async def publish_transcript(role: str, text: str) -> None:
|
||||
@@ -933,9 +1035,9 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
role = getattr(msg, "role", None)
|
||||
text = getattr(msg, "text_content", None)
|
||||
if role == "user":
|
||||
asyncio.create_task(publish_transcript("user", text))
|
||||
asyncio.create_task(publish_transcript("user", text or ""))
|
||||
elif role == "assistant":
|
||||
asyncio.create_task(publish_transcript("agent", text))
|
||||
asyncio.create_task(publish_transcript("agent", text or ""))
|
||||
|
||||
@session.on("user_input_transcribed")
|
||||
def _on_user_transcribed(ev) -> None:
|
||||
@@ -1017,6 +1119,17 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
)
|
||||
|
||||
await ctx.connect()
|
||||
await _pause_music_for_conversation()
|
||||
music_poll_task = asyncio.create_task(
|
||||
_poll_music_state(), name=f"music-poller-{ctx.room.name}"
|
||||
)
|
||||
|
||||
async def _stop_music_poller(*_args) -> None:
|
||||
music_poll_task.cancel()
|
||||
await asyncio.gather(music_poll_task, return_exceptions=True)
|
||||
await _resume_music_after_conversation()
|
||||
|
||||
ctx.add_shutdown_callback(_stop_music_poller)
|
||||
# The participant can appear in the browser slightly before AgentSession is
|
||||
# ready to accept lk.chat streams. Explicitly signal readiness so the first
|
||||
# typed message is never raced against session startup.
|
||||
|
||||
+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 {}
|
||||
|
||||
@@ -16,7 +16,6 @@ dependencies = [
|
||||
"livekit-plugins-openai~=1.7",
|
||||
"python-dotenv",
|
||||
"uvicorn>=0.52.4",
|
||||
"yt-dlp>=2026.8.19",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
Generated
+1
-12
@@ -664,7 +664,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -2767,7 +2767,6 @@ dependencies = [
|
||||
{ name = "livekit-plugins-openai" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "yt-dlp" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -2779,7 +2778,6 @@ requires-dist = [
|
||||
{ name = "livekit-plugins-openai", specifier = "~=1.7" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "uvicorn", specifier = ">=0.52.4" },
|
||||
{ name = "yt-dlp", specifier = ">=2026.8.19" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3062,12 +3060,3 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yt-dlp"
|
||||
version = "2026.8.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/e0/832fa4ca334b766a06933a196066edc3dba37cdb6f14cd98d59bcc69a4b4/yt_dlp-2026.8.19.tar.gz", hash = "sha256:9e213e48cea35c66b378e4447903f118f6392a5fa380a2b6d7070ec86f4e0af1", size = 3052025, upload-time = "2026-08-19T23:48:59.291Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/8cd1613f56eed7ceb64fbd4df3f1c01246bfb098e6f398228bafda22b80b/yt_dlp-2026.8.19-py3-none-any.whl", hash = "sha256:1d57897e94c6665a0a6f9bc54b34e584284e32c034ffab3a7df25d8f7b24eedf", size = 3185533, upload-time = "2026-08-19T23:48:56.925Z" },
|
||||
]
|
||||
@@ -10,4 +10,3 @@ Requires-Dist: livekit-plugins-azure~=1.7
|
||||
Requires-Dist: livekit-plugins-openai~=1.7
|
||||
Requires-Dist: python-dotenv
|
||||
Requires-Dist: uvicorn>=0.52.4
|
||||
Requires-Dist: yt-dlp>=2026.8.19
|
||||
@@ -5,4 +5,3 @@ livekit-plugins-azure~=1.7
|
||||
livekit-plugins-openai~=1.7
|
||||
python-dotenv
|
||||
uvicorn>=0.52.4
|
||||
yt-dlp>=2026.8.19
|
||||
Reference in new issue
Block a user